Follow-up to #309 / PR #313, and a correction to the scope of GHSA-5v6f.
Problem
bash is an ungated, untainted read primitive, so the provenance bump merged in #313 can be walked around entirely on the default configuration.
isUntrustedSource() (src/core/executor.ts) covers read, grep, web_fetch, mcp__* — but not bash.
cat, head, tail, grep, find are all in SAFE_COMMANDS (src/tools/exec/bash.ts), so they skip confirmation regardless of the path they touch.
Verified against feature/309-provenance-bump before merge:
=== CHAIN: bash `cat` a secret, then exfiltrate ===
bash.requiresConfirmation : false <- safe-listed, no prompt
isUntrustedSource('bash') : false <- latch never arms
secret returned to model : true
latch armed after bash : false
bumpRequired(web_fetch) : false
prompts so far : [] <- zero
=== CONTRAST: same secret via `read` ===
isUntrustedSource('read') : true
latch armed after read : true
bumpRequired(web_fetch) : true <- the defense works here
[bash cat <secret>, web_fetch attacker] in a single batch prompts nothing.
Why the sandbox does not cover it
auto sandbox: cat /tmp/victim/.ssh/id_rsa -> exit 0, leaked: true
strict sandbox: cat /tmp/victim/.ssh/id_rsa -> exit 1, leaked: false
strict blocks it, but auto is the default and deliberately permits reads outside cwd (it denies writes + network). web_fetch runs in the agent process, not under the sandbox, so the network denial does not apply to it either.
Scope note
This also makes GHSA-5v6f's claim too strong. That advisory states an injected agent "cannot silently read ~/.ssh/id_rsa" — via bash cat it can. The advisory gated the read/grep/glob/ls tools, not bash.
Suggested fix (prototyped, 21 cases verified)
Make the safe-command allowlist path-aware, reusing the existing GHSA-5v6f helpers, and feed the same predicate into isUntrustedSource so bash both gates and taints when it touches an out-of-project or credential path:
import { escapesCwdSync, isCredentialPath } from "../file/paths.js";
function bashTouchesUntrustedPath(cmd: string): boolean {
const tokens = cmd.split(/[\s;|&<>()"']+/).filter(Boolean);
for (const raw of tokens) {
if (!/^(\/|~|\.\.\/)/.test(raw)) continue;
const p = raw.replace(/^~/, process.env.HOME ?? "~");
if (escapesCwdSync(p) || isCredentialPath(p)) return true;
}
for (const raw of tokens) if (isCredentialPath(raw)) return true; // bare `.env`
return false;
}
Wire into:
bashTool.requiresConfirmation → ... || bashTouchesUntrustedPath(cmd)
isUntrustedSource → if (toolName === "bash") return bashTouchesUntrustedPath(String(args.command ?? ""))
Verified behaviour:
- flags:
cat ~/.aws/credentials, head -50 ~/.ssh/id_ed25519, grep -r secret ~/.config, cat .env, tail -n5 ../../etc/passwd, find / -name id_rsa, cat /etc/passwd, cat ~/.netrc
- silent on:
ls -la, npm test, cat package.json, grep -r TODO src/, git status, head -20 README.md, find . -name '*.ts', cat ./docs/roadmap.md, npm run build && npm test, wc -l src/*.ts
Tainting only path-touching bash (rather than all bash) matters: a bump that fires on every npm test trains users to reflex-approve and destroys the control's value.
Also
docs/design/prompt-injection-defenses.md currently lists only turn-scoping and in-project injection as residual gaps. It should name bash as an untainted read primitive until this lands, otherwise the doc reads as though the exfiltration channel is closed when the default-configuration path is still open.
Follow-up to #309 / PR #313, and a correction to the scope of GHSA-5v6f.
Problem
bashis an ungated, untainted read primitive, so the provenance bump merged in #313 can be walked around entirely on the default configuration.isUntrustedSource()(src/core/executor.ts) coversread,grep,web_fetch,mcp__*— but notbash.cat,head,tail,grep,findare all inSAFE_COMMANDS(src/tools/exec/bash.ts), so they skip confirmation regardless of the path they touch.Verified against
feature/309-provenance-bumpbefore merge:[bash cat <secret>, web_fetch attacker]in a single batch prompts nothing.Why the sandbox does not cover it
strictblocks it, butautois the default and deliberately permits reads outside cwd (it denies writes + network).web_fetchruns in the agent process, not under the sandbox, so the network denial does not apply to it either.Scope note
This also makes GHSA-5v6f's claim too strong. That advisory states an injected agent "cannot silently read
~/.ssh/id_rsa" — viabash catit can. The advisory gated theread/grep/glob/lstools, notbash.Suggested fix (prototyped, 21 cases verified)
Make the safe-command allowlist path-aware, reusing the existing GHSA-5v6f helpers, and feed the same predicate into
isUntrustedSourcesobashboth gates and taints when it touches an out-of-project or credential path:Wire into:
bashTool.requiresConfirmation→... || bashTouchesUntrustedPath(cmd)isUntrustedSource→if (toolName === "bash") return bashTouchesUntrustedPath(String(args.command ?? ""))Verified behaviour:
cat ~/.aws/credentials,head -50 ~/.ssh/id_ed25519,grep -r secret ~/.config,cat .env,tail -n5 ../../etc/passwd,find / -name id_rsa,cat /etc/passwd,cat ~/.netrcls -la,npm test,cat package.json,grep -r TODO src/,git status,head -20 README.md,find . -name '*.ts',cat ./docs/roadmap.md,npm run build && npm test,wc -l src/*.tsTainting only path-touching
bash(rather than allbash) matters: a bump that fires on everynpm testtrains users to reflex-approve and destroys the control's value.Also
docs/design/prompt-injection-defenses.mdcurrently lists only turn-scoping and in-project injection as residual gaps. It should namebashas an untainted read primitive until this lands, otherwise the doc reads as though the exfiltration channel is closed when the default-configuration path is still open.