Skip to content

Commit 08037ee

Browse files
committed
Merge remote-tracking branch origin/main (PRs #2, #5, #6)
2 parents b329458 + 54ffead commit 08037ee

9 files changed

Lines changed: 225 additions & 38 deletions

File tree

app/src/agent-tools.test.ts

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

681+
// `git diff -- "<path>"` and `git commit -m "<message>"` are assembled
682+
// into a string that ends up at `sh -c`. Double quotes do not stop the
683+
// shell from expanding `$(...)`, so a model-supplied path or message
684+
// used to be able to run arbitrary commands — including when the user
685+
// had granted git_diff "always allow" as a read-only tool.
686+
it("git_diff does not let a path argument reach the shell", async () => {
687+
const marker = path.join(workspace, "diff-injection-marker");
688+
await gitDiff(workspace, false, `.$(touch ${marker})`);
689+
expect(fs.existsSync(marker)).toBe(false);
690+
});
691+
692+
it("git_commit does not let a commit message reach the shell", async () => {
693+
const marker = path.join(workspace, "commit-injection-marker");
694+
await gitCommit(workspace, `initial $(touch ${marker})`);
695+
expect(fs.existsSync(marker)).toBe(false);
696+
});
697+
698+
it("git_commit preserves a message containing shell metacharacters", async () => {
699+
const message = "fix: handle $HOME and `backticks` and 'quotes' and \"doubles\"";
700+
await gitCommit(workspace, message);
701+
const log = await gitLog(workspace, 5);
702+
// The whole subject, not just its prefix — quoting that drops or
703+
// mangles part of the message is as wrong as quoting that executes it.
704+
expect(log).toContain(message);
705+
});
706+
681707
it("git_log returns nothing unusual with no commits yet", async () => {
682708
const output = await gitLog(workspace);
683709
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

@@ -104,6 +104,27 @@ describe("wrapCommand", () => {
104104
});
105105
});
106106

107+
describe("shellQuote", () => {
108+
it("single-quotes for POSIX shells so substitutions stay inert", () => {
109+
expect(shellQuote("simple.txt", "linux")).toBe("'simple.txt'");
110+
// The whole point: a POSIX shell expands these inside double quotes.
111+
expect(shellQuote("$(id)", "linux")).toBe("'$(id)'");
112+
expect(shellQuote("`id`", "linux")).toBe("'`id`'");
113+
expect(shellQuote("it's", "linux")).toBe("'it'\\''s'");
114+
expect(shellQuote("", "linux")).toBe("''");
115+
});
116+
117+
// cmd.exe does not treat ' as a quote character, so POSIX quoting there
118+
// would split ordinary arguments on their spaces instead of protecting
119+
// them. It has no $(...) or backtick substitution to defend against.
120+
it("double-quotes for cmd.exe, where single quotes are not quoting", () => {
121+
expect(shellQuote("a message with spaces", "win32")).toBe('"a message with spaces"');
122+
expect(shellQuote('say "hi"', "win32")).toBe('"say ""hi"""');
123+
expect(shellQuote("a & b", "win32")).toBe('"a & b"');
124+
expect(shellQuote("", "win32")).toBe('""');
125+
});
126+
});
127+
107128
describe("applySandbox", () => {
108129
it("returns the command unchanged when no sandbox mechanism is available", () => {
109130
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
@@ -158,12 +158,27 @@ function buildMacSandboxProfile(workspaceRoot: string, allowNetwork: boolean): s
158158
].join("\n");
159159
}
160160

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

@@ -180,5 +195,5 @@ export function applySandbox(
180195
): string {
181196
const wrapped = wrapCommand(command, opts, platform, hasCommand);
182197
if (!wrapped) return command;
183-
return [wrapped.command, ...wrapped.args].map(shellQuote).join(" ");
198+
return [wrapped.command, ...wrapped.args].map((arg) => shellQuote(arg, platform)).join(" ");
184199
}

app/src/json-store.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ describe("json-store", () => {
3434
expect(entries).toEqual(["data.json"]);
3535
});
3636

37+
// These files hold API keys and conversation history; the default umask
38+
// would leave them readable by other accounts on the machine.
39+
it.skipIf(process.platform === "win32")("writes owner-only files", () => {
40+
writeJson(file, { token: "value" });
41+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
42+
});
43+
44+
it.skipIf(process.platform === "win32")("keeps the file owner-only on rewrite", () => {
45+
writeJson(file, { token: "first" });
46+
writeJson(file, { token: "second" });
47+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
48+
});
49+
50+
it.skipIf(process.platform === "win32")("stays owner-only when a stale temp file exists", () => {
51+
const stale = `${file}.tmp-${process.pid}`;
52+
fs.writeFileSync(stale, "leftover", { mode: 0o666 });
53+
writeJson(file, { token: "value" });
54+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
55+
});
56+
57+
// A file written by an older build is only ever read if its contents never
58+
// change, so tightening on write alone would never reach it.
59+
it.skipIf(process.platform === "win32")("tightens an existing world-readable file on read", () => {
60+
fs.writeFileSync(file, JSON.stringify({ token: "value" }), { mode: 0o644 });
61+
expect(readJson(file, {})).toEqual({ token: "value" });
62+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
63+
});
64+
3765
it("backs up and falls back to the default when the file is corrupted", () => {
3866
fs.writeFileSync(file, "{ not valid json");
3967
const result = readJson(file, { safe: true });

app/src/json-store.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export function readJson<T>(filePath: string, fallback: T): T {
3030
return fallback;
3131
}
3232

33+
restrictExistingPermissions(filePath);
34+
3335
try {
3436
return JSON.parse(raw) as T;
3537
} catch (err) {
@@ -43,9 +45,48 @@ export function readJson<T>(filePath: string, fallback: T): T {
4345
}
4446
}
4547

48+
// These files hold provider API keys (secrets.json) and full conversation
49+
// history. Written with the process umask they land as 0644 — or 0664 under
50+
// the umask 002 several distributions ship — leaving their contents readable
51+
// to anything that reaches them: another account on a shared machine, a
52+
// backup or sync tool, an archive unpacked somewhere else. The userData
53+
// directory is usually restrictive enough to cover that on a single-user
54+
// desktop, but a stored credential shouldn't depend on its parent directory's
55+
// mode.
56+
const PRIVATE_FILE_MODE = 0o600;
57+
58+
// Files written before this existed keep the mode they were created with, and
59+
// writeJson alone would never reach them: a key set once and never changed is
60+
// only ever read. So the mode is also tightened on first read, once per path
61+
// per run to keep it off the hot path.
62+
const permissionsChecked = new Set<string>();
63+
64+
function restrictExistingPermissions(filePath: string): void {
65+
if (process.platform === "win32" || permissionsChecked.has(filePath)) return;
66+
permissionsChecked.add(filePath);
67+
try {
68+
if ((fs.statSync(filePath).mode & 0o777) !== PRIVATE_FILE_MODE) {
69+
fs.chmodSync(filePath, PRIVATE_FILE_MODE);
70+
}
71+
} catch (err) {
72+
// Best effort — unusual ownership or an exotic filesystem must not
73+
// stop the app from reading its own data.
74+
logger.error(`Failed to restrict permissions on ${filePath}: ${(err as Error).message}`);
75+
}
76+
}
77+
4678
export function writeJson(filePath: string, data: unknown): void {
4779
fs.mkdirSync(path.dirname(filePath), { recursive: true });
4880
const tmpPath = `${filePath}.tmp-${process.pid}`;
49-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
81+
// The mode goes on the temp file, because the rename below replaces the
82+
// destination inode and takes the temp file's mode with it. Chmod'ing the
83+
// destination afterwards would leave a window where the contents are
84+
// readable, and would be undone by the next write.
85+
//
86+
// Removed first so writeFileSync always creates the file and so always
87+
// applies `mode` — it ignores the option for a path that already exists,
88+
// and an interrupted earlier write can leave one behind under this pid.
89+
fs.rmSync(tmpPath, { force: true });
90+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: PRIVATE_FILE_MODE });
5091
fs.renameSync(tmpPath, filePath);
5192
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from "vitest";
2+
import { canAlwaysAllow } from "./tool-approval";
3+
4+
describe("canAlwaysAllow", () => {
5+
it("allows a standing grant for workspace-local reads", () => {
6+
expect(canAlwaysAllow("read_file")).toBe(true);
7+
expect(canAlwaysAllow("list_dir")).toBe(true);
8+
expect(canAlwaysAllow("git_diff")).toBe(true);
9+
expect(canAlwaysAllow("find_symbol_references")).toBe(true);
10+
});
11+
12+
// A standing grant on these would let content the model reads steer an
13+
// outbound request on every later turn with no prompt shown.
14+
it("never allows a standing grant for tools that reach the network", () => {
15+
expect(canAlwaysAllow("fetch_url")).toBe(false);
16+
expect(canAlwaysAllow("web_search")).toBe(false);
17+
expect(canAlwaysAllow("github_read_file")).toBe(false);
18+
expect(canAlwaysAllow("github_list_repositories")).toBe(false);
19+
expect(canAlwaysAllow("github_repository_tree")).toBe(false);
20+
});
21+
22+
// Not read-only in either sense: it fetches a model-chosen URL and writes
23+
// a PNG into the workspace.
24+
it("never allows a standing grant for capture_page_screenshot", () => {
25+
expect(canAlwaysAllow("capture_page_screenshot")).toBe(false);
26+
});
27+
28+
it("never allows a standing grant for tools with side effects", () => {
29+
for (const tool of ["write_file", "run_command", "run_code", "apply_patch", "delete_path", "git_commit", "http_request", "write_to_terminal"]) {
30+
expect(canAlwaysAllow(tool)).toBe(false);
31+
}
32+
});
33+
34+
it("defaults to denying an unrecognised tool", () => {
35+
expect(canAlwaysAllow("some_future_tool")).toBe(false);
36+
});
37+
});

frontend/src/lib/tool-approval.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Which agent tools may be granted "always allow for this session".
2+
//
3+
// The bar for that grant is not "doesn't write to the workspace" — it's "the
4+
// user has nothing to lose by never being asked again". Those differ: a tool
5+
// can leave the workspace untouched and still reach the network or the disk.
6+
//
7+
// This matters because the model's inputs are not trusted. Agent mode reads
8+
// file contents and web pages, and instructions embedded in that content can
9+
// steer subsequent tool calls. The per-call approval prompt is what stops that
10+
// from becoming unattended action, so anything with a side effect the user
11+
// would want to see keeps its prompt. Notably that excludes:
12+
//
13+
// - web_search, fetch_url and the github_* tools, which each send a request
14+
// to a destination the model chooses — a standing grant on those is an
15+
// unattended outbound channel for whatever the model has already read.
16+
// - capture_page_screenshot, which fetches a model-chosen URL *and* writes a
17+
// PNG into the workspace, so it is not read-only in either sense.
18+
//
19+
// Listing what qualifies, rather than what doesn't, keeps this default-deny: a
20+
// tool added to AGENT_TOOLS without being classified here needs per-call
21+
// approval until someone decides otherwise.
22+
export const AUTO_APPROVABLE_TOOLS = new Set([
23+
"read_file",
24+
"find_files",
25+
"file_info",
26+
"list_dir",
27+
"search_files",
28+
"git_status",
29+
"git_diff",
30+
"git_log",
31+
"read_notes",
32+
"get_background_output",
33+
"list_background_commands",
34+
"find_symbol_references",
35+
"read_terminal_output",
36+
]);
37+
38+
export function canAlwaysAllow(toolName: string): boolean {
39+
return AUTO_APPROVABLE_TOOLS.has(toolName);
40+
}

frontend/src/pages/Chat.tsx

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { speakText, stopSpeaking } from "@/lib/tts";
7373
import { computeLineDiff } from "@/lib/diff";
7474
import { useToast } from "@/components/toast";
7575
import { isTransientError } from "@/lib/transient-errors";
76+
import { canAlwaysAllow } from "@/lib/tool-approval";
7677
import {
7778
COMPACTION_BUDGET_TOKENS,
7879
COMPACTION_KEEP_RECENT,
@@ -118,36 +119,12 @@ const RAG_THRESHOLD_CHARS = 20_000;
118119
// the most recent window renders by default, with older ones revealed a
119120
// window at a time on request rather than all at once.
120121
const RENDER_WINDOW_SIZE = 60;
121-
// Read-only tools are safe to let the model call repeatedly without a fresh
122-
// click each time — write_file and run_command always require explicit
123-
// per-call approval since they have real, potentially irreversible effects.
122+
124123
interface PlanStep {
125124
text: string;
126125
done: boolean;
127126
}
128127

129-
const READ_ONLY_TOOLS = new Set([
130-
"read_file",
131-
"find_files",
132-
"file_info",
133-
"list_dir",
134-
"search_files",
135-
"git_status",
136-
"git_diff",
137-
"git_log",
138-
"web_search",
139-
"fetch_url",
140-
"read_notes",
141-
"github_list_repositories",
142-
"github_repository_tree",
143-
"github_read_file",
144-
"get_background_output",
145-
"list_background_commands",
146-
"capture_page_screenshot",
147-
"find_symbol_references",
148-
"read_terminal_output",
149-
]);
150-
151128
// Vision models can already reason over any attached image — these just save
152129
// re-typing a good prompt for the common "I attached a diagram/wireframe"
153130
// case. Selecting one fills the composer; the user can still edit before sending.
@@ -2258,7 +2235,7 @@ export default function Chat() {
22582235
>
22592236
<X className="size-3.5" /> {t.deny}
22602237
</Button>
2261-
{READ_ONLY_TOOLS.has(call.name) && (
2238+
{canAlwaysAllow(call.name) && (
22622239
<Button
22632240
size="sm"
22642241
variant="ghost"

0 commit comments

Comments
 (0)