From 4aa4e471dbe792567b09319dac70d5c956e00261 Mon Sep 17 00:00:00 2001 From: Aryan Panwar Date: Thu, 12 Mar 2026 00:02:55 +0530 Subject: [PATCH] fix: shell-escape package names in npm install commands (CWE-78) The install_npm_package, install_mcp_server, and installNpmPackage functions interpolated package names directly into shell commands. While a regex guard exists, defense-in-depth requires shell escaping as a second layer to prevent command injection. - Add shared escapeShellArg utility in src/shell-escape.ts - Apply escapeShellArg at all 3 npm install call sites - Update test expectation to match escaped output Closes #181 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/tools-security.test.ts | 2 +- src/agent/tools.ts | 4 ++-- src/self-mod/tools-manager.ts | 3 ++- src/shell-escape.ts | 7 +++++++ 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 src/shell-escape.ts diff --git a/src/__tests__/tools-security.test.ts b/src/__tests__/tools-security.test.ts index 6a9eecd7..a0d70c88 100644 --- a/src/__tests__/tools-security.test.ts +++ b/src/__tests__/tools-security.test.ts @@ -654,7 +654,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 f32a069e..48234949 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -559,7 +559,7 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { 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); const { ulid } = await import("ulid"); ctx.db.insertModification({ @@ -955,7 +955,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..5a63df5e 100644 --- a/src/self-mod/tools-manager.ts +++ b/src/self-mod/tools-manager.ts @@ -9,6 +9,7 @@ import type { AutomatonDatabase, InstalledTool, } from "../types.js"; +import { escapeShellArg } from "../shell-escape.js"; import { logModification } from "./audit-log.js"; import { ulid } from "ulid"; @@ -29,7 +30,7 @@ export async function installNpmPackage( } const result = await conway.exec( - `npm install -g ${packageName}`, + `npm install -g ${escapeShellArg(packageName)}`, 120000, ); diff --git a/src/shell-escape.ts b/src/shell-escape.ts new file mode 100644 index 00000000..f1812ef7 --- /dev/null +++ b/src/shell-escape.ts @@ -0,0 +1,7 @@ +/** + * Escape a string for safe interpolation into a shell command. + * Wraps in single quotes and escapes any embedded single quotes. + */ +export function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +}