From fe7ce4af115c4aa04eb73df544a2bcffd28a3de6 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 8 Jan 2026 13:58:08 -0800 Subject: [PATCH] Updates --- agents/claude.ts | 6 +- agents/codex.ts | 13 +++- agents/cursor.ts | 24 +++++-- agents/gemini.ts | 55 ++++++++++----- agents/instructions.ts | 11 ++- agents/opencode.ts | 29 ++++++-- agents/shared.ts | 1 + docs/security.md | 155 +++++++++++++++++++++++++++++------------ entry | 136 ++++++++++++++++++++++-------------- main.ts | 3 +- mcp/bash.ts | 51 ++++++-------- 11 files changed, 321 insertions(+), 163 deletions(-) diff --git a/agents/claude.ts b/agents/claude.ts index e8db88d..a5723e8 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -21,8 +21,10 @@ export const claude = agent({ const prompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(prompt)); - // SECURITY: Claude Code spawns subprocesses with full process.env, leaking API keys. + // SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys. // disable native Bash; agents use MCP bash tool which filters secrets. + // for private repos, native Bash is allowed since secrets are less exposed. + const disallowedTools = repo.isPublic ? ["Bash"] : []; const sandboxOptions: Options = payload.sandbox ? { permissionMode: "default", @@ -35,7 +37,7 @@ export const claude = agent({ } : { permissionMode: "bypassPermissions" as const, - disallowedTools: ["Bash"], + disallowedTools, }; if (payload.sandbox) { diff --git a/agents/codex.ts b/agents/codex.ts index 6c9bb81..8c9ca7b 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -43,7 +43,16 @@ export const codex = agent({ log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); } - // Codex SDK isolates subprocess env - native bash is safe, no MCP override needed + // SECURITY NOTE: Codex SDK does not have an option to disable native shell commands. + // For public repos, we rely on instructions telling the agent to use MCP bash instead. + // The MCP bash tool filters sensitive environment variables. + // This is not as robust as Claude/Cursor/OpenCode which can explicitly disable native bash. + if (repo.isPublic) { + log.info( + "πŸ”’ public repo: instructions direct to MCP bash (native shell NOT disabled in SDK)" + ); + } + const codex = new Codex(codexOptions); const thread = codex.startThread( payload.sandbox @@ -61,7 +70,7 @@ export const codex = agent({ ); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); let finalOutput = ""; for await (const event of streamedTurn.events) { diff --git a/agents/cursor.ts b/agents/cursor.ts index eb91b3c..4bdf3d0 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -93,7 +93,7 @@ export const cursor = agent({ }, run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false }); + configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); // track logged model_call_ids to avoid duplicates // cursor emits each assistant message twice: once without model_call_id, then again with it @@ -315,33 +315,45 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) { /** * Configure Cursor CLI sandbox mode via cli-config.json. * - * SECURITY: Cursor spawns subprocesses with full process.env, leaking API keys. + * SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys. * We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which * filters secrets. Note: Shell(**) does NOT work, must use Shell(*). + * For private repos, native Shell is allowed. * * Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv * sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md. */ -function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void { +function configureCursorSandbox({ + sandbox, + isPublicRepo, +}: { + sandbox: boolean; + isPublicRepo: boolean; +}): void { const realHome = homedir(); const cursorConfigDir = join(realHome, ".config", "cursor"); const cliConfigPath = join(cursorConfigDir, "cli-config.json"); mkdirSync(cursorConfigDir, { recursive: true }); + // deny native shell for public repos to prevent secret leakage + const denyShell = isPublicRepo ? ["Shell(*)"] : []; + const config = sandbox ? { permissions: { allow: ["Read(**)"], - deny: ["Write(**)", "Shell(*)"], + deny: ["Write(**)", ...denyShell], }, } : { permissions: { allow: ["Read(**)", "Write(**)"], - deny: ["Shell(*)"], + deny: denyShell, }, }; writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8"); - log.info(`Β» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`); + log.info( + `Β» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + ); } diff --git a/agents/gemini.ts b/agents/gemini.ts index e30ee33..b76cd08 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -157,28 +157,37 @@ export const gemini = agent({ }); }, run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => { - configureGeminiMcpServers({ mcpServers, cliPath }); + configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true }); + const sessionPrompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(sessionPrompt)); - // Gemini CLI isolates subprocess env - native bash is safe, no MCP override needed - // sandbox mode uses --allowed-tools to restrict to read-only operations - const args = payload.sandbox - ? [ - "--allowed-tools", - "read_file,list_directory,search_file_content,glob,save_memory,write_todos", - "--allowed-mcp-server-names", - "gh_pullfrog", - "--output-format=stream-json", - "-p", - sessionPrompt, - ] - : ["--yolo", "--output-format=stream-json", "-p", sessionPrompt]; + // build CLI args based on sandbox mode + // for public repos, native shell is disabled via excludeTools in settings.json + let args: string[]; + if (payload.sandbox) { + // sandbox mode: read-only tools only + args = [ + "--allowed-tools", + "read_file,list_directory,search_file_content,glob,save_memory,write_todos", + "--allowed-mcp-server-names", + "gh_pullfrog", + "--output-format=stream-json", + "-p", + sessionPrompt, + ]; + } else { + // normal mode: --yolo for auto-approval + // for public repos, shell is excluded via settings.json excludeTools + args = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt]; + if (repo.isPublic) { + log.info("πŸ”’ public repo: native shell disabled via excludeTools, using MCP bash"); + } + } if (payload.sandbox) { log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); @@ -264,12 +273,19 @@ export const gemini = agent({ }, }); +type ConfigureGeminiParams = { + mcpServers: ConfigureMcpServersParams["mcpServers"]; + isPublicRepo: boolean; +}; + /** * Configure MCP servers for Gemini by writing to settings.json. * Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport. * See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md + * + * For public repos, also configures excludeTools to disable native shell. */ -function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): void { +function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void { const realHome = homedir(); const geminiConfigDir = join(realHome, ".gemini"); const settingsPath = join(geminiConfigDir, "settings.json"); @@ -314,11 +330,16 @@ function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): v } // merge with existing settings, overwriting mcpServers - const newSettings = { + // for public repos, exclude native shell tool to prevent secret leakage via env + const newSettings: Record = { ...existingSettings, mcpServers: geminiMcpServers, }; + if (isPublicRepo) { + newSettings.excludeTools = ["run_shell_command"]; + } + writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`Β» MCP config written to ${settingsPath}`); } diff --git a/agents/instructions.ts b/agents/instructions.ts index 5d17e94..3e9c757 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -8,6 +8,7 @@ interface RepoInfo { owner: string; name: string; defaultBranch: string; + isPublic: boolean; } /** @@ -53,14 +54,12 @@ function buildRuntimeContext(repo: RepoInfo): string { interface AddInstructionsParams { payload: Payload; repo: RepoInfo; - useNativeBash?: boolean; } -export const addInstructions = ({ - payload, - repo, - useNativeBash = false, -}: AddInstructionsParams) => { +export const addInstructions = ({ payload, repo }: AddInstructionsParams) => { + // for public repos, always use MCP bash for security (filters secrets) + // for private repos, agents can use their native bash + const useNativeBash = !repo.isPublic; let encodedEvent = ""; const eventKeys = Object.keys(payload.event); diff --git a/agents/opencode.ts b/agents/opencode.ts index ea63b60..e23253c 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -28,7 +28,11 @@ export const opencode = agent({ const configDir = join(tempHome, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); - configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false }); + configureOpenCode({ + mcpServers, + sandbox: payload.sandbox ?? false, + isPublicRepo: repo.isPublic, + }); const prompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(prompt)); @@ -190,13 +194,14 @@ export const opencode = agent({ interface ConfigureOpenCodeParams { mcpServers: ConfigureMcpServersParams["mcpServers"]; sandbox: boolean; + isPublicRepo: boolean; } /** * Configure OpenCode via opencode.json config file. * Builds complete config with MCP servers and permissions in a single write to avoid race conditions. */ -function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): void { +function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void { const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); @@ -220,11 +225,25 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo }; } - // SECURITY: OpenCode spawns subprocesses with full process.env, leaking API keys. + // SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys. // disable native bash; agents use MCP bash tool which filters secrets. + // for private repos, native bash is allowed. + const bashPermission = isPublicRepo ? "deny" : "allow"; const permission = sandbox - ? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" } - : { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" }; + ? { + edit: "deny", + bash: "deny", + webfetch: "deny", + doom_loop: "allow", + external_directory: "allow", + } + : { + edit: "allow", + bash: bashPermission, + webfetch: "allow", + doom_loop: "allow", + external_directory: "allow", + }; // build complete config in one object const config = { diff --git a/agents/shared.ts b/agents/shared.ts index 59145fa..f7da026 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -27,6 +27,7 @@ export interface RepoInfo { owner: string; name: string; defaultBranch: string; + isPublic: boolean; } /** diff --git a/docs/security.md b/docs/security.md index 3503b36..e6a23d8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,8 @@ # Bash Tool Security -## Architecture +> **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) ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -15,7 +17,7 @@ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ Agent CLI (Claude/Cursor/OpenCode/etc.) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ - receives filtered env (only API key it needs) β”‚ β”‚ β”‚ -β”‚ β”‚ β”‚ - has built-in Bash tool (DISABLED) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - has built-in Bash tool (DISABLED for public) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ - connects to MCP server for tools β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ @@ -35,11 +37,24 @@ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -**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. +**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. --- -## Threat Model +## 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. @@ -55,7 +70,7 @@ The first two are solved by passing filtered env to subprocess. The third requir --- -## Attack: /proc/$PPID/environ +## 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: @@ -75,7 +90,7 @@ This bypasses environment filtering because we're reading the parent process's m --- -## Solution: PID Namespace Isolation +## Solution: PID Namespace Isolation (Public Repos) We use Linux PID namespaces to hide the parent process: @@ -104,53 +119,72 @@ unshare --pid --fork --mount-proc bash -c "$CMD" ```typescript import { spawn } from "node:child_process"; -// filter sensitive env vars (defense in depth) -function filterEnv(): Record { - const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /^ANTHROPIC/i, ...]; +// 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 && !SENSITIVE.some(p => p.test(key))) { - filtered[key] = value; - } + 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 GitHub Actions, plain spawn locally -function spawnSandboxed(command: string, options: { env, cwd }): ChildProcess { - if (process.env.GITHUB_ACTIONS === "true") { +// 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:** -1. `filterEnv()` - prevents `env` and `echo $VAR` attacks +**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 +## Disabling Native Bash Tools (Public Repos) -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: +For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets: ```typescript -// Claude -disallowedTools: ["Bash"], +// Claude - conditional based on repo.isPublic +const disallowedTools = repo.isPublic ? ["Bash"] : []; +{ permissionMode: "bypassPermissions", disallowedTools } -// Cursor -permissions: { deny: ["Shell(**)"] } +// Cursor - conditional shell denial +const denyShell = isPublicRepo ? ["Shell(*)"] : []; +{ permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } } -// OpenCode -permission: { bash: "deny" } +// 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 - NO SDK mechanism to disable native shell +// Relies on instructions only (limitation) ``` +For **private repos**, native bash is allowed for all agents. + --- -## Testing +## Testing (Public Repo Scenario) -Run the vulnerability test in Docker: +Run the vulnerability test in Docker to verify protection for public repos: ```bash # from action/ directory @@ -177,7 +211,26 @@ Expected output: --- -## What This Does NOT Protect Against +## 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) @@ -185,31 +238,47 @@ Expected output: 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 -### Platform Environments +### Claude, Cursor, OpenCode (Public Repos) -| Environment | `CI` | Our approach | -|-------------|------|--------------| -| GitHub Actions (Linux) | `"true"` | filterEnv + unshare | -| Local dev (any OS) | unset | filterEnv only | +These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`. -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 +For private repos, native bash is enabled for these agents. -GitHub Actions uses Ubuntu runners where `unshare` works without root. +### Gemini (Public Repos) -### Agents Using MCP Bash (Claude, Cursor, OpenCode) +Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool: -These agents have their native Bash disabled. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`. +```typescript +// written to ~/.gemini/settings.json +newSettings.excludeTools = ["run_shell_command"]; +``` -### Gemini +This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools. -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()`. +Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set. -### Codex +### Codex (Limitation) -Uses `shell_environment_policy` in config. Needs proper configuration or MCP bash fallback. +**⚠️ Codex SDK does not support disabling native shell commands.** The SDK only offers `sandboxMode` options which control filesystem access, not specific tool availability. + +For public repos, we rely on: +1. **Instructions** telling the agent to use MCP bash instead of native shell +2. **MCP bash tool** being available as an alternative + +This is a known limitation. Codex may still use native shell if it doesn't follow instructions. + +### 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 | Instructions only (**⚠️ not enforced**) | Native bash allowed | diff --git a/entry b/entry index b89c780..982ff98 100755 --- a/entry +++ b/entry @@ -100536,11 +100536,8 @@ function buildRuntimeContext(repo) { } return lines.join("\n"); } -var addInstructions = ({ - payload, - repo, - useNativeBash = false -}) => { +var addInstructions = ({ payload, repo }) => { + const useNativeBash = !repo.isPublic; let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { @@ -104754,6 +104751,7 @@ var claude = agent({ delete process.env.ANTHROPIC_API_KEY; const prompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(prompt)); + const disallowedTools = repo.isPublic ? ["Bash"] : []; const sandboxOptions = payload.sandbox ? { permissionMode: "default", disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"], @@ -104764,7 +104762,7 @@ var claude = agent({ } } : { permissionMode: "bypassPermissions", - disallowedTools: ["Bash"] + disallowedTools }; if (payload.sandbox) { log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); @@ -105229,6 +105227,11 @@ var codex = agent({ if (payload.sandbox) { log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); } + if (repo.isPublic) { + log.info( + "\u{1F512} public repo: instructions direct to MCP bash (native shell NOT disabled in SDK)" + ); + } const codex2 = new Codex(codexOptions); const thread = codex2.startThread( payload.sandbox ? { @@ -105243,7 +105246,7 @@ var codex = agent({ } ); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; @@ -105387,7 +105390,7 @@ var cursor = agent({ }, run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false }); + configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); const loggedModelCallIds = /* @__PURE__ */ new Set(); const messageHandlers5 = { system: (_event) => { @@ -105552,24 +105555,30 @@ function configureCursorMcpServers({ mcpServers }) { writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } -function configureCursorSandbox({ sandbox }) { +function configureCursorSandbox({ + sandbox, + isPublicRepo +}) { const realHome = homedir2(); const cursorConfigDir = join7(realHome, ".config", "cursor"); const cliConfigPath = join7(cursorConfigDir, "cli-config.json"); mkdirSync4(cursorConfigDir, { recursive: true }); + const denyShell = isPublicRepo ? ["Shell(*)"] : []; const config4 = sandbox ? { permissions: { allow: ["Read(**)"], - deny: ["Write(**)", "Shell(*)"] + deny: ["Write(**)", ...denyShell] } } : { permissions: { allow: ["Read(**)", "Write(**)"], - deny: ["Shell(*)"] + deny: denyShell } }; writeFileSync(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); - log.info(`\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`); + log.info( + `\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + ); } // agents/gemini.ts @@ -105737,21 +105746,29 @@ var gemini = agent({ }); }, run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => { - configureGeminiMcpServers({ mcpServers, cliPath }); + configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true }); + const sessionPrompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(sessionPrompt)); - const args3 = payload.sandbox ? [ - "--allowed-tools", - "read_file,list_directory,search_file_content,glob,save_memory,write_todos", - "--allowed-mcp-server-names", - "gh_pullfrog", - "--output-format=stream-json", - "-p", - sessionPrompt - ] : ["--yolo", "--output-format=stream-json", "-p", sessionPrompt]; + let args3; + if (payload.sandbox) { + args3 = [ + "--allowed-tools", + "read_file,list_directory,search_file_content,glob,save_memory,write_todos", + "--allowed-mcp-server-names", + "gh_pullfrog", + "--output-format=stream-json", + "-p", + sessionPrompt + ]; + } else { + args3 = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt]; + if (repo.isPublic) { + log.info("\u{1F512} public repo: native shell disabled via excludeTools, using MCP bash"); + } + } if (payload.sandbox) { log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); } @@ -105818,7 +105835,7 @@ var gemini = agent({ } } }); -function configureGeminiMcpServers({ mcpServers }) { +function configureGeminiMcpServers({ mcpServers, isPublicRepo }) { const realHome = homedir3(); const geminiConfigDir = join8(realHome, ".gemini"); const settingsPath = join8(geminiConfigDir, "settings.json"); @@ -105847,6 +105864,9 @@ function configureGeminiMcpServers({ mcpServers }) { ...existingSettings, mcpServers: geminiMcpServers }; + if (isPublicRepo) { + newSettings.excludeTools = ["run_shell_command"]; + } writeFileSync2(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${settingsPath}`); } @@ -105868,7 +105888,11 @@ var opencode = agent({ const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); - configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false }); + configureOpenCode({ + mcpServers, + sandbox: payload.sandbox ?? false, + isPublicRepo: repo.isPublic + }); const prompt = addInstructions({ payload, repo }); log.group("Full prompt", () => log.info(prompt)); const args3 = ["run", prompt, "--format", "json"]; @@ -105985,7 +106009,7 @@ var opencode = agent({ }; } }); -function configureOpenCode({ mcpServers, sandbox }) { +function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); @@ -106005,7 +106029,20 @@ function configureOpenCode({ mcpServers, sandbox }) { url: serverConfig.url }; } - const permission = sandbox ? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" } : { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" }; + const bashPermission = isPublicRepo ? "deny" : "allow"; + const permission = sandbox ? { + edit: "deny", + bash: "deny", + webfetch: "deny", + doom_loop: "allow", + external_directory: "allow" + } : { + edit: "allow", + bash: bashPermission, + webfetch: "allow", + doom_loop: "allow", + external_directory: "allow" + }; const config4 = { mcp: opencodeMcpServers, permission @@ -137976,38 +138013,24 @@ var BashParams = type({ "timeout?": "number", "working_directory?": "string" }); -var SENSITIVE_PATTERNS = [ - /_KEY$/i, - /_SECRET$/i, - /_TOKEN$/i, - /PASSWORD/i, - /CREDENTIAL/i, - /AUTH/i, - /^ANTHROPIC/i, - /^OPENAI/i, - /^GEMINI/i, - /^GOOGLE/i, - /^CLAUDE/i, - /^CODEX/i, - /^CURSOR/i, - /^AWS/i, - /^GITHUB_TOKEN$/i, - /^GH_TOKEN$/i -]; +var SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i]; function isSensitive(key) { return SENSITIVE_PATTERNS.some((p) => p.test(key)); } -function filterEnv() { +function filterEnv(isPublicRepo) { const filtered = {}; for (const [key, value2] of Object.entries(process.env)) { - if (value2 !== void 0 && !isSensitive(key)) filtered[key] = value2; + if (value2 === void 0) continue; + if (isPublicRepo && isSensitive(key)) continue; + filtered[key] = value2; } return filtered; } function spawnSandboxed(command, options) { const stdio = ["ignore", "pipe", "pipe"]; const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true }; - return process.env.CI === "true" ? spawn5("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn5("bash", ["-c", command], spawnOpts); + const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; + return useNamespaceIsolation ? spawn5("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn5("bash", ["-c", command], spawnOpts); } async function killProcessGroup(proc) { if (!proc.pid) return; @@ -138022,21 +138045,27 @@ async function killProcessGroup(proc) { } } } -function BashTool(_ctx) { +function BashTool(ctx) { + const isPublicRepo = !ctx.repo.private; return tool({ name: "bash", - description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets. + description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""} Use this tool to: - Run shell commands (ls, cat, grep, find, etc.) - Execute build tools (npm, pnpm, cargo, make, etc.) - Run tests and linters -- Perform git operations`, +- Perform git operations +${isPublicRepo ? "\nThe command runs in a bash shell with a filtered environment that excludes sensitive variables like API keys and tokens." : ""}`, parameters: BashParams, execute: execute(async (params) => { const timeout = Math.min(params.timeout ?? 12e4, 6e5); const cwd2 = params.working_directory ?? process.cwd(); - const proc = spawnSandboxed(params.command, { env: filterEnv(), cwd: cwd2 }); + const proc = spawnSandboxed(params.command, { + env: filterEnv(isPublicRepo), + cwd: cwd2, + isPublicRepo + }); let stdout = "", stderr = "", timedOut = false, exited = false; proc.stdout?.on("data", (chunk) => { stdout += chunk.toString(); @@ -138609,7 +138638,8 @@ ${encode(eventWithoutContext)}`; repo: { owner: ctx.owner, name: ctx.name, - defaultBranch: ctx.repo.default_branch + defaultBranch: ctx.repo.default_branch, + isPublic: !ctx.repo.private } }); } diff --git a/main.ts b/main.ts index 74708b0..d596a26 100644 --- a/main.ts +++ b/main.ts @@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { flatMorph } from "@ark/util"; -import { Octokit } from "@octokit/rest"; +import type { Octokit } from "@octokit/rest"; import { encode as toonEncode } from "@toon-format/toon"; import { type } from "arktype"; import { type Agent, agents } from "./agents/index.ts"; @@ -513,6 +513,7 @@ async function runAgent(ctx: AgentContext): Promise { owner: ctx.owner, name: ctx.name, defaultBranch: ctx.repo.default_branch, + isPublic: !ctx.repo.private, }, }); } diff --git a/mcp/bash.ts b/mcp/bash.ts index 80eabab..4547ebe 100644 --- a/mcp/bash.ts +++ b/mcp/bash.ts @@ -11,49 +11,37 @@ export const BashParams = type({ }); // patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes -const SENSITIVE_PATTERNS = [ - /_KEY$/i, - /_SECRET$/i, - /_TOKEN$/i, - /PASSWORD/i, - /CREDENTIAL/i, - /AUTH/i, - /^ANTHROPIC/i, - /^OPENAI/i, - /^GEMINI/i, - /^GOOGLE/i, - /^CLAUDE/i, - /^CODEX/i, - /^CURSOR/i, - /^AWS/i, - /^GITHUB_TOKEN$/i, - /^GH_TOKEN$/i, -]; +const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i]; function isSensitive(key: string): boolean { return SENSITIVE_PATTERNS.some((p) => p.test(key)); } -/** filter env vars, removing sensitive values */ -function filterEnv(): Record { +/** filter env vars, removing sensitive values (only for public repos) */ +function filterEnv(isPublicRepo: boolean): Record { const filtered: Record = {}; for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !isSensitive(key)) filtered[key] = value; + if (value === undefined) continue; + // only filter sensitive vars for public repos + if (isPublicRepo && isSensitive(key)) continue; + filtered[key] = value; } return filtered; } /** * spawn command with filtered env. in CI, also use PID namespace isolation - * to prevent child from reading /proc/$PPID/environ + * to prevent child from reading /proc/$PPID/environ (only for public repos) */ function spawnSandboxed( command: string, - options: { env: Record; cwd: string } + options: { env: Record; cwd: string; isPublicRepo: boolean } ): ChildProcess { const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"]; const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true }; - return process.env.CI === "true" // requires linux runner or this will fail. + // only use PID namespace isolation for public repos in CI + const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; + return useNamespaceIsolation ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn("bash", ["-c", command], spawnOpts); } @@ -74,21 +62,28 @@ async function killProcessGroup(proc: ChildProcess): Promise { } } -export function BashTool(_ctx: ToolContext) { +export function BashTool(ctx: ToolContext) { + const isPublicRepo = !ctx.repo.private; + return tool({ name: "bash", - description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets. + description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""} Use this tool to: - Run shell commands (ls, cat, grep, find, etc.) - Execute build tools (npm, pnpm, cargo, make, etc.) - Run tests and linters -- Perform git operations`, +- Perform git operations +${isPublicRepo ? "\nThe command runs in a bash shell with a filtered environment that excludes sensitive variables like API keys and tokens." : ""}`, parameters: BashParams, execute: execute(async (params) => { const timeout = Math.min(params.timeout ?? 120000, 600000); const cwd = params.working_directory ?? process.cwd(); - const proc = spawnSandboxed(params.command, { env: filterEnv(), cwd }); + const proc = spawnSandboxed(params.command, { + env: filterEnv(isPublicRepo), + cwd, + isPublicRepo, + }); let stdout = "", stderr = "",