# Bash Tool Security > **Note**: Security measures described here apply to **PUBLIC repositories only**. For private repos, agents can use native bash with full environment access. ## Architecture (Public Repos) ``` ┌─────────────────────────────────────────────────────────────────┐ │ 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 for public) │ │ │ │ │ │ - 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**: For **public repos**, the Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust since malicious actors can submit PRs with prompt injections. We disable those and provide our own MCP Bash tool that spawns subprocesses securely. For **private repos**, the threat model is different — only trusted collaborators can trigger workflows, so we allow native bash with full environment access for better performance and compatibility. --- ## Public vs Private Repos | Repo Visibility | Native Bash | Env Filtering | PID Isolation | |-----------------|-------------|---------------|---------------| | **Public** | Disabled | Yes | Yes (in CI) | | **Private** | Enabled | No | No | **Rationale**: Public repos are at risk from prompt injection attacks via pull requests from untrusted contributors. Private repos only allow trusted collaborators, so the attack surface is much smaller. --- ## Threat Model (Public Repos) 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 (Public Repos) 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: ```bash # 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. in `process.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 (Public Repos) We use Linux PID namespaces to hide the parent process: ```bash 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) - `/proc` only shows processes in child's namespace - Parent's PID is invisible → `/proc/$PPID/environ` fails --- ## Implementation ### mcp/bash.ts ```typescript import { spawn } from "node:child_process"; // filter sensitive env vars (only for public repos) function filterEnv(isPublicRepo: boolean): Record { const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i]; const filtered: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value === undefined) continue; // only filter sensitive vars for public repos if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue; filtered[key] = value; } return filtered; } // spawn with PID namespace in CI for public repos, plain spawn otherwise function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess { const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; if (useNamespaceIsolation) { return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options); } return spawn("bash", ["-c", command], options); } // BashTool uses ctx.repo.private to determine visibility export function BashTool(ctx: ToolContext) { const isPublicRepo = !ctx.repo.private; // ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag } ``` **Defense in depth (public repos only):** 1. `filterEnv(true)` - prevents `env` and `echo $VAR` attacks 2. `unshare` - prevents `/proc/$PPID/environ` attack --- ## Disabling Native Bash Tools (Public Repos) For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets: ```typescript // Claude - conditional based on repo.isPublic const disallowedTools = repo.isPublic ? ["Bash"] : []; { permissionMode: "bypassPermissions", disallowedTools } // Cursor - conditional shell denial const denyShell = isPublicRepo ? ["Shell(*)"] : []; { permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } } // OpenCode - conditional bash denial const bashPermission = isPublicRepo ? "deny" : "allow"; { permission: { edit: "allow", bash: bashPermission, ... } } // Gemini - uses excludeTools in ~/.gemini/settings.json newSettings.excludeTools = ["run_shell_command"]; // Codex - CLI internally scrubs env before spawning shell // No SDK-level config needed; Codex handles this automatically ``` For **private repos**, native bash is allowed for all agents. --- ## Testing (Public Repo Scenario) Run the vulnerability test in Docker to verify protection for public repos: ```bash # 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 ✓ ``` --- ## Platform Notes | Environment | Repo | Our approach | |-------------|------|--------------| | GitHub Actions (Linux) | Public | filterEnv + unshare + disable native bash | | GitHub Actions (Linux) | Private | Full env + native bash allowed | | Local dev (any OS) | Any | No filtering (local dev assumed trusted) | We check `process.env.CI === "true"` (set by GitHub Actions) combined with `ctx.repo.private` to determine the security posture: - **CI + Public repo**: Full protection with PID namespace isolation - **CI + Private repo**: No protection (trusted collaborators only) - **Local**: No protection (developer's own machine) GitHub Actions uses Ubuntu runners where `unshare` works without root. --- ## What This Does NOT Protect Against (Public Repos) Even with protections enabled, bash subprocesses can still: - **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. For **private repos**, none of these protections apply since we trust collaborators. --- ## Agent-Specific Notes ### Claude, Cursor, OpenCode (Public Repos) These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`. For private repos, native bash is enabled for these agents. ### Gemini (Public Repos) Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool: ```typescript // written to ~/.gemini/settings.json newSettings.excludeTools = ["run_shell_command"]; ``` This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools. Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set. ### Codex Codex CLI filters out env vars matching `KEY`, `SECRET`, or `TOKEN` (case-insensitive) by default via `shell_environment_policy.ignore_default_excludes = false`. **Vulnerability**: If a user's `~/.codex/config.toml` has `ignore_default_excludes = true`, secrets will leak to shell commands. **Our mitigation**: We set `CODEX_HOME` to a temp directory and write our own `config.toml` with `ignore_default_excludes = false` to enforce filtering regardless of what config exists in the user's `~/.codex/`. ```typescript // set CODEX_HOME to override user's config setupProcessAgentEnv({ CODEX_HOME: codexDir }); // write secure config to $CODEX_HOME/config.toml writeFileSync(join(codexDir, "config.toml"), ` [shell_environment_policy] ignore_default_excludes = false `); ``` See [GitHub Issue #3064](https://github.com/openai/codex/issues/3064) and [config docs](https://github.com/openai/codex/blob/main/docs/config.md#shell_environment_policy). **Verified behavior** (tested via `pnpm play codex-env-test.ts`): - Default (no config): ✅ secrets filtered - `ignore_default_excludes = false`: ✅ secrets filtered - `ignore_default_excludes = true`: ❌ secrets leak Example output when running `env | grep TEST` with our config: ``` TEST_SAFE_VAR=VISIBLE-SAFE-VALUE # FAKE_SECRET_KEY and TEST_API_TOKEN are NOT visible (filtered) ``` ### Summary by Agent | Agent | Public Repo | Private Repo | |-------|-------------|--------------| | Claude | Native bash **disabled** | Native bash allowed | | Cursor | Native shell **disabled** | Native shell allowed | | OpenCode | Native bash **disabled** | Native bash allowed | | Gemini | Native shell **disabled** (via excludeTools) | Native bash allowed | | Codex | Native shell allowed (CLI scrubs env internally) | Native bash allowed |