Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 35 additions & 26 deletions agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent
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, parseCliArgs } from "./shared.ts";

export const claude = agent({
name: "claude",
Expand All @@ -14,47 +14,56 @@ export const claude = agent({
executablePath: "cli.js",
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, repo, agentConfig }) => {
// 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 });
log.group("Full prompt", () => log.info(prompt));

// configure sandbox mode if enabled
const sandboxOptions: Options = payload.sandbox
? {
permissionMode: "default",
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
async canUseTool(toolName, input, _options) {
if (toolName.startsWith("mcp__gh_pullfrog__"))
// build disallowed tools list based on config
const disallowedTools = [
...(agentConfig.readonly ? ["Write"] : []),
...(!agentConfig.network ? ["WebSearch", "WebFetch"] : []),
...(!agentConfig.bash ? ["Bash"] : []),
];

// build SDK options
const configOptions: Options =
disallowedTools.length > 0
? {
permissionMode: "default",
disallowedTools,
async canUseTool(toolName, input, _options) {
if (toolName.startsWith("mcp__gh_pullfrog__"))
return {
behavior: "allow",
updatedInput: input,
updatedPermissions: [],
};

console.error("can i use this tool?", toolName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug log left in. Remove this console.error before merging.

return {
behavior: "allow",
updatedInput: input,
updatedPermissions: [],
behavior: "deny",
message: "You are not allowed to use this tool.",
};
},
}
: {
permissionMode: "bypassPermissions",
};

console.error("can i use this tool?", toolName);
return {
behavior: "deny",
message: "You are not allowed to use this tool.",
};
},
}
: {
permissionMode: "bypassPermissions" as const,
};

if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
const cliArgs = parseCliArgs(agentConfig.cliArgs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsed cliArgs are logged but never used. They need to be passed to the query() call or removed if not needed for the Claude SDK.

if (cliArgs.length > 0) {
log.info(`📋 extra CLI args: ${cliArgs.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 queryInstance = query({
prompt,
options: {
...sandboxOptions,
...configOptions,
mcpServers,
// model: "claude-opus-4-5",
pathToClaudeCodeExecutable: cliPath,
Expand Down
29 changes: 11 additions & 18 deletions agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
agent,
type ConfigureMcpServersParams,
installFromNpmTarball,
parseCliArgs,
setupProcessAgentEnv,
} from "./shared.ts";

Expand All @@ -20,7 +21,7 @@ export const codex = agent({
executablePath: "bin/codex.js",
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, repo, agentConfig }) => {
// create config directory for codex before setting HOME
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "codex");
Expand All @@ -39,26 +40,18 @@ export const codex = agent({
codexPathOverride: cliPath,
};

if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
const cliArgs = parseCliArgs(agentConfig.cliArgs);
if (cliArgs.length > 0) {
log.info(`📋 extra CLI args: ${cliArgs.join(" ")}`);
}

const codex = new Codex(codexOptions);
const codexInstance = new Codex(codexOptions);
// valid sandbox modes: read-only, workspace-write, danger-full-access
const thread = codex.startThread(
payload.sandbox
? {
approvalPolicy: "never",
sandboxMode: "read-only",
networkAccessEnabled: false,
}
: {
approvalPolicy: "never",
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
sandboxMode: "danger-full-access",
networkAccessEnabled: true,
}
);
const thread = codexInstance.startThread({
approvalPolicy: "never",
sandboxMode: agentConfig.readonly ? "read-only" : "danger-full-access",
networkAccessEnabled: agentConfig.network,
});

try {
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
Expand Down
85 changes: 40 additions & 45 deletions agents/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ConfigureMcpServersParams,
createAgentEnv,
installFromCurl,
parseCliArgs,
} from "./shared.ts";

// cursor cli event types inferred from stream-json output
Expand Down Expand Up @@ -91,9 +92,24 @@ export const cursor = agent({
executableName: "cursor-agent",
});
},
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
run: async ({ payload, apiKey, cliPath, mcpServers, repo, agentConfig }) => {
configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false });

// build permissions based on config
const allow: string[] = ["Read(**)"];
const deny: string[] = [];
if (!agentConfig.readonly) {
allow.push("Write(**)");
} else {
deny.push("Write(**)");
}
if (agentConfig.bash) {
allow.push("Shell(**)");
} else {
deny.push("Shell(**)");
}

configureCursorCliConfig({ permissions: { allow, deny } });

// track logged model_call_ids to avoid duplicates
// cursor emits each assistant message twice: once without model_call_id, then again with it
Expand Down Expand Up @@ -169,22 +185,17 @@ export const cursor = agent({
const fullPrompt = addInstructions({ payload, repo });
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
const cursorArgs = payload.sandbox
? [
"--print",
fullPrompt,
"--output-format",
"stream-json",
"--approve-mcps",
// --force removed in sandbox mode to enforce safety checks
]
: ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];

if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
}
// build CLI args based on config
const useForce = !agentConfig.readonly && agentConfig.bash && agentConfig.network;
const cursorArgs = [
"--print",
fullPrompt,
"--output-format",
"stream-json",
"--approve-mcps",
...(useForce ? ["--force"] : []),
...parseCliArgs(agentConfig.cliArgs),
];

log.info("Running Cursor CLI...");

Expand Down Expand Up @@ -312,41 +323,25 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
log.info(`» MCP config written to ${mcpConfigPath}`);
}

interface CursorCliConfigParams {
permissions: {
allow: string[];
deny: string[];
};
}

/**
* Configure Cursor CLI sandbox mode via cli-config.json.
* When sandbox is enabled, denies all file writes and shell commands.
* In print mode without --force, writes are blocked by default, but we add
* explicit deny rules as defense in depth.
*
* Configure Cursor CLI permissions via cli-config.json.
* See: https://cursor.com/docs/cli/reference/permissions
*/
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
function configureCursorCliConfig({ permissions }: CursorCliConfigParams): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".cursor");
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
mkdirSync(cursorConfigDir, { recursive: true });

const config = sandbox
? {
// sandbox mode: deny all writes and shell commands
permissions: {
allow: [
"Read(**)", // allow reading all files
],
deny: [
"Write(**)", // deny all file writes
"Shell(**)", // deny all shell commands
],
},
}
: {
// normal mode: allow everything
permissions: {
allow: ["Read(**)", "Write(**)", "Shell(**)"],
deny: [],
},
};
const config = { permissions };

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}`);
}
40 changes: 22 additions & 18 deletions agents/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ConfigureMcpServersParams,
createAgentEnv,
installFromGithub,
parseCliArgs,
} from "./shared.ts";

// gemini cli event types inferred from stream-json output (NDJSON format)
Expand Down Expand Up @@ -156,7 +157,7 @@ export const gemini = agent({
...(githubInstallationToken && { githubInstallationToken }),
});
},
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
run: async ({ payload, apiKey, mcpServers, cliPath, repo, agentConfig }) => {
configureGeminiMcpServers({ mcpServers, cliPath });

if (!apiKey) {
Expand All @@ -166,25 +167,28 @@ export const gemini = agent({
const sessionPrompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(sessionPrompt));

// configure sandbox mode if enabled
// --allowed-tools restricts which tools are available (removes others from registry entirely)
// in sandbox mode: only read-only tools available (no write_file, run_shell_command, web_fetch)
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];

if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
// build CLI args based on config
const baseArgs: string[] = [];
if (agentConfig.readonly) {
// in readonly mode: only read-only tools available
baseArgs.push(
"--allowed-tools",
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
"--allowed-mcp-server-names",
"gh_pullfrog"
);
} else {
baseArgs.push("--yolo");
}

const args = [
...baseArgs,
...parseCliArgs(agentConfig.cliArgs),
"--output-format=stream-json",
"-p",
sessionPrompt,
];

let finalOutput = "";
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
try {
Expand Down
Loading