Note: Security measures described here apply to PUBLIC repositories only. For private repos, agents can use native bash with full environment access.
┌─────────────────────────────────────────────────────────────────┐
│ 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.
| 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.
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.
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 KEYThis 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
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
import { spawn } from "node:child_process";
// filter sensitive env vars (only for public repos)
function filterEnv(isPublicRepo: boolean): Record<string, string> {
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
const filtered: Record<string, string> = {};
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):
filterEnv(true)- preventsenvandecho $VARattacksunshare- prevents/proc/$PPID/environattack
For public repos, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets:
// 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 automaticallyFor private repos, native bash is allowed for all agents.
Run the vulnerability test in Docker to verify protection for public repos:
# 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 ✓
| 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.
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.
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 CLI supports excludeTools in its user-level settings file (~/.gemini/settings.json). For public repos, we exclude the native shell tool:
// 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 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/.
// 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 and config docs.
Verified behavior (tested via pnpm play codex-env-test.ts):
- Default (no config): ✅ secrets filtered
ignore_default_excludes = false: ✅ secrets filteredignore_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)
| 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 |