- Remove shell commands section from agent instructions - Merge Platform Notes into Agent-Specific Notes section - Remove redundant description text from bash tool
8.6 KiB
Bash Tool Security
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ GitHub Actions Runner │
│ (has secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Pullfrog Action (Node.js) │ │
│ │ - process.env contains all secrets │ │
│ │ - spawns agent CLI as child process │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
│ │ │ - receives filtered env (only API key it needs) │ │ │
│ │ │ - has built-in Bash tool (DISABLED) │ │ │
│ │ │ - connects to MCP server for tools │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
│ │ │ │ MCP Bash Tool (our code) │ │ │ │
│ │ │ │ - agent calls this for shell commands │ │ │ │
│ │ │ │ - spawns bash with filtered env │ │ │ │
│ │ │ │ - uses PID namespace isolation │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ Bash subprocess │ │ │ │ │
│ │ │ │ │ - runs user-controlled commands │ │ │ │ │
│ │ │ │ │ - MUST NOT access secrets │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key insight: The Pullfrog Action process has all secrets in process.env. Agent CLIs have built-in Bash tools that we can't trust. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
Threat Model
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
Attack vectors:
| Vector | Example | Mitigation |
|---|---|---|
| Direct env access | env | grep KEY |
Filter env vars before spawn |
| Echo variable | echo $ANTHROPIC_API_KEY |
Filter env vars before spawn |
/proc/$PPID/environ |
cat /proc/$PPID/environ |
PID namespace isolation |
The first two are solved by passing filtered env to subprocess. The third requires special handling on Linux.
Attack: /proc/$PPID/environ
On Linux, any process can read its parent's environment via /proc/$PPID/environ. Even if we spawn bash with a clean environment, the bash process can:
# read parent's (Node.js) environment - contains all secrets!
tr '\0' '\n' < /proc/$PPID/environ | grep KEY
This bypasses environment filtering because we're reading the parent process's memory, not our own env.
Why this matters:
- Pullfrog Action (Node.js) has
ANTHROPIC_API_KEY,OPENAI_API_KEY, etc. inprocess.env - We spawn agent CLI with filtered env (only its own API key)
- Agent CLI spawns MCP Bash tool
- MCP Bash tool spawns bash with filtered env (no secrets)
- BUT bash can read
/proc/$PPID/environ→ gets Node.js process's full env
Solution: PID Namespace Isolation
We use Linux PID namespaces to hide the parent process:
unshare --pid --fork --mount-proc bash -c "$CMD"
| Flag | Purpose |
|---|---|
--pid |
Create new PID namespace |
--fork |
Fork so child is actually in new namespace |
--mount-proc |
Mount fresh /proc for new namespace |
Result:
- Child sees itself as PID 1
- Child's PPID is 0 (doesn't exist)
/proconly shows processes in child's namespace- Parent's PID is invisible →
/proc/$PPID/environfails
Implementation
mcp/bash.ts
import { spawn } from "node:child_process";
// filter sensitive env vars (defense in depth)
function filterEnv(): Record<string, string> {
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /^ANTHROPIC/i, ...];
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value && !SENSITIVE.some(p => p.test(key))) {
filtered[key] = value;
}
}
return filtered;
}
// spawn with PID namespace in GitHub Actions, plain spawn locally
function spawnSandboxed(command: string, options: { env, cwd }): ChildProcess {
if (process.env.GITHUB_ACTIONS === "true") {
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
}
return spawn("bash", ["-c", command], options);
}
Defense in depth:
filterEnv()- preventsenvandecho $VARattacksunshare- prevents/proc/$PPID/environattack
Disabling Native Bash Tools
Each agent has built-in Bash/Shell tools that we can't control. We disable them and force agents to use our MCP Bash tool:
// Claude
disallowedTools: ["Bash"],
// Cursor
permissions: { deny: ["Shell(**)"] }
// OpenCode
permission: { bash: "deny" }
Testing
Run the vulnerability test in Docker:
# from action/ directory
docker run --rm \
-v "$(pwd):/app/action:cached" \
-v "pullfrog-action-node-modules:/app/action/node_modules" \
-w /app/action \
-e GITHUB_ACTIONS=true \
-e TEST_SECRET_KEY=test-secret \
-e ANTHROPIC_API_KEY=sk-test \
--cap-add SYS_ADMIN \
--security-opt seccomp:unconfined \
node:22 bash -c "corepack enable pnpm && pnpm install --frozen-lockfile && node test/proc-environ-vuln.ts"
Expected output:
1. UNPROTECTED (filterEnv only):
Leaked: YES ❌
2. PROTECTED (unshare --pid --fork --mount-proc):
Leaked: NO ✓
What This Does NOT Protect Against
- Network exfiltration: Child has full network access
- File access: Child can read any file the runner can (same UID)
- Resource exhaustion: No cgroup limits
For those, you'd need bwrap with --unshare-net, --ro-bind, etc. But for the stated goal—preventing secret exfiltration via env—this is sufficient.
Agent-Specific Notes
Platform Environments
| Environment | CI |
Our approach |
|---|---|---|
| GitHub Actions (Linux) | "true" |
filterEnv + unshare |
| Local dev (any OS) | unset | filterEnv only |
We check CI=true (set automatically by GitHub) rather than platform detection. This means:
- In CI: Full protection with PID namespace isolation
- Locally: Easier testing without Docker/unshare requirements
GitHub Actions uses Ubuntu runners where unshare works without root.
Agents Using MCP Bash (Claude, Cursor, OpenCode)
These agents have their native Bash disabled. They use our gh_pullfrog MCP server's bash tool which implements filterEnv() + unshare.
Gemini
Has built-in CI detection that filters shell env when GITHUB_SHA or SURFACE=Github is set. We set SURFACE=Github in our env. Double protection with our createAgentEnv().
Codex
Uses shell_environment_policy in config. Needs proper configuration or MCP bash fallback.