Repositorios
Una concesión sobre el host de un repositorio, con un token puesto, es una concesión para pushear a cualquier parte del repositorio, main incluida. Por eso un repositorio se da junto con las ramas que vienen con él, y el proxy lee cada push antes de dejarlo pasar.
Cuatro palabras, tres concesiones
│ › /repo acme/website │ │ This plane holds no GitHub token. Make a fine-grained one at │ │ https://github.com/settings/personal-access-tokens/new with │ │ Contents: read and write on acme/website — and Pull requests: │ │ read and write, if it should open them — then paste it here: │ │ /repo github_pat_… │ │ │ │ It is kept here, spent by the proxy, and never given to an │ │ agent. │ │ │ │ › /repo … │ │ scout holds https://github.com/acme/website. It can clone it │ │ and push to scout/*; a push to any other branch is refused │ │ before it leaves, and it can open pull requests. The token │ │ stays here, never in the sandbox. │
Lo que el plano deriva de acme/website son tres concesiones, ninguna escrita a mano: github.com bajo /acme/website, con el alcance de push encima y el token puesto como basic auth, que es el clone y el push; api.github.com bajo /repos/acme/website para GET, que es leer; y bajo /repos/acme/website/pulls también para POST y PATCH, que es abrir un pull request. Dos concesiones de API en vez de una porque la API es por donde se podría rodear el alcance de ramas — un PUT en contents/ commitea a cualquier rama, un PATCH en git/refs/ mueve cualquier ref, un POST en merges mergea a main. Abrir un PR es la única escritura que recibe; mergearlo es un PUT que no se le da.
El token es del plano, uno solo para todos los repositorios que se den acá, guardado al lado de las claves de proveedor como GITHUB_TOKEN — exportarlo en el entorno del plano también funciona, y lo que se pega en una consola gana. Un token fine-grained responde solo por los repositorios que le diste, así que dale los que vas a repartir y nada más: es el cinturón debajo del alcance del proxy, no una segunda copia. El repositorio responde bajo su nombre con .git al final y también sin él, porque el agente pega el que le mostró la caja de clone.
Su propio carril, salvo que digas otra cosa
Si no se dice nada, las ramas son el nombre del agente y todo lo que cuelga de él — scout/* — así que nada cae en main porque nadie dijo qué ramas. /repo acme/website fix/* docs las nombra en cambio. Un patrón tiene la forma de un refspec: un nombre pelado es una rama, * vale por cualquier cosa, barras incluidas, y * solo es cualquier parte. Decir /repo de nuevo para un repositorio que ya tiene cambia a dónde puede pushear, no agrega una fila.
│ $ git push origin main │ │ remote: squad: main is not granted to this agent; push scout/* here │ │ To https://github.com/acme/website │ │ ! [remote rejected] main -> main (not granted: push scout/* here) │ │ error: failed to push some refs to 'https://github.com/acme/website' │
La rama está en la cabecera del propio push: una línea por ref, cerrada por un flush, y el packfile después. El proxy lee hasta el flush antes de abrir nada hacia arriba, y si una ref no está en la lista no pasa nada — ni el packfile, ni las otras refs que iban con ella. El rechazo no es un 403, porque un 403 le llega a git como RPC failed; HTTP 403, que es exactamente lo que parece un token equivocado, y un agente que lee eso se va a revisar el token. Lo que git imprime palabra por palabra es el reporte del servidor, así que el proxy rechaza como lo haría un hook de pre-receive, nombrando la rama que negó y las que tiene. El log de auditoría lleva las refs de cada push, pasado o no.
Dicho en cada turno, después de las reglas de la casa
Una concesión que nadie menciona es una concesión que se descubre a los tumbos — clonando con el nombre equivocado y encontrando un 403, o pusheando a main y encontrando un rechazo que no sabía que venía. Así que el plano le dice al agente qué tiene al empezar cada turno, como le dice las reglas de la casa: la URL, dónde va el checkout dentro de su workspace, y las ramas a las que puede pushear. Sus commits llevan su propio nombre, y el git del sandbox nunca se detiene a pedir una contraseña, porque la credencial la pone el proxy y un prompt sería un turno colgado de una pregunta que nadie contesta.
│ › /repo │ │ scout holds: │ │ https://github.com/acme/website push scout/* from here │ │ https://github.com/acme/api push fix/* docs from the file │ │ │ │ /repo drop <owner/name> takes one back; /repo <owner/name> │ │ <branch>… changes what it may push. │
Un agente puede pedir /repo para ver qué tiene, y no puede pedir tener uno: tener un repositorio gasta tu token en él, y un token que un agente tiene en la mano es uno que sacó de algo que leyó. El rechazo dice qué línea te toca escribir a vos, y nunca imprime el token.
Un force push parece un push
El proxy ve las refs y no la historia que hay detrás, así que un force push a una rama a la que el agente puede pushear pasa, y borrarla también. Nada de main cambia de manos en ningún caso, y esa es la promesa: las ramas que nombraste son las ramas que puede tocar. El segundo cinturón es de GitHub, un ruleset sobre main que rechace el push directo, con bypass para vos — vale la pena porque el token es del plano y vive fuera del sandbox, donde el proxy no es lo que lo cuida.
Las mismas cuatro palabras van en deploy/config.yaml, bajo el agente, y lo que el archivo declara la consola se lo deja al archivo:
agents:
- id: scout
repos:
- repo: acme/website
- repo: acme/api
push: [fix/*, docs]
Alcance es qué es una concesión y dónde queda la credencial, y esto es una forma de concesión con un alcance que el método no podía llevar.