This commit is contained in:
Colin McDonnell
2026-01-08 13:58:08 -08:00
parent 6260b23de7
commit fe7ce4af11
11 changed files with 321 additions and 163 deletions
+4 -2
View File
@@ -21,8 +21,10 @@ export const claude = agent({
const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt));
// SECURITY: Claude Code spawns subprocesses with full process.env, leaking API keys.
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
// disable native Bash; agents use MCP bash tool which filters secrets.
// for private repos, native Bash is allowed since secrets are less exposed.
const disallowedTools = repo.isPublic ? ["Bash"] : [];
const sandboxOptions: Options = payload.sandbox
? {
permissionMode: "default",
@@ -35,7 +37,7 @@ export const claude = agent({
}
: {
permissionMode: "bypassPermissions" as const,
disallowedTools: ["Bash"],
disallowedTools,
};
if (payload.sandbox) {
+11 -2
View File
@@ -43,7 +43,16 @@ export const codex = agent({
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
}
// Codex SDK isolates subprocess env - native bash is safe, no MCP override needed
// SECURITY NOTE: Codex SDK does not have an option to disable native shell commands.
// For public repos, we rely on instructions telling the agent to use MCP bash instead.
// The MCP bash tool filters sensitive environment variables.
// This is not as robust as Claude/Cursor/OpenCode which can explicitly disable native bash.
if (repo.isPublic) {
log.info(
"🔒 public repo: instructions direct to MCP bash (native shell NOT disabled in SDK)"
);
}
const codex = new Codex(codexOptions);
const thread = codex.startThread(
payload.sandbox
@@ -61,7 +70,7 @@ export const codex = agent({
);
try {
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true }));
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
let finalOutput = "";
for await (const event of streamedTurn.events) {
+18 -6
View File
@@ -93,7 +93,7 @@ export const cursor = agent({
},
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
// track logged model_call_ids to avoid duplicates
// cursor emits each assistant message twice: once without model_call_id, then again with it
@@ -315,33 +315,45 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
/**
* Configure Cursor CLI sandbox mode via cli-config.json.
*
* SECURITY: Cursor spawns subprocesses with full process.env, leaking API keys.
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
* For private repos, native Shell is allowed.
*
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
*/
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
function configureCursorSandbox({
sandbox,
isPublicRepo,
}: {
sandbox: boolean;
isPublicRepo: boolean;
}): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".config", "cursor");
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
mkdirSync(cursorConfigDir, { recursive: true });
// deny native shell for public repos to prevent secret leakage
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
const config = sandbox
? {
permissions: {
allow: ["Read(**)"],
deny: ["Write(**)", "Shell(*)"],
deny: ["Write(**)", ...denyShell],
},
}
: {
permissions: {
allow: ["Read(**)", "Write(**)"],
deny: ["Shell(*)"],
deny: denyShell,
},
};
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
log.info(`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
log.info(
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
);
}
+38 -17
View File
@@ -157,28 +157,37 @@ export const gemini = agent({
});
},
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
configureGeminiMcpServers({ mcpServers, cliPath });
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
if (!apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
}
const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true });
const sessionPrompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(sessionPrompt));
// Gemini CLI isolates subprocess env - native bash is safe, no MCP override needed
// sandbox mode uses --allowed-tools to restrict to read-only operations
const args = payload.sandbox
? [
"--allowed-tools",
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
"--allowed-mcp-server-names",
"gh_pullfrog",
"--output-format=stream-json",
"-p",
sessionPrompt,
]
: ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
// build CLI args based on sandbox mode
// for public repos, native shell is disabled via excludeTools in settings.json
let args: string[];
if (payload.sandbox) {
// sandbox mode: read-only tools only
args = [
"--allowed-tools",
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
"--allowed-mcp-server-names",
"gh_pullfrog",
"--output-format=stream-json",
"-p",
sessionPrompt,
];
} else {
// normal mode: --yolo for auto-approval
// for public repos, shell is excluded via settings.json excludeTools
args = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
if (repo.isPublic) {
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
}
}
if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
@@ -264,12 +273,19 @@ export const gemini = agent({
},
});
type ConfigureGeminiParams = {
mcpServers: ConfigureMcpServersParams["mcpServers"];
isPublicRepo: boolean;
};
/**
* Configure MCP servers for Gemini by writing to settings.json.
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
*
* For public repos, also configures excludeTools to disable native shell.
*/
function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): void {
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void {
const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini");
const settingsPath = join(geminiConfigDir, "settings.json");
@@ -314,11 +330,16 @@ function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): v
}
// merge with existing settings, overwriting mcpServers
const newSettings = {
// for public repos, exclude native shell tool to prevent secret leakage via env
const newSettings: Record<string, unknown> = {
...existingSettings,
mcpServers: geminiMcpServers,
};
if (isPublicRepo) {
newSettings.excludeTools = ["run_shell_command"];
}
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» MCP config written to ${settingsPath}`);
}
+5 -6
View File
@@ -8,6 +8,7 @@ interface RepoInfo {
owner: string;
name: string;
defaultBranch: string;
isPublic: boolean;
}
/**
@@ -53,14 +54,12 @@ function buildRuntimeContext(repo: RepoInfo): string {
interface AddInstructionsParams {
payload: Payload;
repo: RepoInfo;
useNativeBash?: boolean;
}
export const addInstructions = ({
payload,
repo,
useNativeBash = false,
}: AddInstructionsParams) => {
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
// for public repos, always use MCP bash for security (filters secrets)
// for private repos, agents can use their native bash
const useNativeBash = !repo.isPublic;
let encodedEvent = "";
const eventKeys = Object.keys(payload.event);
+24 -5
View File
@@ -28,7 +28,11 @@ export const opencode = agent({
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
configureOpenCode({
mcpServers,
sandbox: payload.sandbox ?? false,
isPublicRepo: repo.isPublic,
});
const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt));
@@ -190,13 +194,14 @@ export const opencode = agent({
interface ConfigureOpenCodeParams {
mcpServers: ConfigureMcpServersParams["mcpServers"];
sandbox: boolean;
isPublicRepo: boolean;
}
/**
* Configure OpenCode via opencode.json config file.
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
*/
function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): void {
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
@@ -220,11 +225,25 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
};
}
// SECURITY: OpenCode spawns subprocesses with full process.env, leaking API keys.
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
// disable native bash; agents use MCP bash tool which filters secrets.
// for private repos, native bash is allowed.
const bashPermission = isPublicRepo ? "deny" : "allow";
const permission = sandbox
? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" }
: { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" };
? {
edit: "deny",
bash: "deny",
webfetch: "deny",
doom_loop: "allow",
external_directory: "allow",
}
: {
edit: "allow",
bash: bashPermission,
webfetch: "allow",
doom_loop: "allow",
external_directory: "allow",
};
// build complete config in one object
const config = {
+1
View File
@@ -27,6 +27,7 @@ export interface RepoInfo {
owner: string;
name: string;
defaultBranch: string;
isPublic: boolean;
}
/**