2960d51493
unbounded shell tool output blows the agent's context window on commands
that dump big logs (test runners, build tools, grep on large trees). cap
the inline body at 5000 chars; on overflow, persist the full output to
${PULLFROG_TEMP_DIR}/shell-<id>.log and return the tail prefixed with a
sentinel pointing at the saved path. agents re-read the tempfile with
cat/tail/grep when they need more.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
// changes to shell security (filterEnv, spawnShell) should be reflected in wiki/security.md and docs/security.mdx
|
|
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { closeSync, openSync, writeFileSync } from "node:fs";
|
|
import { userInfo } from "node:os";
|
|
import { join } from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { type } from "arktype";
|
|
import { ensureBrowserDaemon } from "../utils/browser.ts";
|
|
import { log } from "../utils/log.ts";
|
|
import { resolveEnv } from "../utils/secrets.ts";
|
|
import type { ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
export const ShellParams = type({
|
|
command: "string",
|
|
description: "string",
|
|
"timeout?": type.number.describe(
|
|
"Timeout in MILLISECONDS (not seconds). Default 30000 (30s), max 120000 (2m). e.g. timeout: 180000 for 3 minutes; timeout: 180 means 180ms and will kill the process almost immediately."
|
|
),
|
|
"working_directory?": "string",
|
|
"background?": "boolean",
|
|
});
|
|
|
|
type SpawnParams = {
|
|
command: string;
|
|
env: Record<string, string | undefined>;
|
|
cwd: string;
|
|
stdio: StdioOptions;
|
|
};
|
|
|
|
export type SandboxMethod = "unshare" | "sudo-unshare" | "none";
|
|
|
|
/** cached result of sandbox capability check */
|
|
let detectedSandboxMethod: SandboxMethod | undefined;
|
|
|
|
/** get the current sandbox method (for testing/diagnostics) */
|
|
export function getSandboxMethod(): SandboxMethod {
|
|
return detectSandboxMethod();
|
|
}
|
|
|
|
/** detect which sandbox method is available on this system */
|
|
function detectSandboxMethod(): SandboxMethod {
|
|
if (detectedSandboxMethod !== undefined) {
|
|
return detectedSandboxMethod;
|
|
}
|
|
|
|
// only attempt in CI environments - sandbox has overhead and is primarily for untrusted code
|
|
if (process.env.CI !== "true") {
|
|
detectedSandboxMethod = "none";
|
|
log.debug("sandbox disabled (CI !== true)");
|
|
return "none";
|
|
}
|
|
|
|
// try unprivileged unshare first (works on some systems)
|
|
try {
|
|
const result = spawnSync("unshare", ["--pid", "--fork", "--mount-proc", "true"], {
|
|
timeout: 5000,
|
|
stdio: "ignore",
|
|
});
|
|
if (result.status === 0) {
|
|
detectedSandboxMethod = "unshare";
|
|
log.debug("PID namespace isolation enabled (unprivileged unshare)");
|
|
return "unshare";
|
|
}
|
|
} catch {
|
|
// continue to try sudo
|
|
}
|
|
|
|
// sudo unshare (works on GHA runners)
|
|
try {
|
|
const result = spawnSync("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], {
|
|
timeout: 5000,
|
|
stdio: "ignore",
|
|
});
|
|
if (result.status === 0) {
|
|
detectedSandboxMethod = "sudo-unshare";
|
|
log.debug("PID namespace isolation enabled (sudo unshare)");
|
|
return "sudo-unshare";
|
|
}
|
|
} catch {
|
|
// no sandbox available
|
|
}
|
|
|
|
detectedSandboxMethod = "none";
|
|
log.info("PID namespace isolation not available");
|
|
return "none";
|
|
}
|
|
|
|
// strip inherited proc mount that sits underneath --mount-proc's overlay.
|
|
// --mount-proc mounts fresh proc on top, but `umount /proc` peels it off and exposes the
|
|
// host's proc with all host PIDs — allowing /proc/<pid>/environ exfiltration.
|
|
// double-umount removes both layers, then a clean mount gives only sandbox PIDs.
|
|
// on unprivileged systems where umount fails, --mount-proc still provides isolation
|
|
// (the agent also can't umount in that case).
|
|
const PROC_CLEANUP =
|
|
"umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
|
|
|
|
function spawnShell(params: SpawnParams): ChildProcess {
|
|
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
|
const sandboxMethod = detectSandboxMethod();
|
|
const ci = process.env.CI === "true";
|
|
|
|
if (ci && sandboxMethod === "none") {
|
|
throw new Error(
|
|
"pid namespace isolation is required in CI but unavailable (both unshare and sudo unshare failed)"
|
|
);
|
|
}
|
|
|
|
if (sandboxMethod === "unshare") {
|
|
return spawn(
|
|
"unshare",
|
|
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
|
|
spawnOpts
|
|
);
|
|
}
|
|
|
|
if (sandboxMethod === "sudo-unshare") {
|
|
const envArgs: string[] = [];
|
|
for (const [k, v] of Object.entries(params.env)) {
|
|
if (v !== undefined) {
|
|
envArgs.push(`${k}=${v}`);
|
|
}
|
|
}
|
|
// drop back to original user after PROC_CLEANUP so files aren't owned by root.
|
|
// sudo is only needed for unshare; the actual command should run as the normal user
|
|
// to avoid ownership mismatches with files created by the Node.js parent process.
|
|
const username = userInfo().username;
|
|
// su -p resets PATH on many Linux systems (ALWAYS_SET_PATH in /etc/login.defs).
|
|
// restore it from the SANDBOX_PATH env var that survives the su transition.
|
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: we need to restore the PATH variable
|
|
const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
|
|
const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
|
|
envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
|
|
return spawn(
|
|
"sudo",
|
|
[
|
|
"env",
|
|
...envArgs,
|
|
"unshare",
|
|
"--pid",
|
|
"--fork",
|
|
"--mount-proc",
|
|
"bash",
|
|
"-c",
|
|
`${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
|
|
],
|
|
{ ...spawnOpts, env: {} }
|
|
);
|
|
}
|
|
|
|
return spawn("bash", ["-c", params.command], spawnOpts);
|
|
}
|
|
|
|
/** kill process and its entire process group */
|
|
async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
|
if (!proc.pid) return;
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
try {
|
|
proc.kill("SIGKILL");
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}
|
|
}
|
|
|
|
function getTempDir(): string {
|
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
|
if (!tempDir) {
|
|
throw new Error("PULLFROG_TEMP_DIR not set");
|
|
}
|
|
return tempDir;
|
|
}
|
|
|
|
/** chars of shell output kept inline in the agent reply. anything past this
|
|
* blows the agent's context budget on commands that dump big logs (test
|
|
* runners, build tools, grep on large trees), so the overflow is spilled
|
|
* to a tempfile the agent can re-read selectively (cat/tail/grep). */
|
|
export const MAX_OUTPUT_CHARS = 5000;
|
|
|
|
/** if `output` exceeds `MAX_OUTPUT_CHARS`, persist the full body to a
|
|
* tempfile and return the last `MAX_OUTPUT_CHARS` prefixed with a sentinel
|
|
* pointing at the saved path. otherwise return as-is. */
|
|
function capOutput(output: string): string {
|
|
if (output.length <= MAX_OUTPUT_CHARS) return output;
|
|
const fullPath = join(getTempDir(), `shell-${randomUUID().slice(0, 8)}.log`);
|
|
writeFileSync(fullPath, output);
|
|
const elided = output.length - MAX_OUTPUT_CHARS;
|
|
return `... [${elided} chars truncated; full output saved to ${fullPath}] ...\n${output.slice(-MAX_OUTPUT_CHARS)}`;
|
|
}
|
|
|
|
/** detect git as a command invocation (not as part of another word like .gitignore) */
|
|
function isGitCommand(command: string): boolean {
|
|
const trimmed = command.trim();
|
|
if (trimmed === "git" || trimmed.startsWith("git ")) return true;
|
|
if (trimmed.startsWith("sudo git")) return true;
|
|
return /[;&|]\s*(?:sudo\s+)?git(?:\s|$)/.test(trimmed);
|
|
}
|
|
|
|
export function ShellTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "shell",
|
|
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
|
|
|
Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
|
|
|
|
Use this tool to:
|
|
- Run shell commands (ls, cat, grep, find, etc.)
|
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
|
- Run tests and linters
|
|
|
|
Output is capped at ${MAX_OUTPUT_CHARS} chars: if exceeded, only the tail is returned and the full body is saved to a tempfile (path included in the response). Re-read the tempfile with cat/tail/grep when you need more.
|
|
|
|
Do NOT use this tool for git commands — use the dedicated git tools instead.`,
|
|
parameters: ShellParams,
|
|
execute: execute(async (params) => {
|
|
if (isGitCommand(params.command)) {
|
|
throw new Error(
|
|
"git commands are not allowed in the shell tool. use the dedicated git tools instead:\n" +
|
|
"- git: local operations (status, log, diff, add, commit, checkout, merge, rebase, etc.)\n" +
|
|
"- push_branch: push to remote (handles authentication)\n" +
|
|
"- git_fetch: fetch from remote (handles authentication)\n" +
|
|
"- checkout_pr: check out PR branches"
|
|
);
|
|
}
|
|
|
|
const timeout = Math.min(params.timeout ?? 30000, 120000);
|
|
const cwd = params.working_directory ?? process.cwd();
|
|
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
|
|
|
|
if (params.command.includes("agent-browser")) {
|
|
const daemonError = ensureBrowserDaemon(ctx.toolState);
|
|
if (daemonError) {
|
|
return {
|
|
output: `browser daemon unavailable: ${daemonError}`,
|
|
exit_code: 1,
|
|
timed_out: false,
|
|
};
|
|
}
|
|
const binDir = ctx.toolState.browserDaemon?.binDir;
|
|
if (binDir) {
|
|
env.PATH = `${binDir}:${env.PATH ?? ""}`;
|
|
}
|
|
}
|
|
|
|
if (params.background) {
|
|
const tempDir = getTempDir();
|
|
const handle = `bg-${randomUUID().slice(0, 8)}`;
|
|
const outputPath = join(tempDir, `${handle}.log`);
|
|
const pidPath = join(tempDir, `${handle}.pid`);
|
|
const logFd = openSync(outputPath, "a");
|
|
let proc: ChildProcess;
|
|
try {
|
|
proc = spawnShell({
|
|
command: params.command,
|
|
env,
|
|
cwd,
|
|
stdio: ["ignore", logFd, logFd],
|
|
});
|
|
} finally {
|
|
closeSync(logFd);
|
|
}
|
|
if (!proc.pid) {
|
|
throw new Error("failed to start background process");
|
|
}
|
|
proc.unref();
|
|
writeFileSync(pidPath, `${proc.pid}\n`);
|
|
ctx.toolState.backgroundProcesses.set(handle, { pid: proc.pid, outputPath, pidPath });
|
|
return {
|
|
handle,
|
|
outputPath,
|
|
pidPath,
|
|
message: `started background process ${handle} (pid ${proc.pid})`,
|
|
};
|
|
}
|
|
|
|
const proc = spawnShell({
|
|
command: params.command,
|
|
env,
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "",
|
|
stderr = "",
|
|
timedOut = false,
|
|
exited = false;
|
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
const timeoutId = setTimeout(async () => {
|
|
if (!exited) {
|
|
timedOut = true;
|
|
await killProcessGroup(proc);
|
|
}
|
|
}, timeout);
|
|
|
|
const exitCode = await new Promise<number | null>((resolve) => {
|
|
const done = (code: number | null) => {
|
|
exited = true;
|
|
clearTimeout(timeoutId);
|
|
resolve(code);
|
|
};
|
|
proc.on("exit", done);
|
|
proc.on("error", () => done(null));
|
|
});
|
|
|
|
let output = stderr ? (stdout ? `${stdout}\n${stderr}` : stderr) : stdout;
|
|
if (timedOut)
|
|
output = output
|
|
? `${output}\n[timed out after ${timeout}ms]`
|
|
: `[timed out after ${timeout}ms]`;
|
|
|
|
const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
|
|
const trimmed = output.trim();
|
|
if (finalExitCode !== 0) {
|
|
log.info(`shell command failed with exit code ${finalExitCode}: ${params.command}`);
|
|
if (trimmed) log.info(`output: ${trimmed}`);
|
|
}
|
|
|
|
return {
|
|
output: capOutput(trimmed),
|
|
exit_code: finalExitCode,
|
|
timed_out: timedOut,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
export const KillBackgroundParams = type({
|
|
handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)"),
|
|
});
|
|
|
|
export function KillBackgroundTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "kill_background",
|
|
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with shell({ background: true }).`,
|
|
parameters: KillBackgroundParams,
|
|
execute: execute(async (params) => {
|
|
const proc = ctx.toolState.backgroundProcesses.get(params.handle);
|
|
if (!proc) {
|
|
return {
|
|
success: false,
|
|
message: `no background process with handle ${params.handle}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
await sleep(200);
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
|
|
ctx.toolState.backgroundProcesses.delete(params.handle);
|
|
return {
|
|
success: true,
|
|
message: `killed background process ${params.handle} (pid ${proc.pid})`,
|
|
};
|
|
}),
|
|
});
|
|
}
|