The Forgejo coily* scope gate does not bind user-repo list, because its swagger parameter is {username} and restrict keys on the parameter name #1378

Closed
opened 2026-08-29 05:03:22 +00:00 by coilyco-ops · 1 comment
Owner

Found while measuring the operator surface against the MCP for #1365. Small blast radius, but it is a declared boundary that silently does not bind, which is the failure class worth naming precisely.

The claim the guardfile makes

.specgen/guardfiles/aosguard/forgejo.kdl:28

// Scope gate: every leaf whose path carries {owner} must name a coily* owner.

and it declares two clauses, at lines 29 and 687:

restrict owner matches coily*
restrict org matches coily*

What actually binds

restrict gates by parameter name, so a clause binds a leaf only when the resolved path spells that exact parameter. user-repo list resolves to GET /users/{username}/repos, and username is neither owner nor org.

Confirmed against the built binary rather than by reading the file, with a negative control:

$ ./dist/aosguard ops forgejo user-repo list torvalds --dry-run
headers:
    Authorization: token <redacted>
method: GET
url: https://forgejo.coilysiren.me/api/v1/users/torvalds/repos

$ ./dist/aosguard ops forgejo repo get torvalds linux --dry-run
aosguard: argument owner="torvalds" is outside the allowed scope (restrict owner matches [coily*])

The control refuses, so the gate is working where its parameter matches. The first call builds a live request against an arbitrary user with the coilyco-ops credential attached.

Impact

Low. It is a read-only list, bounded independently by what the coilyco-ops token can see, and there is no write path here. Nothing is leaking that the token could not already reach by another route.

The reason to fix it anyway is that the file states a gate it does not have. Its own describe explains the leaf exists because "coilysiren is a user, not an org, so the route survey reaches its repos here", which is a coily*-shaped intent stated in prose and not enforced anywhere.

Fix

One clause:

restrict username matches coily*

That is a behavior change on a live operator surface: user-repo list starts refusing a non-coily username, which is what the file already claims it does. I have not applied it, because it is a separate concern from the #1365 vocabulary arc it was found in and it changes a running CLI.

#1365 is moving the operator surface to the MCP's resource nouns. The MCP avoids this entire class by hand-writing its paths and normalising the parameter to {owner} everywhere, so one restrict owner clause covers /orgs/{owner}, /orgs/{owner}/repos, and /users/{owner}/repos.

A spec-resolved surface cannot do that. Path parameters come from the swagger, so the operator gets {org} and {username} whether it wants them or not, and has to carry one clause per spelling. That also means the reverse move has a trap: if deploy's MCP goes to spec mode, it loses its hand-written {owner} normalisation, and restrict owner alone would stop covering three routes it covers today. Adding the matching clauses is part of that migration rather than a follow-up to it.

Not established

Whether any other wrapped entity has the same shape. I checked Forgejo because that is what #1365 had me resolving; aws, kubectl, netlify, signoz, tailscale, and forgejo-admin all declare restrict clauses of their own and none of them has been checked against its resolved parameter names.

