Repository navigation
chore(loop): make the branch ruleset the gate, not the GitHub credential - #46
Merged
Merged
Conversation
Contributor
Coverage
|
Alex1990
force-pushed
the
chore/loop-l2c
branch
from
September 15, 2026 16:03
0602410 to
8d29b21
Compare
…ver can The A1 report boundary proved the loop is safe but left the write half of the host unexecuted: with no credential, every proposal died in the report file. This lands the L2c split the observation week was gating, so the loop can produce a PR without ever being able to merge one. Three credentials, three holders: github.token agent, report read-only (permissions:) reading LOOP_GH_TOKEN agent, auto Issues+PR write, no Contents labels, PR LOOP_PUSH_TOKEN host process Contents write branch push `agentEnv` builds the child environment instead of inheriting it: it deletes the push credential, the PAT and the R2 keys, and sets GH_TOKEN to the least token the level allows. `persist-credentials: false` is what makes that hold — checkout's default writes the job token into the repo's git config, where the agent's own `git push` would find it and no environment can un-write a file. `pr-opened` now plans push → pr-create → label/comment, executed by the host from the agent's proposal in result.json. A failed push or PR creation aborts the rest, and the outcome is downgraded before it is recorded, so a task cannot wait in `waiting-merge` for a PR that does not exist. The host pushes nothing but `loop/<issueNo>-<slug>`, refuses a dirty tree (the commit would stay behind and the PR would ship an incomplete diff), and repairs the `Closes #n` / `loop-task: #n` markers the router pairs with the branch — a missing marker would make the loop's own PR look external, costing it the acceptance row. `validatePush` rejects an unbacked `pr-opened` in `entry.mjs` for the same reason. The PR number that `gh pr create` returns is recorded in `task.prs` with a `pr-created` timeline row. Verification (local, four throwaway harnesses, all green): - `applyActions` against a local bare repo (git's url.insteadOf standing in for GitHub): the push lands, and the refusals fire for a dirty tree, a branch outside `loop/`, and a missing push credential — with the label correctly skipped after a failed PR creation. - `agentEnv`: the push credential is absent at both write levels, the report level hands over the read-only token, and `auto` injects a git identity for the local commit. - `finishRun` success path through the new `exec` seam: push → PR #4242 → label → comment, `waiting-merge` with `ready-for-human`, PR number and timeline recorded, lock released, markers present in the PR body. - `entry.mjs` end to end with a stub engine: the branch really lands, and each fail-closed path (bad branch name, report boundary, no push token, live GitHub refusing the PR) downgrades to `failed` with the task in the inbox. The GitHub `auto` path itself has never run on Actions: that is the first thing to sample once `LOOP_EXECUTE_WRITES=true` and the two secrets exist.
…r one Reviewing the split landed in the previous commit showed the middle credential earned nothing. Every GitHub write — labels, comments, closing, opening the PR, pushing the branch — is executed by `applyActions` in the *host's* process from the agent's `result.json`. The agent's `Issues/Pull requests write` PAT was never used by any code path: the agent exports a file and reads GitHub. So the split is by who runs the code, not by scope list: agent → github.token, read-only, in BOTH modes (via agentEnv) host → LOOP_GH_TOKEN, one PAT: Contents + Issues + Pull requests write This removes a secret and makes the guarantee structural twice over. Before, "the loop cannot merge" rested on scoping one PAT to omit Contents; now the agent holds no write credential whatsoever, and the `Contents: write` the host needs — which is also the merge permission — exists only where the untrusted party cannot reach it. The agent cannot relabel, comment, push or merge however it is prompted, and the job's `permissions:` block stays read-only, so the platform lock is unchanged. Behaviour changes beyond the credential layout: - The `auto` prompt no longer offers `gh` writes to the agent; it states plainly that every write answers 403 and must not be retried, and it now also tells the `report` agent not to commit (no branch will be pushed, so a commit is discarded with the checkout — the work belongs in the report as a patch). - `applyActions`' push spends `process.env.GH_TOKEN` (the host PAT) instead of `LOOP_PUSH_TOKEN`; the warning names both the Actions secret and the local `GH_TOKEN=$(gh auth token)` form. - `gh-check.mjs` drops the third-credential section and checks the agent's read-only property unconditionally, independent of the write level. Verification (local, four harnesses, all green): `agentEnv` hands over the read-only token and strips the PAT at both levels; `applyActions` pushes with the host credential and refuses `main`, `loop/fix-thing`, `loop/42-`, a dirty tree and a missing credential, aborting the label/comment chain after a failed PR; `finishRun` walks the success path (push → PR #4242 → label → comment → `waiting-merge`); `entry.mjs` end to end pushes the branch for real and fails closed on a bad branch name, a branch carrying another task's number, the report boundary and a missing host credential.
The loop's safety property is "nothing reaches `main` without a human". The previous
commits on this branch tried to express it with credentials — a PAT for the host, a
read-only job token for the agent — and that does not hold. A PAT acts as its owner,
who is this repository's only admin and therefore the ruleset's bypass actor, so a
loop holding one can merge an unapproved PR. Worse, every PR it opens is authored by
the owner, whom GitHub forbids approving their own work: the loop's output would be
neither approvable nor safely mergeable. Measured, not assumed: the agent can read
the host's environment through /proc/<ppid>/environ, so "the child does not inherit
the token" was never a containment argument to begin with.
Repository configuration settles it instead, so no credential has to:
Main branch ruleset (21855851, ~DEFAULT_BRANCH, enforcement: active)
rules: deletion · non_fast_forward · pull_request(1 approval)
bypass_actors: User(owner), bypass_mode: pull_request
| actor | push to main | merge without an approval |
| the owner | refused | allowed (bypass) — merging is the consent |
| the loop (github.token) | refused | refused — and it cannot approve its own PR |
`bypass_mode: pull_request` is the load-bearing part: it lets the owner merge their
own PR (solo repo, self-approval is forbidden) while refusing every direct push,
including the owner's. Verified — an idempotent `PATCH /git/refs/heads/main` answers
`422 Changes must be made through a pull request`. `permissions:` cannot grant
`administration`, so the loop cannot weaken the rule that bounds it.
Consequences, all of them deliberate:
- `GH_TOKEN` is now just `github.token`; no PAT exists anywhere in the loop, and
`agentEnv` strips only the R2 keys. The host still owns every write, but as a
division of labour (one place to enforce branch/PR-marker rules), not as a
permission boundary — if the agent wrote to GitHub itself, `main` would still be
refused.
- Job permissions rise to contents/issues/pull-requests write, because the loop now
needs them for real; the ruleset, not the scope list, is what bounds them.
- The loop can no longer change `.github/workflows/**`: the job token has no
`workflows` permission and `permissions:` has no key to grant one. Such tasks route
to a human, and `applyActions` recognises the rejection and says so instead of
retrying. A PAT would push them and bring back the bypass — a real trade,
resolved in favour of the gate.
- `gh-check.mjs` is no longer a scope report: it asserts the two properties that
matter (main is un-writable, the loop cannot edit the ruleset), then the scopes it
needs to work, and exits non-zero when either property fails so it can gate the
switchover.
- The agent prompt now says plainly that the job token can write and that the ruleset
is the boundary, rather than claiming a 403 that would no longer happen.
Docs: `scripts/loop/README.md` gains "Branch protection is the gate" and "The
ruleset, exactly" (the API payload and its verification); the L2a/L2b/L2c tier table
is recorded as a dead end with the reason, so the next reader does not re-derive it.
`docs/agents/ops.md` states the gate next to the no-self-merge rule.
Verification: four local harnesses green (agentEnv, applyActions push/refusals incl.
the workflow-file message, finishRun's push→PR→label path, entry.mjs end to end);
`gh-check.mjs` run live against the repository reports main protection holding and
exits 0.
The Actions run showed the check reporting its own job token as "a user token for null": `GET /user` is refused for an installation token, and the code read that refusal as a missing login rather than as the identifying signal it is. Since the whole point of the check is to say which credential the loop holds, getting that wrong is worse than not asking. `identity()` now returns the status, and the three outcomes are distinguished: a login (user token — the configuration that would defeat the ruleset), a 403/404 (`github.token`, refused there by design), and a 401 (an unusable credential, said plainly instead of being mislabelled as the job token). Verified against all three: Actions (job token, reported correctly), the local developer login, and a deliberately invalid token (reported as unusable).
GitHub adds `require_extra_approval_for_unattributed_changes: true` to any ruleset carrying a `pull_request` rule — a public-preview default that appears in the read-back payload without having been set. It targets Copilot-authored PRs, so it does not touch the loop's path (its PRs come from `github-actions[bot]`, and the owner's bypass merges regardless of the count), but a future reader comparing the payload to this file would otherwise have to rediscover where the parameter came from.
Alex1990
force-pushed
the
chore/loop-l2c
branch
from
September 15, 2026 17:29
31b459e to
e9fd860
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto
mainafter #45 merged; the five commits here are the whole diff.The property, and where it now lives
Nothing reaches
mainwithout a human. Earlier commits on this branch tried to enforce that with credentials — a PAT for the host, a read-only job token for the agent. That does not work, and the reasons are worth stating because they are the whole design:/proc/<ppid>/environ), measured, so "the child does not inherit the token" was never containment.Repository configuration settles it instead, so no credential has to:
maingithub.token)bypass_mode: pull_requestis the load-bearing choice: it lets the owner merge their own PR (solo repo — self-approval is forbidden) while refusing every direct push, including the owner's.permissions:cannot grantadministration, so the loop cannot weaken the rule that bounds it.Measured, not asserted — an idempotent
PATCH /git/refs/heads/mainreturns:and on Actions the job token's
POST /rulesetsreturns403.Consequences, all deliberate
GH_TOKENis now justgithub.token. No PAT exists anywhere in the loop;agentEnvstrips only the R2 keys. The host still owns every write, but as a division of labour (one place to enforce the branch/PR-marker rules), not as a permission boundary — if the agent wrote to GitHub itself,mainwould still be refused.contents/issues/pull-requests: write, because the loop needs them for real. The ruleset, not the scope list, is what bounds them..github/workflows/**: the job token has noworkflowspermission andpermissions:has no key that grants one. Such tasks route to a human;applyActionsrecognises the rejection and says so instead of retrying. A PAT would push them and bring back the bypass — a real trade, resolved in favour of the gate.gh-check.mjsis no longer a scope report. It asserts, in order of importance:mainis un-writable; the loop cannot edit the ruleset; and the loop can still read and do its own writes. Non-zero exit when either of the first two fails, so it can gate a switchover.Implementation beyond the ruleset
pr-openedplanspush → pr-create → label/comment, executed by the host from the agent'sresult.json, with the PR number recorded intask.prs. Fail-closed checks, in order of cost:validatePush(entry.mjs): an unbackedpr-opened— branch outsideloop/<taskId>-<slug>, another task's number, empty title, orwriteLevel=report— is downgraded tofailed, so a task cannot wait inwaiting-mergefor a PR nobody opened.applyActions: pushes nothing butloop/<issueNo>-<slug>; refuses a dirty tree (the commit would stay behind and the PR would ship an incomplete diff); aborts the chain after a failed push/PR creation; and explains a workflow-file rejection rather than retrying it.finishRun: runs the push and PR creation before recording the outcome, so failure lands the task in the inbox rather thanwaiting-merge.ensureLoopPrBody: repairs theCloses #<n>/loop-task: #<n>markers the router pairs with the branch — a missing marker would make the loop's own PR look external: no acceptance row, and its merge would close the task by the wrong path.Verification
Local (four throwaway harnesses, green):
agentEnv;applyActionspush + every refusal incl. the workflow-file message;finishRun's push → PR #4242 → label path;entry.mjsend to end with a stub engine (branch really lands; bad branch name, another task's number, report boundary and missing credential all fail closed).On Actions (run from this branch, so the new
permissions:apply):pnpm lint,pnpm fmt:checkandnode --checkon every touched module are clean (the 39 lint warnings are pre-existing).What this PR does not claim
The GitHub
autopath has still never run on Actions. The ruleset and the scope probes are verified live; the write path (push → pr-create → label) is verified locally only. Firstautorun is the thing to watch.Human setup
The ruleset half is already applied (and was verified by the probes above). Remaining:
LOOP_EXECUTE_WRITES=truewhen you want the loop to write.LOOP_GH_TOKENsecret — nothing references it any more, and a PAT there is the one configuration that could defeat the gate.Do not merge without review; this is host machinery (AGENTS.md: the loop is maker or checker, never both).