SLOPSHOPPER

sandbox

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

guard
★ 18v?MITupdated 2026-10-19bendrucker/claude/plugins/sandbox
A shopper browsing a rack in a slop shop
README

Sandbox

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.

Examples

With git:* and agent-browser:* excluded:

Scaffolding around an excluded command

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.

An exported session variable

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.

A bare excluded command

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.

No reminder

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 &.

Contents

  • 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.
  • Events: sandbox.bypass per flagged call (verdict remind or allow, scaffolds, reason, repeat) through mod-events.

Tests

bun scripts/mod-test.ts sandbox

Removal

The sandbox-bypass-scaffold query in observability:session reports scaffolded_pct and repeat_pct.

  • Retire the plugin once scaffolded_pct stays under 10% for four weeks, or once Claude Code's own matching ignores these scaffolds.
  • Switch it to deny if repeat_pct stays high.
Source 2 files
mod/register.ts 85 lines
1import 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}
85
mod/scaffold.ts 292 lines
1/** 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