ADR-047 — Keep the forge token out of the cloned repo's .git/config
Status: Proposed (implementation pending cloud e2e — see Validation)
Context
The cloud runner clones a tenant's repository with a per-org forge token so commit-producing bots (Featurly/feature-dev, review bots) can fetch and push. Today injectGitToken rewrites the clone URL to https://oauth2:<forge_token>@host/… and git persists that URL verbatim in <workspace>/.git/config. The workspace (including .git/config) is then bind-mounted (docker) / tar-copied (k8s) into the sandbox where bots execute attacker-authored repository content — e.g. an external PR under review runs its own build/test scripts. Those scripts can read .git/config, recover the bound per-tenant token, and exfiltrate it to the (allowlisted) forge host. This crosses the sandbox trust boundary — cross-tenant credential theft.
This was surfaced repeatedly by iterion's own Seki (sec-audit-source) dogfood self-audit (runs 019f02f4, 019f034b, 019f03df; deepsec, medium/high).
The constraint that makes it non-trivial: there is no git push in iterion's Go code — bots push from inside the sandbox using that persisted token. So the credential cannot simply be stripped (that breaks the validated in-sandbox push), and it cannot be hidden from untrusted code that runs in the same sandbox as the push.
Key existing fact: the forge token is already delivered to the sandbox as a deliberately-mounted 0600 secret file (as: file, e.g. /run/secrets/forge_token or /run/iterion/secrets/<name> — the MountPath is per-binding, pkg/secrets/files.go), separate from the .git/config URL token. The .git/config token is an unintended second copy that git creates from the URL.
Decision
Stop persisting the token in .git/config. Authenticate git via a credential helper that reads the deliberately-mounted secret file at git-auth time.
- Runner clone/fetch: clone the clean URL (no userinfo token). Supply the runner's in-process token transiently via
GIT_ASKPASS(an env-pointed helper script) or-c credential.helper=…on the command line — neither is written to.git/config. - Persist a credential helper (not the token) in the clone's local
.git/configthat, onget, printsusername=oauth2+password=$(cat <forge_token MountPath>). TheMountPathis threaded from the resolved secret binding (it is per-binding, so it must be passed in, not hardcoded).store/eraseno-op. - Result:
.git/configcontains only acredential.helperthat references a file path — never the raw token. In-sandbox push works (the helper reads the same mounted secret the bots already use). Untrusted code reading.git/configfinds a path reference; to get the token it must read the mounted secret file — which is the existing, separately-scoped sandbox-secret exposure, not a.git/configleak.
Kill-switch: ITERION_RUNNER_FORGE_LEGACY_URL_TOKEN=1 reverts to today's URL-embedding, so a cloud regression in the push path can be unblocked instantly.
Follow-on (deeper trust separation)
The credential-helper change removes the unintended .git/config copy but the token is still reachable by untrusted code that runs in the same sandbox as a push-capable bot. The complete trust fix classifies bots by trust: untrusted-input bots (reviewing an external PR's code) get no push credential mounted at all — they post via the forge API or push to a fork; only trusted-author bots (operating on the operator's own repo) get the push secret. Alternatively, mint short-lived, narrowly-scoped tokens per run (GitHub App installation tokens scoped to one repo/branch with a few-minute TTL) so an exfiltrated token's blast radius and lifetime are bounded. Both are larger, forge-specific changes tracked separately.
Consequences
.git/configno longer contains a credential — the literal finding is closed.- The clone/push auth path changes; must be validated in cloud e2e before it is the default (see Validation). Until then it ships behind the kill-switch / off-by-default per the implementation PR.
- The credential helper must be robust to the per-binding
MountPathand to both sandbox drivers (docker bind-mount, k8s secret projection).
Validation (the part that cannot be done from a local dev box)
- Featurly in-sandbox push still works end-to-end on the preprod k8s runner (the validated
issues.labeled → featurly → linked PRflow). .git/configof a cloned run contains no token (only the helper).- The forge_token
MountPathresolves correctly under both the docker and kubernetes sandbox drivers, and the helper reads it at push time. - Review-bot flows that use the token file directly (glab/gh) are unaffected.
- The kill-switch restores the legacy path.
References
- Finding: Seki
sec-audit-sourceself-audit (deepsec),docs/bot-runs/sec-audit-source.md. - Code:
pkg/runner/loop.go(injectGitToken,prepareRepoWorkspace),pkg/secrets/files.go(file-secret MountPath), the SSRF sibling hardening in the same clone path (ADR/commit28a8690e8).
