Refuse a sandbox bypass whose commands already escape through sandbox.excludedCommands, naming the form that escapes without the flag

Teaches Claude to drop dangerouslyDisableSandbox when the command would escape the sandbox anyway through sandbox.excludedCommands. The call still runs. The reminder is attached to its result.
With git:* and agent-browser:* excluded:
The call: cd /repo && git push -u origin HEAD 2>&1 | tail -5, with the flag set. What comes back with its result:
That call needed no `dangerouslyDisableSandbox`: every command in it matches `sandbox.excludedCommands` (`git:*`), so it runs outside the sandbox on its own.
Next time, leave the flag off, along with the pieces that keep the chain from matching:
git push -u origin HEAD
- `cd /repo`: the command already runs in the session's directory.
- `2>&1`: the harness already captures stderr.
- `| tail -5`: the harness truncates long output itself.
The call: export AGENT_BROWSER_SESSION=docs; agent-browser open https://example.com, with the flag set. What comes back with its result:
That call needed no `dangerouslyDisableSandbox`: every command in it matches `sandbox.excludedCommands` (`agent-browser:*`), so it runs outside the sandbox on its own.
Next time, leave the flag off, along with the pieces that keep the chain from matching:
agent-browser --session docs open https://example.com
- `export AGENT_BROWSER_SESSION=docs`: pass `--session docs` to `agent-browser` instead.
The call: git status, with the flag set. What comes back with its result:
That call needed no `dangerouslyDisableSandbox`: every command in it matches `sandbox.excludedCommands` (`git:*`), so it runs outside the sandbox on its own.
It escapes as written. Next time, leave the flag off.
git status && bun test gets nothing back, because bun matches no entry. It also stays quiet for a cd elsewhere or in a subagent, any other variable, $(...), another redirection, ||, and &.
mod/register.ts: a tool.call hook on Bash calls that set the flag while the sandbox is on. It unions excludedCommands across settings sources and fails open.mod/scaffold.ts: the parser that strips the scaffolding and writes the reminder.sandbox.bypass per flagged call (verdict remind or allow, scaffolds, reason, repeat) through mod-events.bun scripts/mod-test.ts sandbox
The sandbox-bypass-scaffold query in observability:session reports scaffolded_pct and repeat_pct.
scaffolded_pct stays under 10% for four weeks, or once Claude Code's own matching ignores these scaffolds.repeat_pct stays high.mod/register.ts 85 lines1import type { EngineInterface, On, Settings } from "claude-code";
2import { judge, message } from "./scaffold";
3
4const MOD = "sandbox";
5
6function failed($: EngineInterface, hook: string, kind: string, text?: string): void {
7 $.modEvents
8 .emit({ mod: MOD, event: "error", ok: false, detail: { hook, kind, message: text ?? null } })
9 .catch(() => undefined);
10}
11
12const SOURCES = ["user", "project", "local", "flag", "policy"] as const;
13
14function sandboxOf(settings: Settings): Record<string, unknown> {
15 const sandbox: unknown = settings.sandbox;
16 return sandbox !== null && typeof sandbox === "object" ? { ...sandbox } : {};
17}
18
19/**
20 * The active `sandbox.excludedCommands`, or none while the sandbox is off and
21 * the flag is a no-op. The engine concatenates the list across sources and
22 * takes `enabled` from the highest one that sets it, so each source is read
23 * on its own rather than through one merged `sandbox` key.
24 */
25async function excluded($: EngineInterface): Promise<string[]> {
26 const sources = (await Promise.all(SOURCES.map((source) => $.settings.read({ source })))).map(
27 sandboxOf,
28 );
29 const enabled = sources.findLast((sandbox) => typeof sandbox.enabled === "boolean")?.enabled;
30 if (enabled !== true) return [];
31 const entries = sources.flatMap(({ excludedCommands }) =>
32 Array.isArray(excludedCommands) ? excludedCommands : [],
33 );
34 return [
35 ...new Set(
36 entries.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""),
37 ),
38 ];
39}
40
41export function register(on: On): void {
42 on("session.start", ($, e, next) => {
43 void $.modEvents.emit({ mod: MOD, event: "session.start" });
44 return next(e);
45 });
46
47 // Reminders already given per loop, so a repeat after one shows the note did not land.
48 const reminded = new Map<string, number>();
49
50 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
51 if (e.dangerouslyDisableSandbox !== true) return next(e);
52 const patterns = await excluded($);
53 if (patterns.length === 0) return next(e);
54
55 // A subagent's shell may start in its own worktree, which the session's
56 // cwd does not name, so a `cd` there is never judged redundant.
57 const cwd = e.agentId === undefined ? await $.session.cwd() : null;
58 const verdict = judge(e.command, patterns, cwd);
59 const agent = e.agentId ?? null;
60 const repeat = reminded.get(agent ?? "") ?? 0;
61 if (verdict.escapes) reminded.set(agent ?? "", repeat + 1);
62 void $.modEvents.emit({
63 mod: MOD,
64 event: "bypass",
65 detail: {
66 verdict: verdict.escapes ? "remind" : "allow",
67 reason: verdict.escapes ? null : verdict.reason,
68 scaffolds: verdict.escapes ? [...new Set(verdict.scaffolds.map((s) => s.kind))] : [],
69 verbs: verdict.verbs,
70 agent,
71 repeat: verdict.escapes ? repeat : null,
72 },
73 });
74 // A deny costs a turn on every call it fires on, so the bypass runs and
75 // the rewrite rides along for next time.
76 const result = await next(e);
77 if (!verdict.escapes || result.deny !== undefined) return result;
78 return { ...result, context: [...(result.context ?? []), message(verdict)] };
79 // Fails open: a broken parse must never block a bypass the person allowed.
80 }).catch(($, e, next) => {
81 failed($, "tool.call", next.error.kind, next.error.message);
82 return next(e);
83 });
84}
85mod/scaffold.ts 292 lines1/** One argument: `text` as the shell reads it, `raw` as written, quotes included. */
2export interface Word {
3 text: string;
4 raw: string;
5}
6
7/** A pipeline, and the `&&` or `;` that joined it to the one before. */
8interface Item {
9 op: "&&" | ";" | null;
10 elements: Word[][];
11}
12
13/** What stood between the chain and an `excludedCommands` match. */
14export type Scaffold =
15 | { kind: "cd"; raw: string }
16 | { kind: "export"; raw: string; name: string; value: string }
17 | { kind: "env"; raw: string; name: string; value: string }
18 | { kind: "redirect"; raw: string }
19 | { kind: "filter"; raw: string };
20
21/** Why a bypass is let through: no form without the flag is known to escape. */
22export type Pass = "unparsed" | "cd-elsewhere" | "env" | "unmatched" | "empty";
23
24export type Verdict =
25 | { escapes: true; command: string; scaffolds: Scaffold[]; patterns: string[]; verbs: string[] }
26 | { escapes: false; reason: Pass; verbs: string[] };
27
28/**
29 * Output filters the harness makes redundant: it truncates long output, so a
30 * trailing one only keeps the chain from matching.
31 */
32const FILTERS = new Set(["tail", "head", "grep", "wc", "sort", "uniq", "cut", "jq"]);
33
34/** Variables a command takes as a flag, so the flag replaces the export. */
35const FLAGS: Record<string, { command: string; flag: string }> = {
36 AGENT_BROWSER_SESSION: { command: "agent-browser", flag: "--session" },
37};
38
39/** Options before a git subcommand that keep `git:*` from matching. */
40const GIT_GLOBALS = /^(?:-c|-C|--git-dir|--work-tree)/;
41
42const ASSIGNMENT = /^([A-Za-z_]\w*)=(.*)$/s;
43const REDIRECT = "2>&1";
44
45/**
46 * Splits a command into pipelines of simple commands, or null for anything
47 * past `&&`, `;`, newlines, pipes, quotes, and a `2>&1`. Expansion, grouping,
48 * other redirections, and background jobs all return null, so the caller lets
49 * the call through rather than guess at what the shell would run.
50 */
51export function parse(command: string): Item[] | null {
52 const items: Item[] = [];
53 let elements: Word[][] = [];
54 let words: Word[] = [];
55 let text = "";
56 let start = -1;
57 let op: Item["op"] = null;
58
59 const endWord = (end: number) => {
60 if (start === -1) return;
61 words.push({ text, raw: command.slice(start, end) });
62 text = "";
63 start = -1;
64 };
65 const endElement = (end: number): boolean => {
66 endWord(end);
67 if (words.length === 0) return false;
68 elements.push(words);
69 words = [];
70 return true;
71 };
72 const endItem = (end: number, next: Item["op"]): boolean => {
73 if (!endElement(end) && elements.length > 0) return false;
74 if (elements.length > 0) items.push({ op, elements });
75 elements = [];
76 op = next;
77 return true;
78 };
79
80 for (let i = 0; i < command.length; i++) {
81 const char = command.charAt(i);
82 if (char === "'" || char === '"') {
83 if (start === -1) start = i;
84 const close = command.indexOf(char, i + 1);
85 if (close === -1) return null;
86 const body = command.slice(i + 1, close);
87 if (char === '"' && /[$`\\]/.test(body)) return null;
88 text += body;
89 i = close;
90 } else if (char === "\\") {
91 if (start === -1) start = i;
92 text += command.charAt(i + 1);
93 i++;
94 } else if (start === -1 && command.startsWith(REDIRECT, i)) {
95 words.push({ text: REDIRECT, raw: REDIRECT });
96 i += REDIRECT.length - 1;
97 } else if (char === "&" && command[i + 1] === "&") {
98 if (!endItem(i, "&&")) return null;
99 i++;
100 } else if (char === ";" || char === "\n") {
101 if (!endItem(i, ";")) return null;
102 } else if (char === "|") {
103 if (command[i + 1] === "|" || command[i + 1] === "&" || !endElement(i)) return null;
104 } else if ("&<>()`{}#".includes(char) || command.startsWith("$(", i)) {
105 return null;
106 } else if (/\s/.test(char)) {
107 endWord(i);
108 } else {
109 if (start === -1) start = i;
110 text += char;
111 }
112 }
113 if (words.length === 0 && start === -1 && elements.length > 0) return null;
114 endItem(command.length, null);
115 return items;
116}
117
118/** Whether `text` matches one `excludedCommands` entry, in Bash permission-rule syntax. */
119export function matches(pattern: string, text: string): boolean {
120 const entry = pattern.trim();
121 const prefix = entry.endsWith(":*") || entry.endsWith(" *") ? entry.slice(0, -2) : null;
122 if (prefix !== null && !prefix.includes("*"))
123 return text === prefix || text.startsWith(`${prefix} `);
124 if (!entry.includes("*")) return text === entry;
125 const source = entry
126 .split("*")
127 .map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`))
128 .join(".*");
129 return new RegExp(`^${source}$`, "s").test(text);
130}
131
132function escapes(words: Word[], patterns: string[]): string | undefined {
133 if (words[0]?.text === "git" && GIT_GLOBALS.test(words[1]?.text ?? "")) return undefined;
134 const text = words.map((word) => word.text).join(" ");
135 return patterns.find((pattern) => matches(pattern, text));
136}
137
138function trimSlash(path: string): string {
139 return path.length > 1 ? path.replace(/\/+$/, "") : path;
140}
141
142interface Walk {
143 scaffolds: Scaffold[];
144 flags: Map<string, string>;
145 patterns: Set<string>;
146 verbs: string[];
147 kept: { op: Item["op"]; elements: Word[][] }[];
148}
149
150/** A pipeline that only sets up the next: a `cd` or an `export`. Undefined when it is not one. */
151function prelude(words: Word[], cwd: string | null, walk: Walk): Pass | null | undefined {
152 const [head, ...rest] = words;
153 if (head?.text === "cd" && rest.length === 1) {
154 const target = rest[0]?.text ?? "";
155 if (cwd === null || (target !== "." && trimSlash(target) !== trimSlash(cwd)))
156 return "cd-elsewhere";
157 walk.scaffolds.push({ kind: "cd", raw: words.map((word) => word.raw).join(" ") });
158 return null;
159 }
160 const assigns = head?.text === "export" ? rest : words;
161 if (assigns.length === 0 || !assigns.every((word) => ASSIGNMENT.test(word.text)))
162 return undefined;
163 for (const word of assigns) {
164 const [, name = "", value = ""] = ASSIGNMENT.exec(word.text) ?? [];
165 if (!(name in FLAGS)) return "env";
166 walk.flags.set(name, value);
167 walk.scaffolds.push({ kind: "export", raw: words.map((w) => w.raw).join(" "), name, value });
168 }
169 return null;
170}
171
172/** The command of one pipeline element, its inline assignments and `2>&1` lifted off. */
173function lift(words: Word[], walk: Walk): Word[] | Pass {
174 let index = 0;
175 for (; index < words.length; index++) {
176 const word = words[index];
177 const match = ASSIGNMENT.exec(word?.text ?? "");
178 if (word === undefined || match === null) break;
179 const [, name = "", value = ""] = match;
180 if (!(name in FLAGS)) return "env";
181 walk.flags.set(name, value);
182 walk.scaffolds.push({ kind: "env", raw: word.raw, name, value });
183 }
184 const command = words.slice(index).filter((word) => {
185 if (word.text !== REDIRECT) return true;
186 walk.scaffolds.push({ kind: "redirect", raw: REDIRECT });
187 return false;
188 });
189 return command.length === 0 ? "empty" : command;
190}
191
192/** A pipeline's commands, trailing filters dropped, or why it does not escape. */
193function pipeline(elements: Word[][], patterns: string[], walk: Walk): Word[][] | Pass {
194 const commands: Word[][] = [];
195 for (const [index, element] of elements.entries()) {
196 const command = lift(element, walk);
197 if (typeof command === "string") return command;
198 const verb = command[0]?.text ?? "";
199 const pattern = escapes(command, patterns);
200 if (pattern !== undefined && commands.length === index) {
201 commands.push(command);
202 walk.patterns.add(pattern);
203 walk.verbs.push(verb);
204 } else if (index > 0 && FILTERS.has(verb)) {
205 walk.scaffolds.push({
206 kind: "filter",
207 raw: `| ${command.map((word) => word.raw).join(" ")}`,
208 });
209 } else {
210 walk.verbs.push(verb);
211 return "unmatched";
212 }
213 }
214 return commands;
215}
216
217/** Each command with a flag in place of an exported variable it reads. */
218function withFlags(words: Word[], flags: Map<string, string>): string {
219 const raw = words.map((word) => word.raw);
220 for (const [name, value] of flags) {
221 const { command, flag } = FLAGS[name] ?? { command: "", flag: "" };
222 if (words[0]?.text !== command || words.some((word) => word.text === flag)) continue;
223 const quoted = /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
224 raw.splice(1, 0, flag, quoted);
225 }
226 return raw.join(" ");
227}
228
229/**
230 * Judges a `dangerouslyDisableSandbox` call: denied when every command in it
231 * matches `patterns` once the scaffolding is gone, since that form escapes the
232 * sandbox with no flag. `cwd` is the directory the command starts in, the one
233 * a `cd` to it is redundant in, or null where that is unknown and every `cd`
234 * counts as going elsewhere.
235 */
236export function judge(command: string, patterns: string[], cwd: string | null): Verdict {
237 const items = parse(command);
238 if (items === null) return { escapes: false, reason: "unparsed", verbs: [] };
239 const walk: Walk = { scaffolds: [], flags: new Map(), patterns: new Set(), verbs: [], kept: [] };
240 for (const { op, elements } of items) {
241 const only = elements.length === 1 ? elements[0] : undefined;
242 const set = only === undefined ? undefined : prelude(only, cwd, walk);
243 if (typeof set === "string") return { escapes: false, reason: set, verbs: walk.verbs };
244 if (set === null) continue;
245 const commands = pipeline(elements, patterns, walk);
246 if (typeof commands === "string")
247 return { escapes: false, reason: commands, verbs: walk.verbs };
248 walk.kept.push({ op, elements: commands });
249 }
250 if (walk.kept.length === 0) return { escapes: false, reason: "empty", verbs: walk.verbs };
251 const rewritten = walk.kept
252 .map(({ op, elements }, index) => {
253 const line = elements.map((words) => withFlags(words, walk.flags)).join(" | ");
254 return index === 0 ? line : `${op === ";" ? ";" : " &&"} ${line}`;
255 })
256 .join("");
257 return {
258 escapes: true,
259 command: rewritten,
260 scaffolds: walk.scaffolds,
261 patterns: [...walk.patterns],
262 verbs: walk.verbs,
263 };
264}
265
266function note(scaffold: Scaffold): string {
267 if (scaffold.kind === "export" || scaffold.kind === "env") {
268 const { command, flag } = FLAGS[scaffold.name] ?? { command: "", flag: "" };
269 return `\`${scaffold.raw}\`: pass \`${flag} ${scaffold.value}\` to \`${command}\` instead.`;
270 }
271 if (scaffold.kind === "cd")
272 return `\`${scaffold.raw}\`: the command already runs in the session's directory.`;
273 if (scaffold.kind === "redirect") return "`2>&1`: the harness already captures stderr.";
274 return `\`${scaffold.raw}\`: the harness truncates long output itself.`;
275}
276
277/** The reminder: the form that escapes, and why each dropped piece is safe to drop. */
278export function message(verdict: Extract<Verdict, { escapes: true }>): string {
279 const seen = new Set<string>();
280 const notes = verdict.scaffolds.map(note).filter((text) => !seen.has(text) && seen.add(text));
281 const entries = verdict.patterns.map((pattern) => `\`${pattern}\``).join(", ");
282 const lines = [
283 `That call needed no \`dangerouslyDisableSandbox\`: every command in it matches \`sandbox.excludedCommands\` (${entries}), so it runs outside the sandbox on its own.`,
284 notes.length === 0
285 ? "It escapes as written. Next time, leave the flag off."
286 : "Next time, leave the flag off, along with the pieces that keep the chain from matching:",
287 ];
288 if (notes.length > 0)
289 lines.push("", ` ${verdict.command}`, "", ...notes.map((text) => `- ${text}`));
290 return lines.join("\n");
291}
292