Found while measuring the operator surface against the MCP for `#1365`. Small blast radius, but it is a declared boundary that silently does not bind, which is the failure class worth naming precisely. ## The claim the guardfile makes `.specgen/guardfiles/aosguard/forgejo.kdl:28` > `// Scope gate: every leaf whose path carries {owner} must name a coily* owner.` and it declares two clauses, at lines 29 and 687: ```kdl restrict owner matches coily* restrict org matches coily* ``` ## What actually binds `restrict` gates by **parameter name**, so a clause binds a leaf only when the resolved path spells that exact parameter. `user-repo list` resolves to `GET /users/{username}/repos`, and `username` is neither `owner` nor `org`. Confirmed against the built binary rather than by reading the file, with a negative control: ``` $ ./dist/aosguard ops forgejo user-repo list torvalds --dry-run headers: Authorization: token <redacted> method: GET url: https://forgejo.coilysiren.me/api/v1/users/torvalds/repos $ ./dist/aosguard ops forgejo repo get torvalds linux --dry-run aosguard: argument owner="torvalds" is outside the allowed scope (restrict owner matches [coily*]) ``` The control refuses, so the gate is working where its parameter matches. The first call builds a live request against an arbitrary user with the coilyco-ops credential attached. ## Impact Low. It is a read-only list, bounded independently by what the coilyco-ops token can see, and there is no write path here. Nothing is leaking that the token could not already reach by another route. The reason to fix it anyway is that the file states a gate it does not have. Its own describe explains the leaf exists because "coilysiren is a user, not an org, so the route survey reaches its repos here", which is a coily*-shaped intent stated in prose and not enforced anywhere. ## Fix One clause: ```kdl restrict username matches coily* ``` That is a behavior change on a live operator surface: `user-repo list` starts refusing a non-coily username, which is what the file already claims it does. I have not applied it, because it is a separate concern from the `#1365` vocabulary arc it was found in and it changes a running CLI. ## Related, and why this will come up again `#1365` is moving the operator surface to the MCP's resource nouns. The MCP avoids this entire class by hand-writing its paths and normalising the parameter to `{owner}` everywhere, so one `restrict owner` clause covers `/orgs/{owner}`, `/orgs/{owner}/repos`, and `/users/{owner}/repos`. **A spec-resolved surface cannot do that.** Path parameters come from the swagger, so the operator gets `{org}` and `{username}` whether it wants them or not, and has to carry one clause per spelling. That also means the reverse move has a trap: if deploy's MCP goes to spec mode, it loses its hand-written `{owner}` normalisation, and `restrict owner` alone would stop covering three routes it covers today. Adding the matching clauses is part of that migration rather than a follow-up to it. ## Not established Whether any other wrapped entity has the same shape. I checked Forgejo because that is what `#1365` had me resolving; `aws`, `kubectl`, `netlify`, `signoz`, `tailscale`, and `forgejo-admin` all declare restrict clauses of their own and none of them has been checked against its resolved parameter names.
Author
Owner

Fixed and merged in #1387.

restrict username matches coily* added, aosguard rebuilt, verified against the binary with a control:

before   user-repo list torvalds     -> built the request
after    user-repo list torvalds     -> refused: restrict username matches [coily*]
after    user-repo list coilysiren   -> still builds the request

The control is the half that matters: a clause refusing everything would also have made the first line pass.

The gate is complete rather than one spelling closer

Rather than fixing only the leaf I tripped over, I counted the owner-shaped parameters across the pruned spec:

owner      34 paths
org         5
username    1

All three carry a clause now. Every other parameter (repo, index, id, run_id, ref, job_id, workflowfilename, identifier, position) is a sub-resource inside an already-gated owner, so none needs one. That makes this closable rather than partially done.

Still open, and it was this issue's own question

The other wrapped entities remain unswept. aws, kubectl, netlify, signoz, tailscale, and forgejo-admin all declare restrict clauses and none has been checked against its resolved parameter names. I am not folding that into this issue, because each is a different spec with a different vendor's parameter naming, and the answer for one says nothing about the next.

Worth its own issue if anyone wants the sweep. The check is mechanical now that the shape is known: enumerate each spec's path parameters, compare against the wrap's restrict clauses, and flag any owner-shaped parameter with no clause.

Fixed and merged in `#1387`. `restrict username matches coily*` added, aosguard rebuilt, verified against the binary with a control: ``` before user-repo list torvalds -> built the request after user-repo list torvalds -> refused: restrict username matches [coily*] after user-repo list coilysiren -> still builds the request ``` The control is the half that matters: a clause refusing everything would also have made the first line pass. ## The gate is complete rather than one spelling closer Rather than fixing only the leaf I tripped over, I counted the owner-shaped parameters across the pruned spec: ``` owner 34 paths org 5 username 1 ``` All three carry a clause now. Every other parameter (`repo`, `index`, `id`, `run_id`, `ref`, `job_id`, `workflowfilename`, `identifier`, `position`) is a sub-resource inside an already-gated owner, so none needs one. That makes this closable rather than partially done. ## Still open, and it was this issue's own question The other wrapped entities remain unswept. `aws`, `kubectl`, `netlify`, `signoz`, `tailscale`, and `forgejo-admin` all declare restrict clauses and none has been checked against its resolved parameter names. I am not folding that into this issue, because each is a different spec with a different vendor's parameter naming, and the answer for one says nothing about the next. Worth its own issue if anyone wants the sweep. The check is mechanical now that the shape is known: enumerate each spec's path parameters, compare against the wrap's restrict clauses, and flag any owner-shaped parameter with no clause.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os#1378
No description provided.