add file_read/file_write tools, sandbox tests, CI improvements (#239)

* migrate to flags

* init

* iterate on file write lockdown tests

* improve ci

* fix lockfile

* fix typecheck

* fix lint

* improve pushRestricted

* ok

* fix more

* ok

* remove process.env spreading rule

Co-authored-by: Cursor <cursoragent@cursor.com>

* enhanced fs rw tools

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
This commit is contained in:
David Blass
2026-02-10 06:35:47 +00:00
committed by pullfrog[bot]
parent 23df8bf967
commit 19df8372cd
30 changed files with 1801 additions and 648 deletions
+3
View File
@@ -39,6 +39,9 @@ function buildDisallowedTools(ctx: AgentRunContext): string[] {
// "restricted" means use MCP bash tool instead
const bash = ctx.payload.bash;
if (bash !== "enabled") disallowed.push("Bash", "Task(Bash)");
// always block native file tools (use MCP file_read/file_write instead)
disallowed.push("Read", "Write", "Edit", "MultiEdit");
disallowed.push("Task(Read)", "Task(Write)", "Task(Edit)", "Task(MultiEdit)");
return disallowed;
}
+21 -3
View File
@@ -42,15 +42,26 @@ function writeCodexConfig(ctx: AgentRunContext): string {
features.push("shell_command_tool = false");
features.push("unified_exec = false");
}
// note: there is no Codex feature flag to disable the native apply_patch tool.
// apply_patch_freeform only controls the freeform variant and defaults to false.
// native file tools are steered to MCP via instructions, and the sandbox (workspace-write
// or read-only) constrains what the native tool can access even if the agent ignores instructions.
const featuresSection = features.length > 0 ? `[features]\n${features.join("\n")}` : "";
// trust the project so codex loads repo-level .codex/config.toml
const cwd = process.cwd();
const projectTrustSection = `[projects."${cwd}"]\ntrust_level = "trusted"`;
// set approval_policy = "never" so we can avoid --dangerously-bypass-approvals-and-sandbox.
// this keeps sandbox enforcement active while still running non-interactively.
// the sandbox (workspace-write or read-only) constrains native file tool access.
const approvalSection = `approval_policy = "never"`;
writeFileSync(
configPath,
`# written by pullfrog
${approvalSection}
${featuresSection}
${projectTrustSection}
@@ -98,8 +109,11 @@ export const codex = agent({
}
// determine sandbox mode based on push permission
// push: "disabled" → read-only sandbox, otherwise full access for git ops
const sandboxMode = ctx.payload.push === "disabled" ? "read-only" : "danger-full-access";
// push: "disabled" → read-only sandbox, otherwise workspace-write.
// we avoid danger-full-access because it completely disables the sandbox,
// which would let native file tools (apply_patch) write anywhere unrestricted.
// workspace-write constrains native file access to the working directory.
const sandboxMode = ctx.payload.push === "disabled" ? "read-only" : "workspace-write";
// determine network and search permissions
// web: "disabled" → no network access, otherwise enabled
@@ -107,11 +121,15 @@ export const codex = agent({
// search: "disabled" → no web search, otherwise enabled
const webSearchEnabled = ctx.payload.search !== "disabled";
// note: we intentionally do NOT use --dangerously-bypass-approvals-and-sandbox.
// that flag bypasses both approvals AND the sandbox. instead, we set
// approval_policy = "never" in config.toml and keep the sandbox active.
// this ensures native file tools (apply_patch) are constrained by the sandbox
// even if the agent ignores MCP-only instructions.
const args: string[] = [
cliPath,
"exec",
ctx.instructions.full,
"--dangerously-bypass-approvals-and-sandbox",
"--model",
effortConfig.model,
"--sandbox",
+3 -1
View File
@@ -398,10 +398,12 @@ function configureCursorTools(ctx: AgentRunContext): void {
if (ctx.payload.search === "disabled") deny.push("WebSearch");
// both "disabled" and "restricted" block native shell
if (bash !== "enabled") deny.push("Shell(*)");
// always block native file tools (use MCP file_read/file_write instead)
deny.push("Read(*)", "Write(*)", "StrReplace(*)", "EditNotebook(*)", "Delete(*)");
const config: CursorCliConfig = {
permissions: {
allow: ["Read(**)", "Write(**)"],
allow: [],
deny,
},
};
+109 -64
View File
@@ -85,6 +85,23 @@ type GeminiEvent =
| GeminiToolResultEvent
| GeminiResultEvent;
// transient API error patterns that warrant a retry.
// these are server-side issues, not client errors.
const TRANSIENT_ERROR_PATTERNS = [
"INTERNAL",
"status: 500",
"status: 503",
"UNAVAILABLE",
"RESOURCE_EXHAUSTED",
];
function isTransientApiError(output: string): boolean {
return TRANSIENT_ERROR_PATTERNS.some((pattern) => output.includes(pattern));
}
const MAX_ATTEMPTS = 2;
const RETRY_DELAY_MS = 5_000;
let assistantMessageBuffer = "";
const messageHandlers = {
@@ -203,85 +220,111 @@ export const gemini = agent({
ctx.instructions.full,
];
let finalOutput = "";
let stdoutBuffer = "";
const thinkingTimer = new ThinkingTimer();
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let finalOutput = "";
let stdoutBuffer = "";
assistantMessageBuffer = "";
const thinkingTimer = new ThinkingTimer();
try {
const result = await spawn({
cmd: "node",
args: [cliPath, ...args],
env: process.env,
onStdout: async (chunk) => {
const text = chunk.toString();
finalOutput += text;
try {
const result = await spawn({
cmd: "node",
args: [cliPath, ...args],
env: process.env,
onStdout: async (chunk) => {
const text = chunk.toString();
finalOutput += text;
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
log.debug(`[gemini stdout] ${trimmed}`);
log.debug(`[gemini stdout] ${trimmed}`);
try {
const event = JSON.parse(trimmed) as GeminiEvent;
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never, thinkingTimer);
try {
const event = JSON.parse(trimmed) as GeminiEvent;
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never, thinkingTimer);
}
} catch {
// ignore parse errors - might be non-JSON output from gemini cli
log.debug(`[gemini] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
} catch {
// ignore parse errors - might be non-JSON output from gemini cli
log.debug(`[gemini] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.debug(`[gemini stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n";
}
},
});
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.debug(`[gemini stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n";
}
},
});
if (result.exitCode !== 0) {
const errorMessage =
result.stderr ||
finalOutput ||
result.stdout ||
"Unknown error - no output from Gemini CLI";
log.error(`Gemini CLI exited with code ${result.exitCode}: ${errorMessage}`);
if (result.exitCode !== 0) {
const errorMessage =
result.stderr ||
finalOutput ||
result.stdout ||
"Unknown error - no output from Gemini CLI";
// retry on transient API errors (500, 503, INTERNAL, etc.)
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
log.warning(
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
continue;
}
log.error(`Gemini CLI exited with code ${result.exitCode}: ${errorMessage}`);
return {
success: false,
error: errorMessage,
output: finalOutput || result.stdout || "",
};
}
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
log.info("» Gemini CLI completed successfully");
return {
success: true,
output: finalOutput,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// retry on transient API errors from spawn exceptions too
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
log.warning(
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
continue;
}
log.error(`Failed to run Gemini CLI: ${errorMessage}`);
return {
success: false,
error: errorMessage,
output: finalOutput || result.stdout || "",
output: finalOutput || "",
};
}
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
log.info("» Gemini CLI completed successfully");
return {
success: true,
output: finalOutput,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Failed to run Gemini CLI: ${errorMessage}`);
return {
success: false,
error: errorMessage,
output: finalOutput || "",
};
}
// should never reach here, but satisfy TypeScript
return { success: false, error: "exhausted all retry attempts", output: "" };
},
});
@@ -338,6 +381,8 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
if (bash !== "enabled") exclude.push("run_shell_command");
if (ctx.payload.web === "disabled") exclude.push("web_fetch");
if (ctx.payload.search === "disabled") exclude.push("google_web_search");
// always block native file tools (use MCP file_read/file_write instead)
exclude.push("read_file", "write_file", "list_directory");
// merge with existing settings, overwriting mcpServers and modelConfig
const newSettings: Record<string, unknown> = {
+195 -96
View File
@@ -11,6 +11,28 @@ import { spawn } from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// known provider error patterns in stderr (from --print-logs output).
// when OpenCode encounters these, it often goes silent on stdout (Issue #752),
// so we surface them prominently instead of burying them in debug warnings.
const PROVIDER_ERROR_PATTERNS = [
{ pattern: "429", label: "rate limited (429)" },
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
{ pattern: "quota", label: "quota error" },
{ pattern: "status: 500", label: "provider 500 error" },
{ pattern: "INTERNAL", label: "provider internal error" },
{ pattern: "status: 503", label: "provider unavailable (503)" },
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
{ pattern: "rate limit", label: "rate limited" },
{ pattern: "limit: 0", label: "zero quota" },
];
function detectProviderError(text: string): string | null {
for (const entry of PROVIDER_ERROR_PATTERNS) {
if (text.includes(entry.pattern)) return entry.label;
}
return null;
}
async function installOpencode(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
@@ -34,8 +56,19 @@ export const opencode = agent({
configureOpenCode(ctx);
// message positional must come right after "run", before flags
const args = ["run", ctx.instructions.full, "--format", "json"];
// message positional must come right after "run", before flags.
// --print-logs makes OpenCode write internal logs to stderr (otherwise they only go to a log file).
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
// only override model when OPENCODE_MODEL is set (e.g., test environments with
// restricted API quotas). in production, OpenCode auto-selects the best available
// model based on which provider API keys are present.
const modelOverride = process.env.OPENCODE_MODEL;
if (modelOverride) {
args.push("--model", modelOverride);
log.info(`» using model override: ${modelOverride}`);
}
process.env.HOME = tempHome;
@@ -64,114 +97,180 @@ export const opencode = agent({
let eventCount = 0;
const thinkingTimer = new ThinkingTimer();
// track recent stderr lines for provider error diagnosis.
// when OpenCode goes silent on stdout, these are the only clue.
const recentStderr: string[] = [];
const MAX_STDERR_LINES = 20;
let lastProviderError: string | null = null;
let output = "";
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
const result = await spawn({
cmd: cliPath,
args,
cwd: repoDir,
env,
timeout: 600000, // 10 minutes timeout to prevent infinite hangs
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
const text = chunk.toString();
output += text;
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
try {
const result = await spawn({
cmd: cliPath,
args,
cwd: repoDir,
env,
timeout: 600000, // 10 minutes timeout to prevent infinite hangs
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
const text = chunk.toString();
output += text;
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
try {
const event = JSON.parse(trimmed) as OpenCodeEvent;
eventCount++;
// debug log all events to diagnose ordering and missing MCP/bash tool calls
log.debug(JSON.stringify(event, null, 2));
const timeSinceLastActivity = getIdleMs();
if (timeSinceLastActivity > 10000) {
const activeToolCalls = toolCallTimings.size;
const toolCallInfo =
activeToolCalls > 0
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
log.warning(
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never, thinkingTimer);
} else {
// log unhandled event types for visibility
log.info(
`» OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
);
try {
const event = JSON.parse(trimmed) as OpenCodeEvent;
eventCount++;
// debug log all events to diagnose ordering and missing MCP/bash tool calls
log.debug(JSON.stringify(event, null, 2));
const timeSinceLastActivity = getIdleMs();
if (timeSinceLastActivity > 10000) {
const activeToolCalls = toolCallTimings.size;
const toolCallInfo =
activeToolCalls > 0
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
log.warning(
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
);
}
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never, thinkingTimer);
} else {
// log unhandled event types for visibility
log.info(
`» OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
);
}
} catch {
// non-JSON lines are ignored (might be debug output from opencode)
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
} catch {
// non-JSON lines are ignored (might be debug output from opencode)
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
}
},
onStderr: (chunk) => {
try {
const parsed = JSON.parse(chunk);
log.debug(JSON.stringify(parsed, null, 2));
} catch {
// if not JSON, fall through to regular error logging
}
const trimmed = chunk.trim();
if (trimmed) {
log.warning(trimmed);
}
},
});
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (!trimmed) return;
const duration = Date.now() - startTime;
log.info(`» OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
// track recent stderr for diagnosis
recentStderr.push(trimmed);
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
// 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
log.table([
[
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Total Tokens", header: true },
],
[String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)],
]);
}
// detect provider errors and surface them prominently
const providerError = detectProviderError(trimmed);
if (providerError) {
lastProviderError = providerError;
log.error(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else {
// try to parse as JSON for structured logging, fall back to warning
try {
const parsed = JSON.parse(trimmed);
log.debug(JSON.stringify(parsed, null, 2));
} catch {
log.warning(trimmed);
}
}
},
});
const duration = Date.now() - startTime;
log.info(`» OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
// if zero events processed, something went wrong - surface stderr context
if (eventCount === 0) {
const stderrContext = recentStderr.join("\n");
const diagnosis = lastProviderError
? `provider error: ${lastProviderError}`
: "unknown cause (no stdout events received)";
log.error(`» OpenCode produced 0 events (${diagnosis})`);
if (stderrContext) {
log.error(`» last stderr output:\n${stderrContext}`);
}
}
// log tokens if they weren't logged yet (fallback if result event wasn't emitted)
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
log.table([
[
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Total Tokens", header: true },
],
[String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)],
]);
}
// return result
if (result.exitCode !== 0) {
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
const errorMessage =
result.stderr ||
result.stdout ||
`unknown error - no output from OpenCode CLI${errorContext}`;
log.error(
`OpenCode CLI exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
);
log.debug(`OpenCode stdout: ${result.stdout?.substring(0, 500)}`);
log.debug(`OpenCode stderr: ${result.stderr?.substring(0, 500)}`);
return {
success: false,
output: finalOutput || output,
error: errorMessage,
};
}
return {
success: true,
output: finalOutput || output,
};
} catch (error) {
// activity timeout or process timeout - surface the real cause
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
const isActivityTimeout = errorMessage.includes("activity timeout");
// build a diagnostic message that includes provider context
const stderrContext = recentStderr.slice(-10).join("\n");
const diagnosis = lastProviderError
? `likely cause: ${lastProviderError}`
: eventCount === 0
? "OpenCode produced 0 stdout events - check if the model provider is reachable"
: `${eventCount} events were processed before the hang`;
log.error(
`» OpenCode ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
);
log.error(`» diagnosis: ${diagnosis}`);
if (stderrContext) {
log.error(
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
);
}
// 9. return result
if (result.exitCode !== 0) {
const errorMessage =
result.stderr || result.stdout || "Unknown error - no output from OpenCode CLI";
log.error(`OpenCode CLI exited with code ${result.exitCode}: ${errorMessage}`);
log.debug(`OpenCode stdout: ${result.stdout?.substring(0, 500)}`);
log.debug(`OpenCode stderr: ${result.stderr?.substring(0, 500)}`);
return {
success: false,
output: finalOutput || output,
error: errorMessage,
error: `${errorMessage} [${diagnosis}]`,
};
}
return {
success: true,
output: finalOutput || output,
};
},
});
@@ -193,11 +292,11 @@ function configureOpenCode(ctx: AgentRunContext): void {
// note: OpenCode has no built-in web search tool
const bash = ctx.payload.bash;
const permission = {
edit: "allow",
edit: "deny",
read: "deny",
bash: bash !== "enabled" ? "deny" : "allow",
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
doom_loop: "allow",
external_directory: "allow",
external_directory: "deny",
};
// build complete config in one object