Skip to content

chore(loop): make the branch ruleset the gate, not the GitHub credential - #46

Merged
Alex1990 merged 5 commits into
mainfrom
chore/loop-l2c
Sep 15, 2026
Merged

Alex1990 merged 5 commits into
mainfrom
chore/loop-l2c

Conversation

@Alex1990

@Alex1990 Alex1990 commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Rebased onto main after #45 merged; the five commits here are the whole diff.

Read #45 first if you have not. It is merged and carries the endpoint-level evidence
for the facts this PR builds on (merging lives under Contents, a PR needs a head
branch). Its conclusion — that the loop's safety should be expressed by splitting
credentials — is what this PR replaces, and main currently contains that superseded
wording until this merges.

The property, and where it now lives

Nothing reaches main without 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:

  • A PAT acts as its owner, who is this repository's only admin and therefore the ruleset's bypass actor. A loop holding one can merge an unapproved PR.
  • PRs opened with a PAT are authored by the owner, whom GitHub forbids approving their own work — so the loop's output would be simultaneously un-approvable and unapproved-mergeable.
  • The agent can read the host's environment (/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:

Main branch (ruleset 21855851, target ~DEFAULT_BRANCH, enforcement: active)
  rules:          deletion · non_fast_forward · pull_request(required_approving_review_count: 1)
  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 — not a bypass actor refused — and it cannot approve its own PR

bypass_mode: pull_request is 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 grant administration, so the loop cannot weaken the rule that bounds it.

Measured, not asserted — an idempotent PATCH /git/refs/heads/main returns:

422 Repository rule violations found
    Changes must be made through a pull request.

and on Actions the job token's POST /rulesets returns 403.

Consequences, all deliberate

  • GH_TOKEN is now just github.token. No PAT exists anywhere in the loop; agentEnv strips 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, main would still be refused.
  • Job permissions rise to contents/issues/pull-requests: write, because the loop 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 that grants one. Such tasks route to a human; 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, in order of importance: main is 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.
  • The agent prompt states the boundary the platform enforces rather than a 403 the agent would no longer receive.

Implementation beyond the ruleset

pr-opened plans push → pr-create → label/comment, executed by the host from the agent's result.json, with the PR number recorded in task.prs. Fail-closed checks, in order of cost:

  • validatePush (entry.mjs): an unbacked pr-opened — branch outside loop/<taskId>-<slug>, another task's number, empty title, or writeLevel=report — is downgraded to failed, so a task cannot wait in waiting-merge for a PR nobody opened.
  • applyActions: pushes nothing but loop/<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 than waiting-merge.
  • ensureLoopPrBody: 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: no acceptance row, and its merge would close the task by the wrong path.

Verification

Local (four throwaway harnesses, green): agentEnv; applyActions push + every refusal incl. the workflow-file message; finishRun's push → PR #4242 → label path; entry.mjs end 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):

[loop:gh] MAIN PROTECTION — the property that matters:
  active rules on main: deletion, non_fast_forward, pull_request
  refused  direct push to main (idempotent; writes back the SHA it read) (HTTP 422)
[loop:gh] CAN THE LOOP EDIT ITS OWN GATE (administration)?
  refused  create a ruleset (HTTP 403)
  present  Issues: write / Pull requests: write / Contents: write
  ABSENT   Actions: write (403) / Secrets access
[loop:gh] OK: `main` requires a reviewed pull request, the loop cannot edit that rule,
          and the loop's own writes work.

pnpm lint, pnpm fmt:check and node --check on every touched module are clean (the 39 lint warnings are pre-existing).

What this PR does not claim

The GitHub auto path 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. First auto run is the thing to watch.

Human setup

The ruleset half is already applied (and was verified by the probes above). Remaining:

  1. Set repository variable LOOP_EXECUTE_WRITES=true when you want the loop to write.
  2. Delete the LOOP_GH_TOKEN secret — 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).

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Metric % Covered / Total
Lines 90.19% 1003/1112
Statements 89.06% 1083/1216
Functions 91.03% 193/212
Branches 76.43% 519/679

@Alex1990 Alex1990 changed the title chore(loop): implement L2c — the host pushes the branch, the agent never can chore(loop): give the agent no write credential, and let the host do every write Sep 15, 2026
@Alex1990 Alex1990 changed the title chore(loop): give the agent no write credential, and let the host do every write chore(loop): make the branch ruleset the gate, not the GitHub credential Sep 15, 2026
…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
Alex1990 changed the base branch from chore/loop-write-scope to main September 15, 2026 17:29
@Alex1990
Alex1990 merged commit 65071c3 into main Sep 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant