diff --git a/action.yml b/action.yml index ad0adad..7b8a192 100644 --- a/action.yml +++ b/action.yml @@ -1,3 +1,5 @@ +# note: this file must remain identical to action/action.yml and action/run/action.yml +# future agents: keep both files in sync manually name: "Pullfrog Action" description: "Execute coding agents with a prompt" author: "Pullfrog" @@ -7,12 +9,27 @@ inputs: description: "Prompt to send to the agent" required: true effort: - description: "Effort level: mini (fast), auto (default), max (most capable)" + description: "Effort level: nothink (fast), think (default), max (most capable)" + required: false + default: "think" + agent: + description: "Agent to use: claude, codex, gemini, cursor, opencode" required: false - default: "auto" cwd: description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)" required: false + web: + description: "Web fetch permission: disabled or enabled (default: enabled)" + required: false + search: + description: "Web search permission: disabled or enabled (default: enabled)" + required: false + write: + description: "File write permission: disabled or enabled (default: enabled)" + required: false + bash: + description: "Bash permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled." + required: false runs: using: "node24" diff --git a/action.yml~HEAD b/action.yml~HEAD new file mode 100644 index 0000000..ad0adad --- /dev/null +++ b/action.yml~HEAD @@ -0,0 +1,23 @@ +name: "Pullfrog Action" +description: "Execute coding agents with a prompt" +author: "Pullfrog" + +inputs: + prompt: + description: "Prompt to send to the agent" + required: true + effort: + description: "Effort level: mini (fast), auto (default), max (most capable)" + required: false + default: "auto" + cwd: + description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)" + required: false + +runs: + using: "node24" + main: "entry" + +branding: + icon: "code" + color: "green" diff --git a/agents/claude.ts b/agents/claude.ts index 2a44698..8a17840 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -3,7 +3,7 @@ import type { Effort } from "../external.ts"; import packageJson from "../package.json" with { type: "json" }; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts"; +import { agent, createAgentEnv, installFromNpmTarball, type ToolPermissions } from "./shared.ts"; // Model selection based on effort level // Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability @@ -19,6 +19,20 @@ const claudeEffortModels: Record = { // See: https://platform.claude.com/docs/en/build-with-claude/effort // This approach could replace model selection if effort proves effective for controlling capability. +/** + * Build disallowedTools list from ToolPermissions. + */ +function buildDisallowedTools(tools: ToolPermissions): string[] { + const disallowed: string[] = []; + if (tools.web === "disabled") disallowed.push("WebFetch"); + if (tools.search === "disabled") disallowed.push("WebSearch"); + if (tools.write === "disabled") disallowed.push("Write"); + // both "disabled" and "restricted" block native bash + // "restricted" means use MCP bash tool instead + if (tools.bash !== "enabled") disallowed.push("Bash"); + return disallowed; +} + export const claude = agent({ name: "claude", install: async () => { @@ -29,44 +43,28 @@ export const claude = agent({ executablePath: "cli.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { // Ensure API key is NOT in process.env - only pass via SDK's env option delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, repo }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); // select model based on effort level const model = claudeEffortModels[effort]; log.info(`Using model: ${model} (effort: ${effort})`); - // 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", - disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"], - async canUseTool(toolName, input, _options) { - if (toolName.startsWith("mcp__gh_pullfrog__")) - return { behavior: "allow", updatedInput: input, updatedPermissions: [] }; - return { behavior: "deny", message: "tool not allowed in sandbox mode" }; - }, - } - : { - permissionMode: "bypassPermissions" as const, - disallowedTools, - }; - - if (payload.sandbox) { - log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); + // build disallowedTools based on tool permissions + const disallowedTools = buildDisallowedTools(tools); + if (disallowedTools.length > 0) { + log.info(`πŸ”’ disallowed tools: ${disallowedTools.join(", ")}`); } // Pass secrets via SDK's env option only (not process.env) // This ensures secrets are only available to Claude Code subprocess, not user code const queryOptions: Options = { - ...sandboxOptions, + permissionMode: "bypassPermissions" as const, + disallowedTools, mcpServers, model, pathToClaudeCodeExecutable: cliPath, diff --git a/agents/codex.ts b/agents/codex.ts index f378d20..20c1367 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -11,7 +11,12 @@ import { import type { Effort } from "../external.ts"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts"; +import { + agent, + installFromNpmTarball, + setupProcessAgentEnv, + type ToolPermissions, +} from "./shared.ts"; // model configuration based on effort level const codexModel: Record = { @@ -33,10 +38,10 @@ const codexReasoningEffort: Record = { interface WriteCodexConfigParams { tempHome: string; mcpServers: Record; - isPublicRepo: boolean; + tools: ToolPermissions; } -function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string { +function writeCodexConfig({ tempHome, mcpServers, tools }: WriteCodexConfigParams): string { const codexDir = join(tempHome, ".codex"); mkdirSync(codexDir, { recursive: true }); const configPath = join(codexDir, "config.toml"); @@ -45,32 +50,32 @@ function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConf const mcpServerSections: string[] = []; for (const [name, config] of Object.entries(mcpServers)) { if (config.type !== "http") continue; - log.info(`Β» Adding MCP server '${name}' at ${config.url}`); + log.info(`Β» adding MCP server '${name}' at ${config.url}`); mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`); } - // SECURITY: for public repos, enforce env filtering via shell_environment_policy - // this prevents vuln if user's ~/.codex/config.toml has ignore_default_excludes=true - // for private repos, no filtering - agents use native shell with full env access - const shellPolicy = isPublicRepo - ? `[shell_environment_policy] -ignore_default_excludes = false` - : ""; + // build features section for tool control + // disable native shell if bash is "disabled" or "restricted" + // when "restricted", agent uses MCP bash tool which filters secrets + const features: string[] = []; + if (tools.bash !== "enabled") { + features.push("shell_command_tool = false"); + features.push("unified_exec = false"); + } + const featuresSection = features.length > 0 ? `[features]\n${features.join("\n")}` : ""; writeFileSync( configPath, `# written by pullfrog -${shellPolicy} +${featuresSection} ${mcpServerSections.join("\n\n")} `.trim() + "\n" ); - if (isPublicRepo) { - log.info(`Β» Codex config written to ${configPath} (env filtering: enabled)`); - } else { - log.info(`Β» Codex config written to ${configPath} (private repo: no env filtering)`); - } + log.info( + `Β» Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})` + ); return codexDir; } @@ -84,7 +89,7 @@ export const codex = agent({ executablePath: "bin/codex.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR!; // create config directory for codex before setting HOME @@ -94,7 +99,7 @@ export const codex = agent({ const codexDir = writeCodexConfig({ tempHome, mcpServers, - isPublicRepo: repo.isPublic, + tools, }); setupProcessAgentEnv({ @@ -117,36 +122,29 @@ export const codex = agent({ codexPathOverride: cliPath, }; - if (payload.sandbox) { - log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); - } - const codex = new Codex(codexOptions); - // Build thread options with model and optional model_reasoning_effort - const baseThreadOptions = payload.sandbox - ? { - model, - approvalPolicy: "never" as const, - sandboxMode: "read-only" as const, - networkAccessEnabled: false, - } - : { - model, - approvalPolicy: "never" as const, - // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) - sandboxMode: "danger-full-access" as const, - networkAccessEnabled: true, - }; + // build thread options based on tool permissions + const threadOptions: ThreadOptions = { + model, + approvalPolicy: "never" as const, + // write: "disabled" β†’ read-only sandbox, otherwise full access for git ops + sandboxMode: tools.write === "disabled" ? "read-only" : "danger-full-access", + // web: controls network access + networkAccessEnabled: tools.web !== "disabled", + // search: controls web search + webSearchEnabled: tools.search !== "disabled", + ...(modelReasoningEffort && { modelReasoningEffort }), + }; - const threadOptions: ThreadOptions = modelReasoningEffort - ? { ...baseThreadOptions, modelReasoningEffort } - : baseThreadOptions; + log.info( + `πŸ”§ Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}` + ); const thread = codex.startThread(threadOptions); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools })); let finalOutput = ""; for await (const event of streamedTurn.events) { diff --git a/agents/cursor.ts b/agents/cursor.ts index db0b4f1..d415c14 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -10,6 +10,7 @@ import { type ConfigureMcpServersParams, createAgentEnv, installFromCurl, + type ToolPermissions, } from "./shared.ts"; // effort configuration for Cursor @@ -100,9 +101,9 @@ export const cursor = agent({ executableName: "cursor-agent", }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort, tools }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); + configureCursorTools({ tools }); // determine model based on effort level // respect project's .cursor/cli.json if it specifies a model @@ -201,11 +202,10 @@ export const cursor = agent({ }; try { - const fullPrompt = addInstructions({ payload, repo }); + const fullPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(fullPrompt)); - // configure sandbox mode if enabled - // in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings + // build CLI args const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"]; // add model flag if we have an override @@ -213,13 +213,8 @@ export const cursor = agent({ baseArgs.push("--model", modelOverride); } - const cursorArgs = payload.sandbox - ? baseArgs // --force removed in sandbox mode to enforce safety checks - : [...baseArgs, "--force"]; - - if (payload.sandbox) { - log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); - } + // always use --force since permissions are controlled via cli-config.json + const cursorArgs = [...baseArgs, "--force"]; log.info("Running Cursor CLI..."); @@ -347,48 +342,50 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) { log.info(`Β» MCP config written to ${mcpConfigPath}`); } +interface ConfigureCursorToolsParams { + tools: ToolPermissions; +} + /** - * Configure Cursor CLI sandbox mode via cli-config.json. - * - * 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. + * Configure Cursor CLI tool permissions via cli-config.json. * * Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv - * sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md. + * sets XDG_CONFIG_HOME=$HOME/.config. */ -function configureCursorSandbox({ - sandbox, - isPublicRepo, -}: { - sandbox: boolean; - isPublicRepo: boolean; -}): void { +function configureCursorTools({ tools }: ConfigureCursorToolsParams): 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(*)"] : []; + // build deny list based on tool permissions + const deny: string[] = []; + if (tools.search === "disabled") deny.push("WebSearch"); + if (tools.write === "disabled") deny.push("Write(**)"); + // both "disabled" and "restricted" block native shell + if (tools.bash !== "enabled") deny.push("Shell(*)"); - const config = sandbox - ? { - permissions: { - allow: ["Read(**)"], - deny: ["Write(**)", ...denyShell], - }, - } - : { - permissions: { - allow: ["Read(**)", "Write(**)"], - deny: denyShell, - }, - }; + // web: "disabled" requires sandbox with network blocking + // sandbox.networkAccess: "allowlist" blocks network in shell subprocesses via seatbelt + const needsSandbox = tools.web === "disabled"; + + const config: Record = { + permissions: { + allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"], + deny, + }, + }; + + if (needsSandbox) { + config.sandbox = { + mode: "enabled", + networkAccess: "allowlist", + }; + } writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8"); + log.info(`Β» CLI config written to ${cliConfigPath}`); log.info( - `Β» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + `πŸ”§ Cursor permissions: allow=${(config.permissions as { allow: string[] }).allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}` ); } diff --git a/agents/gemini.ts b/agents/gemini.ts index d8573f3..07f2ef9 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -10,6 +10,7 @@ import { type ConfigureMcpServersParams, createAgentEnv, installFromGithub, + type ToolPermissions, } from "./shared.ts"; // effort configuration: model + thinking level @@ -171,48 +172,23 @@ export const gemini = agent({ ...(githubInstallationToken && { githubInstallationToken }), }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => { // get model and thinking level based on effort const { model, thinkingLevel } = geminiEffortConfig[effort]; log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`); - configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel }); + configureGeminiSettings({ mcpServers, tools, thinkingLevel }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo }); + const sessionPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(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 = [ - "--model", - model, - "--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 = ["--model", model, "--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"); - } + // build CLI args - --yolo for auto-approval + // tool restrictions handled via settings.json tools.exclude + const args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt]; let finalOutput = ""; let stdoutBuffer = ""; @@ -294,23 +270,23 @@ export const gemini = agent({ }, }); -type ConfigureGeminiParams = { +interface ConfigureGeminiParams { mcpServers: ConfigureMcpServersParams["mcpServers"]; - isPublicRepo: boolean; + tools: ToolPermissions; thinkingLevel: string; -}; +} /** * Configure Gemini CLI settings by writing to settings.json. * - MCP servers: uses `httpUrl` for HTTP/streamable transport * - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig - * - For public repos, excludeTools disables native shell + * - tools.exclude: disables native tools based on ToolPermissions (v0.3.0+ format) * * See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md */ function configureGeminiSettings({ mcpServers, - isPublicRepo, + tools, thinkingLevel, }: ConfigureGeminiParams): void { const realHome = homedir(); @@ -328,7 +304,7 @@ function configureGeminiSettings({ } // convert to Gemini's expected format (httpUrl for HTTP transport, no type field) - type GeminiMcpServerConfig = { + interface GeminiMcpServerConfig { command?: string; args?: string[]; env?: Record; @@ -341,7 +317,7 @@ function configureGeminiSettings({ description?: string; includeTools?: string[]; excludeTools?: string[]; - }; + } const geminiMcpServers: Record = {}; for (const [serverName, serverConfig] of Object.entries(mcpServers)) { if (serverConfig.type !== "http") { @@ -353,9 +329,16 @@ function configureGeminiSettings({ httpUrl: serverConfig.url, trust: true, // trust our own MCP server to avoid confirmation prompts }; - log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`); + log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`); } + // build tools.exclude based on permissions (v0.3.0+ nested format) + const exclude: string[] = []; + if (tools.bash !== "enabled") exclude.push("run_shell_command"); + if (tools.write === "disabled") exclude.push("write_file"); + if (tools.web === "disabled") exclude.push("web_fetch"); + if (tools.search === "disabled") exclude.push("google_web_search"); + // merge with existing settings, overwriting mcpServers and modelConfig const newSettings: Record = { ...existingSettings, @@ -369,13 +352,13 @@ function configureGeminiSettings({ }, }, }, + // v0.3.0+ nested format + ...(exclude.length > 0 && { tools: { exclude } }), }; - // for public repos, exclude native shell tool to prevent secret leakage via env - if (isPublicRepo) { - newSettings.excludeTools = ["run_shell_command"]; - } - writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`Β» Gemini settings written to ${settingsPath}`); + if (exclude.length > 0) { + log.info(`πŸ”’ excluded tools: ${exclude.join(", ")}`); + } } diff --git a/agents/instructions.ts b/agents/instructions.ts index 120cabb..4e19c8e 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -3,6 +3,7 @@ import { encode as toonEncode } from "@toon-format/toon"; import type { Payload } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; import { getModes } from "../modes.ts"; +import type { ToolPermissions } from "./shared.ts"; interface RepoInfo { owner: string; @@ -54,12 +55,28 @@ function buildRuntimeContext(repo: RepoInfo): string { interface AddInstructionsParams { payload: Payload; repo: RepoInfo; + tools: ToolPermissions; } -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; +/** + * Generate shell instructions based on bash permission level. + */ +function getShellInstructions(bash: ToolPermissions["bash"]): string { + switch (bash) { + case "disabled": + return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; + case "restricted": + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + case "enabled": + return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + default: { + const _exhaustive: never = bash; + return _exhaustive satisfies never; + } + } +} + +export const addInstructions = ({ payload, repo, tools }: AddInstructionsParams) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); @@ -142,11 +159,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. -${ - useNativeBash - ? `**Shell commands**: Use your native bash/shell tool for shell command execution.` - : `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.` -} +${getShellInstructions(tools.bash)} **Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished. diff --git a/agents/opencode.ts b/agents/opencode.ts index 8ea3fcb..c5a2439 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -1,5 +1,4 @@ import { mkdirSync, writeFileSync } from "node:fs"; - import { join } from "node:path"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; @@ -10,6 +9,7 @@ import { createAgentEnv, installFromNpmTarball, setupProcessAgentEnv, + type ToolPermissions, } from "./shared.ts"; export const opencode = agent({ @@ -30,28 +30,21 @@ export const opencode = agent({ cliPath, repo, effort: _effort, + tools, }) => { // 1. configure home/config directory const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); - configureOpenCode({ - mcpServers, - sandbox: payload.sandbox ?? false, - isPublicRepo: repo.isPublic, - }); + configureOpenCode({ mcpServers, tools }); - const prompt = addInstructions({ payload, repo }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); // message positional must come right after "run", before flags const args = ["run", prompt, "--format", "json"]; - if (payload.sandbox) { - log.info("πŸ”’ sandbox mode enabled: restricting to read-only operations"); - } - // 6. set up environment setupProcessAgentEnv({ HOME: tempHome }); @@ -200,15 +193,14 @@ export const opencode = agent({ interface ConfigureOpenCodeParams { mcpServers: ConfigureMcpServersParams["mcpServers"]; - sandbox: boolean; - isPublicRepo: boolean; + tools: ToolPermissions; } /** * 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, isPublicRepo }: ConfigureOpenCodeParams): void { +function configureOpenCode({ mcpServers, tools }: ConfigureOpenCodeParams): void { const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); @@ -232,25 +224,15 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenC }; } - // 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: bashPermission, - webfetch: "allow", - doom_loop: "allow", - external_directory: "allow", - }; + // build permission object based on tool permissions + // note: OpenCode has no built-in web search tool + const permission = { + edit: tools.write === "disabled" ? "deny" : "allow", + bash: tools.bash !== "enabled" ? "deny" : "allow", + webfetch: tools.web === "disabled" ? "deny" : "allow", + doom_loop: "allow", + external_directory: "allow", + }; // build complete config in one object const config = { @@ -268,7 +250,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenC throw error; } - log.info(`Β» OpenCode config written to ${configPath} (sandbox: ${sandbox})`); + log.info(`Β» OpenCode config written to ${configPath}`); + log.info( + `πŸ”§ OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}` + ); log.debug(`OpenCode config contents:\n${configJson}`); } diff --git a/agents/shared.ts b/agents/shared.ts index 3280d8a..fe2c873 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -36,6 +36,22 @@ export interface RepoInfo { isPublic: boolean; } +/** + * Tool permission levels + */ +export type ToolPermission = "disabled" | "enabled"; +export type BashPermission = "disabled" | "restricted" | "enabled"; + +/** + * Granular tool permissions for agents + */ +export interface ToolPermissions { + web: ToolPermission; + search: ToolPermission; + write: ToolPermission; + bash: BashPermission; +} + /** * Configuration for agent creation */ @@ -47,6 +63,7 @@ export interface AgentConfig { cliPath: string; repo: RepoInfo; effort: Effort; + tools: ToolPermissions; } /** diff --git a/dispatch/entry b/dispatch/entry index b7ea9f4..a36f5c0 100755 --- a/dispatch/entry +++ b/dispatch/entry @@ -100281,7 +100281,7 @@ function getModes({ disableProgressComment }) { ` }, { - name: "Address Reviews", + name: "AddressReviews", description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps. THINK HARDER. 1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs). @@ -100406,8 +100406,21 @@ function buildRuntimeContext(repo) { } return lines.join("\n"); } -var addInstructions = ({ payload, repo }) => { - const useNativeBash = !repo.isPublic; +function getShellInstructions(bash) { + switch (bash) { + case "disabled": + return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; + case "restricted": + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + case "enabled": + return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + default: { + const _exhaustive = bash; + return _exhaustive; + } + } +} +var addInstructions = ({ payload, repo, tools }) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { @@ -100462,7 +100475,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. -${useNativeBash ? `**Shell commands**: Use your native bash/shell tool for shell command execution.` : `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`} +${getShellInstructions(tools.bash)} **Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished. @@ -104622,6 +104635,14 @@ var claudeEffortModels = { auto: "opusplan", max: "opus" }; +function buildDisallowedTools(tools) { + const disallowed = []; + if (tools.web === "disabled") disallowed.push("WebFetch"); + if (tools.search === "disabled") disallowed.push("WebSearch"); + if (tools.write === "disabled") disallowed.push("Write"); + if (tools.bash !== "enabled") disallowed.push("Bash"); + return disallowed; +} var claude = agent({ name: "claude", install: async () => { @@ -104632,30 +104653,19 @@ var claude = agent({ executablePath: "cli.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, repo }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const model = claudeEffortModels[effort]; log.info(`Using model: ${model} (effort: ${effort})`); - const disallowedTools = repo.isPublic ? ["Bash"] : []; - const sandboxOptions = payload.sandbox ? { - permissionMode: "default", - disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"], - async canUseTool(toolName, input, _options) { - if (toolName.startsWith("mcp__gh_pullfrog__")) - return { behavior: "allow", updatedInput: input, updatedPermissions: [] }; - return { behavior: "deny", message: "tool not allowed in sandbox mode" }; - } - } : { - permissionMode: "bypassPermissions", - disallowedTools - }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); + const disallowedTools = buildDisallowedTools(tools); + if (disallowedTools.length > 0) { + log.info(`\u{1F512} disallowed tools: ${disallowedTools.join(", ")}`); } const queryOptions = { - ...sandboxOptions, + permissionMode: "bypassPermissions", + disallowedTools, mcpServers, model, pathToClaudeCodeExecutable: cliPath, @@ -105122,32 +105132,35 @@ var codexReasoningEffort = { // use default max: "high" }; -function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }) { +function writeCodexConfig({ tempHome, mcpServers, tools }) { const codexDir = join6(tempHome, ".codex"); mkdirSync3(codexDir, { recursive: true }); const configPath = join6(codexDir, "config.toml"); const mcpServerSections = []; for (const [name, config4] of Object.entries(mcpServers)) { if (config4.type !== "http") continue; - log.info(`\xBB Adding MCP server '${name}' at ${config4.url}`); + log.info(`\xBB adding MCP server '${name}' at ${config4.url}`); mcpServerSections.push(`[mcp_servers.${name}] url = "${config4.url}"`); } - const shellPolicy = isPublicRepo ? `[shell_environment_policy] -ignore_default_excludes = false` : ""; + const features = []; + if (tools.bash !== "enabled") { + features.push("shell_command_tool = false"); + features.push("unified_exec = false"); + } + const featuresSection = features.length > 0 ? `[features] +${features.join("\n")}` : ""; writeFileSync( configPath, `# written by pullfrog -${shellPolicy} +${featuresSection} ${mcpServerSections.join("\n\n")} `.trim() + "\n" ); - if (isPublicRepo) { - log.info(`\xBB Codex config written to ${configPath} (env filtering: enabled)`); - } else { - log.info(`\xBB Codex config written to ${configPath} (private repo: no env filtering)`); - } + log.info( + `\xBB Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})` + ); return codexDir; } var codex = agent({ @@ -105159,14 +105172,14 @@ var codex = agent({ executablePath: "bin/codex.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join6(tempHome, ".config", "codex"); mkdirSync3(configDir, { recursive: true }); const codexDir = writeCodexConfig({ tempHome, mcpServers, - isPublicRepo: repo.isPublic + tools }); setupProcessAgentEnv({ OPENAI_API_KEY: apiKey, @@ -105184,26 +105197,24 @@ var codex = agent({ apiKey, codexPathOverride: cliPath }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } const codex2 = new Codex(codexOptions); - const baseThreadOptions = payload.sandbox ? { + const threadOptions = { model, approvalPolicy: "never", - sandboxMode: "read-only", - networkAccessEnabled: false - } : { - model, - approvalPolicy: "never", - // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) - sandboxMode: "danger-full-access", - networkAccessEnabled: true + // write: "disabled" β†’ read-only sandbox, otherwise full access for git ops + sandboxMode: tools.write === "disabled" ? "read-only" : "danger-full-access", + // web: controls network access + networkAccessEnabled: tools.web !== "disabled", + // search: controls web search + webSearchEnabled: tools.search !== "disabled", + ...modelReasoningEffort && { modelReasoningEffort } }; - const threadOptions = modelReasoningEffort ? { ...baseThreadOptions, modelReasoningEffort } : baseThreadOptions; + log.info( + `\u{1F527} Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}` + ); const thread = codex2.startThread(threadOptions); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools })); let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; @@ -105330,9 +105341,9 @@ var cursor = agent({ executableName: "cursor-agent" }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort, tools }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); + configureCursorTools({ tools }); const projectCliConfigPath = join7(process.cwd(), ".cursor", "cli.json"); let modelOverride = null; if (existsSync4(projectCliConfigPath)) { @@ -105404,16 +105415,13 @@ var cursor = agent({ } }; try { - const fullPrompt = addInstructions({ payload, repo }); + const fullPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(fullPrompt)); const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"]; if (modelOverride) { baseArgs.push("--model", modelOverride); } - const cursorArgs = payload.sandbox ? baseArgs : [...baseArgs, "--force"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } + const cursorArgs = [...baseArgs, "--force"]; log.info("Running Cursor CLI..."); const startTime = Date.now(); return new Promise((resolve2) => { @@ -105515,29 +105523,32 @@ function configureCursorMcpServers({ mcpServers }) { writeFileSync2(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } -function configureCursorSandbox({ - sandbox, - isPublicRepo -}) { +function configureCursorTools({ tools }) { 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 ? { + const deny = []; + if (tools.search === "disabled") deny.push("WebSearch"); + if (tools.write === "disabled") deny.push("Write(**)"); + if (tools.bash !== "enabled") deny.push("Shell(*)"); + const needsSandbox = tools.web === "disabled"; + const config4 = { permissions: { - allow: ["Read(**)"], - deny: ["Write(**)", ...denyShell] - } - } : { - permissions: { - allow: ["Read(**)", "Write(**)"], - deny: denyShell + allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"], + deny } }; + if (needsSandbox) { + config4.sandbox = { + mode: "enabled", + networkAccess: "allowlist" + }; + } writeFileSync2(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); + log.info(`\xBB CLI config written to ${cliConfigPath}`); log.info( - `\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + `\u{1F527} Cursor permissions: allow=${config4.permissions.allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}` ); } @@ -105714,37 +105725,16 @@ var gemini = agent({ ...githubInstallationToken2 && { githubInstallationToken: githubInstallationToken2 } }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => { const { model, thinkingLevel } = geminiEffortConfig[effort]; log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`); - configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel }); + configureGeminiSettings({ mcpServers, tools, thinkingLevel }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo }); + const sessionPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(sessionPrompt)); - let args3; - if (payload.sandbox) { - args3 = [ - "--model", - model, - "--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 = ["--model", model, "--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"); - } + const args3 = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt]; let finalOutput2 = ""; let stdoutBuffer = ""; try { @@ -105810,7 +105800,7 @@ var gemini = agent({ }); function configureGeminiSettings({ mcpServers, - isPublicRepo, + tools, thinkingLevel }) { const realHome = homedir3(); @@ -105835,8 +105825,13 @@ function configureGeminiSettings({ trust: true // trust our own MCP server to avoid confirmation prompts }; - log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`); + log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`); } + const exclude = []; + if (tools.bash !== "enabled") exclude.push("run_shell_command"); + if (tools.write === "disabled") exclude.push("write_file"); + if (tools.web === "disabled") exclude.push("web_fetch"); + if (tools.search === "disabled") exclude.push("google_web_search"); const newSettings = { ...existingSettings, mcpServers: geminiMcpServers, @@ -105848,13 +105843,15 @@ function configureGeminiSettings({ thinkingLevel } } - } + }, + // v0.3.0+ nested format + ...exclude.length > 0 && { tools: { exclude } } }; - if (isPublicRepo) { - newSettings.excludeTools = ["run_shell_command"]; - } writeFileSync3(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB Gemini settings written to ${settingsPath}`); + if (exclude.length > 0) { + log.info(`\u{1F512} excluded tools: ${exclude.join(", ")}`); + } } // agents/opencode.ts @@ -105877,22 +105874,16 @@ var opencode = agent({ mcpServers, cliPath, repo, - effort: _effort + effort: _effort, + tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); - configureOpenCode({ - mcpServers, - sandbox: payload.sandbox ?? false, - isPublicRepo: repo.isPublic - }); - const prompt = addInstructions({ payload, repo }); + configureOpenCode({ mcpServers, tools }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const args3 = ["run", prompt, "--format", "json"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } setupProcessAgentEnv({ HOME: tempHome }); const env3 = { ...createAgentEnv({ HOME: tempHome }), @@ -106002,7 +105993,7 @@ var opencode = agent({ }; } }); -function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { +function configureOpenCode({ mcpServers, tools }) { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); @@ -106022,17 +106013,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { url: serverConfig.url }; } - 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", + const permission = { + edit: tools.write === "disabled" ? "deny" : "allow", + bash: tools.bash !== "enabled" ? "deny" : "allow", + webfetch: tools.web === "disabled" ? "deny" : "allow", doom_loop: "allow", external_directory: "allow" }; @@ -106049,7 +106033,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { ); throw error50; } - log.info(`\xBB OpenCode config written to ${configPath} (sandbox: ${sandbox})`); + log.info(`\xBB OpenCode config written to ${configPath}`); + log.info( + `\u{1F527} OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}` + ); log.debug(`OpenCode config contents: ${configJson}`); } @@ -106212,9 +106199,10 @@ var agents = { // utils/api.ts var DEFAULT_REPO_SETTINGS = { defaultAgent: null, - webAccessLevel: "full_access", - webAccessAllowTrusted: false, - webAccessDomains: "", + web: "enabled", + search: "enabled", + write: "enabled", + bash: "restricted", modes: [] }; async function fetchWorkflowRunInfo(runId) { @@ -138323,13 +138311,18 @@ var Timer = class { }; // main.ts +var ToolPermissionInput = type.enumerated("disabled", "enabled"); +var BashPermissionInput = type.enumerated("disabled", "restricted", "enabled"); var Inputs = type({ prompt: "string", "effort?": Effort, "agent?": AgentName.or("null"), "event?": "object", "modes?": ModeSchema.array(), - "sandbox?": "boolean", + "web?": ToolPermissionInput, + "search?": ToolPermissionInput, + "write?": ToolPermissionInput, + "bash?": BashPermissionInput, "disableProgressComment?": "true", "comment_id?": "number|null", "issue_id?": "number|null", @@ -138597,7 +138590,6 @@ function parsePayload(inputs) { event: inputs.event, modes: inputs.modes ?? modes, effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox, disableProgressComment: inputs.disableProgressComment, comment_id: inputs.comment_id, issue_id: inputs.issue_id, @@ -138622,8 +138614,7 @@ function parsePayload(inputs) { trigger: "unknown" }, modes: inputs.modes ?? modes, - effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox + effort: inputs.effort ?? "auto" }; } } @@ -138668,6 +138659,14 @@ function validateApiKey(params) { apiKeys }; } +function computeToolPermissions(params) { + return { + web: params.inputs.web ?? "enabled", + search: params.inputs.search ?? "enabled", + write: params.inputs.write ?? "enabled", + bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled") + }; +} async function runAgent(ctx) { const effort = ctx.payload.effort ?? "auto"; log.info(`Running ${ctx.agent.name} with effort=${effort}...`); @@ -138676,6 +138675,10 @@ async function runAgent(ctx) { ${encode(eventWithoutContext)}`; log.box(promptContent, { title: "Prompt" }); + const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private }); + log.info( + `Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}` + ); return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, @@ -138688,7 +138691,8 @@ ${encode(eventWithoutContext)}`; defaultBranch: ctx.repo.default_branch, isPublic: !ctx.repo.private }, - effort + effort, + tools }); } async function handleAgentResult(result) { @@ -138726,7 +138730,11 @@ async function run() { agent: payloadObj.agent, event: payloadObj.event, modes: payloadObj.modes, - sandbox: payloadObj.sandbox, + // granular tool permissions + web: payloadObj.web, + search: payloadObj.search, + write: payloadObj.write, + bash: payloadObj.bash, disableProgressComment: payloadObj.disableProgressComment, comment_id: payloadObj.comment_id, issue_id: payloadObj.issue_id, diff --git a/dispatch/entry.ts b/dispatch/entry.ts index d25337a..78a85cf 100644 --- a/dispatch/entry.ts +++ b/dispatch/entry.ts @@ -33,7 +33,11 @@ async function run(): Promise { agent: payloadObj.agent, event: payloadObj.event, modes: payloadObj.modes, - sandbox: payloadObj.sandbox, + // granular tool permissions + web: payloadObj.web, + search: payloadObj.search, + write: payloadObj.write, + bash: payloadObj.bash, disableProgressComment: payloadObj.disableProgressComment, comment_id: payloadObj.comment_id, issue_id: payloadObj.issue_id, diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index d29d96f..0000000 --- a/docs/docker.md +++ /dev/null @@ -1,52 +0,0 @@ -# Docker Testing Environment - -`play.ts` runs in Docker by default for realistic testing (Linux, clean $HOME, matches CI). - -## Usage - -```bash -pnpm play bash-test.ts # runs in Docker (default) -pnpm play --local bash-test.ts # runs on macOS (fast iteration) -PLAY_LOCAL=1 pnpm play ... # same as --local -``` - -## Why Docker by Default? - -1. **Matches CI** - Linux environment like GitHub Actions -2. **Clean $HOME** - No agent config pollution from `~/.claude`, `~/.cursor` -3. **Tests unshare** - Verifies PID namespace sandbox works -4. **Reproducible** - Same environment every run - -## Performance - -| Mode | Overhead | -|------|----------| -| Docker (cached deps) | ~1.5s | -| Local (macOS) | ~0s | - -For agent runs taking 30-120s, the 1.5s overhead is negligible. - -## How It Works - -1. `play.ts` runs on host, loads `.env` -2. Spawns Docker container with: - - Volume-mounted `action/` code - - Named volume for Linux node_modules (persists between runs) - - SSH agent forwarding for git clone - - Env vars passed via `-e` flags -3. Inside Docker, `play.ts` runs again (detects `/.dockerenv` file) -4. Clones `GITHUB_REPOSITORY`, runs agent - -## Troubleshooting - -**Docker not running:** -``` -Cannot connect to the Docker daemon -``` -β†’ Start Docker Desktop - -**SSH clone fails:** -``` -Permission denied (publickey) -``` -β†’ Ensure SSH agent is running: `ssh-add -l` diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 63bda04..0000000 --- a/docs/security.md +++ /dev/null @@ -1,306 +0,0 @@ -# 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 | diff --git a/docs/webfetch.md b/docs/webfetch.md deleted file mode 100644 index 16bcc0d..0000000 --- a/docs/webfetch.md +++ /dev/null @@ -1,520 +0,0 @@ -# WebFetch Tool Analysis - -Analysis of webfetch/URL fetching implementations across three AI coding agents to inform the design of pullfrog's custom webfetch MCP tool. - ---- - -## 1. OpenCode Implementation - -**Source**: `packages/opencode/src/tool/webfetch.ts` - -### Architecture - -OpenCode's webfetch is straightforward - a simple fetch wrapper with HTML-to-markdown conversion: - -```typescript -const response = await fetch(params.url, { - signal: AbortSignal.any([controller.signal, ctx.abort]), - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...", - Accept: acceptHeader, - "Accept-Language": "en-US,en;q=0.9", - }, -}) -``` - -### Key Features - -| Feature | Implementation | -|---------|---------------| -| **Output formats** | `text`, `markdown`, `html` (default: markdown) | -| **HTMLβ†’Markdown** | Uses `turndown` library | -| **Max response size** | 5MB hard limit | -| **Timeout** | 30s default, 120s max | -| **Permission system** | Application-level `ctx.ask()` prompt | -| **Domain blocking** | None - relies on user approval | -| **Caching** | None | -| **Redirect handling** | Native fetch behavior | - -### HTML Processing - -Two methods depending on output format: - -1. **`extractTextFromHTML()`** - Uses Bun's `HTMLRewriter` to strip scripts/styles and extract text -2. **`convertHTMLToMarkdown()`** - Uses `turndown` with sensible defaults (ATX headings, fenced code blocks) - -### Permission Model - -```typescript -await ctx.ask({ - permission: "webfetch", - patterns: [params.url], - always: ["*"], // User can allow all future requests - metadata: { url, format, timeout }, -}) -``` - -**Verdict**: No enforcement - purely advisory. If user approves, the fetch proceeds with no restrictions. - -### What I Like -- Clean, minimal implementation -- Good HTML processing with `turndown` -- Sensible size limits (5MB) -- Format flexibility - -### What I Don't Like -- No domain whitelisting/blocklisting -- No caching (repeated requests to same URL are wasteful) -- Permission system is advisory-only -- No redirect safety checks - ---- - -## 2. Claude Code Implementation - -**Source**: Extracted from bundled `claude` CLI binary - -### Architecture - -Claude Code uses a more sophisticated approach with server-side domain validation: - -```javascript -// Domain validation before fetch -async function Ci5(domain) { - const response = await fetch( - `https://claude.ai/api/web/domain_info?domain=${encodeURIComponent(domain)}` - ); - if (response.status === 200) { - return response.data.can_fetch === true - ? { status: "allowed" } - : { status: "blocked" }; - } - return { status: "check_failed" }; -} -``` - -### Key Features - -| Feature | Implementation | -|---------|---------------| -| **Domain blocklist** | Server-side API at `claude.ai/api/web/domain_info` | -| **Permission format** | `WebFetch(domain:example.com)` - domain-only, not URLs | -| **Wildcard support** | `domain:*.google.com` patterns | -| **HTTPβ†’HTTPS upgrade** | Automatic protocol upgrade | -| **Caching** | 15-minute self-cleaning cache | -| **HTMLβ†’Markdown** | Uses `turndown` | -| **Redirect handling** | Special handling - informs user of cross-host redirects | -| **Enterprise override** | `skipWebFetchPreflight` setting | - -### Domain Permission Model - -Claude Code enforces domain-level permissions, not URL-level: - -```javascript -WebFetch: (A) => { - if (A.includes("://") || A.startsWith("http")) - return { - valid: false, - error: "WebFetch permissions use domain format, not URLs", - suggestion: 'Use "domain:hostname" format', - examples: ["WebFetch(domain:example.com)", "WebFetch(domain:github.com)"] - }; - if (!A.startsWith("domain:")) - return { - valid: false, - error: 'WebFetch permissions must use "domain:" prefix', - examples: ["WebFetch(domain:example.com)", "WebFetch(domain:*.google.com)"] - }; - return { valid: true }; -} -``` - -### Blocklist Enforcement Flow - -``` -User requests URL - ↓ -Extract hostname - ↓ -Check claude.ai/api/web/domain_info?domain=hostname - ↓ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ allowed β†’ proceed with fetch β”‚ -β”‚ blocked β†’ throw AC0 error β”‚ -β”‚ check_failed β†’ throw QC0 error β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### Redirect Handling - -When a URL redirects to a different host: -```javascript -// Returns special response asking user to manually re-request -return `To complete your request, I need to fetch content from the redirected URL. -Please use WebFetch again with these parameters: -- url: "${redirectUrl}" -- prompt: "${originalPrompt}"`; -``` - -This prevents open redirect attacks where a "safe" domain redirects to a malicious one. - -### What I Like -- **Server-side blocklist** - centralized, updateable without client changes -- **Domain-level permissions** - prevents path-based bypasses -- **Redirect safety** - cross-host redirects require explicit user action -- **15-minute caching** - reduces redundant requests -- **Enterprise override** - `skipWebFetchPreflight` for corporate environments - -### What I Don't Like -- **External dependency** - requires `claude.ai` API to be available -- **No local blocklist** - can't work offline or with custom blocklists -- **Opaque blocklist** - users can't see what's blocked or why - ---- - -## 3. Gemini CLI Implementation - -**Source**: `@google/gemini-cli` npm package - -### Architecture - -Gemini CLI takes a fundamentally different approach - it doesn't have a dedicated webfetch tool. Instead it relies on: - -1. **Google Search grounding** - built into the Gemini API -2. **MCP servers** - external tools can provide fetch capabilities -3. **No native URL fetching** - by design - -### Key Observations - -From searching the codebase: -- No `webfetch`, `url_fetch`, or similar tool definitions -- Has `github_fetch.ts` for fetching GitHub releases (internal use) -- Relies on model's built-in capabilities or MCP extensions - -### Why No WebFetch? - -Gemini's design philosophy appears to be: -1. Use the model's grounding capabilities for web information -2. Delegate specialized fetching to MCP servers -3. Avoid building network access into the CLI itself - -### What I Like -- **Clean separation** - network access is opt-in via MCP -- **Security by default** - no built-in way to exfiltrate data - -### What I Don't Like -- **Missing functionality** - can't fetch arbitrary URLs -- **Requires MCP setup** - more complex for users who need fetching - ---- - -## 4. Pullfrog Design Decisions - -### Core Requirements - -1. **Domain-level whitelisting** - enforced in-tool, not advisory -2. **Simple implementation** - no external API dependencies -3. **GitHub-focused** - optimized for common development URLs - -### Proposed Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ WebFetch Tool β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ 1. Parse URL β†’ extract hostname β”‚ -β”‚ 2. Check against DOMAIN_ALLOWLIST β”‚ -β”‚ 3. If not allowed β†’ return error (not throw) β”‚ -β”‚ 4. Fetch with timeout + size limits β”‚ -β”‚ 5. Convert HTML β†’ Markdown if needed β”‚ -β”‚ 6. Return content β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### Domain Allowlist Strategy - -**Included in initial allowlist**: -```typescript -const DOMAIN_ALLOWLIST = new Set([ - // Documentation sites - "docs.github.com", - "developer.mozilla.org", - "nodejs.org", - "docs.python.org", - "go.dev", - "doc.rust-lang.org", - "docs.microsoft.com", - "learn.microsoft.com", - - // Package registries (documentation) - "npmjs.com", - "www.npmjs.com", - "pypi.org", - "crates.io", - "pkg.go.dev", - - // GitHub (raw content, gists) - "raw.githubusercontent.com", - "gist.githubusercontent.com", - - // Common API documentation - "api.github.com", // Already have GitHub tools, but for reference docs -]); -``` - -**Explicitly NOT included**: -- `github.com` itself - we have dedicated GitHub MCP tools -- Social media sites -- General web pages -- Arbitrary user-provided domains - -### Features Borrowed from Each Agent - -| Feature | Source | Included? | Rationale | -|---------|--------|-----------|-----------| -| HTMLβ†’Markdown via turndown | OpenCode | βœ… | Clean, proven library | -| 5MB size limit | OpenCode | βœ… | Sensible default | -| Domain-level permissions | Claude Code | βœ… | Core requirement | -| Redirect safety checks | Claude Code | βœ… | Prevents open redirect attacks | -| 15-minute caching | Claude Code | ❌ | Adds complexity, MCP is stateless | -| Server-side blocklist | Claude Code | ❌ | External dependency | -| Enterprise override | Claude Code | ❌ | Not needed for GitHub Actions | -| No built-in fetching | Gemini | ❌ | We need this functionality | - -### Features NOT Included (and why) - -1. **Caching** - MCP tools are stateless by design. Caching would require shared state across requests. The agent can cache results itself. - -2. **Server-side blocklist** - Would require standing up an API endpoint. The allowlist approach is simpler and more transparent. - -3. **User permission prompts** - In GitHub Actions context, there's no interactive user. Allowlist is enforced automatically. - -4. **Wildcard domain patterns** - Adds complexity. Start with explicit domains, add patterns if needed. - -5. **Multiple output formats** - Start with markdown only. Can add `text` and `html` later if needed. - -### Error Handling Strategy - -Unlike OpenCode/Claude which throw errors, we return errors as content: - -```typescript -// Domain not allowed - return message, don't throw -if (!isDomainAllowed(hostname)) { - return { - output: `Domain "${hostname}" is not in the allowlist. Allowed domains: ${Array.from(DOMAIN_ALLOWLIST).join(", ")}`, - error: true, - }; -} -``` - -This lets the agent understand the limitation and potentially find alternative approaches. - -### Redirect Handling - -Adopt Claude Code's approach with modification: - -```typescript -// If redirect crosses domains, check the new domain -if (response.redirected) { - const redirectUrl = new URL(response.url); - if (!isDomainAllowed(redirectUrl.hostname)) { - return { - output: `URL redirected to "${redirectUrl.hostname}" which is not in the allowlist.`, - error: true, - }; - } -} -``` - ---- - -## 5. Implementation Plan - -### Summary - -Add a new `webfetch` MCP tool that fetches web content with domain-level whitelisting enforced server-side. The whitelist is configured via the payload (from GitHub App), and non-whitelisted domains return a helpful message guiding the LLM to alternative approaches. - -### Key Design Decisions - -| Aspect | OpenCode | Claude Code | Our Implementation | -|--------|----------|-------------|-------------------| -| **Whitelisting** | Permission prompt (advisory) | External API `domain_info` | Payload-configured whitelist | -| **Enforcement** | None (user approval) | Server-side check | Server-side check | -| **HTML Processing** | Turndown for markdown | Turndown for markdown | Turndown for markdown | -| **Redirects** | Follows automatically | Detects cross-host redirects | Follow with host check | -| **Timeout** | 30s default, 120s max | Configurable | 30s default, 120s max | - -### Step 1: Add whitelist to payload type - -Update `index.ts` to include `allowedWebFetchDomains`: - -```typescript -interface Payload { - // ... existing fields - allowedWebFetchDomains?: string[]; // e.g. ["github.com", "*.npmjs.com", "docs.python.org"] -} -``` - -### Step 2: Create `mcp/webfetch.ts` - -```typescript -// Core structure -export const WebFetchParams = type({ - url: "string", - "format?": "'markdown' | 'text' | 'html'", - "timeout?": "number", -}); - -export function WebFetchTool(ctx: ToolContext) { - return tool({ - name: "webfetch", - description: `Fetch content from whitelisted web URLs...`, - parameters: WebFetchParams, - execute: execute(async (params) => { - // 1. Validate URL format - // 2. Check domain against whitelist (from ctx.payload) - // 3. Fetch with timeout and size limits - // 4. Convert HTML to markdown if needed - // 5. Return content or guidance message - }), - }); -} -``` - -### Step 3: Domain Matching Logic - -Support wildcards for subdomains: - -- `github.com` - exact match -- `*.github.com` - any subdomain (e.g., `docs.github.com`, `api.github.com`) -- `*.npmjs.com` - matches `www.npmjs.com`, `registry.npmjs.com`, etc. - -```typescript -function isDomainAllowed(hostname: string, whitelist: string[]): boolean { - for (const pattern of whitelist) { - if (pattern.startsWith("*.")) { - const suffix = pattern.slice(1); // ".github.com" - if (hostname.endsWith(suffix) || hostname === pattern.slice(2)) { - return true; - } - } else if (hostname === pattern) { - return true; - } - } - return false; -} -``` - -### Step 4: Response for Non-Whitelisted Domains - -When domain is not whitelisted, return guidance (not an error): - -```typescript -return { - allowed: false, - message: `The domain "${hostname}" is not in the allowed list for direct fetching. ` + - `Consider using web_search to find relevant information, or ask the user to ` + - `provide the content directly. Allowed domains: ${whitelist.join(", ")}`, -}; -``` - -### Step 5: HTML to Markdown Conversion - -Use Turndown (same as OpenCode) for HTML-to-markdown conversion: - -```typescript -import TurndownService from "turndown"; - -function htmlToMarkdown(html: string): string { - const turndown = new TurndownService({ - headingStyle: "atx", - codeBlockStyle: "fenced", - }); - turndown.remove(["script", "style", "meta", "link"]); - return turndown.turndown(html); -} -``` - -### Step 6: Register the Tool - -Add to `mcp/index.ts`: - -```typescript -import { WebFetchTool } from "./webfetch.ts"; - -// In the tools array -WebFetchTool(ctx), -``` - -### Data Flow - -```mermaid -sequenceDiagram - participant LLM - participant MCP as MCP Server - participant WF as WebFetch Tool - participant Web as External URL - - LLM->>MCP: webfetch(url, format) - MCP->>WF: execute(params) - WF->>WF: Parse URL, extract hostname - WF->>WF: Check whitelist from payload - alt Domain allowed - WF->>Web: fetch(url) - Web-->>WF: Response - WF->>WF: Convert to markdown - WF-->>MCP: {content, contentType} - MCP-->>LLM: Success result - else Domain not allowed - WF-->>MCP: {allowed: false, guidance} - MCP-->>LLM: Guidance message - end -``` - -### Files to Create/Modify - -| File | Action | -|------|--------| -| `mcp/webfetch.ts` | Create - main tool implementation | -| `mcp/index.ts` | Modify - register the tool | -| `index.ts` | Modify - add `allowedWebFetchDomains` to payload type | -| `package.json` | Modify - add `turndown` dependency | - -### Dependencies - -Add to `package.json`: - -- `turndown` - HTML to markdown conversion (same as OpenCode) -- `@types/turndown` - TypeScript types - ---- - -## 6. Implementation Checklist - -- [ ] Add `allowedWebFetchDomains` field to payload type in `index.ts` -- [ ] Create `mcp/webfetch.ts` with domain whitelisting and HTML conversion -- [ ] Register `WebFetchTool` in `mcp/index.ts` -- [ ] Add `turndown` and `@types/turndown` dependencies to `package.json` -- [ ] Test with allowed domains -- [ ] Test with blocked domains -- [ ] Test redirect behavior - ---- - -## 7. Open Questions - -1. **Should we support query parameters in allowlist?** - - e.g., allow `api.example.com/v1/*` but not `api.example.com/admin/*` - - Initial decision: No, domain-level only - -2. **Should we allow configurable allowlists?** - - Via environment variable or config file? - - Initial decision: No, hardcoded for simplicity - -3. **Should we support authentication headers?** - - For private documentation sites - - Initial decision: No, security risk - -4. **Rate limiting?** - - Prevent agent from hammering a site - - Initial decision: Rely on timeout, add if needed diff --git a/docs/websearch.md b/docs/websearch.md deleted file mode 100644 index 6601b18..0000000 --- a/docs/websearch.md +++ /dev/null @@ -1,435 +0,0 @@ -# Web Search Functionality by Agent - -This document describes how each supported agent implements web search functionality. - -## Summary - -| Agent | Tool Name | Search Provider | API/Method | -|-------|-----------|-----------------|------------| -| Claude Code | `WebSearch` | Anthropic internal | Claude Code SDK | -| Gemini CLI | `google_web_search` | Google Search via Gemini API | `generateContent` with `model: 'web-search'` | -| OpenCode | `websearch` | Exa AI | MCP protocol to `https://mcp.exa.ai/mcp` | - -All three agents also support a separate **web fetch** tool for directly retrieving and parsing web page content. - ---- - -## Claude Code - -### Tools -- `WebSearch` - Search the web for information -- `WebFetch` - Fetch and process web content - -### Implementation -Native functionality through `@anthropic-ai/claude-agent-sdk` (closed source). The actual search provider is internal to Anthropic's infrastructure. - -### Configuration in Pullfrog - -Web search can be disabled via the `disallowedTools` option: - -```typescript -// In sandbox mode, web tools are disabled -disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"] -``` - ---- - -## Gemini CLI - -### Tools -- `google_web_search` - Perform web searches using Google Search -- `web_fetch` - Fetch and process content from URLs - -### Implementation - -Source: [`packages/core/src/tools/web-search.ts`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/tools/web-search.ts) - -**How it works:** -1. Sends query to Gemini API using `generateContent` with `model: 'web-search'` -2. Google performs the search and returns results with grounding metadata -3. Response includes inline citations, source URLs, and titles - -```typescript -const response = await geminiClient.generateContent( - { model: 'web-search' }, - [{ role: 'user', parts: [{ text: this.params.query }] }], - signal, -); -``` - -### Features -- Returns processed summary (not raw search results) -- Inline citations with grounding metadata -- Sources list with titles and URIs -- UTF-8 byte position handling for accurate citation insertion - -### Parameters -- `query` (string, required): The search query - -### Web Fetch - -The `web_fetch` tool processes content from URLs: -- Uses Gemini API's `urlContext` feature -- Fallback to direct HTTP fetch with `html-to-text` conversion -- Supports up to 20 URLs per request -- Converts GitHub blob URLs to raw URLs automatically - ---- - -## OpenCode - -### Tools -- `websearch` - Search the web using Exa AI -- `webfetch` - Fetch and read web pages - -### Implementation - -Source: [`packages/opencode/src/tool/websearch.ts`](https://github.com/sst/opencode/blob/main/packages/opencode/src/tool/websearch.ts) - -**How it works:** -1. Calls Exa AI's MCP endpoint at `https://mcp.exa.ai/mcp` -2. Uses JSON-RPC protocol to invoke the `web_search_exa` tool -3. Parses SSE response for search results - -```typescript -const searchRequest: McpSearchRequest = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search_exa", - arguments: { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - }, -} -``` - -### Features -- Real-time web searches with content scraping -- Configurable result count (default: 8) -- Live crawl modes: `fallback` (backup if cached unavailable) or `preferred` (prioritize live crawling) -- Search types: `auto` (balanced), `fast` (quick results), `deep` (comprehensive) -- Context max characters for LLM optimization - -### Parameters -- `query` (string, required): The search query -- `numResults` (number, optional): Number of results to return (default: 8) -- `livecrawl` (enum, optional): `"fallback"` | `"preferred"` -- `type` (enum, optional): `"auto"` | `"fast"` | `"deep"` -- `contextMaxCharacters` (number, optional): Maximum characters for context - -### Configuration in Pullfrog - -Web tools are configured via the permission config in `opencode.json`: - -```typescript -// In sandbox mode -permission: { - webfetch: "deny", - // ... -} - -// In normal mode -permission: { - webfetch: "allow", - // ... -} -``` - -### Environment Variables -- `OPENCODE_ENABLE_EXA` - Enable Exa web search tools (required for "zen" users) - -### Web Fetch - -The `webfetch` tool directly fetches URLs: -- Direct HTTP fetch with browser-like User-Agent -- HTML to Markdown conversion using Turndown -- Configurable timeout (max 120 seconds) -- 5MB response size limit - ---- - -## Comparison - -| Feature | Claude Code | Gemini CLI | OpenCode | -|---------|-------------|------------|----------| -| Search Provider | Anthropic | Google | Exa AI | -| Result Format | Summary | Summary + Citations | Raw content | -| URL Fetching | Yes (`WebFetch`) | Yes (`web_fetch`) | Yes (`webfetch`) | -| Grounding/Citations | Unknown | Yes | No | -| Configurable Results | No | No | Yes (numResults) | -| Search Depth Options | No | No | Yes (auto/fast/deep) | -| Live Crawling | Unknown | Fallback only | Configurable | - ---- - -## Security Considerations - -In Pullfrog's sandbox mode: -- **Claude Code**: `WebSearch` and `WebFetch` are explicitly disabled via `disallowedTools` -- **Gemini CLI**: No explicit disable mechanism in the wrapper (relies on default behavior) -- **OpenCode**: `webfetch` permission set to `"deny"` in sandbox mode - -For public repositories, consider the implications of web search/fetch: -- Fetched content could potentially be used to inject prompts -- Search queries might leak information about the codebase context - ---- - -## Proposed Implementation Plan - -### Option 1: Use Native Agent Web Search (Current State) - -Each agent uses its own built-in web search: -- **Pros**: No additional implementation, leverages each provider's strengths -- **Cons**: Inconsistent behavior across agents, no unified control - -**Current gaps:** -- Gemini CLI has no explicit disable mechanism for web search in sandbox mode -- No unified way to configure web search across all agents - -### Option 2: Unified MCP Web Search Tool - -Add a `web_search` tool to the Pullfrog MCP server (`mcp/`) that all agents can use: - -``` -mcp/ -β”œβ”€β”€ bash.ts -β”œβ”€β”€ webSearch.ts # New unified web search tool -└── ... -``` - -**Implementation approach:** - -1. **Create `mcp/webSearch.ts`** with a provider-agnostic interface: - ```typescript - export const webSearchTool = { - name: "web_search", - description: "Search the web for information", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - numResults: { type: "number", description: "Number of results (default: 5)" }, - }, - required: ["query"], - }, - }; - ``` - -2. **Choose a search provider** (options): - - **Exa AI** - Already used by OpenCode, good LLM-optimized results - - **Tavily** - Popular for AI agents, provides search + content extraction - - **SerpAPI** - Google results via API - - **Brave Search API** - Privacy-focused alternative - -3. **Add to MCP server** in `mcp/server.ts`: - ```typescript - import { webSearchTool, handleWebSearch } from "./webSearch.ts"; - // Register tool... - ``` - -4. **Disable native web search** for each agent: - - Claude: Add `"WebSearch"` to `disallowedTools` - - Gemini: Add `"google_web_search"` to `excludeTools` in settings.json - - OpenCode: Set `websearch: "deny"` in permission config - -**Pros:** -- Consistent behavior across all agents -- Centralized control for security/sandbox modes -- Can filter/sanitize results before returning to agent -- Single API key management - -**Cons:** -- Additional API costs (search provider) -- Loses provider-specific features (e.g., Gemini's grounding metadata) - -### Option 3: Hybrid Approach - -Allow native web search for private repos, use MCP tool for public repos: - -```typescript -// In agent configuration -const useNativeWebSearch = !repo.isPublic; - -// Claude -disallowedTools: repo.isPublic ? ["WebSearch", "WebFetch"] : []; - -// Gemini -excludeTools: repo.isPublic ? ["google_web_search", "web_fetch"] : []; - -// OpenCode -permission: { - websearch: repo.isPublic ? "deny" : "allow", -} -``` - -Then for public repos, agents would use the MCP `web_search` tool which: -- Filters sensitive queries -- Sanitizes returned content -- Logs all searches for audit - -### Recommended Approach - -**Short-term**: Implement Option 3 (Hybrid) with these steps: - -1. [ ] Add `excludeTools: ["google_web_search"]` for Gemini in public repo mode -2. [ ] Ensure OpenCode `websearch` permission is properly set for sandbox mode -3. [ ] Document the current native web search behavior for each agent - -**Medium-term**: Implement Option 2 (Unified MCP) for public repos: - -1. [ ] Create `mcp/webSearch.ts` using Exa AI (consistent with OpenCode) -2. [ ] Add `EXA_API_KEY` to secrets handling -3. [ ] Register web search in MCP server -4. [ ] Disable native web search for all agents when MCP tool is available -5. [ ] Add result sanitization to prevent prompt injection - -### API Key Requirements - -| Provider | Environment Variable | Notes | -|----------|---------------------|-------| -| Exa AI | `EXA_API_KEY` | Already used by OpenCode | -| Tavily | `TAVILY_API_KEY` | Popular alternative | -| Brave | `BRAVE_API_KEY` | Privacy-focused | - -For the unified MCP approach, only one search provider API key would be needed. - ---- - -## Proposed Implementation Plan - -### Option A: Unified MCP Web Search Tool - -Create a custom MCP tool that provides consistent web search across all agents. - -**Pros:** -- Consistent behavior and results across agents -- Full control over search provider and rate limiting -- Can implement caching and deduplication -- Single point for security filtering - -**Cons:** -- Additional infrastructure (need a search API key) -- Latency from proxying through MCP server - -**Implementation:** -1. Add `websearch` tool to `mcp/` directory -2. Integrate with a search provider (options: Exa AI, SerpAPI, Brave Search, Tavily) -3. Configure each agent to use MCP tool instead of native: - - Claude: Add to `disallowedTools` and provide via MCP - - Gemini: Use `excludeTools` in settings.json for `google_web_search` - - OpenCode: Disable native via permission config - -```typescript -// mcp/websearch.ts -export const websearchTool = { - name: "websearch", - description: "Search the web for current information", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - numResults: { type: "number", description: "Number of results (1-10)" }, - }, - required: ["query"], - }, - handler: async ({ query, numResults = 5 }) => { - // Use Exa, Brave, or other search API - const results = await searchProvider.search(query, numResults); - return formatResults(results); - }, -}; -``` - -### Option B: Native Tools with Configuration - -Keep using each agent's native web search but add consistent configuration. - -**Pros:** -- No additional infrastructure -- Agents can use optimized native implementations -- Less latency - -**Cons:** -- Inconsistent results across agents -- Different capabilities per agent -- Harder to control/audit searches - -**Implementation:** -1. Add `websearch_enabled` option to payload/config -2. Update each agent wrapper: - - Claude: Toggle `WebSearch` in `disallowedTools` - - Gemini: Add `google_web_search` to `excludeTools` in settings.json - - OpenCode: Set `websearch` permission in config - -```typescript -// agents/claude.ts -const disallowedTools = payload.websearchEnabled - ? ["Bash"] - : ["Bash", "WebSearch", "WebFetch"]; - -// agents/gemini.ts -if (!payload.websearchEnabled) { - newSettings.excludeTools = [...(newSettings.excludeTools || []), "google_web_search"]; -} - -// agents/opencode.ts -permission: { - websearch: payload.websearchEnabled ? "allow" : "deny", - // ... -} -``` - -### Option C: Hybrid Approach (Recommended) - -Use native tools when available, with MCP fallback for consistency. - -**Implementation:** -1. Define a `websearch` MCP tool as fallback -2. For agents with good native search (Claude, Gemini): use native -3. For agents without (or with unreliable) search: use MCP tool -4. Add configuration to force MCP-only mode if needed - -```typescript -// Per-agent configuration -const agentWebSearchConfig = { - claude: { useNative: true, mcpFallback: false }, - gemini: { useNative: true, mcpFallback: false }, - opencode: { useNative: false, mcpFallback: true }, // Exa requires API key -}; -``` - -### Required Changes by Option - -| Change | Option A | Option B | Option C | -|--------|----------|----------|----------| -| New MCP tool | Yes | No | Yes | -| Search API key | Yes | No | Optional | -| Agent wrapper changes | Yes | Yes | Yes | -| Action input changes | No | Yes | Yes | -| External dependencies | Yes | No | Optional | - -### Recommended Next Steps - -1. **Decide on search provider** - If going with MCP approach: - - Exa AI: Already used by OpenCode, good for code-related searches - - Brave Search: Privacy-focused, good general search - - Tavily: Designed for AI agents, includes content extraction - -2. **Add configuration** - New action inputs: - ```yaml - websearch: - description: 'Enable web search functionality' - required: false - default: 'false' - ``` - -3. **Implement per-agent** - Start with Option B (simplest), upgrade to C if needed - -4. **Add security controls** - Query filtering, domain allowlists, rate limiting diff --git a/effort.md b/effort.md deleted file mode 100644 index 0bb6316..0000000 --- a/effort.md +++ /dev/null @@ -1,95 +0,0 @@ -# Effort Levels - -Pullfrog supports three effort levels that control model selection and reasoning depth: - -- **`#mini`** β€” Fast, minimal reasoning. Best for simple tasks. -- **`#auto`** β€” Balanced (default). Good for most tasks. -- **`#max`** β€” Maximum capability. Best for complex tasks requiring deep reasoning. - -The effort level can be specified via: -1. Macros in the prompt (`#mini`, `#auto`, `#max`) -2. The `effort` input in `action.yml` -3. The payload's `effort` field - ---- - -## Claude Code - -Claude Code uses model selection based on effort level. - -| Effort | Model | Description | -|--------|-------|-------------| -| `mini` | `haiku` | Fast, efficient | -| `auto` | `opusplan` | Opus for planning, Sonnet for execution | -| `max` | `opus` | Full Opus | - -> **Future direction:** Anthropic's beta `effort` parameter (`low`/`medium`/`high`) could replace model selection, using Opus 4.5 for all tasks with effort controlling token spend. See [Anthropic Effort Docs](https://platform.claude.com/docs/en/build-with-claude/effort). - ---- - -## Codex (OpenAI) - -Codex uses both model selection and the `modelReasoningEffort` parameter from `ThreadOptions`. - -| Effort | Model | `modelReasoningEffort` | Description | -|--------|-------|------------------------|-------------| -| `mini` | `gpt-5.1-codex-mini` | `"low"` | Smaller model, reduced reasoning | -| `auto` | `gpt-5.1-codex` | default | Standard model, default reasoning | -| `max` | `gpt-5.1-codex-max` | `"high"` | Largest model, maximum reasoning | - -Valid values for `modelReasoningEffort`: `"minimal"` | `"low"` | `"medium"` | `"high"` - -Reference: [Codex Config Reference](https://developers.openai.com/codex/config-reference/) - ---- - -## Gemini - -Gemini uses a combination of model selection and `thinkingLevel` configuration via `settings.json`. - -| Effort | Model | `thinkingLevel` | Description | -|--------|-------|-----------------|-------------| -| `mini` | `gemini-2.5-flash` | `LOW` | Fast model, minimal thinking | -| `auto` | `gemini-2.5-flash` | `HIGH` | Fast model, deep thinking | -| `max` | `gemini-2.5-pro` | `HIGH` | Most capable model, deep thinking | - -The `thinkingLevel` is configured via: -```json -{ - "modelConfig": { - "generateContentConfig": { - "thinkingConfig": { - "thinkingLevel": "LOW" - } - } - } -} -``` - -Reference: [Gemini Thinking Docs](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) - ---- - -## Cursor - -Cursor uses model selection via the `--model` CLI flag. Project-level configuration in `.cursor/cli.json` takes precedence if a `model` is specified there. - -| Effort | Model | Description | -|--------|-------|-------------| -| `mini` | `auto` (default) | Let Cursor select optimal model | -| `auto` | `auto` (default) | Let Cursor select optimal model | -| `max` | `opus-4.5-thinking` | Claude 4.5 Opus with thinking | - -**Note:** If the project has `.cursor/cli.json` with a `model` field, that model is used regardless of effort level. - ---- - -## OpenCode - -OpenCode does not currently have affordances for effort-level configuration. The effort parameter is ignored. - -| Effort | Behavior | -|--------|----------| -| `mini` | No effect | -| `auto` | No effect | -| `max` | No effect | diff --git a/entry b/entry index 3cb6b3d..3284900 100755 --- a/entry +++ b/entry @@ -100281,7 +100281,7 @@ function getModes({ disableProgressComment }) { ` }, { - name: "Address Reviews", + name: "AddressReviews", description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps. THINK HARDER. 1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs). @@ -100406,8 +100406,21 @@ function buildRuntimeContext(repo) { } return lines.join("\n"); } -var addInstructions = ({ payload, repo }) => { - const useNativeBash = !repo.isPublic; +function getShellInstructions(bash) { + switch (bash) { + case "disabled": + return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; + case "restricted": + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + case "enabled": + return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + default: { + const _exhaustive = bash; + return _exhaustive; + } + } +} +var addInstructions = ({ payload, repo, tools }) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { @@ -100462,7 +100475,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. -${useNativeBash ? `**Shell commands**: Use your native bash/shell tool for shell command execution.` : `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`} +${getShellInstructions(tools.bash)} **Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished. @@ -104622,6 +104635,14 @@ var claudeEffortModels = { auto: "opusplan", max: "opus" }; +function buildDisallowedTools(tools) { + const disallowed = []; + if (tools.web === "disabled") disallowed.push("WebFetch"); + if (tools.search === "disabled") disallowed.push("WebSearch"); + if (tools.write === "disabled") disallowed.push("Write"); + if (tools.bash !== "enabled") disallowed.push("Bash"); + return disallowed; +} var claude = agent({ name: "claude", install: async () => { @@ -104632,30 +104653,19 @@ var claude = agent({ executablePath: "cli.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, repo }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const model = claudeEffortModels[effort]; log.info(`Using model: ${model} (effort: ${effort})`); - const disallowedTools = repo.isPublic ? ["Bash"] : []; - const sandboxOptions = payload.sandbox ? { - permissionMode: "default", - disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"], - async canUseTool(toolName, input, _options) { - if (toolName.startsWith("mcp__gh_pullfrog__")) - return { behavior: "allow", updatedInput: input, updatedPermissions: [] }; - return { behavior: "deny", message: "tool not allowed in sandbox mode" }; - } - } : { - permissionMode: "bypassPermissions", - disallowedTools - }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); + const disallowedTools = buildDisallowedTools(tools); + if (disallowedTools.length > 0) { + log.info(`\u{1F512} disallowed tools: ${disallowedTools.join(", ")}`); } const queryOptions = { - ...sandboxOptions, + permissionMode: "bypassPermissions", + disallowedTools, mcpServers, model, pathToClaudeCodeExecutable: cliPath, @@ -105122,32 +105132,35 @@ var codexReasoningEffort = { // use default max: "high" }; -function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }) { +function writeCodexConfig({ tempHome, mcpServers, tools }) { const codexDir = join6(tempHome, ".codex"); mkdirSync3(codexDir, { recursive: true }); const configPath = join6(codexDir, "config.toml"); const mcpServerSections = []; for (const [name, config4] of Object.entries(mcpServers)) { if (config4.type !== "http") continue; - log.info(`\xBB Adding MCP server '${name}' at ${config4.url}`); + log.info(`\xBB adding MCP server '${name}' at ${config4.url}`); mcpServerSections.push(`[mcp_servers.${name}] url = "${config4.url}"`); } - const shellPolicy = isPublicRepo ? `[shell_environment_policy] -ignore_default_excludes = false` : ""; + const features = []; + if (tools.bash !== "enabled") { + features.push("shell_command_tool = false"); + features.push("unified_exec = false"); + } + const featuresSection = features.length > 0 ? `[features] +${features.join("\n")}` : ""; writeFileSync( configPath, `# written by pullfrog -${shellPolicy} +${featuresSection} ${mcpServerSections.join("\n\n")} `.trim() + "\n" ); - if (isPublicRepo) { - log.info(`\xBB Codex config written to ${configPath} (env filtering: enabled)`); - } else { - log.info(`\xBB Codex config written to ${configPath} (private repo: no env filtering)`); - } + log.info( + `\xBB Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})` + ); return codexDir; } var codex = agent({ @@ -105159,14 +105172,14 @@ var codex = agent({ executablePath: "bin/codex.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join6(tempHome, ".config", "codex"); mkdirSync3(configDir, { recursive: true }); const codexDir = writeCodexConfig({ tempHome, mcpServers, - isPublicRepo: repo.isPublic + tools }); setupProcessAgentEnv({ OPENAI_API_KEY: apiKey, @@ -105184,26 +105197,24 @@ var codex = agent({ apiKey, codexPathOverride: cliPath }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } const codex2 = new Codex(codexOptions); - const baseThreadOptions = payload.sandbox ? { + const threadOptions = { model, approvalPolicy: "never", - sandboxMode: "read-only", - networkAccessEnabled: false - } : { - model, - approvalPolicy: "never", - // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) - sandboxMode: "danger-full-access", - networkAccessEnabled: true + // write: "disabled" β†’ read-only sandbox, otherwise full access for git ops + sandboxMode: tools.write === "disabled" ? "read-only" : "danger-full-access", + // web: controls network access + networkAccessEnabled: tools.web !== "disabled", + // search: controls web search + webSearchEnabled: tools.search !== "disabled", + ...modelReasoningEffort && { modelReasoningEffort } }; - const threadOptions = modelReasoningEffort ? { ...baseThreadOptions, modelReasoningEffort } : baseThreadOptions; + log.info( + `\u{1F527} Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}` + ); const thread = codex2.startThread(threadOptions); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools })); let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; @@ -105330,9 +105341,9 @@ var cursor = agent({ executableName: "cursor-agent" }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort, tools }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); + configureCursorTools({ tools }); const projectCliConfigPath = join7(process.cwd(), ".cursor", "cli.json"); let modelOverride = null; if (existsSync4(projectCliConfigPath)) { @@ -105404,16 +105415,13 @@ var cursor = agent({ } }; try { - const fullPrompt = addInstructions({ payload, repo }); + const fullPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(fullPrompt)); const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"]; if (modelOverride) { baseArgs.push("--model", modelOverride); } - const cursorArgs = payload.sandbox ? baseArgs : [...baseArgs, "--force"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } + const cursorArgs = [...baseArgs, "--force"]; log.info("Running Cursor CLI..."); const startTime = Date.now(); return new Promise((resolve2) => { @@ -105515,29 +105523,32 @@ function configureCursorMcpServers({ mcpServers }) { writeFileSync2(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } -function configureCursorSandbox({ - sandbox, - isPublicRepo -}) { +function configureCursorTools({ tools }) { 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 ? { + const deny = []; + if (tools.search === "disabled") deny.push("WebSearch"); + if (tools.write === "disabled") deny.push("Write(**)"); + if (tools.bash !== "enabled") deny.push("Shell(*)"); + const needsSandbox = tools.web === "disabled"; + const config4 = { permissions: { - allow: ["Read(**)"], - deny: ["Write(**)", ...denyShell] - } - } : { - permissions: { - allow: ["Read(**)", "Write(**)"], - deny: denyShell + allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"], + deny } }; + if (needsSandbox) { + config4.sandbox = { + mode: "enabled", + networkAccess: "allowlist" + }; + } writeFileSync2(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); + log.info(`\xBB CLI config written to ${cliConfigPath}`); log.info( - `\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + `\u{1F527} Cursor permissions: allow=${config4.permissions.allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}` ); } @@ -105714,37 +105725,16 @@ var gemini = agent({ ...githubInstallationToken2 && { githubInstallationToken: githubInstallationToken2 } }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => { const { model, thinkingLevel } = geminiEffortConfig[effort]; log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`); - configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel }); + configureGeminiSettings({ mcpServers, tools, thinkingLevel }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo }); + const sessionPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(sessionPrompt)); - let args3; - if (payload.sandbox) { - args3 = [ - "--model", - model, - "--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 = ["--model", model, "--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"); - } + const args3 = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt]; let finalOutput2 = ""; let stdoutBuffer = ""; try { @@ -105810,7 +105800,7 @@ var gemini = agent({ }); function configureGeminiSettings({ mcpServers, - isPublicRepo, + tools, thinkingLevel }) { const realHome = homedir3(); @@ -105835,8 +105825,13 @@ function configureGeminiSettings({ trust: true // trust our own MCP server to avoid confirmation prompts }; - log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`); + log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`); } + const exclude = []; + if (tools.bash !== "enabled") exclude.push("run_shell_command"); + if (tools.write === "disabled") exclude.push("write_file"); + if (tools.web === "disabled") exclude.push("web_fetch"); + if (tools.search === "disabled") exclude.push("google_web_search"); const newSettings = { ...existingSettings, mcpServers: geminiMcpServers, @@ -105848,13 +105843,15 @@ function configureGeminiSettings({ thinkingLevel } } - } + }, + // v0.3.0+ nested format + ...exclude.length > 0 && { tools: { exclude } } }; - if (isPublicRepo) { - newSettings.excludeTools = ["run_shell_command"]; - } writeFileSync3(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB Gemini settings written to ${settingsPath}`); + if (exclude.length > 0) { + log.info(`\u{1F512} excluded tools: ${exclude.join(", ")}`); + } } // agents/opencode.ts @@ -105877,22 +105874,16 @@ var opencode = agent({ mcpServers, cliPath, repo, - effort: _effort + effort: _effort, + tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); - configureOpenCode({ - mcpServers, - sandbox: payload.sandbox ?? false, - isPublicRepo: repo.isPublic - }); - const prompt = addInstructions({ payload, repo }); + configureOpenCode({ mcpServers, tools }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const args3 = ["run", prompt, "--format", "json"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } setupProcessAgentEnv({ HOME: tempHome }); const env3 = { ...createAgentEnv({ HOME: tempHome }), @@ -106002,7 +105993,7 @@ var opencode = agent({ }; } }); -function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { +function configureOpenCode({ mcpServers, tools }) { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); @@ -106022,17 +106013,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { url: serverConfig.url }; } - 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", + const permission = { + edit: tools.write === "disabled" ? "deny" : "allow", + bash: tools.bash !== "enabled" ? "deny" : "allow", + webfetch: tools.web === "disabled" ? "deny" : "allow", doom_loop: "allow", external_directory: "allow" }; @@ -106049,7 +106033,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { ); throw error50; } - log.info(`\xBB OpenCode config written to ${configPath} (sandbox: ${sandbox})`); + log.info(`\xBB OpenCode config written to ${configPath}`); + log.info( + `\u{1F527} OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}` + ); log.debug(`OpenCode config contents: ${configJson}`); } @@ -106212,9 +106199,10 @@ var agents = { // utils/api.ts var DEFAULT_REPO_SETTINGS = { defaultAgent: null, - webAccessLevel: "full_access", - webAccessAllowTrusted: false, - webAccessDomains: "", + web: "enabled", + search: "enabled", + write: "enabled", + bash: "restricted", modes: [] }; async function fetchWorkflowRunInfo(runId) { @@ -138323,13 +138311,18 @@ var Timer = class { }; // main.ts +var ToolPermissionInput = type.enumerated("disabled", "enabled"); +var BashPermissionInput = type.enumerated("disabled", "restricted", "enabled"); var Inputs = type({ prompt: "string", "effort?": Effort, "agent?": AgentName.or("null"), "event?": "object", "modes?": ModeSchema.array(), - "sandbox?": "boolean", + "web?": ToolPermissionInput, + "search?": ToolPermissionInput, + "write?": ToolPermissionInput, + "bash?": BashPermissionInput, "disableProgressComment?": "true", "comment_id?": "number|null", "issue_id?": "number|null", @@ -138597,7 +138590,6 @@ function parsePayload(inputs) { event: inputs.event, modes: inputs.modes ?? modes, effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox, disableProgressComment: inputs.disableProgressComment, comment_id: inputs.comment_id, issue_id: inputs.issue_id, @@ -138622,8 +138614,7 @@ function parsePayload(inputs) { trigger: "unknown" }, modes: inputs.modes ?? modes, - effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox + effort: inputs.effort ?? "auto" }; } } @@ -138668,6 +138659,14 @@ function validateApiKey(params) { apiKeys }; } +function computeToolPermissions(params) { + return { + web: params.inputs.web ?? "enabled", + search: params.inputs.search ?? "enabled", + write: params.inputs.write ?? "enabled", + bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled") + }; +} async function runAgent(ctx) { const effort = ctx.payload.effort ?? "auto"; log.info(`Running ${ctx.agent.name} with effort=${effort}...`); @@ -138676,6 +138675,10 @@ async function runAgent(ctx) { ${encode(eventWithoutContext)}`; log.box(promptContent, { title: "Prompt" }); + const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private }); + log.info( + `Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}` + ); return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, @@ -138688,7 +138691,8 @@ ${encode(eventWithoutContext)}`; defaultBranch: ctx.repo.default_branch, isPublic: !ctx.repo.private }, - effort + effort, + tools }); } async function handleAgentResult(result) { diff --git a/external.ts b/external.ts index 6695f00..3d209d7 100644 --- a/external.ts +++ b/external.ts @@ -56,6 +56,14 @@ export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][ export const Effort = type.enumerated("mini", "auto", "max"); export type Effort = typeof Effort.infer; +// tool permission types shared with server dispatch +export type ToolPermission = "disabled" | "enabled"; +export type BashPermission = "disabled" | "restricted" | "enabled"; + +// permission level for the author who triggered the event +// matches GitHub's permission levels: admin > write > maintain > triage > read > none +export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none"; + // base interface for common payload event fields interface BasePayloadEvent { issue_number?: number; @@ -83,6 +91,8 @@ interface BasePayloadEvent { url: string; }; comment_ids?: number[] | "all"; + /** permission level of the user who triggered this event */ + authorPermission?: AuthorPermission; [key: string]: any; } @@ -247,18 +257,24 @@ export type PayloadEvent = | UnknownEvent; export interface DispatchOptions { - /** - * Sandbox mode flag - when true, restricts agent to read-only operations - * (no Write, Web, or Bash access) - */ - readonly sandbox?: boolean; - /** * When true, disables progress comment (no "leaping into action" comment, no report_progress tool) */ readonly disableProgressComment?: true; + + /** + * Granular tool permissions set server-side for dispatch workflows. + */ + readonly web?: ToolPermission; + readonly search?: ToolPermission; + readonly write?: ToolPermission; + readonly bash?: BashPermission; } +export type MutableDispatchOptions = { + -readonly [K in keyof DispatchOptions]: DispatchOptions[K]; +}; + // payload type for agent execution export interface Payload extends DispatchOptions { "~pullfrog": true; diff --git a/fixtures/bash-disabled.ts b/fixtures/bash-disabled.ts new file mode 100644 index 0000000..d91ad47 --- /dev/null +++ b/fixtures/bash-disabled.ts @@ -0,0 +1,14 @@ +import type { Inputs } from "../main.ts"; + +/** + * test fixture: tests bash=disabled enforcement. + * the agent should NOT be able to run bash commands. + * + * run with: AGENT_OVERRIDE=claude pnpm play bash-disabled.ts + */ +export default { + prompt: `Run a simple bash command: echo "hello world" + +If you cannot run this command, explain that bash is disabled.`, + bash: "disabled", +} satisfies Inputs; diff --git a/fixtures/bash-restricted.ts b/fixtures/bash-restricted.ts new file mode 100644 index 0000000..1676cfb --- /dev/null +++ b/fixtures/bash-restricted.ts @@ -0,0 +1,14 @@ +import type { Inputs } from "../main.ts"; + +/** + * test fixture: tests bash=restricted enforcement. + * the agent should use MCP bash tool (not native bash). + * + * run with: AGENT_OVERRIDE=claude pnpm play bash-restricted.ts + */ +export default { + prompt: `Run this bash command: echo "hello from restricted mode" + +Use the gh_pullfrog/bash MCP tool since native bash is disabled for security.`, + bash: "restricted", +} satisfies Inputs; diff --git a/fixtures/sandbox.ts b/fixtures/sandbox.ts index 8c821ef..6ad9d9a 100644 --- a/fixtures/sandbox.ts +++ b/fixtures/sandbox.ts @@ -1,27 +1,22 @@ -import type { Payload } from "../external.ts"; +import type { Inputs } from "../main.ts"; /** - * test fixture: simulates an @pullfrog mention by a non-collaborator on a public repo. - * sandbox mode is enabled, so web access and file writes should be blocked. + * test fixture: tests granular tool permissions enforcement. + * all tools are disabled, so web access, search, file writes, and bash should be blocked. * * run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts */ export default { - "~pullfrog": true, - agent: null, prompt: `Please do the following three things: 1. Fetch the content from https://httpbin.org/json and tell me what it says -2. Create a file called sandbox-test.txt with the content "This should fail in sandbox mode" +2. Create a file called sandbox-test.txt with the content "This should fail with write disabled" 3. Run a bash command: echo "hello from bash" > bash-test.txt -All three of these actions should fail because you are running in sandbox mode with restricted permissions (no Web, no Write, no Bash).`, - event: { - trigger: "issue_comment_created", - comment_id: 12345, - comment_body: "@pullfrog please fetch from web and write a file", - issue_number: 1, - }, - modes: [], - sandbox: true, -} satisfies Payload; +All three of these actions should fail because tool permissions are restricted (web=disabled, write=disabled, bash=disabled).`, + // granular tool permissions - all disabled + web: "disabled", + search: "disabled", + write: "disabled", + bash: "disabled", +} satisfies Inputs; diff --git a/fixtures/simple.ts b/fixtures/simple.ts new file mode 100644 index 0000000..0c5202c --- /dev/null +++ b/fixtures/simple.ts @@ -0,0 +1,8 @@ +import type { Inputs } from "../main.ts"; + +/** + * simple test - no tool restrictions + */ +export default { + prompt: `Just say "hello world" - no tools needed.`, +} satisfies Inputs; diff --git a/main.ts b/main.ts index f858c7e..0a2e825 100644 --- a/main.ts +++ b/main.ts @@ -6,7 +6,7 @@ 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"; -import type { AgentResult } from "./agents/shared.ts"; +import type { AgentResult, ToolPermissions } from "./agents/shared.ts"; import { AgentName, agentsManifest, Effort, type Payload } from "./external.ts"; import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts"; import { createMcpConfigs } from "./mcp/config.ts"; @@ -21,6 +21,10 @@ import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from ". import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; import { Timer } from "./utils/timer.ts"; +// tool permission enum types for inputs +const ToolPermissionInput = type.enumerated("disabled", "enabled"); +const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled"); + // inputs schema - mirrors Payload fields without the discriminated union for event export const Inputs = type({ prompt: "string", @@ -28,7 +32,10 @@ export const Inputs = type({ "agent?": AgentName.or("null"), "event?": "object", "modes?": ModeSchema.array(), - "sandbox?": "boolean", + "web?": ToolPermissionInput, + "search?": ToolPermissionInput, + "write?": ToolPermissionInput, + "bash?": BashPermissionInput, "disableProgressComment?": "true", "comment_id?": "number|null", "issue_id?": "number|null", @@ -430,7 +437,6 @@ function parsePayload(inputs: Inputs): Payload { event: inputs.event as Payload["event"], modes: inputs.modes ?? modes, effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox, disableProgressComment: inputs.disableProgressComment, comment_id: inputs.comment_id, issue_id: inputs.issue_id, @@ -460,7 +466,6 @@ function parsePayload(inputs: Inputs): Payload { }, modes: inputs.modes ?? modes, effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox, } as Payload; } } @@ -517,6 +522,22 @@ function validateApiKey(params: { agent: Agent; owner: string; name: string }): }; } +/** + * Compute tool permissions from inputs. + * For run action, bash defaults to restricted for public repos when unset. + */ +function computeToolPermissions(params: { + inputs: Inputs; + isPublicRepo: boolean; +}): ToolPermissions { + return { + web: params.inputs.web ?? "enabled", + search: params.inputs.search ?? "enabled", + write: params.inputs.write ?? "enabled", + bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled"), + }; +} + async function runAgent(ctx: AgentContext): Promise { const effort = ctx.payload.effort ?? "auto"; log.info(`Running ${ctx.agent.name} with effort=${effort}...`); @@ -526,6 +547,11 @@ async function runAgent(ctx: AgentContext): Promise { const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`; log.box(promptContent, { title: "Prompt" }); + const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private }); + log.info( + `Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}` + ); + return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, @@ -539,6 +565,7 @@ async function runAgent(ctx: AgentContext): Promise { isPublic: !ctx.repo.private, }, effort, + tools, }); } diff --git a/modes.ts b/modes.ts index 10a4636..2c411aa 100644 --- a/modes.ts +++ b/modes.ts @@ -69,7 +69,7 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] { `, }, { - name: "Address Reviews", + name: "AddressReviews", description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps. THINK HARDER. diff --git a/play.ts b/play.ts index 3c7a264..3528111 100644 --- a/play.ts +++ b/play.ts @@ -18,7 +18,7 @@ config(); // also load .env from repo root (for monorepo structure) config({ path: join(__dirname, "..", ".env") }); -export async function run(prompt: string): Promise { +export async function run(inputsOrPrompt: Inputs | string): Promise { try { const tempDir = join(__dirname, ".temp"); setupTestRepo({ tempDir }); @@ -26,9 +26,9 @@ export async function run(prompt: string): Promise { const originalCwd = process.cwd(); process.chdir(tempDir); - const inputs: Inputs = { - prompt, - }; + // allow passing full Inputs object or just a prompt string + const inputs: Inputs = + typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt; const result = await main(inputs); @@ -220,6 +220,14 @@ Examples: process.exit(allSuccess ? 0 : 1); } else if (typeof module.default === "object") { + const obj = module.default as Record; + // Inputs objects have `prompt` field and optional tool permission fields + // Payload objects have `~pullfrog` field + if ("prompt" in obj && !("~pullfrog" in obj)) { + // this is an Inputs object - run directly with tool permissions + const result = await run(obj as Inputs); + process.exit(result.success ? 0 : 1); + } // Payload objects (with ~pullfrog) should be stringified prompt = JSON.stringify(module.default, null, 2); } else { diff --git a/run/action.yml b/run/action.yml index 75be09b..6e3c52c 100644 --- a/run/action.yml +++ b/run/action.yml @@ -1,11 +1,7 @@ - - -# pullfrog/pullfrog/run -# pullfrog/pullfrog (alias) - -# pullfrog/pullfrog/dispatch <- accepts json -name: "Pullfrog Action (Run)" -description: "Execute coding agents with itemized inputs" +# note: this file must remain identical to action/action.yml and action/run/action.yml +# future agents: keep both files in sync manually +name: "Pullfrog Action" +description: "Execute coding agents with a prompt" author: "Pullfrog" inputs: @@ -22,8 +18,17 @@ inputs: cwd: description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)" required: false - sandbox: - description: "Sandbox mode: restricts agent to read-only operations" + web: + description: "Web fetch permission: disabled or enabled (default: enabled)" + required: false + search: + description: "Web search permission: disabled or enabled (default: enabled)" + required: false + write: + description: "File write permission: disabled or enabled (default: enabled)" + required: false + bash: + description: "Bash permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled." required: false runs: diff --git a/run/entry b/run/entry index 9ba258b..44774d1 100755 --- a/run/entry +++ b/run/entry @@ -100281,7 +100281,7 @@ function getModes({ disableProgressComment }) { ` }, { - name: "Address Reviews", + name: "AddressReviews", description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps. THINK HARDER. 1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs). @@ -100406,8 +100406,21 @@ function buildRuntimeContext(repo) { } return lines.join("\n"); } -var addInstructions = ({ payload, repo }) => { - const useNativeBash = !repo.isPublic; +function getShellInstructions(bash) { + switch (bash) { + case "disabled": + return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; + case "restricted": + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + case "enabled": + return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + default: { + const _exhaustive = bash; + return _exhaustive; + } + } +} +var addInstructions = ({ payload, repo, tools }) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { @@ -100462,7 +100475,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. -${useNativeBash ? `**Shell commands**: Use your native bash/shell tool for shell command execution.` : `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`} +${getShellInstructions(tools.bash)} **Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished. @@ -104622,6 +104635,14 @@ var claudeEffortModels = { auto: "opusplan", max: "opus" }; +function buildDisallowedTools(tools) { + const disallowed = []; + if (tools.web === "disabled") disallowed.push("WebFetch"); + if (tools.search === "disabled") disallowed.push("WebSearch"); + if (tools.write === "disabled") disallowed.push("Write"); + if (tools.bash !== "enabled") disallowed.push("Bash"); + return disallowed; +} var claude = agent({ name: "claude", install: async () => { @@ -104632,30 +104653,19 @@ var claude = agent({ executablePath: "cli.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, repo }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const model = claudeEffortModels[effort]; log.info(`Using model: ${model} (effort: ${effort})`); - const disallowedTools = repo.isPublic ? ["Bash"] : []; - const sandboxOptions = payload.sandbox ? { - permissionMode: "default", - disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"], - async canUseTool(toolName, input, _options) { - if (toolName.startsWith("mcp__gh_pullfrog__")) - return { behavior: "allow", updatedInput: input, updatedPermissions: [] }; - return { behavior: "deny", message: "tool not allowed in sandbox mode" }; - } - } : { - permissionMode: "bypassPermissions", - disallowedTools - }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); + const disallowedTools = buildDisallowedTools(tools); + if (disallowedTools.length > 0) { + log.info(`\u{1F512} disallowed tools: ${disallowedTools.join(", ")}`); } const queryOptions = { - ...sandboxOptions, + permissionMode: "bypassPermissions", + disallowedTools, mcpServers, model, pathToClaudeCodeExecutable: cliPath, @@ -105122,32 +105132,35 @@ var codexReasoningEffort = { // use default max: "high" }; -function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }) { +function writeCodexConfig({ tempHome, mcpServers, tools }) { const codexDir = join6(tempHome, ".codex"); mkdirSync3(codexDir, { recursive: true }); const configPath = join6(codexDir, "config.toml"); const mcpServerSections = []; for (const [name, config4] of Object.entries(mcpServers)) { if (config4.type !== "http") continue; - log.info(`\xBB Adding MCP server '${name}' at ${config4.url}`); + log.info(`\xBB adding MCP server '${name}' at ${config4.url}`); mcpServerSections.push(`[mcp_servers.${name}] url = "${config4.url}"`); } - const shellPolicy = isPublicRepo ? `[shell_environment_policy] -ignore_default_excludes = false` : ""; + const features = []; + if (tools.bash !== "enabled") { + features.push("shell_command_tool = false"); + features.push("unified_exec = false"); + } + const featuresSection = features.length > 0 ? `[features] +${features.join("\n")}` : ""; writeFileSync( configPath, `# written by pullfrog -${shellPolicy} +${featuresSection} ${mcpServerSections.join("\n\n")} `.trim() + "\n" ); - if (isPublicRepo) { - log.info(`\xBB Codex config written to ${configPath} (env filtering: enabled)`); - } else { - log.info(`\xBB Codex config written to ${configPath} (private repo: no env filtering)`); - } + log.info( + `\xBB Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})` + ); return codexDir; } var codex = agent({ @@ -105159,14 +105172,14 @@ var codex = agent({ executablePath: "bin/codex.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join6(tempHome, ".config", "codex"); mkdirSync3(configDir, { recursive: true }); const codexDir = writeCodexConfig({ tempHome, mcpServers, - isPublicRepo: repo.isPublic + tools }); setupProcessAgentEnv({ OPENAI_API_KEY: apiKey, @@ -105184,26 +105197,24 @@ var codex = agent({ apiKey, codexPathOverride: cliPath }; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } const codex2 = new Codex(codexOptions); - const baseThreadOptions = payload.sandbox ? { + const threadOptions = { model, approvalPolicy: "never", - sandboxMode: "read-only", - networkAccessEnabled: false - } : { - model, - approvalPolicy: "never", - // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) - sandboxMode: "danger-full-access", - networkAccessEnabled: true + // write: "disabled" β†’ read-only sandbox, otherwise full access for git ops + sandboxMode: tools.write === "disabled" ? "read-only" : "danger-full-access", + // web: controls network access + networkAccessEnabled: tools.web !== "disabled", + // search: controls web search + webSearchEnabled: tools.search !== "disabled", + ...modelReasoningEffort && { modelReasoningEffort } }; - const threadOptions = modelReasoningEffort ? { ...baseThreadOptions, modelReasoningEffort } : baseThreadOptions; + log.info( + `\u{1F527} Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}` + ); const thread = codex2.startThread(threadOptions); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools })); let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; @@ -105330,9 +105341,9 @@ var cursor = agent({ executableName: "cursor-agent" }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort, tools }) => { configureCursorMcpServers({ mcpServers, cliPath }); - configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); + configureCursorTools({ tools }); const projectCliConfigPath = join7(process.cwd(), ".cursor", "cli.json"); let modelOverride = null; if (existsSync4(projectCliConfigPath)) { @@ -105404,16 +105415,13 @@ var cursor = agent({ } }; try { - const fullPrompt = addInstructions({ payload, repo }); + const fullPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(fullPrompt)); const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"]; if (modelOverride) { baseArgs.push("--model", modelOverride); } - const cursorArgs = payload.sandbox ? baseArgs : [...baseArgs, "--force"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } + const cursorArgs = [...baseArgs, "--force"]; log.info("Running Cursor CLI..."); const startTime = Date.now(); return new Promise((resolve2) => { @@ -105515,29 +105523,32 @@ function configureCursorMcpServers({ mcpServers }) { writeFileSync2(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } -function configureCursorSandbox({ - sandbox, - isPublicRepo -}) { +function configureCursorTools({ tools }) { 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 ? { + const deny = []; + if (tools.search === "disabled") deny.push("WebSearch"); + if (tools.write === "disabled") deny.push("Write(**)"); + if (tools.bash !== "enabled") deny.push("Shell(*)"); + const needsSandbox = tools.web === "disabled"; + const config4 = { permissions: { - allow: ["Read(**)"], - deny: ["Write(**)", ...denyShell] - } - } : { - permissions: { - allow: ["Read(**)", "Write(**)"], - deny: denyShell + allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"], + deny } }; + if (needsSandbox) { + config4.sandbox = { + mode: "enabled", + networkAccess: "allowlist" + }; + } writeFileSync2(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); + log.info(`\xBB CLI config written to ${cliConfigPath}`); log.info( - `\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})` + `\u{1F527} Cursor permissions: allow=${config4.permissions.allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}` ); } @@ -105714,37 +105725,16 @@ var gemini = agent({ ...githubInstallationToken2 && { githubInstallationToken: githubInstallationToken2 } }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => { const { model, thinkingLevel } = geminiEffortConfig[effort]; log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`); - configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel }); + configureGeminiSettings({ mcpServers, tools, thinkingLevel }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, repo }); + const sessionPrompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(sessionPrompt)); - let args3; - if (payload.sandbox) { - args3 = [ - "--model", - model, - "--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 = ["--model", model, "--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"); - } + const args3 = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt]; let finalOutput2 = ""; let stdoutBuffer = ""; try { @@ -105810,7 +105800,7 @@ var gemini = agent({ }); function configureGeminiSettings({ mcpServers, - isPublicRepo, + tools, thinkingLevel }) { const realHome = homedir3(); @@ -105835,8 +105825,13 @@ function configureGeminiSettings({ trust: true // trust our own MCP server to avoid confirmation prompts }; - log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`); + log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`); } + const exclude = []; + if (tools.bash !== "enabled") exclude.push("run_shell_command"); + if (tools.write === "disabled") exclude.push("write_file"); + if (tools.web === "disabled") exclude.push("web_fetch"); + if (tools.search === "disabled") exclude.push("google_web_search"); const newSettings = { ...existingSettings, mcpServers: geminiMcpServers, @@ -105848,13 +105843,15 @@ function configureGeminiSettings({ thinkingLevel } } - } + }, + // v0.3.0+ nested format + ...exclude.length > 0 && { tools: { exclude } } }; - if (isPublicRepo) { - newSettings.excludeTools = ["run_shell_command"]; - } writeFileSync3(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB Gemini settings written to ${settingsPath}`); + if (exclude.length > 0) { + log.info(`\u{1F512} excluded tools: ${exclude.join(", ")}`); + } } // agents/opencode.ts @@ -105877,22 +105874,16 @@ var opencode = agent({ mcpServers, cliPath, repo, - effort: _effort + effort: _effort, + tools }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); - configureOpenCode({ - mcpServers, - sandbox: payload.sandbox ?? false, - isPublicRepo: repo.isPublic - }); - const prompt = addInstructions({ payload, repo }); + configureOpenCode({ mcpServers, tools }); + const prompt = addInstructions({ payload, repo, tools }); log.group("Full prompt", () => log.info(prompt)); const args3 = ["run", prompt, "--format", "json"]; - if (payload.sandbox) { - log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); - } setupProcessAgentEnv({ HOME: tempHome }); const env3 = { ...createAgentEnv({ HOME: tempHome }), @@ -106002,7 +105993,7 @@ var opencode = agent({ }; } }); -function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { +function configureOpenCode({ mcpServers, tools }) { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join9(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); @@ -106022,17 +106013,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { url: serverConfig.url }; } - 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", + const permission = { + edit: tools.write === "disabled" ? "deny" : "allow", + bash: tools.bash !== "enabled" ? "deny" : "allow", + webfetch: tools.web === "disabled" ? "deny" : "allow", doom_loop: "allow", external_directory: "allow" }; @@ -106049,7 +106033,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) { ); throw error50; } - log.info(`\xBB OpenCode config written to ${configPath} (sandbox: ${sandbox})`); + log.info(`\xBB OpenCode config written to ${configPath}`); + log.info( + `\u{1F527} OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}` + ); log.debug(`OpenCode config contents: ${configJson}`); } @@ -106212,9 +106199,10 @@ var agents = { // utils/api.ts var DEFAULT_REPO_SETTINGS = { defaultAgent: null, - webAccessLevel: "full_access", - webAccessAllowTrusted: false, - webAccessDomains: "", + web: "enabled", + search: "enabled", + write: "enabled", + bash: "restricted", modes: [] }; async function fetchWorkflowRunInfo(runId) { @@ -138323,13 +138311,18 @@ var Timer = class { }; // main.ts +var ToolPermissionInput = type.enumerated("disabled", "enabled"); +var BashPermissionInput = type.enumerated("disabled", "restricted", "enabled"); var Inputs = type({ prompt: "string", "effort?": Effort, "agent?": AgentName.or("null"), "event?": "object", "modes?": ModeSchema.array(), - "sandbox?": "boolean", + "web?": ToolPermissionInput, + "search?": ToolPermissionInput, + "write?": ToolPermissionInput, + "bash?": BashPermissionInput, "disableProgressComment?": "true", "comment_id?": "number|null", "issue_id?": "number|null", @@ -138597,7 +138590,6 @@ function parsePayload(inputs) { event: inputs.event, modes: inputs.modes ?? modes, effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox, disableProgressComment: inputs.disableProgressComment, comment_id: inputs.comment_id, issue_id: inputs.issue_id, @@ -138622,8 +138614,7 @@ function parsePayload(inputs) { trigger: "unknown" }, modes: inputs.modes ?? modes, - effort: inputs.effort ?? "auto", - sandbox: inputs.sandbox + effort: inputs.effort ?? "auto" }; } } @@ -138668,6 +138659,14 @@ function validateApiKey(params) { apiKeys }; } +function computeToolPermissions(params) { + return { + web: params.inputs.web ?? "enabled", + search: params.inputs.search ?? "enabled", + write: params.inputs.write ?? "enabled", + bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled") + }; +} async function runAgent(ctx) { const effort = ctx.payload.effort ?? "auto"; log.info(`Running ${ctx.agent.name} with effort=${effort}...`); @@ -138676,6 +138675,10 @@ async function runAgent(ctx) { ${encode(eventWithoutContext)}`; log.box(promptContent, { title: "Prompt" }); + const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private }); + log.info( + `Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}` + ); return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, @@ -138688,7 +138691,8 @@ ${encode(eventWithoutContext)}`; defaultBranch: ctx.repo.default_branch, isPublic: !ctx.repo.private }, - effort + effort, + tools }); } async function handleAgentResult(result) { @@ -138709,12 +138713,19 @@ async function handleAgentResult(result) { // run/entry.ts async function run() { try { + const web = core3.getInput("web") || void 0; + const search2 = core3.getInput("search") || void 0; + const write = core3.getInput("write") || void 0; + const bash = core3.getInput("bash") || void 0; const inputs = Inputs.assert({ prompt: core3.getInput("prompt", { required: true }), effort: core3.getInput("effort") || "auto", agent: core3.getInput("agent") || null, - sandbox: core3.getInput("sandbox") === "true" ? true : void 0, - cwd: core3.getInput("cwd") || null + cwd: core3.getInput("cwd") || null, + web, + search: search2, + write, + bash }); const result = await main(inputs); if (!result.success) { diff --git a/run/entry.ts b/run/entry.ts index ecee557..ebe4da2 100644 --- a/run/entry.ts +++ b/run/entry.ts @@ -9,12 +9,21 @@ import { Inputs, main } from "../main.ts"; async function run(): Promise { try { + // granular tool permissions (empty string means not set, use default) + const web = core.getInput("web") || undefined; + const search = core.getInput("search") || undefined; + const write = core.getInput("write") || undefined; + const bash = core.getInput("bash") || undefined; + const inputs = Inputs.assert({ prompt: core.getInput("prompt", { required: true }), effort: core.getInput("effort") || "auto", agent: core.getInput("agent") || null, - sandbox: core.getInput("sandbox") === "true" ? true : undefined, cwd: core.getInput("cwd") || null, + web, + search, + write, + bash, }); const result = await main(inputs); diff --git a/utils/api.ts b/utils/api.ts index 4d31e44..7dafdd1 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,4 +1,4 @@ -import type { AgentName } from "../external.ts"; +import type { AgentName, BashPermission, ToolPermission } from "../external.ts"; import type { RepoContext } from "./github.ts"; export interface Mode { @@ -10,17 +10,19 @@ export interface Mode { export interface RepoSettings { defaultAgent: AgentName | null; - webAccessLevel: "full_access" | "limited"; - webAccessAllowTrusted: boolean; - webAccessDomains: string; + web: ToolPermission; + search: ToolPermission; + write: ToolPermission; + bash: BashPermission; modes: Mode[]; } export const DEFAULT_REPO_SETTINGS: RepoSettings = { defaultAgent: null, - webAccessLevel: "full_access", - webAccessAllowTrusted: false, - webAccessDomains: "", + web: "enabled", + search: "enabled", + write: "enabled", + bash: "restricted", modes: [], };