Skip to content

Commit 54ffead

Browse files
Merge pull request #2 from ConsultingFuture4200/fix/git-tool-shell-injection
fix(agent): stop git_diff and git_commit arguments reaching the shell
2 parents 72435a1 + c0d1780 commit 54ffead

4 files changed

Lines changed: 75 additions & 11 deletions

File tree

app/src/agent-tools.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,32 @@ describe("agent-tools", () => {
596596
expect(log).toContain("initial commit");
597597
});
598598

599+
// `git diff -- "<path>"` and `git commit -m "<message>"` are assembled
600+
// into a string that ends up at `sh -c`. Double quotes do not stop the
601+
// shell from expanding `$(...)`, so a model-supplied path or message
602+
// used to be able to run arbitrary commands — including when the user
603+
// had granted git_diff "always allow" as a read-only tool.
604+
it("git_diff does not let a path argument reach the shell", async () => {
605+
const marker = path.join(workspace, "diff-injection-marker");
606+
await gitDiff(workspace, false, `.$(touch ${marker})`);
607+
expect(fs.existsSync(marker)).toBe(false);
608+
});
609+
610+
it("git_commit does not let a commit message reach the shell", async () => {
611+
const marker = path.join(workspace, "commit-injection-marker");
612+
await gitCommit(workspace, `initial $(touch ${marker})`);
613+
expect(fs.existsSync(marker)).toBe(false);
614+
});
615+
616+
it("git_commit preserves a message containing shell metacharacters", async () => {
617+
const message = "fix: handle $HOME and `backticks` and 'quotes' and \"doubles\"";
618+
await gitCommit(workspace, message);
619+
const log = await gitLog(workspace, 5);
620+
// The whole subject, not just its prefix — quoting that drops or
621+
// mangles part of the message is as wrong as quoting that executes it.
622+
expect(log).toContain(message);
623+
});
624+
599625
it("git_log returns nothing unusual with no commits yet", async () => {
600626
const output = await gitLog(workspace);
601627
expect(output).toContain("Exit code:");

app/src/agent-tools.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { ToolDefinition } from "./providers/types";
88
import { getAccountToken } from "./accounts";
99
import { capturePageScreenshot } from "./browser-capture";
1010
import { killProcessTree } from "./process-tree";
11-
import { applySandbox } from "./command-sandbox";
11+
import { applySandbox, shellQuote } from "./command-sandbox";
1212
import { monitorProcess } from "./resource-monitor";
1313
import * as settingsStore from "./settings-store";
1414
import { resolveSafePath } from "./workspace-path";
@@ -1028,7 +1028,7 @@ export function gitStatus(workspaceRoot: string): Promise<string> {
10281028
}
10291029

10301030
export function gitDiff(workspaceRoot: string, staged = false, relativePath?: string): Promise<string> {
1031-
const target = relativePath ? ` -- "${relativePath}"` : "";
1031+
const target = relativePath ? ` -- ${shellQuote(relativePath)}` : "";
10321032
return gitCommand(workspaceRoot, `diff${staged ? " --staged" : ""}${target}`);
10331033
}
10341034

@@ -1038,7 +1038,9 @@ export function gitLog(workspaceRoot: string, count = 10): Promise<string> {
10381038

10391039
export async function gitCommit(workspaceRoot: string, message: string): Promise<string> {
10401040
await gitCommand(workspaceRoot, "add -A");
1041-
return gitCommand(workspaceRoot, `commit -m ${JSON.stringify(message)}`);
1041+
// JSON.stringify escapes `"` and `\` but not `$` or backticks, and the
1042+
// result is handed to `sh -c` — so it is not a shell-quoting function.
1043+
return gitCommand(workspaceRoot, `commit -m ${shellQuote(message)}`);
10421044
}
10431045

10441046
const WEB_FETCH_TIMEOUT_MS = 15_000;

app/src/command-sandbox.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { detectSandboxCapabilities, wrapCommand, applySandbox } from "./command-sandbox";
2+
import { detectSandboxCapabilities, wrapCommand, applySandbox, shellQuote } from "./command-sandbox";
33

44
const has = (available: string[]) => (cmd: string) => available.includes(cmd);
55

@@ -81,6 +81,27 @@ describe("wrapCommand", () => {
8181
});
8282
});
8383

84+
describe("shellQuote", () => {
85+
it("single-quotes for POSIX shells so substitutions stay inert", () => {
86+
expect(shellQuote("simple.txt", "linux")).toBe("'simple.txt'");
87+
// The whole point: a POSIX shell expands these inside double quotes.
88+
expect(shellQuote("$(id)", "linux")).toBe("'$(id)'");
89+
expect(shellQuote("`id`", "linux")).toBe("'`id`'");
90+
expect(shellQuote("it's", "linux")).toBe("'it'\\''s'");
91+
expect(shellQuote("", "linux")).toBe("''");
92+
});
93+
94+
// cmd.exe does not treat ' as a quote character, so POSIX quoting there
95+
// would split ordinary arguments on their spaces instead of protecting
96+
// them. It has no $(...) or backtick substitution to defend against.
97+
it("double-quotes for cmd.exe, where single quotes are not quoting", () => {
98+
expect(shellQuote("a message with spaces", "win32")).toBe('"a message with spaces"');
99+
expect(shellQuote('say "hi"', "win32")).toBe('"say ""hi"""');
100+
expect(shellQuote("a & b", "win32")).toBe('"a & b"');
101+
expect(shellQuote("", "win32")).toBe('""');
102+
});
103+
});
104+
84105
describe("applySandbox", () => {
85106
it("returns the command unchanged when no sandbox mechanism is available", () => {
86107
expect(applySandbox("echo hi", { workspaceRoot: "/ws", allowNetwork: false }, "win32", has(["bwrap"]))).toBe("echo hi");

app/src/command-sandbox.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,27 @@ function buildMacSandboxProfile(workspaceRoot: string, allowNetwork: boolean): s
149149
].join("\n");
150150
}
151151

152-
// POSIX shell single-quoting: wraps in '...', escaping any embedded single
153-
// quote as '\''. Used to fold a wrapped {command, args} back into the single
154-
// shell-command string that `child_process.exec`/`spawn(..., {shell:true})`
155-
// expect, without needing to change how the rest of agent-tools.ts invokes
156-
// commands.
157-
function shellQuote(arg: string): string {
152+
// Quotes a single argument so the shell that will run it treats it as one
153+
// literal value. Used both to fold a wrapped {command, args} back into the
154+
// single shell-command string that `child_process.exec`/`spawn(...,
155+
// {shell:true})` expect, and by agent-tools.ts when it builds a fixed command
156+
// around a *value* the model supplied (a path for `git diff`, a message for
157+
// `git commit`).
158+
//
159+
// The two shells need different treatment, and getting this wrong in either
160+
// direction is a bug:
161+
//
162+
// - POSIX `sh`: single quotes, with an embedded quote written as '\''.
163+
// Double quotes would not be enough, because `$(...)` and backticks are
164+
// still expanded inside them.
165+
// - Windows `cmd.exe`: single quotes are not quote characters at all, so
166+
// POSIX quoting there would corrupt ordinary arguments rather than protect
167+
// them. Double quotes are the right tool: `&`, `|`, `<` and `>` are
168+
// literal inside them, and `$(...)`/backticks mean nothing to cmd.exe.
169+
// An embedded double quote is written as "" — the convention both cmd.exe
170+
// and the argv parser of the program being launched understand.
171+
export function shellQuote(arg: string, platform: NodeJS.Platform = process.platform): string {
172+
if (platform === "win32") return `"${arg.replace(/"/g, '""')}"`;
158173
return `'${arg.replace(/'/g, `'\\''`)}'`;
159174
}
160175

@@ -171,5 +186,5 @@ export function applySandbox(
171186
): string {
172187
const wrapped = wrapCommand(command, opts, platform, hasCommand);
173188
if (!wrapped) return command;
174-
return [wrapped.command, ...wrapped.args].map(shellQuote).join(" ");
189+
return [wrapped.command, ...wrapped.args].map((arg) => shellQuote(arg, platform)).join(" ");
175190
}

0 commit comments

Comments
 (0)