diff --git a/src/__tests__/tools-security.test.ts b/src/__tests__/tools-security.test.ts index 1b8ae41d..c93b179b 100644 --- a/src/__tests__/tools-security.test.ts +++ b/src/__tests__/tools-security.test.ts @@ -545,7 +545,7 @@ describe("package install inline validation", () => { const tool = tools.find((t) => t.name === "install_npm_package")!; await tool.execute({ package: "axios" }, ctx); expect(conway.execCalls.length).toBe(1); - expect(conway.execCalls[0].command).toBe("npm install -g axios"); + expect(conway.execCalls[0].command).toBe("npm install -g 'axios'"); }); it("install_npm_package allows scoped packages", async () => { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 8006a7c2..ef9feb52 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -167,7 +167,7 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { return await ctx.conway.readFile(filePath); } catch { // Conway files/read API may be broken — fall back to exec(cat) - const result = await ctx.conway.exec(`cat ${filePath}`, 30_000); + const result = await ctx.conway.exec(`cat ${escapeShellArg(filePath)}`, 30_000); if (result.exitCode !== 0) { return `ERROR: File not found or not readable: ${filePath}`; } @@ -433,7 +433,7 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { return `Blocked: invalid package name "${pkg}"`; } const result = await ctx.conway.exec( - `npm install -g ${pkg}`, + `npm install -g ${escapeShellArg(pkg)}`, 60000, ); @@ -508,7 +508,11 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { let appliedSummary: string; try { if (commit) { - await run(`git cherry-pick ${commit}`); + // Defense-in-depth: validate commit hash at execution time + if (!/^[a-f0-9]{7,40}$/.test(commit)) { + return `Blocked: invalid commit hash "${commit}"`; + } + await run(`git cherry-pick -- ${escapeShellArg(commit)}`); appliedSummary = `Cherry-picked ${commit}`; } else { await run("git pull origin main --ff-only"); @@ -808,7 +812,7 @@ Model: ${ctx.inference.getDefaultModel()} if (!/^[@a-zA-Z0-9._\/-]+$/.test(pkg)) { return `Blocked: invalid package name "${pkg}"`; } - const result = await ctx.conway.exec(`npm install -g ${pkg}`, 60000); + const result = await ctx.conway.exec(`npm install -g ${escapeShellArg(pkg)}`, 60000); if (result.exitCode !== 0) { return `Failed to install MCP server: ${result.stderr}`; diff --git a/src/self-mod/tools-manager.ts b/src/self-mod/tools-manager.ts index 6fa7ed84..9a5a48f2 100644 --- a/src/self-mod/tools-manager.ts +++ b/src/self-mod/tools-manager.ts @@ -12,6 +12,11 @@ import type { import { logModification } from "./audit-log.js"; import { ulid } from "ulid"; +/** Escape a string for safe shell interpolation. */ +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} + /** * Install an npm package globally in the sandbox. */ @@ -29,7 +34,7 @@ export async function installNpmPackage( } const result = await conway.exec( - `npm install -g ${packageName}`, + `npm install -g ${escapeShellArg(packageName)}`, 120000, );