Repository navigation
Description
Follow-up to #676, which shipped the detection half in 7d246958. That change halts a run after a wrong-repo write and names the repository. It does not stop the write. This issue is the half that does.
The problem, measured
The Engine is a claude --dangerously-skip-permissions child process on the owner's machine, spawned at packages/browser-runner/src/coding/headless.ts:420 with env: mergeEnv(process.env, this.config.env) — the machine env inherited wholesale. It therefore reaches GitHub with the account's credentials:
git pushis over SSH. Both remotes aregit@github-personal:…(ProAgentStore/platformandproappstore-online/platform). Git ignores userinfo on an ssh URL, so no token of any kind can gate these pushes.ghis authed from the keyring, accountserge-ivo, scopesadmin:org, delete_repo, gist, repo, workflow— every org the account can see.- Nothing injects a GitHub credential today.
grep -rn "GH_TOKEN\|GITHUB_TOKEN" packages/browser-runner/src workers/api/srcreturns only redaction logic.grep -rni "credential\.helper\|GIT_CONFIG_COUNT\|ASKPASS\|insteadOf\|GIT_SSH_COMMAND"returns 0. - The Engine steers its own credential. Observed on two runs:
export GH_CONFIG_DIR=~/.config/gh-personal; gh pr merge 111 --merge --repo proappstore-online/platform. Anything env-based is therefore a speed bump, not a boundary, unless the engine is also denied the ability to re-point it.
The tension that has to be designed around, not through
#676 item 2 wants writes scoped; item 3 wants reads broad, and gives a real example. A process holds exactly one gh token, so:
- scoped
GH_TOKEN→gh pr create -R other/repofails at the credential (good) andgh pr view 138 --repo proappstore-online/platformfails too (breaks the protected pattern); - broad read-only
GH_TOKEN→ reads fine, but the engine cannot open a PR on its own registered repo.
Verified this is not hypothetical: the cross-repo read is the Engine running gh, not a platform tool. Instance a185b1db ("PAGS Coder"), session csess_42bdfe20, 2026-08-16T01:38:04Z, the Pilot's own instruction: Please run: `gh pr view 138 --repo proappstore-online/platform --json tit…` . The platform's github_read_pull was never called on 138 from that instance.
So the answer is a per-invocation credential chooser, not a single injected token.
Sketch
A runner-owned bin directory prepended to the Engine's PATH, holding a gh shim that classifies argv and routes the credential:
- write verb + target repo == registered → real
ghwith a repo-scoped installation token (repoScopedInstallationTokenalready exists, added in A coding instance can push to and open PRs on any repo the user can reach, not just its registered one #676); - write verb + target repo != registered → refuse, non-zero exit, stderr naming the refused repo;
- read verb → real
ghwith a broad read-only token, or the machine's own auth.
Plus, for git: GIT_CONFIG_* env injecting a credential helper, and url.<https>.insteadOf rules to force github SSH remotes through it.
Risks this carries, stated up front
- Forcing SSH → https means taking over the machine's git configuration. The owner uses a custom
github-personalhost alias; rewriting it affects their own reads, not just the agent's. This is the single biggest design risk and may be a reason to scope this issue toghonly and accept thatgit pushstays ungated. gh's argv surface is large. Misclassifying a write as a read is a hole; default-denying unknown subcommands breaks reads. The classification needs to be a closed, reviewed vocabulary — the same shape aspackages/browser-runner/src/coding/repo-write.ts.- A shim on
PATHis bypassable by absolute path (/opt/homebrew/bin/gh) or by the engine re-exporting its own env, as it already does. This raises the cost of a wrong-repo write; it does not make one impossible. Anything claiming otherwise in the UI would be worse than nothing. - Engine-agnostic? Only Claude Code reports acts, but a
PATHshim applies to every engine — so unlike A coding instance can push to and open PRs on any repo the user can reach, not just its registered one #676's gate, this one would work on Codex/Grok too. That is a point in its favour.
Acceptance
- A write to an unregistered repo through
ghfails at the credential layer, with the refused repo in stderr, and the run reports that rather than a generic failure. - A cross-repo
gh pr viewstill succeeds — pinned by a test, because it is the property most likely to be lost. - Whatever remains bypassable is stated in
coding_diagnosticsalongsidegithubApp.writeScope, replacing theenforcement: "acts-observed-halt"value A coding instance can push to and open PRs on any repo the user can reach, not just its registered one #676 shipped.
Activity
serge-ivo commentedon Aug 18, 2026
Audit — still LIVE. One citation drifted, and one premise is now better than the issue states.
Citation correction
packages/browser-runner/src/coding/headless.ts:420now readsif (this.oneShot) {.f4efe71b(#700) moved the spawn. The line the issue means is:432, and it says exactly what was claimed:(There is a second, identical merge at
:515, andresolveEngineAuthreads the same expression at:254— so any credential decision has three sites, not one. Worth knowing before starting.)The core claims re-verified today
grep -rni "credential\.helper\|GIT_CONFIG_COUNT\|ASKPASS\|insteadOf\|GIT_SSH_COMMAND" packages/browser-runner/src workers/api/src→ still 0.git remote -von this checkout →git@github-personal:ProAgentStore/platform.git, both fetch and push. SSH, so no token can gate it.packages/browser-runner/src/coding/repo.ts:113states the same fact in code, which is a useful corroboration: "Only https carries a credential: git ignores userinfo on an ssh URL, so injecting there would…"Premise update — the token-minting half already exists and is production-wired
The issue says "Nothing injects a GitHub credential today", citing a grep for
GH_TOKEN/GITHUB_TOKEN. That is true of the Engine's process env, and it should be narrowed to that, because the platform does mint and ship a repo-scoped credential on an adjacent path:workers/api/src/lib/git-credentials.ts— "the ONE place a clone credential is minted, for any git provider" — callsrepoScopedInstallationToken(env, userId, owner, name)at:107.{username, token}(packages/browser-runner/src/coding/runtime.ts:37-39) and is embedded in the clone URL byauthenticatedCloneUrl(repo.ts:118), never in the engine's environment.Two consequences for this ticket, both favourable:
repoScopedInstallationTokenis not a thing to build — it is a thing to route. The sketch's "realghwith a repo-scoped installation token" already has its producer, tested and deployed.mayAttachCloneCredential(lib/git-providers.ts:256, doc at:23-29) exists because{githubRepo:"me/private", cloneUrl:"https://attacker.example/x.git"}would otherwise hand a real installation token to an arbitrary host. That is the same shape as "a write verb naming an unregistered repo must not get the credential", one layer up. Thegh-shim classifier in the sketch should be argued from it rather than from first principles.On risk 1 (rewriting the machine's git config) — a reason to scope this to
ghonlyThe owner's remotes use a custom
github-personalSSH host alias, andrepo.ts:113already documents that ssh userinfo is ignored. Forcing https +insteadOfwould therefore mean rewriting configuration the owner uses for their own work, on their own machine, to gate an agent. My read is that thegh-only scope is the right first cut and thatgit pushstaying ungated should be stated incoding_diagnosticsrather than silently accepted — which is already the issue's third acceptance criterion, and is the one I would not let slip.Verified vs inferred
git remote -v;git-credentials.ts:107and its call chain toauthenticatedCloneUrl;mayAttachCloneCredential's existence and rationale.GH_CONFIG_DIR; the cross-repogh pr view 138on sessioncsess_42bdfe20). Those are historical run records; I did not re-read the traces.Leaving open, labels unchanged.
serge-ivo commentedon Aug 18, 2026
Citation corrected before this goes to a dev agent (2026-08-18)
This issue cites
packages/browser-runner/src/coding/headless.ts:420. That line moved — #700's per-tool result rendering shifted it. Today:420is the one-shot start guard:The site this issue is actually about is now
:432:mergeEnv(process.env, …)is the inheritance — the Engine gets the machine's whole environment, and with it whatevergitandghcredentials that machine holds. The claim is unchanged; only the coordinate rotted.Per #707: quote the line, then cite it. The quote above is the durable anchor; treat
:432as a locator that may itself move.#676 folded in here
#676 is closed as folded into this issue. Its detection half shipped; everything it had left was this, in less detail. The asymmetry it recorded must survive and is restated here: the first out-of-scope write still LANDS.
coding_diagnosticsreportsenforcement: "acts-observed-halt"with the note "A write naming a repository outside this list halts the run and is recorded. The first such write still LANDS — the engine uses this machine's own git and gh credentials, which are not scoped." Detection after the fact is not containment, which is the whole reason this issue exists.feat(coder): a `gh` write to an unregistered repo is refused before i…
fix(coder): the gh guard states the gap a reader would otherwise infe…
chore(cli): 0.4.56 — publish the runner halves of #505 and #679
serge-ivo commentedon Aug 18, 2026
The shim shipped —
7e28a7cc+12aaf484. The credential-broker half is rejected on the codeCI
32116965397, Deploy API Worker32116965417and Publish to npm32116965667all green.@proagentstore/cli@0.4.56is on npm — the shim only reaches a machine through that.Adopted the shim; rejected the credential routing
The sketch's "write verb + registered repo → real
ghwith a repo-scoped installation token" iswrong on this codebase, and the audit comment's own finding is what makes it wrong:
repoScopedInstallationTokenis minted in the cloud and delivered once at session start, forthe clone. It lives about an hour; a coding session does not. Swapping the machine's working login
for it turns containment into an outage partway through a long run.
gh pr view --repo <other org>/<repo>is a different installation, so an installation token cannot read it. Themachine's own login can.
execstops thatstrictly earlier than handing over a credential that would have failed anyway.
So the guard refuses; it never brokers.
What shipped
packages/browser-runner/src/coding/gh-guard.ts— a generated POSIX-shshim ahead of the realghon the Engine'sPATH. Closed write vocabulary (one TS constant generating thecasearms,the
repo-write.tsshape),--repo/-R/--repo=extraction, and mutatinggh apiclassified offthe
/repos/{owner}/{repo}path. Refusal is exit 3 with the repository in stderr; everything elseexecs through unchanged.shand not Node deliberately: this runs on everyghthe Engine invokes and must not depend onresolving a module out of a bundle whose layout differs between the monorepo and the published CLI.
The tests execute the generated script against a fake
gh, so what is verified is the artifactthat ships, not a TypeScript twin of it.
Scope comes from the cloud, never from the checkout (
coding-session-open.ts+coding-session-relaunch.ts). The Engine has a shell in that workdir and couldgit remote set-urlits way to a wider allowlist. Absent scope means "the platform did not say" and installs nothing —
never "allow nothing", which would break every session an older cloud starts.
workers/api/src/lib/coding-write-enforcement.tsreplaces the flatenforcement: "acts-observed-halt"with a value derived from what the machine reported persession. The old sentence survives verbatim for unguarded machines, because there it is still
exactly true.
What it does not stop — stated, because a gate that overstates itself is worse than no gate
Four gaps, reported by the runner and passed through
coding_diagnosticsverbatim so the twocannot drift:
git pushis ungated. Remotes are SSH;repo.ts:113says why in code — "Only https carriesa credential: git ignores userinfo on an ssh URL". There is no token to withhold.
PATH.gh api graphqlis unclassified — a mutation can travel in the query text.--repois not gated. This one I added on review (12aaf484), because it isthe gap a reader would infer the opposite of. The shim only checks a write that names a repo:
ghresolves the target from the working directory. Ordinarily that IS the registeredrepo — refusing it would break
gh pr createinside the agent's own checkout, the common case —but the Engine can
cd. Three gaps were listed and there were four; the count is now asserted soa fifth cannot enter the shim without entering the sentence the owner reads.
No git-config takeover. No
insteadOf, nocredential.helper, noGIT_SSH_COMMAND— yourgithub-personalSSH alias is untouched, which was the issue's own biggest stated risk.Verified
16 invariants watched failing under three separate neutralisations, including the acceptance
criterion most likely to be lost — the cross-repo
gh pr view 138 --repo proappstore-online/platformfrom session
csess_42bdfe20runs as a test and still succeeds. Suite 9,891 passing, all gatesgreen.
engine-env.tsis an extraction (mergeEnvand its tests move out) forced by the file-sizeratchet, not a new idea;
headless.tsstayed under 959 without raising the pin.Live, after the deploy —
GET …/coding/diagnosticsona185b1db(PAGS Coder):The new block is present and honest: no machine is running 0.4.56 yet, so nothing claims to be
guarded and the old sentence still stands for this machine. That is the designed behaviour, not a
missing piece.
What I could not observe, plainly
Nobody has watched a wrong-repo write actually be refused. That needs a live autonomous run on
your machine, with the published CLI, against your real
ghcredentials — which is exactly whatthis issue is about and exactly what I will not experiment with. The green suite is evidence about
the shim and the wiring; it is not evidence about behaviour anyone watched.
The check when you next run
pags up(≥ 0.4.56) and start a session:coding_diagnosticsshouldreport
ghGuard.sessionsGuarded ≥ 1and anenforcementvalue naming the guard. If it reportsgh-not-foundorinstall-failed, that is the shim telling you it failed open — the Enginestill works, it is just not gated.
Leaving open for that observation.
serge-ivo commentedon Aug 23, 2026
Audit verdict: DELIVERED — the observation this was left open for has now been made. Closing.
7e28a7cc+12aaf484shipped the shim; the closing comment left this open for one thing, in its own words:Measured live, 2026-08-23
GET /v1/instances/a185b1db-…/coding/diagnostics(PAGS Coder), runner0.4.57onRLs-MacBook-Air.local:That is the previous comment's
"ghGuard": { "sessionsGuarded": 0, "sessionsTracked": 0, "gaps": [], "reasons": [] }replaced by a real one. The flatenforcement: "acts-observed-halt"#676 shipped is gone, which is AC3.The three acceptance criteria
ghwrite to an unregistered repo fails, with the repo in stderr. VERIFIED by test, and the test executes the shipped artefact rather than a TypeScript twin —packages/browser-runner/src/coding/gh-guard.test.ts:45-48runs the generatedshagainst a fakeghand assertsstderrcontainsproappstore-online/platform.gh pr viewstill succeeds. VERIFIED —gh-guard.test.ts:82-84, the literalcsess_42bdfe20call from the issue body:expect(r.stdout).toContain("REAL GH: pr view 138 --repo proappstore-online/platform").coding_diagnosticsalongsidegithubApp.writeScope. VERIFIED LIVE above; the four gaps are passed through verbatim from the machine (workers/api/src/lib/coding-write-enforcement.ts:67), so the sentence and the guard cannot drift.VERIFIED vs INFERRED
writeEnforcementReportderivesenforcementfrom what the machine reported per session (coding-write-enforcement.ts:60-91).ghcredentials that the implementer refused to run for the right reason, and the check the implementer named as the closing bar has now been met.One residual, filed here as a note rather than a new ticket
sessionsGuarded: 1 of 2. The unguarded session's reason is "the platform sent no repository scope for that session (its repo has no GitHub coordinates)" —ghScopeisrepo.githubRepo ? [repo.githubRepo] : undefinedatworkers/api/src/lib/coding-session-open.ts:264andcoding-session-relaunch.ts:52. So a local-path repo with no GitHub coordinates gets no shim at all. That is honest (reasons[]says so) and arguably correct — the platform cannot name a legitimate target for such a repo, and refusing every write would breakgh pr createin the agent's own checkout. Worth a decision if local-path repos become common; not worth reopening this.