Implement granular tool permissions (#82)
* Granular tool permissions * Fix build * Start on UI * Fixes * Fmt * Go ham on UI * Update migrations * Considate wiki files * Clean up * More tweaks. Docs. * Consolidate collab and noncollab * Fix build * Restrict for non-collaborators
This commit is contained in:
committed by
pullfrog[bot]
parent
4547b0032e
commit
97dce099c1
+19
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
+23
-25
@@ -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<Effort, string> = {
|
||||
// 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,
|
||||
|
||||
+40
-42
@@ -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<Effort, string> = {
|
||||
@@ -33,10 +38,10 @@ const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||
interface WriteCodexConfigParams {
|
||||
tempHome: string;
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
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) {
|
||||
|
||||
+39
-42
@@ -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<string, unknown> = {
|
||||
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"}`
|
||||
);
|
||||
}
|
||||
|
||||
+27
-44
@@ -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<string, string>;
|
||||
@@ -341,7 +317,7 @@ function configureGeminiSettings({
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
};
|
||||
}
|
||||
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
|
||||
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<string, unknown> = {
|
||||
...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(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-9
@@ -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.
|
||||
|
||||
|
||||
+19
-34
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+140
-132
@@ -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,
|
||||
|
||||
+5
-1
@@ -33,7 +33,11 @@ async function run(): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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`
|
||||
@@ -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<string, string> {
|
||||
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
// only filter sensitive vars for public repos
|
||||
if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue;
|
||||
filtered[key] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// spawn with PID namespace in CI for public repos, plain spawn otherwise
|
||||
function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess {
|
||||
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||
if (useNamespaceIsolation) {
|
||||
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
||||
}
|
||||
return spawn("bash", ["-c", command], options);
|
||||
}
|
||||
|
||||
// BashTool uses ctx.repo.private to determine visibility
|
||||
export function BashTool(ctx: ToolContext) {
|
||||
const isPublicRepo = !ctx.repo.private;
|
||||
// ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag
|
||||
}
|
||||
```
|
||||
|
||||
**Defense in depth (public repos only):**
|
||||
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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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) {
|
||||
|
||||
+22
-6
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+11
-16
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
isPublic: !ctx.repo.private,
|
||||
},
|
||||
effort,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AgentResult> {
|
||||
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
||||
try {
|
||||
const tempDir = join(__dirname, ".temp");
|
||||
setupTestRepo({ tempDir });
|
||||
@@ -26,9 +26,9 @@ export async function run(prompt: string): Promise<AgentResult> {
|
||||
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<string, unknown>;
|
||||
// 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 {
|
||||
|
||||
+15
-10
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+10
-1
@@ -9,12 +9,21 @@ import { Inputs, main } from "../main.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
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);
|
||||
|
||||
+9
-7
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user