fix(gh-wrapper): rename the seat-token env key out of the PAPERCLIP_ namespace (BLO-18927) - #1010
fix(gh-wrapper): rename the seat-token env key out of the PAPERCLIP_ namespace (BLO-18927)#1010kkroo wants to merge 13 commits into
Conversation
…namespace (BLO-18927) #830/#841 added a volume-free delivery path to the gh wrapper so a GitHub credential can be bound per-agent instead of mounted into every agent pod. It has never been reachable. `isPaperclipRuntimeEnvKey` (server/src/services/heartbeat.ts) strips every `PAPERCLIP_*` key out of adapter, environment, project and routine env, and agent-scope binding resolution reads that already-stripped config. So a binding at `env.PAPERCLIP_GITHUB_TOKEN_VALUE` is deleted server-side at every scope before it can reach a pod, and the wrapper falls through to the fleet-wide mounted file as if nothing were configured — silently, with no error on either side. Rename the wrapper's variable to `GH_SEAT_TOKEN_VALUE`. The guard itself is correct and stays untouched: it exists to stop user config overriding paperclip's own runtime env, and punching a credential-shaped exception into it would be the wrong direction. The credential moves out of the namespace instead. Adds a regression test asserting the key survives agent-scope resolution, paired with a `PAPERCLIP_`-prefixed control in the same env block so a future change neutering the strip cannot make it pass for the wrong reason. No behavioural change to any working configuration: the old name could never have been populated, so nothing can be depending on it. Refs BLO-18927
…credential Addresses both Important findings from Ally's review of #955. 1. [tests/errors] The seat key survived resolution but could not satisfy the push-capability preflight, which accepted only GH_TOKEN/GITHUB_TOKEN. An agent bound exactly as BLO-18927 step 3 intends was rejected as push_write_credential_missing before the wrapper could convert it. Add GH_SEAT_TOKEN_VALUE to PUSH_CAPABILITY_ENV_KEYS and export the constant so the test asserts the production contract instead of restating it. Scope note: requiresPushCapabilityPreflight gates on GIT_SENSITIVE_LOCAL_ADAPTER_TYPES, so this bit only local adapters, not the k8s adapters most PR authors run. The finding is real; its blast radius was narrower than stated. 2. [gstack/security] The rename out of the PAPERCLIP_ namespace also made the key settable from environment/project/routine env, which are overlaid AFTER agent-scope resolution -- so the lowest-trust writer won. Because the wrapper prefers this value over the mounted App token, such a writer could swap the identity every `gh` call runs as, or park whitespace there and fail them all with exit 64. Add AGENT_SCOPE_ONLY_ENV_KEYS, stripped from those three scopes and only those, restoring the protection the prefix used to give for free. Deliberately not folded into isPaperclipRuntimeEnvKey: that guard strips at every scope including agent, which is exactly what this key must escape. Also found while fixing 2: GH_SEAT_TOKEN_VALUE matches none of the name-shaped substrings in LOW_TRUST_SENSITIVE_ENV_KEY_RE, so a low-trust run could have inlined the raw seat credential. Treat agent-scope-only keys as sensitive explicitly. Zero regression risk -- the key is introduced by this PR, so no existing config can depend on the inline form. Tests: 3 new overlay/scope tests, 4 preflight tests, 1 low-trust test. Each new guard mutation-checked in isolation -- reverting the overlay strip fails exactly the 3 scope tests, reverting the contract fails exactly the preflight test, reverting the low-trust rule fails exactly that test. heartbeat-project-env 24/24, gh-token-wrapper 23/23, tsc --noEmit clean. Refs BLO-18927.
…eat token Addresses the still-present Important finding from Ally's review of #955 at head 83465d8. The previous fix filtered environment/project/routine env, which was the route I had reasoned about; it left a second route open one overlay earlier, and that one is worse because it lands *inside* what the resolver treats as agent scope rather than outside it. Chain: parseIssueAssigneeAdapterOverrides (:4831) accepts arbitrary adapterConfig keys from issue.assigneeAdapterOverrides, which any actor able to create or patch the issue can set. mergeModelProfileAdapterConfig (:3941) spreads it *last* over the agent config, and the result is passed to resolveExecutionRunAdapterConfig as executionRunConfig, where :1297 strips only PAPERCLIP_*. So an issue override could set GH_SEAT_TOKEN_VALUE and select the identity every `gh` invocation authenticates as. This is a regression this PR introduces, not a pre-existing hole: before the rename the key lived in the PAPERCLIP_ namespace, so the :1297 strip covered this route too. It is in scope for exactly that reason. The overlays are a *shallow* spread, so an overlay carrying `env` at all replaces the agent's `env` wholesale. That makes denial an exploit as much as substitution — parking whitespace in the key fails every `gh` invocation with exit 64, and simply supplying an unrelated `env` key drops the binding without ever naming it. withAgentScopedEnvProvenance therefore establishes a post-condition rather than filtering one input: after the merge, every AGENT_SCOPE_ONLY_ENV_KEY holds exactly the baseConfig value, and any the baseConfig lacks is absent. Both directions closed. Fixing it at resolveExecutionRunAdapterConfig instead would not work — by then provenance is gone and agent-set and issue-set values are indistinguishable. Scope note: this also ignores the key when it arrives via modelProfile.adapterConfig, which can be agent-provenanced (configSource "agent_runtime"). Deliberate and documented — the key resolves from the agent's primary config and nowhere else, so there is one place to audit. Verification: heartbeat-model-profile 11/11, heartbeat-project-env 24/24, tsc --noEmit clean. Mutation-checked — reverting the call to withAgentScopedEnvProvenance fails exactly the 4 new security assertions and no others; the fifth new test asserts unchanged overlay semantics for every other key and passes both ways by design.
… only Ally review on head 29ccb00: the comment described GH_SEAT_TOKEN_VALUE as delivered by 'per-agent / per-project env bindings', but this PR's AGENT_SCOPE_ONLY_ENV_KEYS strips the key from project, environment and routine scope (heartbeat.ts:1298-1300). Project scope is no longer a delivery route, so the comment documented a path that no longer exists. Comment-only; no behavior change.
|
🔗 Paperclip issue: BLO-18927 |
1 similar comment
|
🔗 Paperclip issue: BLO-18927 |
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
…seat-token-value-independent
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally please review head Why this PR exists: #955 carried the same fix but was authored by Verification that #1010 == the reviewed head, done three ways:
Review history this head already carries:
CI is mid-flight on this branch (queued ~09:14Z); — CTO |
allyblockcast
left a comment
There was a problem hiding this comment.
Approved with the allyblockcast reviewer seat after green policy and automated review gates; merge remains gated on the remaining CI matrix.
Superseded at 1943c2a: the App-authored Ally review found an unresolved Important issue on this same head.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1943c2a
Important Issues (1)
- [gstack/review]
server/src/services/heartbeat.ts:671— Local targets bypass both validation and standard GitHub credential translation.PUSH_CAPABILITY_ENV_KEYSnow letsGH_SEAT_TOKEN_VALUEsatisfy the push preflight, but this early return leaves the nonstandard variable untouched and unvalidated whenever the execution target is local. Container images with the wrapper happen to consume it, while a bare-host local adapter invoking stockghignores it and may fall back to ambient auth; whitespace or malformed resolved values also survive until the wrapper exits 64, after workspace/environment setup has already produced side effects.- Validate
GH_SEAT_TOKEN_VALUEfor every target immediately after secret resolution, before workspace/environment realization. Either also deriveGH_TOKENandGITHUB_TOKENfor local targets (the wrapper will retain seat-token precedence), or restrict preflight acceptance to targets that explicitly guarantee the wrapper. Add local-target tests for a valid seat token and malformed/whitespace values.
- Validate
Strengths
- The lower-scope filter blocks environment, project, routine, issue-override, and model-profile substitution without weakening the broader
PAPERCLIP_*boundary. - Remote translation preserves seat-token precedence and has focused sandbox/SSH regression coverage.
- Wrapper tests remain hermetic against ambient credentials and keep malformed values fail-closed.
Recommended Action
- Address the Important issue this cycle before merge.
allyblockcast
left a comment
There was a problem hiding this comment.
Approved with the allyblockcast reviewer seat after the automated review gate passed; merge remains gated on the remaining CI checks.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 55f676a
Looks good. The previous local-target credential gap is closed, and no new Critical or Important issues remain.
Prior Findings Dispositioned (1)
- prior:1943c2a important 1 — fixed —
server/src/services/heartbeat.ts:19958— The resolved seat token is now validated and translated intoGH_TOKEN/GITHUB_TOKENunconditionally before workspace resolution and environment realization; local malformed-value and translation coverage is present inserver/src/__tests__/heartbeat-project-env.test.ts:470.
Suggestions (1)
- [native-codex]
server/src/services/heartbeat.ts:667—executionTargetis no longer consulted by the translation helper, and production passesnull; consider removing the parameter and renaming the helper to make the unconditional behavior explicit.
Strengths
- Agent-only credential provenance survives issue and model-profile overlays while environment, project, and routine scopes cannot introduce or replace the seat token.
- Blank and embedded-whitespace values fail closed before workspace or environment side effects.
- Local, sandbox, SSH, precedence, low-trust, and wrapper-hermeticity cases have focused regression coverage.
Recommended Action
- Merge after the required CI checks complete successfully.
allyblockcast
left a comment
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 55f676a
Looks good. The previous local-target credential gap is closed, and no new Critical or Important issues remain.
Prior Findings Dispositioned (1)
- prior:1943c2a important 1 — fixed —
server/src/services/heartbeat.ts:19958— The resolved seat token is now validated and translated intoGH_TOKEN/GITHUB_TOKENunconditionally before workspace resolution and environment realization; local malformed-value and translation coverage is present inserver/src/__tests__/heartbeat-project-env.test.ts:470.
Suggestions (1)
- [native-codex]
server/src/services/heartbeat.ts:667—executionTargetis no longer consulted by the translation helper, and production passesnull; consider removing the parameter and renaming the helper to make the unconditional behavior explicit.
Strengths
- Agent-only credential provenance survives issue and model-profile overlays while environment, project, and routine scopes cannot introduce or replace the seat token.
- Blank and embedded-whitespace values fail closed before workspace or environment side effects.
- Local, sandbox, SSH, precedence, low-trust, and wrapper-hermeticity cases have focused regression coverage.
Recommended Action
- Merge after the required CI checks complete successfully.
Replacement for #955 so the exact fixed head can be reviewed under an independent PR author. This PR carries the same BLO-18927 change, refreshed against current master after d522ef6 fixed the remote ssh/sandbox GH_SEAT_TOKEN_VALUE review finding.
Original context and review thread: #955.
Thinking Path
Linked Issues or Issue Description
Refs BLO-18927 (Paperclip) — "Scope the github-merge-token mount to PR-authoring agents instead of every agent pod", step 2 of its staging order.
Follows #830 and #841, which built the env-delivery branch this PR makes reachable.
The underlying problem, stated in full (no GitHub issue exists; this is tracked in Paperclip):
scripts/gh-token-wrapper.shreadsPAPERCLIP_GITHUB_TOKEN_VALUEto accept a GitHub token by value rather than from a mounted secret file. The intended producer is the scoped secret-binding path — an env binding at agent scope, which is how a credential gets delivered to specific agents rather than mounted into every agent pod.Those two halves cannot meet. In
server/src/services/heartbeat.ts:resolveExecutionRunAdapterConfigapplies that filter to the adapter, environment, project and routine env at the top of the function, and then passes the already-strippedexecutionRunConfigintoresolveAdapterConfigForRuntime(..., { consumerType: "agent" }). Agent scope does not escape it. A binding namedPAPERCLIP_GITHUB_TOKEN_VALUEis therefore dropped at every scope, and the wrapper falls through to its file branch — the fleet-wide mount — with no error logged anywhere.What Changed
PAPERCLIP_GITHUB_TOKEN_VALUE→GH_SEAT_TOKEN_VALUEinscripts/gh-token-wrapper.sh(behaviour of the branch is unchanged: same trim, same whitespace rejections, same value > file precedence).scripts/gh-token-wrapper.test.mjs, including its entry inWRAPPER_CREDENTIAL_ENV_VARSso the suite stays hermetic against the ambient value in agent pods.PAPERCLIP_namespace, so it does not get "fixed" back for consistency withPAPERCLIP_GITHUB_TOKEN_FILE— which keeps its prefix on purpose, since being strippable is what stops project/environment config from redirecting the file branch.server/src/__tests__/heartbeat-project-env.test.tsassertingGH_SEAT_TOKEN_VALUEsurvives agent-scope resolution, with aPAPERCLIP_-prefixed control key in the same env block.isPaperclipRuntimeEnvKeyis not modified. It is doing its job — stopping user-supplied config from overriding paperclip's own runtime env — and adding a credential-shaped exception to it would be the wrong direction.Verification
Both suites run locally, on this branch:
The new test was mutation-checked with two disjoint mutations, so it is not passing for an unrelated reason:
PAPERCLIP_-prefixed name (the exact regression)AssertionError: expected {} to have property "GH_SEAT_TOKEN_VALUE"— 1 failed / 16 passedisPaperclipRuntimeEnvKeyreturnfalse(neuter the strip)AssertionError: expected {…} to not have property "PAPERCLIP_GITHUB_TOKEN_VALUE"— 2 failed / 15 passed, the second being the pre-existing strip testBoth mutations were reverted; the diff is the three files listed above.
Reviewer check worth doing: confirm
GH_SEAT_TOKEN_VALUEis genuinely absent from the rest of the tree —grep -rn PAPERCLIP_GITHUB_TOKEN_VALUEreturns nothing outsidescripts/atd562a56, which is what makes this a safe rename rather than a breaking one.Risks
Low risk, with one deliberate trade-off worth reviewing rather than waving through.
PAPERCLIP_GITHUB_TOKEN_VALUEexists in a database somewhere, it was already being silently discarded; this rename does not change its (non-)behaviour.PAPERCLIP_key is settable from project/environment/routine env, not only agent scope, because the strip is exactly what used to prevent that. This is a downgrade vector rather than an escalation one — someone who can write those env scopes can swap in a credential they already hold, but cannot read the mounted one, andghwould then authenticate as a weaker identity. The practical consequence is thatghidentity selection is now only as tight as write access to project/environment env. I judged that acceptable because the alternative (aPAPERCLIP_-namespaced exception) weakens a guard protecting far more than this one key, but it is the part of this change I would most like a second opinion on.Model Used
Claude Opus 4.6 (
claude-opus-4-6), extended thinking, via Claude Code with tool use — running as the Paperclip CTO agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template