Explains each Claude Code sandbox block in plain words and turns recurring blocks into reviewed, one-press, undoable edits of your user settings.

<img src="https://img.shields.io/badge/Claude_Code-2.1.288%2B-D97757" alt="Claude Code 2.1.288+"> <img src="https://img.shields.io/badge/Claude_Code-mod-555555" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-78997A" alt="License: MIT"> <img src="https://img.shields.io/badge/version-1.1.2-555555" alt="Version: 1.1.2">
After a repeated network block, the band explains it and offers Allow.
Sandbox blocks can look like broken networking or file permissions, and the same targets can fail again next session. Sandbox Tuner handles sandbox blocks, not permission prompts: it explains the blocks that it recognizes, within the known limits, and counts the hosts and write folders that recur across sessions. Claude Code's sandbox applies to Bash commands and does not cover Write or WebFetch tool calls (issue #89762). So Sandbox Tuner reads Bash results, and the hosts that background Bash commands and shell Monitor launches list when they start.
/sandbox-tuner lists hosts and write folders, with ready fixes first. Hide the targets that you do not want to see, except a target that your settings already allow./tmp, such as /tmp/build, can get Allow. A file directly in /tmp gets no Allow, because /tmp itself is too broad. Sandbox Tuner offers no wildcard hosts and no command exclusions./sandbox-tuner ranks blocked hosts: a ready fix first, then waiting ones.
Some blocks have no safe setting. Sandbox Tuner says so.
flowchart TD
A[Read each Bash result or shell launch] --> B[Save host and write-folder sightings]
B --> C[Check counts and current settings]
C --> D[Explain waiting, covered, or no-fix targets]
C --> E[Show eligible change and its effect]
E --> F[You press Allow or Deny]
F --> G[Reload sightings and recheck settings]
G -->|Refused| D
G -->|Eligible| H[Lock, read again, back up, record Undo, write, and confirm]
H --> I[You can press Undo]
allowed_domains, or a path taken from a permission error. Allow appears after minimumSightings sightings (3 by default). The threshold filters noise. It is not a security check.sandbox.network.allowedDomains, sandbox.network.deniedDomains, or sandbox.filesystem.allowWrite of your user settings file: ~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json.PATH.backups/settings.json.sandbox-tuner.bak in the config folder. Sandbox Tuner checks that nothing else changed the file, writes it as 2-space JSON, reads it back, frees the lock, and checks the file again two seconds later.Requires Claude Code 2.1.288 or later, with the sandbox turned on through /sandbox, on macOS, Linux, or WSL2. Tested with Claude Code 2.1.296.
Inside Claude Code:
/plugin marketplace add dot-agi/sandbox-tuner
/plugin install sandbox-tuner@sandbox-tuner
From a shell:
claude plugin marketplace add dot-agi/sandbox-tuner
claude plugin install sandbox-tuner@sandbox-tuner
To try it for one session from a clone:
claude --plugin-dir ./sandbox-tuner
Install from a shell with Claude Code 2.1.292 or later. The second part turns on auto-update. It runs only when the install succeeds and the marketplace entry exists:
claude plugin install sandbox-tuner --marketplace dot-agi/sandbox-tuner --scope user --json \
&& f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
&& jq -e --arg name sandbox-tuner '.extraKnownMarketplaces[$name].source' "$f" > /dev/null \
&& tmp=$(mktemp "$f.XXXXXX") \
&& { jq --arg name sandbox-tuner '.extraKnownMarketplaces[$name].autoUpdate = true' "$f" > "$tmp" && mv "$tmp" "$f" || { rm -f "$tmp"; false; }; }
Claude Code 2.1.288 to 2.1.291 has no --marketplace option. Add the marketplace first, then run the auto-update part above:
claude plugin marketplace add dot-agi/sandbox-tuner --scope user --json \
&& claude plugin install sandbox-tuner@sandbox-tuner --scope user --json
Auto-update runs only in interactive sessions. A headless job updates at its start, before the session that uses the mod:
claude plugin update sandbox-tuner@sandbox-tuner --json
Check the result. The first command prints the installed version. The second prints the latest release:
claude plugin list --json | jq -r '.[] | select(.id == "sandbox-tuner@sandbox-tuner") | "\(.version) enabled=\(.enabled)"' \
&& git ls-remote --tags --refs https://github.com/dot-agi/sandbox-tuner 'sandbox-tuner--v*' | sed 's/.*--v//' | sort -V | tail -n 1
CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session./reload-plugins. An agent cannot type /reload-plugins, so it starts a new session.install again does not update. Use update.mv replaces the settings file, with mode 600. If settings.json is a symbolic link, edit its target instead.| Command | What it does |
|---|---|
/sandbox-tuner | Opens the pane and loads the sightings that other sessions saved. Prints the text report when the app cannot place the pane. |
claude -p "/sandbox-tuner" | Prints the ranking as text. Never writes settings. |
The band's buttons have no hotkeys. Click one, or press Ctrl+X, then Tab, to focus the band, then Tab to the button and Enter. Ctrl+X, then Tab is Claude Code's own focus chord. Release Ctrl before Tab: many terminals read Ctrl+Tab as next tab. In Emacs terminals, click the band's button, or rebind abovePrompt:focus with /keybindings. In the pane, Tab moves between buttons, Enter presses one, the arrow keys scroll, and Esc closes it.
| Option | Default | Range | Meaning |
|---|---|---|---|
minimumSightings | 3 | 1–100 | How many times a host or write folder must recur before Sandbox Tuner offers Allow. Refusals at the network prompt stay in the counts but do not count toward it. |
linkCheckMinutes | 15 | 0–1440 | How often, in minutes, an interactive session checks again for links into the folders that Allow opened. 0 turns the timed check off. |
Set them with /plugin configure sandbox-tuner@sandbox-tuner, or in /config.
| Feature or project | What it does | What Sandbox Tuner adds |
|---|---|---|
Built-in /sandbox | Configures Claude Code's sandbox and its operating mode. | Explanations for observed blocks, counts across sessions, and reviewed edits with Undo. |
| Built-in network prompt approvals | Approves hosts for the current session. | Persistent user-settings entries after you review a recurring target. Prompt approvals themselves are not counted. |
Bash allowed_domains | Lets a command declare needed hosts in auto mode, subject to network policy. Saves no lasting grant. | Counts those declarations across sessions and offers an eligible persistent setting after the threshold. |
| ktrysmt's sandbox auto-allow hook | Automatically extracts hosts from many error patterns and appends them to allowedDomains; uses a lock for settings writes. | Review before each grant, Undo, a backup, recurring-target counts, and guarded cache/temp folder fixes. |
allowed_domains, across sessions.PATH folders: before Allow, then at each session start and every 15 minutes by default.The usage guide has every limit in full, also rare ones such as races, size caps, and unusual setups.
Edit(<folder>/) allow rule as cover only when the folder has no wildcard. So it can offer Allow for a folder that a wildcard Edit rule already opens. A deny rule with a wildcard, in permissions.deny or in sandbox.filesystem, withholds Allow for every folder under the folder that holds its first wildcard. For example, Read(~//*.key) withholds Allow for every folder in your home folder. In command-line or managed settings, a rule path that starts with a single /, and a relative sandbox.filesystem entry, have no known anchor. Such an Edit allow rule or allowWrite entry counts as no cover. Such a deny rule or denyWrite or denyRead entry withholds Allow for every folder.Operation not permitted. Punctuation in the error can cut the path short. A relative path counts from the session working directory, which can differ from the folder where the command ran. The error can also come from a blocked read. So a folder with any such sighting never gets Allow, even when sandbox reports name it too. Its counts and explanation remain.<sandbox_violations> block that ends that output, before any proxy note. It skips a block that other output follows, and writes a debug line. But a command that prints a block as its last output, for example from a file, and then fails, still forges a report: Sandbox Tuner counts that block as a block. Such a block counts only while the sandbox is on, and not when the command ran outside the sandbox at Claude's request. A Claude Code version that adds other text after its report makes Sandbox Tuner skip the real report too, with the same debug line. Before you press Allow, check that you expect the host or folder. Also check the command that the band names, or the programs that the pane lists.Sandbox Tuner makes no network request and starts no process. Nothing leaves your machine.
sandbox-tuner: line to the debug log. The pane and the command then show the error, and every other hook passes its event on unchanged. Every hook is in hooks/register.tsx.HOME, CLAUDE_CONFIG_DIR, and PATH, the working directory, the project root, and the session id. When you press Allow on a write folder, it also finds where that folder, your home folder, the config folder, the working directory, the protected places, the credentials folders (such as ~/.ssh), and each PATH folder really lie. At the start of an interactive session, also after /clear, /resume, or /branch, and every 15 minutes during it (linkCheckMinutes), while a folder that Allow opened is still in your settings file, it finds where that folder, the protected places, the credentials folders, and each PATH folder lie. This check lists names and follows links. It opens no file. What a write changes has the full list.sandbox and keeps every other key. The lock file holds only a random token of the press and a time. All other data goes to Claude Code's plugin store.env. It is created with your default file mode, which can be wider than the mode of your settings file. Sandbox Tuner uses no secret, and saves none apart from this copy.$CLAUDE_CONFIG_DIR/plugins/store/, or ~/.claude/plugins/store/ when CLAUDE_CONFIG_DIR is unset): hosts, ports, folder targets, one denied path for each folder, program names, session ids, counts, times, Hide choices, and the Undo journal (each entry that it added and the file it went into). It saves no command line, no output, and no other settings content.sandbox and permissions entries of your settings. When your settings file is not valid JSON, the problem shows … in place of the file text that the JSON parser quotes./sandbox-tuner./status to check that the session loads user settings, and look for a ConfigChange hook that blocks the change./status or /doctor for managed-settings warnings.$CLAUDE_CONFIG_DIR/settings.json, or ~/.claude/settings.json when CLAUDE_CONFIG_DIR is unset), then press again. Sandbox Tuner never edits a file it cannot parse.sandbox.filesystem.allowWrite in your user settings file by hand. See Known limits.~/.ssh linked into ~/.cache. Nothing was written.PATH folder differs from the folder only in letter case, and on macOS that is the same place. Nothing was written.claude --debug and look for lines that contain sandbox-tuner:. Each names the event and the cause. Sandbox Tuner cuts a line longer than 4,096 characters and ends it with ….The usage guide lists every notice and what to do.
MIT.
hooks/register.tsx 1228 lines1// Sandbox Tuner's hooks module: every hook and every $ call.
2
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, HookFailure, Register, RenderElement, Timer, ToolCallResult } from 'claude-code'
5
6import type { SandboxTunerAction, SandboxTunerApplied, SandboxTunerLatest, SandboxTunerNotice, SandboxTunerRecord } from '../types'
7import { causeOf, diagnostic } from './diagnostics'
8import { TMP, absolutePath, commandLine, cutText, displayPath, isTempFolder, isUnder, pathFolders, plainText } from './names'
9import { JOURNAL_PREFIX, WRITER_PREFIX, HIDDEN_PREFIX, aggregateRecords, hasLaterOwner, isUndoUnresolved, isFixedSince, isPressedAgain, isRecord, journalOf, mergeSighting, recordKey, shownRecords } from './records'
10import { addEntry, fileInfo, isApplied, lockText, parseSettings, readLock, removeEntry, serializeSettings } from './settings-file'
11import type { FileInfo, ParsedSettings } from './settings-file'
12import { keepGated, latestOf, readCall, reportQuote } from './sightings'
13import type { BashCall, BashOutcome, GatedSighting, Sighting } from './sightings'
14import { anchorEntry, credentialEntries, denialEntries, denialVerdict, grantsMet, guardedPlaces, guardedWalks, isSkipped, rateRecords, sandboxFacts, verdictOf } from './verdicts'
15import type { Change, Context, Sources, Verdict, Walk } from './verdicts'
16import { bandTree, buildBand, buildObservation, buildPane, canUndo, drawable, paneTree, reportText, summaryLine } from './views'
17import {
18 COMMAND_DESCRIPTION, NOTHING_TO_CHANGE, NOTHING_TO_UNDO, absentNotice, addedNotice, displayTarget, grantWarning, hiddenNotice, notAppliedText, notPlaced,
19 nothingApplied, hostText, refusalText, removedNotice, shownNotice, verdictSentences, verificationRefusalText,
20} from './words'
21
22type BashInput = { command: string; run_in_background?: boolean; dangerouslyDisableSandbox?: boolean; allowed_domains?: string[] }
23
24const backgroundTasks = atom({ plugin: 'sandbox-tuner', key: 'backgroundTasks' } as const, [] as string[])
25const writerId = crypto.randomUUID()
26const evidenceIncomplete = atom({ plugin: 'sandbox-tuner', key: 'evidenceIncomplete' } as const, false)
27const records = atom({ plugin: 'sandbox-tuner', key: 'records' } as const, [] as SandboxTunerRecord[])
28const applied = atom({ plugin: 'sandbox-tuner', key: 'applied' } as const, [] as SandboxTunerApplied[])
29const appliedOffset = atom({ plugin: 'sandbox-tuner', key: 'appliedOffset' } as const, 0)
30const latest = atom({ plugin: 'sandbox-tuner', key: 'latest' } as const, null as SandboxTunerLatest | null)
31const fault = atom({ plugin: 'sandbox-tuner', key: 'fault' } as const, null as string | null)
32
33// Sandbox Tuner's own cap on a debug line. The cut keeps the start of its diagnostic.
34const LOG_LIMIT = 4096
35
36function logDebug($: EngineInterface, text: string): void {
37 $.ui.log(cutText(plainText(text), LOG_LIMIT), { to: 'debug' })
38}
39
40// One queue per module instance, so the store writes of one session never interleave. A hot reload starts a new one.
41let queue: Promise<unknown> = Promise.resolve()
42
43function enqueue<T>(job: () => Promise<T>): Promise<T> {
44 const run = queue.then(job)
45 queue = run.catch(() => undefined)
46 return run
47}
48
49/** Logs a background problem and keeps it for the pane. Never rejects, so no background job ends in silence. */
50async function noteFault($: EngineInterface, text: string): Promise<void> {
51 const unshown = await update($, fault, () => text).then(
52 () => '',
53 (error: unknown) => ` The pane cannot show this problem: ${causeOf(error)}.`,
54 )
55 logDebug($, `${text}${unshown}`)
56}
57
58function hookFailed($: EngineInterface, event: string, detail: string | null, failure: HookFailure): string {
59 const text = diagnostic(event, detail, `hook failed (${failure.kind}): ${failure.message ?? 'no message'}`)
60 logDebug($, text)
61 return text
62}
63
64/** A folder from the environment, resolved from `base` when relative; an empty value counts as unset, as in a shell. */
65function folderOf(value: string | undefined, base: string): string | undefined {
66 return value === undefined || value === '' ? undefined : absolutePath(value, base)
67}
68
69async function homeOf($: EngineInterface): Promise<string | undefined> {
70 return folderOf(await $.env.get('HOME'), '/')
71}
72
73function toCall(e: BashInput): BashCall {
74 return {
75 command: e.command,
76 isBackground: e.run_in_background === true,
77 isUnsandboxed: e.dangerouslyDisableSandbox === true,
78 allowedDomains: e.allowed_domains ?? [],
79 }
80}
81
82// The Bash matcher types the result: on the success arm `result` is Bash's record, which core validates.
83function toOutcome(out: ToolCallResult<'Bash'>): BashOutcome {
84 if (out.deny !== undefined) return { isDenied: true, isError: false, text: '', stderr: '' }
85 if (out.isError === true) return { isDenied: false, isError: true, isReadOnly: out.isReadOnly === true, text: out.text ?? '', stderr: '' }
86 return { isDenied: false, isError: false, isReadOnly: out.isReadOnly === true, backgroundTaskId: out.result.backgroundTaskId, isUnsandboxed: out.result.dangerouslyDisableSandbox, text: out.text ?? '', stderr: out.result.stderr }
87}
88
89async function gatedSightings($: EngineInterface, gated: readonly GatedSighting[], tool: string): Promise<Sighting[]> {
90 try {
91 return keepGated(gated, sandboxFacts(await $.settings.read()))
92 } catch (error) {
93 await noteFault($, diagnostic('tool.call', tool, `could not read settings to check the sandbox: ${causeOf(error)}. Kept only sandbox reports, and none of a call that failed.`))
94 return []
95 }
96}
97
98async function sessionIdOf($: EngineInterface, tool: string): Promise<string | null> {
99 try {
100 return await $.session.id()
101 } catch (error) {
102 await noteFault($, diagnostic('tool.call', tool, `could not read the session id: ${causeOf(error)}`))
103 return null
104 }
105}
106
107async function saveSighting($: EngineInterface, sighting: Sighting, sessionId: string | null, now: number, tool: string): Promise<void> {
108 const key = recordKey(sighting)
109 try {
110 const storeKey = `${WRITER_PREFIX}${writerId}:${key}`
111 const stored = await $.store.get(storeKey)
112 const merged = mergeSighting(isRecord(stored) ? stored : undefined, sighting, sessionId, now)
113 await $.store.set(storeKey, merged)
114 const refreshed = (await readRecords($, key))[0]
115 if (refreshed === undefined) throw new Error('saved evidence was not returned by the store')
116 await update($, records, list => [...list.filter(record => record.key !== key), refreshed])
117 } catch (error) {
118 await update($, evidenceIncomplete, () => true)
119 await noteFault($, diagnostic('tool.call', tool, `could not save the sighting of ${key}: ${causeOf(error)}`))
120 }
121}
122
123async function recordCall($: EngineInterface, call: BashCall, outcome: BashOutcome, signal: AbortSignal, tool = 'Bash'): Promise<void> {
124 if (signal.aborted) return
125 const taskId = outcome.backgroundTaskId
126 if (taskId !== undefined && (await read($, backgroundTasks)).includes(taskId)) return
127 const { reports, gated, unknownLine, observation, skipped } = readCall(call, outcome, await homeOf($), await $.session.cwd())
128 // The debug log only: the pane would show a block that the command printed as a problem.
129 if (skipped) logDebug($, diagnostic('tool.call', tool, 'skipped a <sandbox_violations> block that does not end the text of a failed call (not quoted: the command can print one)'))
130 if (unknownLine !== null) {
131 const quote = reportQuote(unknownLine)
132 const cause = quote === null
133 ? `skipped a sandbox report line of an unknown form (${unknownLine.length} characters, not quoted: it does not start like a report line)`
134 : `skipped a sandbox report line of an unknown form: ${quote}`
135 await noteFault($, diagnostic('tool.call', tool, cause))
136 }
137 const sightings = gated.length === 0 ? reports : [...reports, ...(await gatedSightings($, gated, tool))]
138 if (observation !== undefined) {
139 const at = await $.clock.now()
140 await update($, latest, () => ({ recordKey: `observation:${at}`, detail: observation.detail, reason: 'operation', reasonText: observation.operation, command: commandLine(call.command), at }))
141 }
142 if (taskId !== undefined) await update($, backgroundTasks, list => [...list, taskId])
143 if (sightings.length === 0) return
144 const sessionId = await sessionIdOf($, tool)
145 const now = await $.clock.now()
146 for (const sighting of sightings) {
147 if (signal.aborted) return
148 await saveSighting($, sighting, sessionId, now, tool)
149 }
150 const newest = latestOf(sightings, commandLine(call.command), now)
151 if (newest !== null) await update($, latest, () => newest)
152}
153
154/** Read durable evidence before displaying it or authorizing a new grant. */
155async function readRecords($: EngineInterface, targetKey?: string): Promise<SandboxTunerRecord[]> {
156 const found: SandboxTunerRecord[] = []
157 for (const key of await $.store.keys()) {
158 if (!key.startsWith(WRITER_PREFIX)) continue
159 const writerSeparator = key.indexOf(':', WRITER_PREFIX.length)
160 if (writerSeparator <= WRITER_PREFIX.length) throw new Error(`invalid writer key: ${key}`)
161 const storedTarget = key.slice(writerSeparator + 1)
162 if (targetKey !== undefined && storedTarget !== targetKey) continue
163 const value = await $.store.get(key)
164 if (!isRecord(value) || storedTarget !== value.key) {
165 throw new Error(`invalid saved safety evidence: ${key}`)
166 }
167 found.push(value)
168 }
169 const combined = aggregateRecords(found)
170 for (const record of combined) {
171 const hidden = await $.store.get(HIDDEN_PREFIX + record.key)
172 if (hidden !== undefined && typeof hidden !== 'boolean') throw new Error(`invalid hidden preference: ${record.key}`)
173 if (typeof hidden === 'boolean') record.isHidden = hidden
174 }
175 return combined
176}
177
178/** Display retention never deletes another writer's mutable safety evidence. */
179async function loadSaved($: EngineInterface, event: string, detail: string | null): Promise<SandboxTunerRecord[] | string> {
180 try {
181 const found = await readRecords($)
182 const journal = journalOf(await reconcileJournal($, await readJournal($)))
183 const kept = shownRecords(found, await $.clock.now())
184 await update($, records, () => kept)
185 await update($, applied, () => journal)
186 return kept
187 } catch (error) {
188 const text = diagnostic(event, detail, `could not load saved targets: ${causeOf(error)}`)
189 await noteFault($, text)
190 return text
191 }
192}
193
194// A press that wrote the settings file names the change, which the press checks again once the write has settled.
195type Written = { file: FileInfo; entry: SandboxTunerApplied }
196type PressResult = { text: string; isError: boolean; written: Written | null }
197
198const dismissed = atom({ plugin: 'sandbox-tuner', key: 'dismissed' } as const, null as string | null)
199const notice = atom({ plugin: 'sandbox-tuner', key: 'notice' } as const, null as SandboxTunerNotice | null)
200const PANE = 'sandbox-tuner'
201const PANE_OPEN = { id: PANE, title: 'Sandbox Tuner', focus: true, closeOnEscape: true } as const
202// Claude Code applies a settings edit once its file watcher sees it: draw again after that had time to happen.
203const REDRAW_AFTER_WRITE_MS = 2000
204// The error code that ends the text of a failed $.fs call for a path where nothing is.
205const MISSING = /\b(?:ENOENT|ENOTDIR)$/
206
207type JournalChange = { buttonKey: string; file: FileInfo; entry: SandboxTunerApplied; success: string; didWrite: boolean }
208
209/** The text of a file, or null when it does not exist. */
210async function fileText($: EngineInterface, path: string): Promise<string | null> {
211 return (await $.fs.exists(path)) ? $.fs.read(path) : null
212}
213
214async function isCanonicalFile($: EngineInterface, path: string, event: string): Promise<boolean> {
215 try {
216 const text = await fileText($, path)
217 if (text === null) return true
218 const parsed = parseSettings(text)
219 return 'refusal' in parsed || parsed.isCanonical
220 } catch (error) {
221 logDebug($, diagnostic(event, null, `could not read ${path} to check its format: ${causeOf(error)}`))
222 return true
223 }
224}
225
226/** The settings files of the journal's Undo rows that are not in the 2-space JSON format: an Undo re-formats them. */
227async function reformattedFiles($: EngineInterface, journal: readonly SandboxTunerApplied[], context: Context): Promise<string[]> {
228 const found: string[] = []
229 for (const path of new Set(journal.filter(canUndo).map(entry => entry.file))) {
230 const isCanonical = path === context.file?.path ? context.isCanonical : await isCanonicalFile($, path, 'ui.render (Pane)')
231 if (!isCanonical) found.push(path)
232 }
233 return found
234}
235
236async function loadContext($: EngineInterface, threshold: number, event: string): Promise<Context> {
237 const sources: Sources = {
238 user: await $.settings.read({ source: 'user' }),
239 project: await $.settings.read({ source: 'project' }),
240 local: await $.settings.read({ source: 'local' }),
241 flag: await $.settings.read({ source: 'flag' }),
242 policy: await $.settings.read({ source: 'policy' }),
243 }
244 const home = await homeOf($)
245 const cwd = await $.session.cwd()
246 const root = await $.session.root()
247 // A relative CLAUDE_CONFIG_DIR is taken from the session root, read again on each call: the folder the session
248 // started in, or where /cd or a worktree move took it (types: session.root). The docs ask for an absolute path.
249 const configDir = folderOf(await $.env.get('CLAUDE_CONFIG_DIR'), root) ?? (home === undefined ? undefined : `${home}/.claude`)
250 const file = configDir === undefined ? undefined : fileInfo(`${configDir}/settings.json`, home)
251 return {
252 sources,
253 home,
254 configDir,
255 ...guardedPlaces(home, configDir, [cwd, root]),
256 pathDirs: pathFolders(await $.env.get('PATH'), cwd),
257 tmp: TMP,
258 cwd,
259 root,
260 threshold,
261 file,
262 isCanonical: file === undefined || (await isCanonicalFile($, file.path, event)),
263 }
264}
265
266function failure(buttonKey: string, cause: string): PressResult {
267 return { text: diagnostic('ui.press', buttonKey, cause), isError: true, written: null }
268}
269
270function info(text: string): PressResult {
271 return { text, isError: false, written: null }
272}
273
274/** The text of the settings file as one read found it, null when there was no file, and what it holds. */
275type Loaded = { original: string | null; parsed: ParsedSettings }
276
277/** The text of the settings file (`found`), or the result of a press that cannot read it. */
278async function readText($: EngineInterface, file: FileInfo, buttonKey: string): Promise<{ found: string | null } | PressResult> {
279 try {
280 return { found: await fileText($, file.path) }
281 } catch (error) {
282 return failure(buttonKey, `could not read ${file.display}: ${causeOf(error)}. Nothing was written.`)
283 }
284}
285
286async function readSettingsFile($: EngineInterface, file: FileInfo, buttonKey: string): Promise<Loaded | PressResult> {
287 const read = await readText($, file, buttonKey)
288 if (!('found' in read)) return read
289 const parsed = parseSettings(read.found ?? '')
290 return 'refusal' in parsed ? failure(buttonKey, refusalText(parsed, file)) : { original: read.found, parsed }
291}
292
293// The lock of a settings file, `file.lock` next to it (fileInfo), so that presses in different sessions edit the file in
294// turn. $.fs has no exclusive create and no delete: a press claims the lock file by writing its own token and reading it
295// back, and frees it by writing a free record. Right before its settings write, it checks that it still holds the lock.
296const LOCK_POLL_MS = 100
297// How long a press waits for a lock that another press holds. The press waits in the module queue, so it stays short.
298const LOCK_WAIT_MS = 3_000
299// A lock that no press freed for this long is left over, from a press that stopped: the next press takes it over.
300const LOCK_STALE_MS = 30_000
301
302/** The token of the press that a lock file names, or null when it names none. */
303function holderOf(text: string | null): string | null {
304 const found = readLock(text)
305 return found === null || found === 'free' ? null : found.holder
306}
307
308/**
309 * Takes the lock of the settings file for one press: once it is free, or once it is left over. Returns the press's
310 * token, or the result of a press that cannot take it.
311 */
312async function takeLock($: EngineInterface, file: FileInfo, buttonKey: string): Promise<string | PressResult> {
313 const token = crypto.randomUUID()
314 const started = await $.clock.now()
315 for (;;) {
316 const now = await $.clock.now()
317 try {
318 const found = readLock(await fileText($, file.lock))
319 // A lock file of other text, such as one that a write has not finished, ages from its last change.
320 const since = found === 'free' ? now : found === null ? (await $.fs.stat(file.lock)).mtimeMs : found.since
321 const isLeft = Math.abs(now - since) >= LOCK_STALE_MS
322 if (found === 'free' || isLeft) {
323 await $.fs.write(file.lock, lockText(token, now))
324 // Another press that found the lock free at the same moment can claim it last: then it holds the lock.
325 if (holderOf(await $.fs.read(file.lock)) === token) {
326 if (isLeft) logDebug($, diagnostic('ui.press', buttonKey, `took over the lock on ${file.display} (${file.lockDisplay}), which no press freed within ${LOCK_STALE_MS / 1000} seconds`))
327 return token
328 }
329 }
330 } catch (error) {
331 return failure(buttonKey, `could not take the lock on ${file.display} (${file.lockDisplay}): ${causeOf(error)}. Nothing was written.`)
332 }
333 if (now - started >= LOCK_WAIT_MS) {
334 return failure(buttonKey, `another Sandbox Tuner session holds the lock on ${file.display} (${file.lockDisplay}) and did not free it within ${LOCK_WAIT_MS / 1000} seconds. Nothing was written. Press again.`)
335 }
336 await $.clock.sleep(LOCK_POLL_MS)
337 }
338}
339
340/** Frees the lock that the press holds, and leaves a lock that another press took over. It never rejects. */
341async function freeLock($: EngineInterface, file: FileInfo, token: string, buttonKey: string): Promise<void> {
342 try {
343 if (holderOf(await fileText($, file.lock)) === token) await $.fs.write(file.lock, lockText(null, await $.clock.now()))
344 } catch (error) {
345 logDebug($, diagnostic('ui.press', buttonKey, `could not free the lock on ${file.display} (${file.lockDisplay}): ${causeOf(error)}. Other sessions take it over ${LOCK_STALE_MS / 1000} seconds after this press took it.`))
346 }
347}
348
349/** Null while the press holds the lock of the settings file, else why the press stops. */
350async function lockLoss($: EngineInterface, file: FileInfo, token: string): Promise<string | null> {
351 try {
352 if (holderOf(await fileText($, file.lock)) === token) return null
353 return `another Sandbox Tuner session took over the lock on ${file.display} (${file.lockDisplay}) during this press. Sandbox Tuner left the file as it is. Press again.`
354 } catch (error) {
355 return `could not check the lock on ${file.display} (${file.lockDisplay}): ${causeOf(error)}. Sandbox Tuner left the file as it is. Press again.`
356 }
357}
358
359/**
360 * Writes `file.backup`, a copy of the old text, then `file.path`, the user settings file. The path is not fixed text:
361 * it is `$CLAUDE_CONFIG_DIR/settings.json`, else `$HOME/.claude/settings.json` (loadContext), or for Undo the file that
362 * the change went into. The copy is `backups/settings.json.sandbox-tuner.bak` in the same folder (fileInfo). The press
363 * holds the lock of the file (`token`).
364 */
365async function writeSettings($: EngineInterface, buttonKey: string, file: FileInfo, original: string | null, text: string, entry: SandboxTunerApplied, token: string): Promise<PressResult | null> {
366 const previous = original === null ? 'The file did not exist before.' : `The previous file is saved at ${file.backupDisplay}.`
367 if (original !== null) {
368 try {
369 await $.fs.write(file.backup, original)
370 } catch (error) {
371 return failure(buttonKey, `could not save a copy of ${file.display} to ${file.backupDisplay}: ${causeOf(error)}. Nothing was written.`)
372 }
373 }
374 const pending = await saveJournal($, { buttonKey, file, entry, success: '', didWrite: false })
375 if (pending.isError) return pending
376 // $.fs has no conditional write: check last that no other session or program saved the file since the press read it,
377 // and that no other press took the lock over.
378 const change = await fileText($, file.path).then(
379 current => (current === original ? null : 'another program changed it'),
380 (error: unknown) => causeOf(error),
381 )
382 const stop = change === null
383 ? await lockLoss($, file, token)
384 : `could not confirm that ${file.display} is unchanged since Sandbox Tuner read it (${change}). Sandbox Tuner left the file as it is. Press again.`
385 if (stop !== null) {
386 // No settings write was attempted, so this addition cannot own an entry in the file.
387 if (entry.applyState === 'pending') {
388 try {
389 await $.store.delete(JOURNAL_PREFIX + entry.id)
390 await update($, applied, list => list.filter(item => item.id !== entry.id))
391 } catch (error) {
392 await noteFault($, diagnostic('ui.press', buttonKey, `could not discard the unwritten addition from the Undo journal: ${causeOf(error)}`))
393 }
394 }
395 return failure(buttonKey, stop)
396 }
397 // Keep Undo even if the write or its read-back fails after changing the file.
398 const written = { file, entry }
399 try {
400 await $.fs.write(file.path, text)
401 } catch (error) {
402 const repair = original === null ? 'Delete it if a damaged copy is left.' : `Copy it back if ${file.display} is now damaged.`
403 return { ...failure(buttonKey, `could not write ${file.display}: ${causeOf(error)}. ${previous} ${repair}`), written }
404 }
405 const problem = await $.fs.read(file.path).then(
406 readBack => (readBack === text ? null : 'another program changed it'),
407 (error: unknown) => causeOf(error),
408 )
409 return problem === null ? null : { ...failure(buttonKey, `${file.display} does not hold the new text after the write (${problem}). ${previous}`), written }
410}
411
412async function saveJournal($: EngineInterface, change: JournalChange): Promise<PressResult> {
413 const { entry } = change
414 try {
415 await $.store.set(JOURNAL_PREFIX + entry.id, entry)
416 await update($, applied, list => journalOf([...list.filter(item => item.id !== entry.id), entry]))
417 // The band of a fixed block stays hidden, even once the journal keeps the fix no longer.
418 await update($, latest, last => (last !== null && isFixedSince([entry], last) ? null : last))
419 return { text: change.success, isError: false, written: change.didWrite ? { file: change.file, entry } : null }
420 } catch (error) {
421 const cause = change.didWrite
422 ? `${change.file.display} is saved, but Sandbox Tuner could not update its Undo journal: ${causeOf(error)}. Check "${entry.entry}" in ${entry.settingsKey} by hand.`
423 : entry.applyState === 'pending'
424 ? `could not save the Undo journal: ${causeOf(error)}. Nothing was written to ${change.file.display}.`
425 : `could not mark "${entry.entry}" as undone: ${causeOf(error)}`
426 return { ...failure(change.buttonKey, cause), written: change.didWrite ? { file: change.file, entry } : null }
427 }
428}
429
430/**
431 * True when nothing is at the path: $.fs.stat rejects ENOENT for a missing path, or ENOTDIR below a file. It does not
432 * follow a link, so it finds a link that leads nowhere. Any other failure rejects: then no one knows what is there.
433 */
434async function isMissing($: EngineInterface, path: string): Promise<boolean> {
435 return $.fs.stat(path).then(
436 () => false,
437 (error: unknown) => {
438 if (MISSING.test(causeOf(error))) return true
439 throw error
440 },
441 )
442}
443
444/**
445 * Where an absolute path really lies, found one name at a time as the system finds it: a link is followed before a
446 * `..` after it, which $.fs cannot do, because it folds `..` first. `known` holds the names already looked up in this
447 * press, so a folder that many paths share is looked up once.
448 */
449async function realLocation($: EngineInterface, path: string, known: Map<string, string>): Promise<string> {
450 let real = '/'
451 for (const name of path.split('/')) {
452 if (name === '' || name === '.') continue
453 if (name === '..') {
454 real = real.slice(0, real.lastIndexOf('/')) || '/'
455 continue
456 }
457 const next = `${real === '/' ? '' : real}/${name}`
458 real = known.get(next) ?? (await landing($, next, known))
459 }
460 return real
461}
462
463/**
464 * Where one path whose folder is real lands: its real path, or itself when nothing is there yet. Rejects, naming the
465 * path, when it is a link that leads nowhere or the file system withholds where it lands, because then no one can
466 * tell where a write through it goes (types: FsStat.realPath).
467 */
468async function landing($: EngineInterface, path: string, known: Map<string, string>): Promise<string> {
469 let landed = path
470 if (await $.fs.exists(path)) {
471 const found = (await $.fs.stat(path, { resolve: true })).realPath
472 if (found === undefined) throw new Error(`the file system gave no real path for ${path}`)
473 landed = found
474 } else if (!(await isMissing($, path))) {
475 throw new Error(`${path} is a link that Sandbox Tuner cannot follow`)
476 }
477 known.set(path, landed)
478 return landed
479}
480
481async function realPlaces($: EngineInterface, paths: readonly string[], known: Map<string, string>): Promise<string[]> {
482 const places: string[] = []
483 for (const path of paths) places.push(await realLocation($, path, known))
484 return places
485}
486
487// The most folders and entries that one walk of guarded places lists. With the data folders of the config folder
488// skipped, the walk of a config folder with many plugins and skills lists about 450 folders and 3,000 entries. The
489// walk of 77 VS Code extensions, two levels of each, adds about 340 folders and 4,000 entries.
490const WALK_FOLDERS = 1000
491const WALK_ENTRIES = 25_000
492
493/** A folder that Allow opened: its entry in the settings file, how to remove it, and where it really lies. */
494type Grant = Granted & { place: string }
495/** A link or guarded place, as listed, whose real place lies inside or holds folders that Allow opened: each of them. */
496type Finding = { path: string; place: string; met: { grant: Grant; isInside: boolean }[] }
497/**
498 * What the check at session start gives a walk: the folders that Allow opened, and where the walk keeps what it finds
499 * and the links that it cannot follow, so that a walk that stops keeps both.
500 */
501type Recheck = { grants: Grant[]; found: Finding[]; skipped: string[] }
502
503/** Where a path leads, or null when the check at session start cannot follow it: the check then names it. */
504async function followed($: EngineInterface, path: string, known: Map<string, string>, recheck: Recheck): Promise<string | null> {
505 try {
506 return await realLocation($, path, known)
507 } catch {
508 recheck.skipped.push(path)
509 return null
510 }
511}
512
513/** Keeps a place that meets a folder that Allow opened as a finding. The walk does not enter such a place. */
514function isFinding(recheck: Recheck, path: string, place: string): boolean {
515 const met = grantsMet(place, recheck.grants)
516 if (met.length > 0) recheck.found.push({ path, place, met })
517 return met.length > 0
518}
519
520/**
521 * The real places that the links inside guarded places lead to, nested links too: each walk lists its levels, with
522 * bounded work and cycle detection. A link counts wherever it is listed, and the walk goes on where it leads. For the
523 * check at session start (`recheck`), a link that meets a folder that Allow opened is a finding, and a link that
524 * cannot be followed is skipped.
525 */
526async function linksInside($: EngineInterface, walks: readonly Walk[], known: Map<string, string>, recheck?: Recheck): Promise<string[]> {
527 const places: string[] = []
528 // `at` holds the names from the walked place down to the folder, for its skip entries.
529 const pending = walks.map(walk => ({ ...walk, at: [] as readonly string[] }))
530 // The most levels listed from each folder, so that a folder reached again is listed again only for more levels.
531 const listed = new Map<string, number>()
532 let folders = 0
533 let entries = 0
534 while (pending.length > 0) {
535 const { path: folder, levels, skip, at } = pending.pop()!
536 if ((listed.get(folder) ?? 0) >= levels) continue
537 listed.set(folder, levels)
538 if (!(await $.fs.exists(folder))) {
539 if (!(await isMissing($, folder))) throw new Error(`cannot resolve guarded path ${folder}`)
540 continue
541 }
542 if ((await $.fs.stat(folder)).kind !== 'dir') continue
543 if (++folders > WALK_FOLDERS) throw new Error('guarded directory traversal limit exceeded')
544 const children = await $.fs.list(folder)
545 entries += children.length
546 if (entries > WALK_ENTRIES) throw new Error('guarded directory traversal limit exceeded')
547 for (const entry of children) {
548 const path = `${folder}/${entry.name}`
549 const below = { levels: levels - 1, skip, at: [...at, entry.name] }
550 const isEntered = levels > 1 && !isSkipped(skip, below.at)
551 if (entry.isLink) {
552 const real = recheck === undefined ? await realLocation($, path, known) : await followed($, path, known, recheck)
553 if (real === null || (recheck !== undefined && isFinding(recheck, path, real))) continue
554 places.push(real)
555 if (isEntered) pending.push({ ...below, path: real })
556 } else if (entry.kind === 'dir' && isEntered) pending.push({ ...below, path })
557 }
558 }
559 return places
560}
561
562/** The real places of guarded places, with every place that a link inside them leads to. */
563async function realGuards($: EngineInterface, walks: readonly Walk[], known: Map<string, string>, recheck?: Recheck): Promise<string[]> {
564 const located: Walk[] = []
565 for (const walk of walks) {
566 const path = recheck === undefined ? await realLocation($, walk.path, known) : await followed($, walk.path, known, recheck)
567 if (path !== null && (recheck === undefined || !isFinding(recheck, walk.path, path))) located.push({ ...walk, path })
568 }
569 return [...located.map(walk => walk.path), ...(await linksInside($, located, known, recheck))]
570}
571
572/**
573 * Rates a folder Allow again where the folder really lies, with HOME, the config dir, the working directory, the
574 * protected places and the credentials folders (with the links that their walks find, see guardedWalks), denial
575 * paths, the PATH folders, and for a folder in /tmp also /tmp, resolved the same way. A folder outside /tmp is rated
576 * with no temp folder, as in Sandbox Tuner 1.1.1. The verdict reads spellings, and a link can hide behind one; an
577 * allow-list on realPath is the robust guard (types: FsStat.realPath). Hard links, links in the places that the walks
578 * skip, and later filesystem changes remain outside these checks. Returns why the folder stays closed, or null.
579 */
580async function checkRealFolder($: EngineInterface, record: SandboxTunerRecord, context: Context): Promise<string | null> {
581 const shown = displayPath(record.target, context.home)
582 try {
583 const known = new Map<string, string>()
584 const target = await realLocation($, record.target, known)
585 const denials = denialEntries(context)
586 for (const entry of [...denials]) denials.push({ ...entry, path: await realLocation($, entry.path, known) })
587 const real: Context = {
588 ...context,
589 home: context.home === undefined ? undefined : await realLocation($, context.home, known),
590 configDir: context.configDir === undefined ? undefined : await realLocation($, context.configDir, known),
591 protectedDirs: await realGuards($, guardedWalks(context.protectedDirs, context), known),
592 credentialDirs: await realGuards($, guardedWalks([...context.credentialDirs, ...credentialEntries(context).map(entry => entry.path)], { home: undefined, configDir: undefined }), known),
593 pathDirs: await realPlaces($, context.pathDirs, known),
594 tmp: isTempFolder(record.target, context.home) ? await realLocation($, TMP, known) : null,
595 cwd: await realLocation($, context.cwd, known),
596 }
597 // The denied file is rated where the write really lands: it can be a link too.
598 const located = { ...record, target, sample: await realLocation($, record.sample, known) }
599 const verdict = denialVerdict([record.sample, located.sample], denials) ?? verdictOf(located, real)
600 const place = displayPath(target, real.home)
601 const leads = place === shown ? '' : `${shown} leads to ${place}. `
602 if (verdict.status === 'ready') {
603 // A folder in /tmp must really lie strictly inside /tmp, also where it is a cache folder.
604 if (real.tmp !== null && !isTempFolder(target, real.home, real.tmp)) {
605 return `${leads}Sandbox Tuner opens a folder in /tmp only when it really lies inside ${displayPath(real.tmp, real.home)}, the real place of /tmp, and your home folder neither holds that place nor lies inside it. Nothing was written.`
606 }
607 // The verdict compares with case, and a file system that ignores case reads two spellings as one place.
608 const folded = target.toLowerCase()
609 const alias = [...real.pathDirs, ...real.protectedDirs, ...real.credentialDirs].find(dir => isUnder(folded, dir.toLowerCase()) || isUnder(dir.toLowerCase(), folded))
610 return alias === undefined ? null : `${place} and ${displayPath(alias, real.home)} name one place on a file system that ignores letter case. Nothing was written.`
611 }
612 return `${leads}${verdictSentences(located, verdict, real).join(' ')} Nothing was written.`
613 } catch (error) {
614 return `could not check where ${shown} leads: ${causeOf(error)}. Nothing was written.`
615 }
616}
617
618// A warning of the check at session start names a link, a folder, and what to do: it stays long enough to read.
619const WARNING_MS = 15_000
620
621/**
622 * A folder entry that Allow added and that a settings file still holds: whether a row of the pane can undo it, and each
623 * settings file that holds it with no such row, where only a hand edit removes it.
624 */
625type Granted = { entry: string; hasUndo: boolean; byHand: string[] }
626
627/**
628 * The entries of the folders that Allow opened that their settings file still holds. The file decides, whatever the
629 * journal says: an unconfirmed addition, an Undo that failed or is unconfirmed, or an entry marked undone that is back.
630 */
631async function grantedEntries($: EngineInterface): Promise<Granted[]> {
632 const journal = await readJournal($)
633 // The rows of the Applied list, as the pane shows them.
634 const listed = new Set(journalOf(journal))
635 const granted = new Map<string, { held: Set<string>; undoable: Set<string> }>()
636 const values = new Map<string, Record<string, unknown> | null>()
637 for (const entry of journal) {
638 if (entry.settingsKey !== 'sandbox.filesystem.allowWrite') continue
639 if (!values.has(entry.file)) {
640 const parsed = parseSettings((await fileText($, entry.file)) ?? '')
641 values.set(entry.file, 'refusal' in parsed ? null : parsed.value)
642 }
643 const value = values.get(entry.file)
644 if (value === undefined || value === null || !('isPresent' in addEntry(value, entry.settingsKey, entry.entry))) continue
645 const files = granted.get(entry.entry) ?? { held: new Set<string>(), undoable: new Set<string>() }
646 granted.set(entry.entry, files)
647 files.held.add(entry.file)
648 // A row with an Undo button whose press does not refuse: Undo refuses an entry that a later change owns.
649 if (listed.has(entry) && canUndo(entry) && !hasLaterOwner(journal, entry)) files.undoable.add(entry.file)
650 }
651 return [...granted].map(([entry, { held, undoable }]) => ({ entry, hasUndo: undoable.size > 0, byHand: [...held].filter(file => !undoable.has(file)) }))
652}
653
654/** The event that a check reports with: a check at a session start, or a timed check during the session. */
655type RecheckEvent = 'session.start' | 'clock.every'
656/**
657 * The warnings that a check does not show again (`skip`), and where it keeps the warnings that it shows (`shown`) and
658 * every warning that it finds, shown or not (`found`). See recheckOnce.
659 */
660type Warnings = { skip: readonly ReadonlySet<string>[]; shown: Set<string>; found: Set<string> }
661
662/**
663 * At the start of an interactive session, and on a timer during it, checks the folders that Allow opened and that
664 * their settings file still holds. It walks the guarded places as a folder Allow does, once for all those folders, and
665 * finds where the PATH folders lie. Each link or guarded place that leads into such a folder, or to a folder that holds
666 * one, gives one toast, unless `warnings.skip` holds it. It warns and writes nothing. It runs outside the queue, so that
667 * its walk never holds back the recording of a Bash call. It never rejects: the hook and the timer drop what it returns.
668 */
669async function recheckGrants($: EngineInterface, threshold: number, event: RecheckEvent, warnings: Warnings): Promise<void> {
670 let entries: Granted[]
671 let context: Context
672 try {
673 entries = await grantedEntries($)
674 if (entries.length === 0) return
675 context = await loadContext($, threshold, event)
676 } catch (error) {
677 logDebug($, diagnostic(event, null, `could not check the folders that Allow opened: ${causeOf(error)}`))
678 return
679 }
680 const known = new Map<string, string>()
681 const recheck: Recheck = { grants: [], found: [], skipped: [] }
682 for (const granted of entries) {
683 const path = anchorEntry(granted.entry, 'user', context)
684 const place = path === null ? null : await followed($, path, known, recheck)
685 if (place !== null) recheck.grants.push({ ...granted, place })
686 }
687 const problems: string[] = []
688 if (recheck.grants.length > 0) {
689 const credentials = [...context.credentialDirs, ...credentialEntries(context).map(entry => entry.path)]
690 for (const walks of [guardedWalks(context.protectedDirs, context), guardedWalks(credentials, { home: undefined, configDir: undefined })]) {
691 // A walk that stops keeps what it found before.
692 await realGuards($, walks, known, recheck).catch((error: unknown) => problems.push(causeOf(error)))
693 }
694 for (const dir of context.pathDirs) {
695 const place = await followed($, dir, known, recheck)
696 if (place !== null) isFinding(recheck, dir, place)
697 }
698 }
699 const display = (path: string) => displayPath(path, context.home)
700 const warned = new Set<string>()
701 for (const { path, place, met } of recheck.found) {
702 if (warned.has(path)) continue
703 warned.add(path)
704 const grants = met.map(({ grant, isInside }) => ({ grant: grant.entry, isInside, hasUndo: grant.hasUndo, byHand: grant.byHand.map(file => fileInfo(file, context.home).display) }))
705 const warning = grantWarning(display(path), display(place), grants)
706 const isWarned = warnings.skip.some(skipped => skipped.has(warning))
707 warnings.found.add(warning)
708 if (isWarned) continue
709 warnings.shown.add(warning)
710 const text = diagnostic(event, null, warning)
711 logDebug($, text)
712 $.ui.toast(drawable(hostText(text)), { timeoutMs: WARNING_MS })
713 }
714 if (recheck.skipped.length > 0) problems.unshift(`links that it cannot follow: ${[...new Set(recheck.skipped)].map(display).join(', ')}`)
715 if (problems.length > 0) {
716 await noteFault($, diagnostic(event, null, `could not finish the link check of the folders that Allow opened (${problems.join('; ')}). A link that the check did not reach can still lead into one of them.`))
717 }
718}
719
720// One check at a time in each module instance: `claude --resume` raises session.start and classic.SessionStart at once,
721// and the timer of the session can fire during a walk. Each request waits for the check that runs or waits last.
722let lastRecheck: Promise<void> = Promise.resolve()
723// The number of the newest request. A request that a newer one follows before its turn does not run.
724let newestRecheck = 0
725// The warnings that the checks showed since the last time that no check ran or waited.
726let shownWarnings = new Set<string>()
727// Whether a session start asked for a check that has not begun yet: the next check to begin is then its check.
728let isStartPending = false
729// The warnings that the checks found since the last check of a session start began, shown or not: a timed check does
730// not show them, because the session was warned about them.
731let startWarnings = new Set<string>()
732
733/**
734 * Runs recheckGrants, one check at a time. A request that comes while a check runs waits, the newest only, and its
735 * check runs right after that check ends, with the context that its session has then: a session that /clear,
736 * /resume, or /branch starts during a walk gets a check of its own. A check that waited does not show a warning again
737 * that a check before it showed, so one start warns once. A timed request (`isStart` false) shows only what the
738 * session was not warned about since its start, and a session start that a timed request follows before its turn still
739 * gets its check. It never rejects.
740 */
741async function recheckOnce($: EngineInterface, threshold: number, isStart: boolean): Promise<void> {
742 newestRecheck += 1
743 const request = newestRecheck
744 if (isStart) isStartPending = true
745 const run = async () => {
746 if (request !== newestRecheck) return
747 const isStartCheck = isStartPending
748 isStartPending = false
749 if (isStartCheck) startWarnings = new Set()
750 const skip = isStartCheck ? [shownWarnings] : [shownWarnings, startWarnings]
751 await recheckGrants($, threshold, isStartCheck ? 'session.start' : 'clock.every', { skip, shown: shownWarnings, found: startWarnings })
752 }
753 const check = lastRecheck.then(run, run)
754 lastRecheck = check
755 try {
756 await check
757 } finally {
758 if (lastRecheck === check) shownWarnings = new Set()
759 }
760}
761
762function changeFor(verdict: Verdict, action: SandboxTunerAction): Change | null {
763 if (action === 'allow') return verdict.status === 'ready' ? verdict.allow : null
764 return 'deny' in verdict ? verdict.deny : null
765}
766
767type AddEdit = { kind: 'add'; record: SandboxTunerRecord; action: SandboxTunerAction; context: Context }
768type RemoveEdit = { kind: 'remove'; entry: SandboxTunerApplied }
769type SettingsEdit = AddEdit | RemoveEdit
770
771/** What a press writes over one read of the settings file: the new settings value, its journal entry, and its notice. */
772type Planned = { loaded: Loaded; value: Record<string, unknown>; entry: SandboxTunerApplied; success: string } & ({ edit: AddEdit; change: Change } | { edit: RemoveEdit })
773
774/**
775 * Checks an addition against one read of the settings file: the entry, the verdict with the restrictions that the file
776 * adds, and for a folder where it really lies. A check again after the file changed (`isAgain`) rates the folder with
777 * the new text only: the loaded settings were rated before. Returns the new settings value with the keys that it
778 * creates, `isPresent` when the file holds the entry, or why the addition stops.
779 */
780async function checkAddition($: EngineInterface, edit: AddEdit, change: Change, file: FileInfo, parsed: ParsedSettings, isAgain: boolean): Promise<{ value: Record<string, unknown>; created: string[] } | { isPresent: true } | { cause: string }> {
781 const { record, action, context } = edit
782 const added = addEntry(parsed.value, change.settingsKey, change.entry)
783 if ('refusal' in added) return { cause: refusalText(added, file) }
784 if ('isPresent' in added) return added
785 // Keep loaded restrictions too: the engine may not have applied a removal from the file yet.
786 const snapshot: Context = { ...context, sources: { ...context.sources, user: parsed.value } }
787 const current = verdictOf(record, snapshot)
788 if (changeFor(current, action) === null) return { cause: nothingApplied(verdictSentences(record, current, snapshot)) }
789 if (record.kind === 'path') {
790 for (const checked of isAgain ? [snapshot] : [context, snapshot]) {
791 const problem = await checkRealFolder($, record, checked)
792 if (problem !== null) return { cause: problem }
793 }
794 }
795 return added
796}
797
798/** A time later than `time` and than each change of the same entry of the same file that the journal holds. */
799function laterThanJournal(journal: readonly SandboxTunerApplied[], file: FileInfo, change: Change, time: number): number {
800 return journal.reduce((later, item) => item.file === file.path && item.settingsKey === change.settingsKey && item.entry === change.entry ? Math.max(later, item.appliedAt + 1) : later, time)
801}
802
803/** Plans an edit against the first read of the settings file, or gives the result of a press that stops there. */
804async function planEdit($: EngineInterface, buttonKey: string, file: FileInfo, edit: SettingsEdit, loaded: Loaded): Promise<Planned | PressResult> {
805 if (edit.kind === 'add') {
806 const { record, action, context } = edit
807 const verdict = verdictOf(record, context)
808 const change = changeFor(verdict, action)
809 if (change === null) return info(nothingApplied(verdictSentences(record, verdict, context)))
810 const added = await checkAddition($, edit, change, file, loaded.parsed, false)
811 if ('cause' in added) return failure(buttonKey, added.cause)
812 if ('isPresent' in added) return failure(buttonKey, notAppliedText(change))
813 const now = await $.clock.now()
814 const appliedAt = laterThanJournal(await readJournal($), file, change, now)
815 const entry: SandboxTunerApplied = {
816 id: `${now}:${action}:${record.key}:${crypto.randomUUID()}`,
817 recordKey: record.key,
818 action,
819 settingsKey: change.settingsKey,
820 entry: change.entry,
821 file: file.path,
822 backup: loaded.original === null ? null : file.backup,
823 created: added.created,
824 appliedAt,
825 undoneAt: null,
826 applyState: 'pending',
827 }
828 return { edit, change, loaded, value: added.value, entry, success: addedNotice(change, loaded.original !== null) }
829 }
830 // Undo may remove an old grant that is no longer eligible. It can only remove its journaled entry.
831 const removed = removeEntry(loaded.parsed.value, edit.entry.settingsKey, edit.entry.entry, edit.entry.created)
832 if ('refusal' in removed) return failure(buttonKey, refusalText(removed, file))
833 const entry: SandboxTunerApplied = { ...edit.entry, applyState: undefined, undoneAt: await $.clock.now(), undoState: 'pending' }
834 if ('isAbsent' in removed) return saveJournal($, { buttonKey, file, entry: { ...entry, undoState: undefined }, success: absentNotice(entry), didWrite: false })
835 return { edit, loaded, value: removed.value, entry, success: removedNotice(entry, file) }
836}
837
838/**
839 * Checks a planned edit again under the lock, against what another session can have saved while the press waited: for
840 * an Undo the journal (undoable), and for an addition the sightings of its target (targetRecord). When those sightings
841 * changed, it rates the target again with the loaded settings, a folder where it really lies too. Returns the planned
842 * edit, with the fresh record, or the result of the press that the first check gives with what it finds now.
843 */
844async function recheckPlan($: EngineInterface, buttonKey: string, first: Planned): Promise<Planned | PressResult> {
845 if (!('change' in first)) {
846 const entry = undoable(await readJournal($), first.entry.id, buttonKey)
847 return 'id' in entry ? first : entry
848 }
849 const { edit } = first
850 const record = await targetRecord($, edit.record.key, buttonKey)
851 if (!('key' in record)) return record
852 if (JSON.stringify(record) === JSON.stringify(edit.record)) return first
853 const verdict = verdictOf(record, edit.context)
854 if (changeFor(verdict, edit.action) === null) return info(nothingApplied(verdictSentences(record, verdict, edit.context)))
855 if (record.kind === 'path') {
856 const problem = await checkRealFolder($, record, edit.context)
857 if (problem !== null) return failure(buttonKey, problem)
858 }
859 return { ...first, edit: { ...edit, record } }
860}
861
862/**
863 * Fits a planned edit to the text that another session or program saved after the press first read the file, or to
864 * the fresh sightings of its target (recheckPlan): the same entry, added to or removed from what the file holds now.
865 * Refuses, with the file left as it is, when the edit no longer fits. An Undo whose entry is gone is marked as undone,
866 * as when the first read found it gone.
867 */
868async function refitEdit($: EngineInterface, buttonKey: string, file: FileInfo, planned: Planned, text: string | null): Promise<Planned | PressResult> {
869 const conflict = (cause: string) => failure(buttonKey, text === planned.loaded.original ? cause : `another session or program changed ${file.display} after this press read it, and the press no longer fits the new text: ${cause}`)
870 const parsed = parseSettings(text ?? '')
871 if ('refusal' in parsed) return conflict(refusalText(parsed, file))
872 const loaded: Loaded = { original: text, parsed }
873 const { entry } = planned
874 if ('change' in planned) {
875 const added = await checkAddition($, planned.edit, planned.change, file, parsed, true)
876 if ('cause' in added) return conflict(added.cause)
877 if ('isPresent' in added) return failure(buttonKey, `another session or program added "${entry.entry}" to ${entry.settingsKey} in ${file.display} after this press read it. Nothing was written.`)
878 const refitted: SandboxTunerApplied = { ...entry, backup: text === null ? null : file.backup, created: added.created }
879 return { ...planned, loaded, value: added.value, entry: refitted, success: addedNotice(planned.change, text !== null) }
880 }
881 const removed = removeEntry(parsed.value, entry.settingsKey, entry.entry, entry.created)
882 if ('refusal' in removed) return conflict(refusalText(removed, file))
883 if ('isAbsent' in removed) return saveJournal($, { buttonKey, file, entry: { ...entry, undoState: undefined }, success: absentNotice(entry), didWrite: false })
884 return { ...planned, loaded, value: removed.value }
885}
886
887/**
888 * Under the lock (`token`): checks the planned edit again, reads the settings file again, fits the edit to what it
889 * holds now, and writes it. An addition gets a time later than each change of its entry that the journal holds now, as
890 * a press of another session can have saved one while this press waited for the lock.
891 */
892async function writeEdit($: EngineInterface, buttonKey: string, file: FileInfo, first: Planned, token: string): Promise<PressResult> {
893 const checked = await recheckPlan($, buttonKey, first)
894 if (!('value' in checked)) return checked
895 const read = await readText($, file, buttonKey)
896 if (!('found' in read)) return read
897 const planned = read.found === first.loaded.original && checked === first ? first : await refitEdit($, buttonKey, file, checked, read.found)
898 if (!('value' in planned)) return planned
899 const { loaded, value, success } = planned
900 const entry = 'change' in planned ? { ...planned.entry, appliedAt: laterThanJournal(await readJournal($), file, planned.change, planned.entry.appliedAt) } : planned.entry
901 const problem = await writeSettings($, buttonKey, file, loaded.original, serializeSettings(value, loaded.parsed), entry, token)
902 if (problem !== null) return problem
903 return saveJournal($, { buttonKey, file, entry: { ...entry, applyState: undefined }, success, didWrite: true })
904}
905
906/**
907 * Every edit checks one fresh read of the settings file first. Then it takes the lock of the file, reads it again,
908 * writes the edit to what the file holds now, and frees the lock. The checks of the journal and the sightings run again
909 * under the lock, the checks of the file run again when the text changed between the reads, and the unchanged-file
910 * check right before the write compares with the second read.
911 */
912async function editSettings($: EngineInterface, buttonKey: string, file: FileInfo, edit: SettingsEdit): Promise<PressResult> {
913 const loaded = await readSettingsFile($, file, buttonKey)
914 if (!('parsed' in loaded)) return loaded
915 const planned = await planEdit($, buttonKey, file, edit, loaded)
916 if (!('value' in planned)) return planned
917 const token = await takeLock($, file, buttonKey)
918 if (typeof token !== 'string') return token
919 try {
920 return await writeEdit($, buttonKey, file, planned, token)
921 } finally {
922 await freeLock($, file, token, buttonKey)
923 }
924}
925
926/** The saved record of a target, read again from every writer, or the result of a press that stops. */
927async function targetRecord($: EngineInterface, key: string, buttonKey: string): Promise<SandboxTunerRecord | PressResult> {
928 if (await read($, evidenceIncomplete)) return failure(buttonKey, 'some sightings could not be saved. Durable safety evidence is incomplete. Nothing was written.')
929 return (await readRecords($, key))[0] ?? info(NOTHING_TO_CHANGE)
930}
931
932async function applyFix($: EngineInterface, threshold: number, key: string, action: SandboxTunerAction, buttonKey: string): Promise<PressResult> {
933 let context: Context
934 try {
935 context = await loadContext($, threshold, `ui.press (${buttonKey})`)
936 } catch (error) {
937 return failure(buttonKey, `could not check the current settings: ${causeOf(error)}. Nothing was written.`)
938 }
939 const record = await targetRecord($, key, buttonKey)
940 if (!('key' in record)) return record
941 if (context.file === undefined) return info(nothingApplied(verdictSentences(record, verdictOf(record, context), context)))
942 return editSettings($, buttonKey, context.file, { kind: 'add', record, action, context })
943}
944
945async function openPress($: EngineInterface): Promise<PressResult | null> {
946 try {
947 await loadSaved($, 'ui.press', 'band-open')
948 const opened = await $.ui.open(PANE_OPEN)
949 // A surface that places no panes keeps the pane open but undrawn: say why, or the press seems to do nothing.
950 return opened.isPlaced ? null : failure('band-open', `the pane is not placed: ${opened.reason}`)
951 } catch (error) {
952 return failure('band-open', `could not open the pane: ${causeOf(error)}`)
953 }
954}
955
956async function dismissPress($: EngineInterface, key: string): Promise<null> {
957 await update($, dismissed, () => key)
958 return null
959}
960
961/**
962 * After a write has settled, checks that the entry is still as the press left it, then draws the band and the pane
963 * again with the settings that Claude Code applied. $.fs has no conditional write, so another session that saved the
964 * file at the same moment can have taken the change back. It never rejects: a timer drops what its function returns.
965 */
966async function confirmChange($: EngineInterface, buttonKey: string, written: Written): Promise<void> {
967 const { file, entry } = written
968 try {
969 let journal = await readJournal($)
970 if (isPressedAgain(journal, entry)) return
971 journal = await reconcileJournal($, journal)
972 const parsed = parseSettings((await fileText($, file.path)) ?? '')
973 const probe = 'refusal' in parsed ? parsed : addEntry(parsed.value, entry.settingsKey, entry.entry)
974 const isAdded = entry.undoneAt === null
975 const isTakenBack = !('refusal' in probe) && 'isPresent' in probe !== isAdded
976 if (isTakenBack && !isAdded) {
977 const retry: SandboxTunerApplied = { ...entry, undoState: 'retry' }
978 await $.store.set(JOURNAL_PREFIX + entry.id, retry)
979 await update($, applied, () => journalOf([...journal.filter(item => item.id !== entry.id), retry]))
980 }
981 // A later press of this session that changed the entry again owns it now.
982 const problem = 'refusal' in probe
983 ? verificationRefusalText(probe, file)
984 : isTakenBack && !isPressedAgain(journal, entry)
985 ? `"${entry.entry}" is ${isAdded ? 'no longer' : 'again'} in ${entry.settingsKey} in ${file.display}: another session or program saved the file at the same moment. Press again.`
986 : null
987 if (problem !== null) {
988 const text = diagnostic('ui.press', buttonKey, problem)
989 logDebug($, text)
990 // A toast reaches the person whatever is open: after a band press, the band hides once the change is in the journal.
991 $.ui.toast(drawable(hostText(text)))
992 const now = await $.clock.now()
993 await update($, notice, () => ({ text, isError: true, at: now }))
994 }
995 } catch (error) {
996 logDebug($, diagnostic('ui.press', buttonKey, `could not check ${file.display} after the write: ${causeOf(error)}`))
997 }
998 $.ui.invalidate('ui.render')
999}
1000
1001/** Runs one press in the module queue and shows its result. It never rejects: a Button drops what onPress returns. */
1002async function runPress($: EngineInterface, buttonKey: string, action: () => Promise<PressResult | null>): Promise<void> {
1003 const result = await enqueue(() => action().catch((error: unknown) => failure(buttonKey, causeOf(error))))
1004 if (result === null) return
1005 if (result.isError) logDebug($, result.text)
1006 const { written } = result
1007 if (written !== null) {
1008 $.ui.toast(drawable(hostText(result.text)))
1009 $.clock.after(REDRAW_AFTER_WRITE_MS, () => void enqueue(() => confirmChange($, buttonKey, written)))
1010 }
1011 try {
1012 const now = await $.clock.now()
1013 await update($, notice, () => ({ text: result.text, isError: result.isError, at: now }))
1014 } catch (error) {
1015 logDebug($, diagnostic('ui.press', buttonKey, `could not show the notice "${result.text}": ${causeOf(error)}`))
1016 }
1017}
1018
1019async function readJournal($: EngineInterface): Promise<SandboxTunerApplied[]> {
1020 const journal: SandboxTunerApplied[] = []
1021 for (const key of await $.store.keys()) {
1022 if (!key.startsWith(JOURNAL_PREFIX)) continue
1023 const entry = await $.store.get(key)
1024 if (!isApplied(entry) || JOURNAL_PREFIX + entry.id !== key) throw new Error(`invalid Undo journal entry: ${key}`)
1025 journal.push(entry)
1026 }
1027 return journal
1028}
1029
1030/** Reconcile uncertain edits without writing settings, including after a reload. */
1031async function reconcileJournal($: EngineInterface, journal: readonly SandboxTunerApplied[]): Promise<SandboxTunerApplied[]> {
1032 const reconciled: SandboxTunerApplied[] = []
1033 for (const entry of journal) {
1034 const isAddition = entry.applyState === 'pending'
1035 if (!isAddition && !isUndoUnresolved(entry)) { reconciled.push(entry); continue }
1036 let next: SandboxTunerApplied = isAddition ? entry : { ...entry, undoState: 'retry' }
1037 if (hasLaterOwner(journal, entry)) next = { ...entry, applyState: undefined, undoneAt: entry.undoneAt ?? await $.clock.now(), undoState: 'superseded' }
1038 else {
1039 try {
1040 const parsed = parseSettings((await fileText($, entry.file)) ?? '')
1041 const probe = 'refusal' in parsed ? parsed : addEntry(parsed.value, entry.settingsKey, entry.entry)
1042 if ('refusal' in probe) throw new Error(verificationRefusalText(probe, fileInfo(entry.file, await homeOf($))))
1043 if (isAddition ? 'isPresent' in probe : !('isPresent' in probe)) next = { ...entry, applyState: undefined, undoState: undefined }
1044 } catch (error) {
1045 await noteFault($, diagnostic(isAddition ? 'apply.confirm' : 'undo.confirm', entry.id, causeOf(error)))
1046 }
1047 }
1048 await $.store.set(JOURNAL_PREFIX + entry.id, next)
1049 reconciled.push(next)
1050 }
1051 await update($, applied, () => journalOf(reconciled))
1052 // Every entry, not only the ones that the journal keeps: a fix from another session hides the band too.
1053 await update($, latest, last => (last !== null && isFixedSince(reconciled, last) ? null : last))
1054 return reconciled
1055}
1056
1057/** The journal entry `id` when an Undo can remove it, else the result of the press. */
1058function undoable(journal: readonly SandboxTunerApplied[], id: string, buttonKey: string): SandboxTunerApplied | PressResult {
1059 const entry = journal.find(item => item.id === id)
1060 if (entry === undefined || (entry.undoneAt !== null && !isUndoUnresolved(entry))) return info(NOTHING_TO_UNDO)
1061 return hasLaterOwner(journal, entry) ? failure(buttonKey, 'a later journal entry owns this setting. Nothing was written. Reopen the pane.') : entry
1062}
1063
1064async function undoFix($: EngineInterface, id: string, buttonKey: string): Promise<PressResult> {
1065 const entry = undoable(await readJournal($), id, buttonKey)
1066 if (!('id' in entry)) return entry
1067 const file = fileInfo(entry.file, await homeOf($))
1068 return editSettings($, buttonKey, file, { kind: 'remove', entry })
1069}
1070
1071async function moveApplied($: EngineInterface, delta: number): Promise<null> {
1072 const last = Math.max(0, (await read($, applied)).length - 1)
1073 await update($, appliedOffset, offset => Math.max(0, Math.min(last, Math.min(offset, last) + delta)))
1074 return null
1075}
1076
1077async function setHidden($: EngineInterface, key: string, isHidden: boolean, buttonKey: string): Promise<PressResult> {
1078 const listed = (await read($, records)).find(record => record.key === key)
1079 if (listed === undefined) return info(NOTHING_TO_CHANGE)
1080 const target = displayTarget(listed, await homeOf($))
1081 try {
1082 const next = { ...listed, isHidden }
1083 await $.store.set(HIDDEN_PREFIX + key, isHidden)
1084 await update($, records, list => list.map(record => (record.key === key ? next : record)))
1085 return info(isHidden ? hiddenNotice(target) : shownNotice(target))
1086 } catch (error) {
1087 return failure(buttonKey, `could not save the hidden flag of ${target}: ${causeOf(error)}`)
1088 }
1089}
1090
1091export const register: Register = (on, options) => {
1092 const threshold = Number(options.minimumSightings)
1093 // How often an interactive session checks the folders that Allow opened again. 0 turns the timed check off.
1094 const linkCheckMs = Number(options.linkCheckMinutes) * 60_000
1095 // Whether a person is at the prompt. Only session.start says so, and the process keeps it across /clear.
1096 let isInteractive = false
1097 // The timer of the timed check: one for the process, across /clear. A hot reload cancels it with the old module.
1098 let linkTimer: Timer | null = null
1099 // A hot reload runs session.start again: it registers the command again and loads the store again.
1100 on('session.start', async ($, e, next) => {
1101 const started = await next(e)
1102 isInteractive = e.isInteractive
1103 try {
1104 await $.command.register({ name: 'sandbox-tuner', description: COMMAND_DESCRIPTION, immediate: true })
1105 } catch (error) {
1106 await noteFault($, diagnostic('session.start', null, `could not register /sandbox-tuner: ${causeOf(error)}`))
1107 }
1108 void enqueue(() => loadSaved($, 'session.start', null))
1109 // Outside the queue: its walk must not hold back the recording of the first Bash calls.
1110 if (e.isInteractive) void recheckOnce($, threshold, true)
1111 // A link made during the session: the check at its start cannot see it.
1112 if (e.isInteractive && linkTimer === null && linkCheckMs > 0) linkTimer = $.clock.every(linkCheckMs, () => void recheckOnce($, threshold, false))
1113 return started
1114 }).catch(($, e, next) => {
1115 hookFailed($, 'session.start', null, next.error)
1116 return next(e)
1117 })
1118
1119 // /clear, /resume, and /branch start a new session in the same process, and session.start does not fire.
1120 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
1121 await enqueue(() => loadSaved($, 'classic.SessionStart', null))
1122 if (isInteractive) void recheckOnce($, threshold, true)
1123 return next(e)
1124 }).catch(($, e, next) => {
1125 hookFailed($, 'classic.SessionStart', null, next.error)
1126 return next(e)
1127 })
1128
1129 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1130 const out = await next(e)
1131 // The wait for the queue counts as this hook's own time, so the job stops once the dispatch is abandoned.
1132 if (!next.signal.aborted) await enqueue(() => recordCall($, toCall(e), toOutcome(out), next.signal))
1133 return out
1134 }).catch(($, e, next) => {
1135 hookFailed($, 'tool.call', 'Bash', next.error)
1136 return next(e)
1137 })
1138
1139 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
1140 const out = await next(e)
1141 if (!next.signal.aborted && e.command !== undefined && e.ws === undefined && out.deny === undefined && out.isError !== true && out.result.taskId !== '') {
1142 await enqueue(() => recordCall($, { command: e.command!, isBackground: true, isUnsandboxed: false, allowedDomains: e.allowed_domains ?? [] },
1143 { isDenied: false, isError: false, backgroundTaskId: out.result.taskId, text: '', stderr: '' }, next.signal, 'Monitor'))
1144 }
1145 return out
1146 }).catch(($, e, next) => {
1147 hookFailed($, 'tool.call', 'Monitor', next.error)
1148 return next(e)
1149 })
1150
1151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1152 const last = await read($, latest)
1153 if (e.props.hasSurvey || last === null) return next(e)
1154 if (last.reason === 'operation') {
1155 if ((await read($, dismissed)) === last.recordKey) return next(e)
1156 return bandTree($.ui.resolve(e), buildObservation(last, await read($, notice)), {
1157 allow: () => {}, deny: () => {},
1158 open: () => void runPress($, 'band-open', () => openPress($)),
1159 dismiss: () => void runPress($, 'band-dismiss', () => dismissPress($, last.recordKey)),
1160 }, await next(e))
1161 }
1162 const record = (await read($, records)).find(item => item.key === last.recordKey)
1163 if (record === undefined || record.isHidden || (await read($, dismissed)) === last.recordKey || isFixedSince(await read($, applied), last)) return next(e)
1164 const context = await loadContext($, threshold, 'ui.render (AbovePrompt)')
1165 const model = buildBand(last, record, verdictOf(record, context), context, await read($, notice))
1166 const beneath: RenderElement = await next(e)
1167 return bandTree($.ui.resolve(e), model, {
1168 allow: () => void runPress($, 'band-allow', () => applyFix($, threshold, record.key, 'allow', 'band-allow')),
1169 deny: () => void runPress($, 'band-deny', () => applyFix($, threshold, record.key, 'deny', 'band-deny')),
1170 open: () => void runPress($, 'band-open', () => openPress($)),
1171 dismiss: () => void runPress($, 'band-dismiss', () => dismissPress($, record.key)),
1172 }, beneath)
1173 }).catch(($, e, next) => {
1174 hookFailed($, 'ui.render', 'AbovePrompt', next.error)
1175 return next(e)
1176 })
1177
1178 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1179 const context = await loadContext($, threshold, 'ui.render (Pane)')
1180 const journal = await read($, applied)
1181 const now = await $.clock.now()
1182 const model = buildPane({
1183 // Sightings of this session join $.state without a load, so each drawing applies the display limits again.
1184 rated: rateRecords(shownRecords(await read($, records), now), context),
1185 applied: journal,
1186 reformatted: await reformattedFiles($, journal, context),
1187 appliedOffset: await read($, appliedOffset),
1188 notice: await read($, notice),
1189 fault: await read($, fault),
1190 isSandboxOn: sandboxFacts(await $.settings.read()).isEnabled,
1191 context,
1192 now,
1193 })
1194 return paneTree($.ui.resolve(e), model, {
1195 allow: key => void runPress($, `allow:${key}`, () => applyFix($, threshold, key, 'allow', `allow:${key}`)),
1196 deny: key => void runPress($, `deny:${key}`, () => applyFix($, threshold, key, 'deny', `deny:${key}`)),
1197 hide: key => void runPress($, `hide:${key}`, () => setHidden($, key, true, `hide:${key}`)),
1198 unhide: key => void runPress($, `unhide:${key}`, () => setHidden($, key, false, `unhide:${key}`)),
1199 undo: id => void runPress($, `undo:${id}`, () => undoFix($, id, `undo:${id}`)),
1200 moveApplied: delta => void runPress($, delta < 0 ? 'applied-previous' : 'applied-next', () => moveApplied($, delta)),hooks/diagnostics.ts 12 lines1// The diagnostic form: the mod, the event, and the cause.
2
3const MOD = 'sandbox-tuner'
4
5export function diagnostic(event: string, detail: string | null, cause: string): string {
6 return `${MOD}: ${event}${detail === null ? '' : ` (${detail})`}: ${cause}`
7}
8
9export function causeOf(error: unknown): string {
10 return error instanceof Error && error.message !== '' ? error.message : String(error)
11}
12hooks/names.ts 225 lines1// Naming rules for hosts, write folders, and commands.
2
3type HostPort = { host: string; port: number | null }
4
5const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
6// IPv4 also accepts fewer components, octal digits, and hexadecimal components.
7const IPV4 = /^(?:\d+|0x[0-9a-f]*)(?:\.(?:\d+|0x[0-9a-f]*)){0,3}$/i
8const LOCAL_NAMESPACES = ['localhost', 'local', 'home.arpa', 'internal']
9// Under HOME, `<root>/<name>/...` gives the cache folder `<root>/<name>` of any tool.
10const NAMED_CACHE_ROOTS: readonly (readonly string[])[] = [['.cache'], ['Library', 'Caches']]
11// Under HOME, `<cache>/...` gives `<cache>`: package caches that hold downloads only.
12const PACKAGE_CACHES: readonly (readonly string[])[] = [
13 ['.npm', '_cacache'],
14 ['.cargo', 'registry'],
15 ['.cargo', 'git'],
16 ['.gradle', 'caches'],
17 ['.m2', 'repository'],
18 ['.local', 'share', 'pnpm', 'store'],
19 ['Library', 'pnpm', 'store'],
20]
21const TEMP_ROOTS: readonly (readonly string[])[] = [
22 ['', 'var', 'folders'],
23 ['', 'private', 'var', 'folders'],
24]
25const TEMP_MARKERS: ReadonlySet<string> = new Set(['T', 'C'])
26// The temp folder that every app shares. A folder strictly inside it is a temp folder. /tmp itself is too broad.
27export const TMP = '/tmp'
28const COMMAND_LIMIT = 120
29// What the engine refuses in a text: control characters other than tab, newline, and carriage return, a lone
30// surrogate, and its own placeholder character.
31const UNDRAWABLE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ud800-\udfff\u{10eeee}]/gu
32const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
33// An assignment whose value holds no quote, substitution, brace, or escape: it ends at the next space.
34const PLAIN_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=[^'"`$(){}\\]*$/
35// A file name: at most 255 characters, the most a file system takes.
36const PROGRAM_NAME = /^[\w.+-]{1,255}$/
37// Protected names that hold in any folder: settings or code in them can run outside the sandbox.
38export const CLAUDE_PROTECTED: ReadonlySet<string> = new Set(['settings.json', 'settings.local.json', 'skills', 'agents', 'commands', 'hooks', 'workflows', 'scheduled_tasks.json'])
39const GIT_PROTECTED: ReadonlySet<string> = new Set(['hooks', 'config'])
40const EDITOR_FOLDERS: ReadonlySet<string> = new Set(['.vscode', '.idea'])
41const MCP_FILE = '.mcp.json'
42// Shell startup files and the git configuration.
43const STARTUP_FILES: ReadonlySet<string> = new Set(['.bashrc', '.bash_profile', '.bash_login', '.bash_logout', '.profile', '.zshrc', '.zshenv', '.zprofile', '.zlogin', '.zlogout', '.gitconfig'])
44/** Every protected place that a folder can hold, as a path from that folder. */
45export const PROTECTED_PLACES: readonly string[] = [
46 ...[...CLAUDE_PROTECTED].map(name => `.claude/${name}`),
47 ...[...GIT_PROTECTED].map(name => `.git/${name}`),
48 ...EDITOR_FOLDERS,
49 MCP_FILE,
50 ...STARTUP_FILES,
51]
52const KEY_FILE = /\.(?:pem|key)$/i
53
54/** A host as the sandbox compares it: lowercase, one trailing dot cut. */
55export function normalHost(host: string): string {
56 const lower = host.toLowerCase()
57 return lower.endsWith('.') ? lower.slice(0, -1) : lower
58}
59
60/** The host and port of `host:port`, `[v6]:port`, or a bare host. */
61export function splitHost(text: string): HostPort {
62 const isBracketed = text.startsWith('[')
63 const end = isBracketed ? text.indexOf(']') + 1 : text.lastIndexOf(':')
64 const hasPort = end > 0 && /^:\d+$/.test(text.slice(end))
65 return {
66 host: normalHost(hasPort || isBracketed ? text.slice(0, end) : text),
67 port: hasPort ? Number(text.slice(end + 1)) : null,
68 }
69}
70
71/** True for a DNS name of two or more labels that is no IP literal and no local name. */
72export function isDnsName(host: string): boolean {
73 const labels = host.split('.')
74 return (
75 host.length <= 253 &&
76 labels.length >= 2 &&
77 !IPV4.test(host) &&
78 !LOCAL_NAMESPACES.some(name => host === name || host.endsWith(`.${name}`)) &&
79 labels.every(label => label.length <= 63 && LABEL.test(label))
80 )
81}
82
83/** Whether a settings host entry covers the host on every given port. A record keeps at most five ports, so an earlier port can be missing. */
84export function hostMatches(entry: string, host: string, ports: readonly number[]): boolean {
85 const pattern = splitHost(entry.trim())
86 const isPortMatch = pattern.port === null || (ports.length > 0 && ports.every(port => port === pattern.port))
87 if (!isPortMatch) return false
88 if (pattern.host === '*') return true
89 if (pattern.host.startsWith('*.')) return host.endsWith(pattern.host.slice(1))
90 return pattern.host === host
91}
92
93/** True when `path` is `folder` or lies inside it. */
94export function isUnder(path: string, folder: string): boolean {
95 const base = folder.replace(/\/+$/, '')
96 return path === base || path.startsWith(`${base}/`)
97}
98
99/** `path` made absolute from `base` when relative, with `.`, `..`, and empty segments resolved, as the sandbox resolves it. */
100export function absolutePath(path: string, base: string): string {
101 const parts: string[] = []
102 for (const part of (path.startsWith('/') ? path : `${base}/${path}`).split('/')) {
103 if (part === '..') parts.pop()
104 else if (part !== '' && part !== '.') parts.push(part)
105 }
106 return `/${parts.join('/')}`
107}
108
109/**
110 * The folders of a PATH value as the shell spells them, each made absolute: an empty or relative entry (an empty PATH
111 * too) starts at the working directory. The spelling stays, because the system follows a link before a `..` after it.
112 */
113export function pathFolders(value: string | undefined, cwd: string): string[] {
114 return value === undefined ? [] : value.split(':').map(entry => (entry.startsWith('/') ? entry : `${cwd}/${entry}`))
115}
116
117/** A path that a protected name covers in any folder, in any letter case: many file systems ignore case. */
118export function isProtectedName(path: string): boolean {
119 const parts = path.toLowerCase().split('/')
120 const last = parts.at(-1) ?? ''
121 const follows = (segment: string, names: ReadonlySet<string>) => parts.some((part, index) => part === segment && names.has(parts[index + 1] ?? ''))
122 return follows('.claude', CLAUDE_PROTECTED) || follows('.git', GIT_PROTECTED) || parts.some(part => EDITOR_FOLDERS.has(part)) || last === MCP_FILE || STARTUP_FILES.has(last)
123}
124
125/** A key or certificate file, by its name, in any letter case. */
126export function isKeyFile(path: string): boolean {
127 return KEY_FILE.test(path)
128}
129
130/** A denied path whose name alone rules out Allow for its folder: a protected name or a key file. */
131export function isGuardedPath(path: string): boolean {
132 return isProtectedName(path) || isKeyFile(path)
133}
134
135/** `~/...` for a path under HOME, else the path. */
136export function displayPath(path: string, home: string | undefined): string {
137 return home !== undefined && isUnder(path, home) ? `~${path.slice(home.length)}` : path
138}
139
140function startsWithParts(parts: readonly string[], prefix: readonly string[]): boolean {
141 return prefix.every((part, index) => parts[index] === part)
142}
143
144/** How many leading parts name the macOS per-user temp (T) or cache (C) folder, or null. */
145function sharedFolderLength(parts: readonly string[]): number | null {
146 const root = TEMP_ROOTS.find(prefix => startsWithParts(parts, prefix) && TEMP_MARKERS.has(parts[prefix.length + 2] ?? ''))
147 return root === undefined ? null : root.length + 3
148}
149
150/** How many leading parts name the cache or temp folder that holds the path, or null. */
151function cacheLength(parts: readonly string[], home: string | undefined): number | null {
152 // An empty, `.`, or `..` segment makes the text name another folder (`~/.cache/..` is HOME), so no cache holds it.
153 if (parts.slice(1).some(part => part === '' || part === '.' || part === '..')) return null
154 const shared = sharedFolderLength(parts)
155 if (shared !== null) return parts.length > shared ? shared : null
156 const homeParts = home?.split('/')
157 if (homeParts === undefined || !startsWithParts(parts, homeParts)) return null
158 const rest = parts.slice(homeParts.length)
159 // `<root>/<name>/...`: at least one segment after <name>, so a file directly in a root names no cache.
160 const named = NAMED_CACHE_ROOTS.find(root => startsWithParts(rest, root) && rest.length > root.length + 1)
161 if (named !== undefined) return homeParts.length + named.length + 1
162 const store = PACKAGE_CACHES.find(cache => startsWithParts(rest, cache) && rest.length > cache.length)
163 return store === undefined ? null : homeParts.length + store.length
164}
165
166/** The cache or temp folder that holds a denied path, or null when none does. */
167export function cacheFolder(path: string, home: string | undefined): string | null {
168 const parts = path.split('/')
169 const length = cacheLength(parts, home)
170 return length === null ? null : parts.slice(0, length).join('/')
171}
172
173/** The folder target of a denied path: its cache or temp folder, else the folder that holds it. */
174export function folderTarget(path: string, home: string | undefined): string {
175 return cacheFolder(path, home) ?? (path.slice(0, path.lastIndexOf('/')) || '/')
176}
177
178/**
179 * Whether a folder lies strictly inside `tmp`: /tmp as spelled, or where a press found it. It compares spellings, as
180 * cacheLength does, so a spelling with an empty, `.`, or `..` segment names no temp folder. `tmp` holds no temp folder
181 * when it holds HOME or HOME holds it, in any letter case: many file systems ignore case.
182 */
183export function isTempFolder(folder: string, home: string | undefined, tmp = TMP): boolean {
184 if (folder.split('/').slice(1).some(part => part === '' || part === '.' || part === '..')) return false
185 const [foldedHome, foldedTmp] = [home?.toLowerCase(), tmp.toLowerCase()]
186 if (foldedHome !== undefined && (isUnder(foldedHome, foldedTmp) || isUnder(foldedTmp, foldedHome))) return false
187 return folder !== tmp && isUnder(folder, tmp)
188}
189
190/** True for the macOS per-user temp or cache folder, or a folder strictly inside /tmp: every app of the person shares it. */
191export function isSharedFolder(path: string): boolean {
192 const parts = path.split('/')
193 return sharedFolderLength(parts) === parts.length || isTempFolder(path, undefined)
194}
195
196/** The text cut to at most `limit` characters, with `…` when cut, never between the two halves of one character. */
197export function cutText(text: string, limit: number): string {
198 if (text.length <= limit) return text
199 const end = /[\ud800-\udbff]/.test(text.charAt(limit - 2)) ? limit - 2 : limit - 1
200 return `${text.slice(0, end)}…`
201}
202
203/** The text with each character that the engine refuses to show replaced by `�`. */
204export function plainText(text: string): string {
205 return text.replace(UNDRAWABLE, '\ufffd')
206}
207
208/** A command as one line of at most 120 characters. */
209export function commandLine(command: string): string {
210 return cutText(plainText(command.replace(/\s+/g, ' ').trim()), COMMAND_LIMIT)
211}
212
213/**
214 * The program name of a command, for `seenWith`, or '' when none is sure. A quoted or substituted assignment value
215 * can hold spaces, so the word after it may be part of that value, such as a token: no name is taken then.
216 */
217export function programName(command: string): string {
218 const words = command.trim().replace(/^(?:cd\s+\S+\s*&&\s*)+/, '').split(/\s+/)
219 const index = words.findIndex(word => !ASSIGNMENT.test(word))
220 if (index === -1 || !words.slice(0, index).every(word => PLAIN_ASSIGNMENT.test(word))) return ''
221 const [word = ''] = words.slice(index, index + 1)
222 const program = word.slice(word.lastIndexOf('/') + 1)
223 return PROGRAM_NAME.test(program) ? program : ''
224}
225hooks/records.ts 156 lines1// Saved records: store keys, the merge of a sighting, counts, shape, retention.
2
3import type { SandboxTunerApplied, SandboxTunerCounts, SandboxTunerLatest, SandboxTunerRecord } from '../types'
4import { isGuardedPath, normalHost } from './names'
5import { isCount, isListOf, isPlainObject, isText } from './shapes'
6import type { Sighting } from './sightings'
7
8// Writers keep independent records.
9export const WRITER_PREFIX = 'writer:'
10export const HIDDEN_PREFIX = 'hidden:'
11export const JOURNAL_PREFIX = 'applied:'
12const JOURNAL_LIMIT = 50
13
14const RECORD_LIMIT = 300
15const RECORD_AGE_MS = 30 * 24 * 60 * 60 * 1000
16const PORT_LIMIT = 5
17const PROGRAM_LIMIT = 3
18const SESSION_LIMIT = 20
19const COUNTERS = ['blocked', 'refused', 'listed', 'inferred'] as const
20const KINDS: ReadonlySet<unknown> = new Set(['host', 'path'])
21
22export function recordKey(sighting: Pick<Sighting, 'kind' | 'target'>): string {
23 return `${sighting.kind}:${sighting.target}`
24}
25
26function joinNewest<T>(list: readonly T[], item: T, limit: number): T[] {
27 return [...list.filter(value => value !== item), item].slice(-limit)
28}
29
30/** Distinct values, the newer record's last so that the cut keeps them: a value in both takes its newer place. */
31function mergeNewest<T>(older: readonly T[], newer: readonly T[], limit: number): T[] {
32 const joined = [...older, ...newer]
33 return joined.filter((value, index) => joined.lastIndexOf(value) === index).slice(-limit)
34}
35
36export function mergeSighting(record: SandboxTunerRecord | undefined, sighting: Sighting, sessionId: string | null, now: number): SandboxTunerRecord {
37 const base: SandboxTunerRecord = record ?? {
38 key: recordKey(sighting),
39 kind: sighting.kind,
40 target: sighting.target,
41 counts: { blocked: 0, refused: 0, listed: 0, inferred: 0 },
42 ports: [],
43 sample: '',
44 seenWith: [],
45 sessions: [],
46 firstSeen: now,
47 lastSeen: now,
48 isHidden: false,
49 }
50 return {
51 ...base,
52 counts: {
53 ...base.counts,
54 [sighting.counter]: base.counts[sighting.counter] + 1,
55 refused: base.counts.refused + (sighting.reason === 'refused' ? 1 : 0),
56 },
57 ports: sighting.ports.reduce((list, port) => joinNewest(list, port, PORT_LIMIT), base.ports),
58 seenWith: sighting.program === '' ? base.seenWith : joinNewest(base.seenWith, sighting.program, PROGRAM_LIMIT),
59 sessions: sessionId === null ? base.sessions : joinNewest(base.sessions, sessionId, SESSION_LIMIT),
60 // A folder keeps a protected or key file as its sample, so a later write beside it cannot bring Allow back.
61 sample: base.kind === 'path' && isGuardedPath(base.sample) && !isGuardedPath(sighting.detail) ? base.sample : sighting.detail,
62 lastSeen: now,
63 }
64}
65
66export function sightingTotal(counts: SandboxTunerCounts): number {
67 return counts.blocked + counts.listed + counts.inferred
68}
69
70export function allowCount(counts: SandboxTunerCounts): number {
71 return sightingTotal(counts) - counts.refused
72}
73
74export function isRecord(value: unknown): value is SandboxTunerRecord {
75 if (!isPlainObject(value) || !isPlainObject(value.counts)) return false
76 const counts = value.counts
77 return (
78 KINDS.has(value.kind) &&
79 isText(value.target) &&
80 value.key === `${String(value.kind)}:${value.target}` &&
81 COUNTERS.every(name => isCount(counts[name])) &&
82 isListOf(value.ports, isCount) &&
83 isText(value.sample) &&
84 isListOf(value.seenWith, isText) &&
85 isListOf(value.sessions, isText) &&
86 isCount(value.firstSeen) &&
87 isCount(value.lastSeen) &&
88 typeof value.isHidden === 'boolean'
89 )
90}
91
92/** The record keys omitted from the display after 30 days or beyond the 300 newest targets. */
93export function staleKeys(records: readonly SandboxTunerRecord[], now: number): string[] {
94 const fresh = records.filter(record => now - record.lastSeen <= RECORD_AGE_MS)
95 const kept = new Set([...fresh].sort((a, b) => b.lastSeen - a.lastSeen).slice(0, RECORD_LIMIT).map(record => record.key))
96 return records.filter(record => !kept.has(record.key)).map(record => record.key)
97}
98
99/** The records that the display shows at `now`: each seen within 30 days, at most the 300 newest. */
100export function shownRecords(records: readonly SandboxTunerRecord[], now: number): SandboxTunerRecord[] {
101 const stale = new Set(staleKeys(records, now))
102 return records.filter(record => !stale.has(record.key))
103}
104
105/** The 50 newest history entries plus every unresolved edit, oldest first. */
106export function journalOf(entries: readonly SandboxTunerApplied[]): SandboxTunerApplied[] {
107 const sorted = [...entries].sort((a, b) => a.appliedAt - b.appliedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
108 const unresolved = (entry: SandboxTunerApplied) => entry.applyState === 'pending' || isUndoUnresolved(entry)
109 const history = sorted.filter(entry => !unresolved(entry)).slice(-JOURNAL_LIMIT)
110 return sorted.filter(entry => unresolved(entry) || history.includes(entry))
111}
112
113/** An unconfirmed removal retains its retry action. */
114export function isUndoUnresolved(entry: SandboxTunerApplied): boolean {
115 return entry.undoState === 'pending' || entry.undoState === 'retry'
116}
117
118export function hasLaterOwner(journal: readonly SandboxTunerApplied[], entry: SandboxTunerApplied): boolean {
119 return journal.some(item => item.id !== entry.id && item.file === entry.file && item.settingsKey === entry.settingsKey &&
120 (entry.settingsKey === 'sandbox.filesystem.allowWrite' ? item.entry === entry.entry : normalHost(item.entry) === normalHost(entry.entry)) &&
121 item.appliedAt >= entry.appliedAt)
122}
123
124export function isPressedAgain(journal: readonly SandboxTunerApplied[], written: SandboxTunerApplied): boolean {
125 return hasLaterOwner(journal, written) || journal.some(item => item.id === written.id && item.undoneAt !== written.undoneAt)
126}
127
128/** True when a press changed settings for the target of the latest block at or after that block. */
129export function isFixedSince(journal: readonly SandboxTunerApplied[], latest: SandboxTunerLatest): boolean {
130 return journal.some(entry => entry.applyState !== 'pending' && entry.recordKey === latest.recordKey && entry.appliedAt >= latest.at)
131}
132
133/** Combine independent writers without losing a protected sample or inferred evidence. */
134export function aggregateRecords(records: readonly SandboxTunerRecord[]): SandboxTunerRecord[] {
135 const result = new Map<string, SandboxTunerRecord>()
136 for (const record of [...records].sort((a, b) => a.lastSeen - b.lastSeen)) {
137 const previous = result.get(record.key)
138 if (previous === undefined) { result.set(record.key, record); continue }
139 result.set(record.key, {
140 ...record,
141 counts: {
142 blocked: previous.counts.blocked + record.counts.blocked,
143 refused: previous.counts.refused + record.counts.refused,
144 listed: previous.counts.listed + record.counts.listed,
145 inferred: previous.counts.inferred + record.counts.inferred,
146 },
147 sample: isGuardedPath(previous.sample) ? previous.sample : record.sample,
148 ports: mergeNewest(previous.ports, record.ports, PORT_LIMIT),
149 seenWith: mergeNewest(previous.seenWith, record.seenWith, PROGRAM_LIMIT),
150 sessions: mergeNewest(previous.sessions, record.sessions, SESSION_LIMIT),
151 firstSeen: Math.min(previous.firstSeen, record.firstSeen),
152 })
153 }
154 return [...result.values()]
155}
156hooks/settings-file.ts 193 lines1// Parses, edits, and serializes the user settings file, formats and parses the record of its lock file, and checks the
2// shape of a journal entry.
3
4import type { SandboxTunerApplied, SandboxTunerSettingsKey } from '../types'
5import { causeOf } from './diagnostics'
6import { displayPath, normalHost, plainText } from './names'
7import { isCount, isListOf, isPlainObject, isText } from './shapes'
8
9export type FileInfo = { path: string; display: string; backup: string; backupDisplay: string; lock: string; lockDisplay: string }
10export type ParsedSettings = { value: Record<string, unknown>; bom: string; trailingNewline: boolean; isCanonical: boolean }
11export type Refusal =
12 | { refusal: 'invalid-json'; message: string }
13 | { refusal: 'wrong-type'; path: string; found: string; expected: string }
14type AddResult = { value: Record<string, unknown>; created: string[] } | { isPresent: true } | Refusal
15type RemoveResult = { value: Record<string, unknown> } | { isAbsent: true } | Refusal
16type KeyPath = { parents: readonly string[]; leaf: string }
17type Link = { holder: Record<string, unknown>; name: string; dotted: string }
18type Walked = { node: Record<string, unknown>; links: Link[]; created: string[] }
19
20const BOM = '\ufeff'
21const KEY_PATHS: Readonly<Record<SandboxTunerSettingsKey, KeyPath>> = {
22 'sandbox.network.allowedDomains': { parents: ['sandbox', 'network'], leaf: 'allowedDomains' },
23 'sandbox.network.deniedDomains': { parents: ['sandbox', 'network'], leaf: 'deniedDomains' },
24 'sandbox.filesystem.allowWrite': { parents: ['sandbox', 'filesystem'], leaf: 'allowWrite' },
25}
26const HOST_KEYS: ReadonlySet<SandboxTunerSettingsKey> = new Set(['sandbox.network.allowedDomains', 'sandbox.network.deniedDomains'])
27
28/**
29 * The paths of a settings file, its backup, and its lock file, each with the text that shows it: CLAUDE_CONFIG_DIR can
30 * hold any character.
31 */
32export function fileInfo(path: string, home: string | undefined): FileInfo {
33 const backup = `${path.slice(0, path.lastIndexOf('/'))}/backups/settings.json.sandbox-tuner.bak`
34 const lock = `${path}.sandbox-tuner.lock`
35 return { path, display: plainText(displayPath(path, home)), backup, backupDisplay: plainText(displayPath(backup, home)), lock, lockDisplay: plainText(displayPath(lock, home)) }
36}
37
38/** Who holds the lock of a settings file: the token of one press, and when it took the lock. */
39export type LockRecord = { holder: string; since: number }
40
41/** The text of a lock file: held by the press `holder` since `since`, or free since `since` when `holder` is null. */
42export function lockText(holder: string | null, since: number): string {
43 return `${JSON.stringify({ holder, since })}\n`
44}
45
46/**
47 * What a lock file holds: its holder, `free` when the file is missing or free, or null when its text is no lock record,
48 * such as an empty file that a write has not finished.
49 */
50export function readLock(text: string | null): LockRecord | 'free' | null {
51 if (text === null) return 'free'
52 let value: unknown
53 try {
54 value = JSON.parse(text)
55 } catch {
56 return null
57 }
58 if (!isPlainObject(value) || !isCount(value.since)) return null
59 if (value.holder === null) return 'free'
60 return isText(value.holder) && value.holder !== '' ? { holder: value.holder, since: value.since } : null
61}
62
63function typeName(value: unknown): string {
64 if (value === null) return 'null'
65 if (Array.isArray(value)) return 'an array'
66 return typeof value === 'object' ? 'an object' : `a ${typeof value}`
67}
68
69function wrongType(path: string, value: unknown, expected: string): Refusal {
70 return { refusal: 'wrong-type', path, found: typeName(value), expected }
71}
72
73/**
74 * The JSON parser's reason without the text of the file, because settings can hold secrets in `env` and the
75 * reason goes to the debug log. The parsers quote the file near the fault: V8 quotes the token and an excerpt,
76 * JavaScriptCore quotes the identifier. So all from the first quote to the last one becomes `…`. JavaScriptCore
77 * also names a bad escape character without quotes, so that character goes too.
78 */
79export function parserReason(message: string): string {
80 return message
81 .replace(/["'][\s\S]*["'](?:\.\.\.)?/, '…')
82 .replace(/(escape character) \S+/, '$1')
83 .replace(/\s+/g, ' ')
84 .trim()
85}
86
87export function parseSettings(text: string): ParsedSettings | Refusal {
88 const bom = text.startsWith(BOM) ? BOM : ''
89 const body = text.slice(bom.length)
90 const isBlank = body.trim() === ''
91 let value: unknown
92 try {
93 value = isBlank ? {} : JSON.parse(body)
94 } catch (error) {
95 return { refusal: 'invalid-json', message: parserReason(causeOf(error)) }
96 }
97 if (!isPlainObject(value)) return wrongType('the top level', value, 'an object')
98 const canonical = JSON.stringify(value, null, 2)
99 return { value, bom, trailingNewline: isBlank || body.endsWith('\n'), isCanonical: body === canonical || body === `${canonical}\n` }
100}
101
102function cloneSettings(root: Record<string, unknown>): Record<string, unknown> {
103 return JSON.parse(JSON.stringify(root)) as Record<string, unknown>
104}
105
106function walk(root: Record<string, unknown>, parents: readonly string[], isCreating: true): Walked | Refusal
107function walk(root: Record<string, unknown>, parents: readonly string[], isCreating: false): Walked | Refusal | 'missing'
108function walk(root: Record<string, unknown>, parents: readonly string[], isCreating: boolean): Walked | Refusal | 'missing' {
109 const walked: Walked = { node: root, links: [], created: [] }
110 for (const name of parents) {
111 const dotted = [...walked.links.map(link => link.name), name].join('.')
112 const child = walked.node[name]
113 if (child === undefined && !isCreating) return 'missing'
114 if (child !== undefined && !isPlainObject(child)) return wrongType(dotted, child, 'an object')
115 if (child === undefined) {
116 walked.node[name] = {}
117 walked.created.push(dotted)
118 }
119 walked.links.push({ holder: walked.node, name, dotted })
120 walked.node = walked.node[name] as Record<string, unknown>
121 }
122 return walked
123}
124
125function listAt(node: Record<string, unknown>, leaf: string, key: SandboxTunerSettingsKey): string[] | undefined | Refusal {
126 const list = node[leaf]
127 return list === undefined || isListOf(list, isText) ? list : wrongType(key, list, 'an array of strings')
128}
129
130function sameEntry(key: SandboxTunerSettingsKey, left: string, right: string): boolean {
131 return HOST_KEYS.has(key) ? normalHost(left) === normalHost(right) : left === right
132}
133
134export function addEntry(root: Record<string, unknown>, key: SandboxTunerSettingsKey, entry: string): AddResult {
135 const copy = cloneSettings(root)
136 const { parents, leaf } = KEY_PATHS[key]
137 const walked = walk(copy, parents, true)
138 if ('refusal' in walked) return walked
139 const list = listAt(walked.node, leaf, key)
140 if (list === undefined) {
141 walked.node[leaf] = [entry]
142 return { value: copy, created: [...walked.created, key] }
143 }
144 if ('refusal' in list) return list
145 if (list.some(item => sameEntry(key, item, entry))) return { isPresent: true }
146 list.push(entry)
147 return { value: copy, created: walked.created }
148}
149
150export function removeEntry(root: Record<string, unknown>, key: SandboxTunerSettingsKey, entry: string, created: readonly string[]): RemoveResult {
151 const copy = cloneSettings(root)
152 const { parents, leaf } = KEY_PATHS[key]
153 const walked = walk(copy, parents, false)
154 if (walked === 'missing') return { isAbsent: true }
155 if ('refusal' in walked) return walked
156 const list = listAt(walked.node, leaf, key)
157 if (list === undefined) return { isAbsent: true }
158 if ('refusal' in list) return list
159 const index = list.findIndex(item => sameEntry(key, item, entry))
160 if (index === -1) return { isAbsent: true }
161 list.splice(index, 1)
162 for (const link of [...walked.links, { holder: walked.node, name: leaf, dotted: key }].reverse()) {
163 const value = link.holder[link.name]
164 const isEmpty = (Array.isArray(value) && value.length === 0) || (isPlainObject(value) && Object.keys(value).length === 0)
165 if (isEmpty && created.includes(link.dotted)) delete link.holder[link.name]
166 }
167 return { value: copy }
168}
169
170export function serializeSettings(value: Record<string, unknown>, format: Pick<ParsedSettings, 'bom' | 'trailingNewline'>): string {
171 return `${format.bom}${JSON.stringify(value, null, 2)}${format.trailingNewline ? '\n' : ''}`
172}
173
174/** The shape of one journal entry. */
175export function isApplied(value: unknown): value is SandboxTunerApplied {
176 return (
177 isPlainObject(value) &&
178 isText(value.id) &&
179 isText(value.recordKey) &&
180 (value.action === 'allow' || value.action === 'deny') &&
181 isText(value.settingsKey) &&
182 Object.hasOwn(KEY_PATHS, value.settingsKey) &&
183 isText(value.entry) &&
184 isText(value.file) &&
185 (value.backup === null || isText(value.backup)) &&
186 isListOf(value.created, isText) &&
187 isCount(value.appliedAt) &&
188 (value.undoneAt === null || isCount(value.undoneAt)) &&
189 (value.applyState === undefined || (value.applyState === 'pending' && value.undoneAt === null)) &&
190 (value.undoState === undefined || (value.undoneAt !== null && ['pending', 'retry', 'superseded'].includes(String(value.undoState))))
191 )
192}
193hooks/sightings.ts 230 lines1// Reads one Bash call into sightings.
2
3import type { SandboxTunerKind, SandboxTunerLatest } from '../types'
4import { absolutePath, folderTarget, isGuardedPath, plainText, programName, splitHost } from './names'
5import { recordKey } from './records'
6
7type Counter = 'blocked' | 'listed' | 'inferred'
8type BlockReason = SandboxTunerLatest['reason']
9export type Sighting = {
10 kind: SandboxTunerKind
11 target: string
12 counter: Counter
13 reason: BlockReason | null
14 reasonText: string
15 ports: readonly number[]
16 detail: string
17 program: string
18}
19type Gate = 'sandbox-on' | 'sandbox-required'
20export type GatedSighting = { gate: Gate; sighting: Sighting }
21type CallSightings = { reports: Sighting[]; gated: GatedSighting[]; unknownLine: string | null; observation?: { operation: string; detail: string }; skipped?: true }
22export type SandboxFacts = { isEnabled: boolean; allowsUnsandboxed: boolean }
23export type BashCall = { command: string; isBackground: boolean; isUnsandboxed: boolean; allowedDomains: readonly string[] }
24export type BashOutcome = { backgroundTaskId?: string; isDenied: boolean; isError: boolean; isReadOnly?: boolean; isUnsandboxed?: boolean; text: string; stderr: string }
25
26const SCAN_LIMIT = 200_000
27// No host, path, or reason that the sandbox reports is longer than the longest path a file system takes, or holds a
28// character that the engine refuses to draw. Such a one is noise, such as output that imitates a report.
29const SIGHTING_TEXT_LIMIT = 4096
30const PATH_LIMIT = 3
31const UNKNOWN_LIMIT = 80
32const OPEN_TAG = '<sandbox_violations>'
33const CLOSE_TAG = '</sandbox_violations>'
34// Claude Code 2.1.296 ends the text of a failed call with its report, each tag on a line of its own. Only its proxy
35// note can follow the report.
36const REPORT_OPEN = `\n${OPEN_TAG}\n`
37const REPORT_CLOSE = `\n${CLOSE_TAG}`
38const PROXY_NOTE = '\n\n[agent-proxy] While this command ran, '
39const NETWORK = /^deny network-outbound (\S+) \((.*)\)$/
40const MAC_LINE = /^\S+\(\d+\) deny\(\d+\) \S/
41const MAC_FILE = /^\S+\(\d+\) deny\(\d+\) file-/
42const MAC_WRITE = /^\S+\(\d+\) deny\(\d+\) file-write-[a-z-]+\s+(\/.*)$/
43const LINUX_WRITE = /^deny [a-z0-9_]+ (\/.*)$/
44// The start of every report line: `deny ` for a network line and on Linux, `<program>(<pid>) ` on macOS.
45const REPORT_START = /^(?:deny |\S+\(\d+\) )/
46const EXIT_CODE = /^Exit code \d+/
47const PERMISSION_ERROR = /operation not permitted|read-only file system/i
48const SOCKET_WORDS = /\b(?:listen|connect|socket)\b/i
49const PATH_START = /(?:^|[\s'"‘`(])\/(?!\/)/g
50const PATH_CANDIDATE = /(?:^|[\s'"‘`(])(\/(?!\/)[^\s'"’`:,)]+)(?=[:'"’`,)]|$)/g
51const DEVICE_PATH = /^\/(?:dev|proc|sys)\//
52
53function fits(sighting: Sighting): boolean {
54 const text = `${sighting.detail}${sighting.reasonText}`
55 return text.length <= SIGHTING_TEXT_LIMIT && plainText(text) === text
56}
57
58function distinct<T>(items: readonly T[], keyOf: (item: T) => string): T[] {
59 const seen = new Set<string>()
60 return items.filter(item => {
61 const key = keyOf(item)
62 const isNew = !seen.has(key)
63 seen.add(key)
64 return isNew
65 })
66}
67
68/** A line that must speak for its target: a refusal keeps Deny, and a protected or key file keeps Allow away. */
69function isWeighty(sighting: Sighting): boolean {
70 return sighting.reason === 'refused' || (sighting.kind === 'path' && isGuardedPath(sighting.detail))
71}
72
73/**
74 * One sighting per target and counter, however many lines of the call name the target. It keeps the ports of
75 * every line, and it leads with the first weighty line, so the order of the lines does not change the verdict.
76 */
77function combine(sightings: readonly Sighting[]): Sighting[] {
78 const byKey = new Map<string, Sighting>()
79 for (const sighting of sightings) {
80 const key = `${recordKey(sighting)}:${sighting.counter}`
81 const held = byKey.get(key)
82 const lead = held === undefined || (!isWeighty(held) && isWeighty(sighting)) ? sighting : held
83 byKey.set(key, { ...lead, ports: distinct([...(held?.ports ?? []), ...sighting.ports], String) })
84 }
85 return [...byKey.values()]
86}
87
88function capPaths(sightings: readonly Sighting[]): Sighting[] {
89 const unique = combine(sightings)
90 const paths = unique.filter(sighting => sighting.kind === 'path').slice(0, PATH_LIMIT)
91 return unique.filter(sighting => sighting.kind !== 'path' || paths.includes(sighting))
92}
93
94function hostDetail(host: string, port: number | null): string {
95 return port === null ? host : `${host}:${port}`
96}
97
98function hostSighting(text: string, counter: 'blocked' | 'listed', reason: BlockReason | null, reasonText: string, program: string): Sighting {
99 const { host, port } = splitHost(text)
100 return { kind: 'host', target: host, counter, reason, reasonText, ports: port === null ? [] : [port], detail: hostDetail(host, port), program }
101}
102
103function pathSighting(path: string, counter: 'blocked' | 'inferred', home: string | undefined, program: string): Sighting {
104 const reason: BlockReason = counter === 'blocked' ? 'write' : 'inferred'
105 return { kind: 'path', target: folderTarget(path, home), counter, reason, reasonText: '', ports: [], detail: path, program }
106}
107
108function reportLines(sources: readonly string[]): string[] {
109 const lines = sources.flatMap(source => source.split(OPEN_TAG).slice(1).flatMap(part => part.split(CLOSE_TAG, 1).join('').split('\n')))
110 return distinct(lines.map(line => line.trim()).filter(line => line !== ''), line => line)
111}
112
113/**
114 * The report at the end of the text of a failed call: its last block, when only space or Claude Code's proxy note
115 * follows it. The command's output comes before the report, so a block there is output, and the call skips it.
116 */
117function tailReport(text: string): { lines: string[]; skipped: boolean } {
118 const open = text.lastIndexOf(REPORT_OPEN)
119 const close = open < 0 ? -1 : text.indexOf(REPORT_CLOSE, open + REPORT_OPEN.length - 1)
120 const rest = text.slice(close + REPORT_CLOSE.length)
121 const isTail = close >= 0 && (rest.trim() === '' || rest.startsWith(PROXY_NOTE))
122 const lines = isTail ? reportLines([text.slice(open, close + REPORT_CLOSE.length)]) : []
123 return { lines, skipped: (isTail ? text.slice(0, open) : text).includes(OPEN_TAG) }
124}
125
126/**
127 * The start of a report line of an unknown form, to name in a diagnostic, or null when the line does not start like a
128 * report line: output can imitate a report, so such a line can be any text, even a secret.
129 */
130export function reportQuote(line: string): string | null {
131 return REPORT_START.test(line) ? line.slice(0, UNKNOWN_LIMIT) : null
132}
133
134function reportSighting(line: string, home: string | undefined, program: string): Sighting | null {
135 const network = NETWORK.exec(line)
136 if (network !== null) {
137 const [, reported = '', reasonText = ''] = network
138 const reason: BlockReason = /user denied/i.test(reasonText) ? 'refused' : reasonText.includes('allowed_domains') ? 'unlisted' : 'other'
139 return hostSighting(reported, 'blocked', reason, reasonText, program)
140 }
141 const [, path] = MAC_WRITE.exec(line) ?? LINUX_WRITE.exec(line) ?? []
142 return path === undefined ? null : pathSighting(path, 'blocked', home, program)
143}
144
145/** A report line about a file of any operation: the report then speaks for the call's file blocks. */
146function isFileLine(line: string): boolean {
147 return MAC_FILE.test(line) || LINUX_WRITE.test(line)
148}
149
150/** A report line of a form Sandbox Tuner knows: one it counts, or one it knows and ignores (a read, a lookup, a socket). */
151function isKnownLine(line: string): boolean {
152 return NETWORK.test(line) || MAC_LINE.test(line) || LINUX_WRITE.test(line)
153}
154
155/**
156 * The path of a permission error: the last path on the line. When a later path does not parse (it holds a space),
157 * that one may be the path that was blocked, so the line names none rather than an earlier path in its place.
158 */
159function inferredPath(line: string, cwd: string | undefined): string | null {
160 if (!PERMISSION_ERROR.test(line) || SOCKET_WORDS.test(line)) return null
161 const last = Array.from(line.matchAll(PATH_CANDIDATE)).at(-1)
162 if (last === undefined && cwd !== undefined) {
163 const relative = /(?:^|: )['"]?((?:\.\.?\/)?[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)['"]?: (?:Operation not permitted|Read-only file system)$/i.exec(line)?.[1]
164 return relative === undefined ? null : absolutePath(relative, cwd)
165 }
166 if (last === undefined || last.index !== Array.from(line.matchAll(PATH_START)).at(-1)?.index) return null
167 const [, candidate = ''] = last
168 const path = candidate.replace(/\.$/, '')
169 return DEVICE_PATH.test(path) ? null : path
170}
171
172/** The host and write-folder sightings of one Bash call. */
173export function readCall(call: BashCall, outcome: BashOutcome, home: string | undefined, cwd?: string): CallSightings {
174 if (outcome.isDenied || (call.isBackground && !outcome.backgroundTaskId)) return { reports: [], gated: [], unknownLine: null }
175 const ran = !outcome.isError || EXIT_CODE.test(outcome.text)
176 const sources = [outcome.text, outcome.stderr].map(source => source.slice(-SCAN_LIMIT))
177 const program = programName(call.command)
178 // Claude Code writes the report of a call that succeeded to its error output, apart from the command's output in the
179 // text. A failed call has one text: the command's output, then the report, then any proxy note. So only the block at
180 // the end of that text counts, and the command can print one there: it is gated below.
181 const tail = outcome.isError ? tailReport(outcome.text.slice(-SCAN_LIMIT)) : { lines: reportLines([outcome.stderr.slice(-SCAN_LIMIT)]), skipped: false }
182 const { lines } = tail
183 const reports = capPaths(lines.flatMap(line => reportSighting(line, home, program) ?? []))
184 const listed = ran
185 ? combine(call.allowedDomains.filter(entry => !entry.includes('*')).map(entry => hostSighting(entry.trim(), 'listed', null, '', program)))
186 : []
187 if (outcome.backgroundTaskId !== undefined) {
188 return { reports: [], gated: outcome.isError ? [] : listed.filter(fits).map(sighting => ({ gate: 'sandbox-on', sighting })), unknownLine: null }
189 }
190 const isUnsandboxed = outcome.isUnsandboxed ?? call.isUnsandboxed
191 // Without a reported override, strict sandbox mode can have ignored the requested override.
192 const canInfer = ran && !outcome.isReadOnly && outcome.isUnsandboxed !== true && !lines.some(isFileLine)
193 const inferred = canInfer
194 ? capPaths(sources.flatMap(source => source.split('\n')).flatMap(line => {
195 const path = inferredPath(line, cwd)
196 return path === null ? [] : [pathSighting(path, 'inferred', home, program)]
197 }))
198 : []
199 // The command of a failed call can print a block at the end of its text: it counts only while the sandbox ran the call.
200 const reportGate: Gate = isUnsandboxed ? 'sandbox-required' : 'sandbox-on'
201 return {
202 reports: outcome.isError ? [] : reports.filter(fits),
203 gated: [
204 ...(outcome.isError ? reports : []).map((sighting): GatedSighting => ({ gate: reportGate, sighting })),
205 ...listed.map((sighting): GatedSighting => ({ gate: 'sandbox-on', sighting })),
206 ...inferred.map((sighting): GatedSighting => ({ gate: isUnsandboxed ? 'sandbox-required' : 'sandbox-on', sighting })),
207 ].filter(({ sighting }) => fits(sighting)),
208 observation: lines.flatMap(line => {
209 if (reportSighting(line, home, program) !== null) return []
210 const operation = /^\S+\(\d+\) deny\(\d+\) (\S+)/.exec(line)?.[1] ?? 'unknown'
211 return [{ operation, detail: plainText(line.slice(0, UNKNOWN_LIMIT)) }]
212 }).at(-1),
213 unknownLine: lines.find(line => !isKnownLine(line)) ?? null,
214 skipped: tail.skipped || undefined,
215 }
216}
217
218export function keepGated(gated: readonly GatedSighting[], facts: SandboxFacts): Sighting[] {
219 return gated
220 .filter(({ gate }) => facts.isEnabled && (gate === 'sandbox-on' || !facts.allowsUnsandboxed))
221 .map(({ sighting }) => sighting)
222}
223
224export function latestOf(sightings: readonly Sighting[], command: string, now: number): SandboxTunerLatest | null {
225 const block = [...sightings].reverse().find((sighting): sighting is Sighting & { reason: BlockReason } => sighting.reason !== null)
226 return block === undefined
227 ? null
228 : { recordKey: recordKey(block), detail: block.detail, reason: block.reason, reasonText: block.reasonText, command, at: now }
229}
230hooks/verdicts.ts 422 lines1// Verdicts: what each record means against the current settings.
2
3import type { Settings } from 'claude-code'
4
5import type { SandboxTunerAction, SandboxTunerRecord, SandboxTunerSettingsKey } from '../types'
6import { CLAUDE_PROTECTED, PROTECTED_PLACES, absolutePath, cacheFolder, displayPath, folderTarget, hostMatches, isDnsName, isKeyFile, isProtectedName, isTempFolder, isUnder } from './names'
7import { allowCount, sightingTotal } from './records'
8import type { FileInfo } from './settings-file'
9import { isPlainObject, isText } from './shapes'
10import type { SandboxFacts } from './sightings'
11
12export type SourceName = 'user' | 'project' | 'local' | 'flag' | 'policy'
13export type Sources = Readonly<Record<SourceName, Settings>>
14export type Context = {
15 sources: Sources
16 home: string | undefined
17 configDir: string | undefined
18 // Where the protected places and the credentials folders lie: as spelled, or where a press found them.
19 protectedDirs: readonly string[]
20 credentialDirs: readonly string[]
21 pathDirs: readonly string[]
22 // Where /tmp lies: as spelled, or where a press found it. Null at the press of a folder outside /tmp, which keeps the
23 // rule of Sandbox Tuner 1.1.1.
24 tmp: string | null
25 cwd: string
26 root: string
27 threshold: number
28 file: FileInfo | undefined
29 isCanonical: boolean
30}
31export type Change = { action: SandboxTunerAction; settingsKey: SandboxTunerSettingsKey; entry: string; file: FileInfo }
32export type NoFixReason =
33 | 'unverified-path' | 'not-dns' | 'denied-by-settings' | 'managed-lock' | 'protected' | 'credentials' | 'system' | 'too-broad'
34 | 'glob' | 'on-path' | 'inside-workspace' | 'deny-rule' | 'write-pattern' | 'read-rule' | 'read-pattern' | 'not-a-cache' | 'no-settings-file'
35export type Verdict =
36 | { status: 'ready'; allow: Change; deny: Change | null }
37 | { status: 'waiting'; deny: Change | null }
38 | { status: 'covered'; source: SourceName | 'combined' }
39 | { status: 'no-fix'; reason: NoFixReason; detail: string }
40export type Rated = { record: SandboxTunerRecord; verdict: Verdict }
41
42type Entry = { text: string; path: string }
43type DenialEntry = Entry & { reason: 'read-rule' | 'deny-rule' | 'read-pattern' | 'write-pattern' }
44
45const SOURCE_NAMES: readonly SourceName[] = ['user', 'project', 'local', 'flag', 'policy']
46// The sources a repository cannot write: user settings, --settings, and managed settings.
47const TRUSTED_SOURCES: readonly SourceName[] = ['user', 'flag', 'policy']
48const POLICY_ONLY: readonly SourceName[] = ['policy']
49const WEB_FETCH = /^WebFetch\(domain:(.+)\)$/
50const GLOB = /[*?[]/
51const STATUS_RANK: Readonly<Record<Verdict['status'], number>> = { ready: 0, waiting: 1, 'no-fix': 2, covered: 3 }
52// A git or Claude folder outside the working directory, in any letter case: its hooks and settings run outside the sandbox.
53const REPO_FOLDERS: ReadonlySet<string> = new Set(['.git', '.claude'])
54// Under HOME, beside the protected places that any folder can hold: Claude Code's own settings and the global git configuration.
55const HOME_PROTECTED = ['.claude.json', '.config/git']
56const CREDENTIAL_FOLDERS = ['.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.config/gh', '.docker', '.netrc', '.npmrc', '.pypirc', '.git-credentials', '.password-store', '.local/share/keyrings', 'Library/Keychains']
57const SYSTEM_FOLDERS = ['/bin', '/sbin', '/usr', '/etc', '/private/etc', '/System', '/Library', '/Applications', '/opt/homebrew', '/opt/local', '/nix', '/boot', '/lib', '/lib64']
58const READ_BLOCK = 'permissions.blockReadsOutsideWorkingDirectories'
59// The roots that hold user files, which the read block closes to sandboxed commands (settings-reference.md).
60const USER_ROOTS = ['/Users', '/home', '/root', '/Volumes', '/mnt', '/media', '/run/media', '/srv']
61
62function at(value: unknown, path: readonly string[]): unknown {
63 return path.reduce<unknown>((node, name) => (isPlainObject(node) ? node[name] : undefined), value)
64}
65
66function strings(value: unknown): string[] {
67 return Array.isArray(value) ? value.filter(isText) : []
68}
69
70export function sandboxFacts(merged: Settings): SandboxFacts {
71 return {
72 isEnabled: at(merged, ['sandbox', 'enabled']) === true,
73 allowsUnsandboxed: at(merged, ['sandbox', 'allowUnsandboxedCommands']) !== false,
74 }
75}
76
77function isManagedDomainsOnly(sources: Sources): boolean {
78 return at(sources.policy, ['sandbox', 'network', 'allowManagedDomainsOnly']) === true
79}
80
81function isStrict(sources: Sources): boolean {
82 return TRUSTED_SOURCES.some(name => at(sources[name], ['sandbox', 'network', 'strictAllowlist']) === true)
83}
84
85function honoredSources(sources: Sources): readonly SourceName[] {
86 const policyRetry = at(sources.policy, ['sandbox', 'allowUnsandboxedCommands'])
87 const flagRetry = at(sources.flag, ['sandbox', 'allowUnsandboxedCommands'])
88 const isAdminRequired = policyRetry === false || (flagRetry === false && policyRetry !== true) || isManagedDomainsOnly(sources)
89 return isAdminRequired ? TRUSTED_SOURCES : SOURCE_NAMES
90}
91
92/** The sources whose host entries the sandbox honors. */
93function networkAllowSources(sources: Sources): readonly SourceName[] {
94 if (isManagedDomainsOnly(sources)) return POLICY_ONLY
95 return isStrict(sources) ? TRUSTED_SOURCES : honoredSources(sources)
96}
97
98function hostEntries(settings: Settings, list: 'allowedDomains' | 'deniedDomains', rules: 'allow' | 'deny'): string[] {
99 const fromRules = strings(at(settings, ['permissions', rules])).flatMap(rule => {
100 const [, domain] = WEB_FETCH.exec(rule) ?? []
101 return domain === undefined ? [] : [domain]
102 })
103 return [...strings(at(settings, ['sandbox', 'network', list])), ...fromRules]
104}
105
106/** Anchor a settings path without folding `..`: a press follows links before their following segments. */
107export function anchorEntry(text: string, source: SourceName, context: Context): string | null {
108 const entry = text.replace(/\/\*\*$/, '')
109 if (GLOB.test(entry)) return null
110 const isHome = entry === '~' || entry.startsWith('~/')
111 const base = entry.startsWith('/')
112 ? '/'
113 : isHome
114 ? context.home
115 : source === 'user'
116 ? context.configDir
117 : source === 'project' || source === 'local'
118 ? context.root
119 : undefined
120 return base === undefined ? null : entry.startsWith('/') ? entry : `${base}/${isHome ? entry.slice(2) : entry}`
121}
122
123function resolveAll(texts: readonly string[], source: SourceName, context: Context): Entry[] {
124 return texts.flatMap(text => {
125 const path = anchorEntry(text, source, context)
126 return path === null ? [] : [{ text, path: absolutePath(path, '/') }]
127 })
128}
129
130function entriesOf(context: Context, names: readonly SourceName[], path: readonly string[]): Entry[] {
131 return names.flatMap(name => resolveAll(strings(at(context.sources[name], path)), name, context))
132}
133
134/** Anchor the last literal parent of a sandbox pattern, retaining links and their following segments. */
135function sandboxPath(text: string, source: SourceName, context: Context): { path: string; isPattern: boolean } {
136 const entry = text.replace(/\/\*\*$/, '')
137 const wildcard = entry.search(GLOB)
138 const literal = wildcard < 0 ? entry : entry.slice(0, wildcard)
139 const parent = wildcard < 0 ? literal : literal.slice(0, literal.lastIndexOf('/') + 1) || '.'
140 return { path: anchorEntry(parent, source, context) ?? '/', isPattern: wildcard >= 0 }
141}
142
143/** Denial paths, or the last complete literal parent of each pattern, anchored before following links. */
144export function denialEntries(context: Context): DenialEntry[] {
145 const entries = permissionEntries(context)
146 for (const source of SOURCE_NAMES) {
147 for (const list of ['denyWrite', 'denyRead'] as const) {
148 for (const text of strings(at(context.sources[source], ['sandbox', 'filesystem', list]))) {
149 const { path, isPattern } = sandboxPath(text, source, context)
150 const reason = list === 'denyRead' ? (isPattern ? 'read-pattern' : 'read-rule') : (isPattern ? 'write-pattern' : 'deny-rule')
151 entries.push({ text, path, reason })
152 }
153 }
154 }
155 return entries
156}
157
158/** Permission rules use source-relative slash anchors and working-directory relative bare paths. */
159function permissionAnchor(pattern: string, source: SourceName, context: Context): { base: string | undefined; entry: string } {
160 if (pattern.startsWith('//')) return { base: '/', entry: pattern.slice(2) }
161 if (pattern.startsWith('~/')) return { base: context.home, entry: pattern.slice(2) }
162 if (pattern.startsWith('/')) return { base: source === 'user' ? context.configDir : source === 'project' || source === 'local' ? context.root : undefined, entry: pattern.slice(1) }
163 return { base: context.cwd, entry: pattern.replace(/^\.\//, '') }
164}
165
166/** Only a literal subtree can prove coverage of the entire proposed folder. */
167function editGrants(context: Context, source: SourceName): Entry[] {
168 return strings(at(context.sources[source], ['permissions', 'allow'])).flatMap(text => {
169 const pattern = /^Edit\((.+)\/\*\*\)$/.exec(text)?.[1]
170 if (pattern === undefined || /[*?[\\!]/.test(pattern)) return []
171 const { base, entry } = permissionAnchor(pattern, source, context)
172 if (base === undefined) return []
173 return [{ text, path: absolutePath(entry, base) }]
174 })
175}
176
177/** Permission paths have different anchors from sandbox paths. Uncertain patterns cannot justify opening a folder. */
178function permissionEntries(context: Context): DenialEntry[] {
179 const entries: DenialEntry[] = []
180 for (const source of SOURCE_NAMES) {
181 for (const rule of strings(at(context.sources[source], ['permissions', 'deny']))) {
182 const match = /^(Read|Edit)(?:\((.*)\))?$/.exec(rule)
183 if (match === null) continue
184 const isRead = match[1] === 'Read'
185 const pattern = match[2]
186 const uncertainReason = isRead ? 'read-pattern' : 'write-pattern'
187 if (pattern === undefined || pattern === '') {
188 entries.push({ text: rule, path: '/', reason: uncertainReason })
189 continue
190 }
191 const anchored = permissionAnchor(pattern, source, context)
192 const base = anchored.base
193 let entry = anchored.entry
194 if (!pattern.startsWith('/') && !pattern.startsWith('~/') && !entry.replace(/\/\*\*$/, '').includes('/')) entry = `**/${entry}`
195 if (base === undefined) {
196 entries.push({ text: rule, path: '/', reason: uncertainReason })
197 continue
198 }
199 entry = entry.replace(/\/\*\*$/, '')
200 // Escapes and negation need the full gitignore matcher. Keep their entire anchor uncertain.
201 const wildcard = /[\\!]/.test(entry) ? 0 : entry.search(GLOB)
202 const literal = wildcard < 0 ? entry : entry.slice(0, wildcard)
203 const parent = wildcard < 0 ? literal : literal.slice(0, literal.lastIndexOf('/') + 1)
204 entries.push({ text: rule, path: `${base}/${parent}`, reason: wildcard < 0 ? (isRead ? 'read-rule' : 'deny-rule') : uncertainReason })
205 }
206 }
207 return entries
208}
209
210/** Denials apply to both a requested path and its real location. */
211export function denialVerdict(samples: readonly string[], entries: readonly DenialEntry[]): Verdict | null {
212 const denied = entries.find(entry => samples.some(sample => isUnder(absolutePath(sample, '/'), absolutePath(entry.path, '/'))))
213 return denied === undefined ? null : noFix(denied.reason, denied.text)
214}
215
216/** Keep credential paths anchored for link resolution, using literal parents for patterns and `/` for unknown anchors. */
217export function credentialEntries(context: Context): Entry[] {
218 return SOURCE_NAMES.flatMap(name => {
219 const files = at(context.sources[name], ['sandbox', 'credentials', 'files'])
220 const paths = Array.isArray(files) ? files.map(file => at(file, ['path'])) : []
221 return strings(paths).map(text => ({ text, path: sandboxPath(text, name, context).path }))
222 })
223}
224
225/** A folder and each folder above it, up to the root. */
226function foldersUp(folder: string): string[] {
227 const folders = [folder]
228 let at = folder
229 while (at !== '/') {
230 at = at.slice(0, at.lastIndexOf('/')) || '/'
231 folders.push(at)
232 }
233 return folders
234}
235
236/**
237 * The protected places and the credentials folders, as spelled: the config dir and its protected names, Claude Code's
238 * files and the git configuration under HOME, and the protected places in HOME, in each project folder, and in every
239 * folder above a project folder, where Claude Code protects the `.claude` settings too.
240 */
241export function guardedPlaces(home: string | undefined, configDir: string | undefined, projects: readonly string[]): Pick<Context, 'protectedDirs' | 'credentialDirs'> {
242 const under = (folder: string | undefined, names: Iterable<string>) => (folder === undefined ? [] : [...names].map(name => `${folder === '/' ? '' : folder}/${name}`))
243 const places = [
244 ...(configDir === undefined ? [] : [configDir]),
245 ...under(configDir, CLAUDE_PROTECTED),
246 ...under(home, HOME_PROTECTED),
247 ...[home, ...projects.flatMap(foldersUp)].flatMap(folder => under(folder, PROTECTED_PLACES)),
248 ]
249 return { protectedDirs: [...new Set(places)], credentialDirs: under(home, CREDENTIAL_FOLDERS) }
250}
251
252/**
253 * A guarded place that the link check of a folder Allow walks: `levels` is how many levels of folders it lists, the
254 * place itself first (Infinity for every level), and `skip` names the folders it never enters, each as a path from the
255 * place, where `*` stands for any one name.
256 */
257export type Walk = { path: string; levels: number; skip: readonly string[] }
258
259// In the config folder, the walk lists only the places where code or settings that run outside the sandbox live.
260// It skips the data folders, such as transcripts (projects), todos, logs, and file history. Plugin code is too large to
261// list whole, so the walk lists the top two levels of each installed plugin, marketplace, and plugin data folder.
262const CONFIG_WALKS: readonly Walk[] = [
263 // Beside the protected names: the output styles.
264 { path: 'output-styles', levels: Infinity, skip: [] },
265 { path: 'plugins', levels: 1, skip: [] },
266 // <marketplace>/<plugin>/<version>, then two levels of the plugin.
267 { path: 'plugins/cache', levels: 5, skip: [] },
268 // <marketplace>, then two levels of it, without its git clone data.
269 { path: 'plugins/marketplaces', levels: 3, skip: ['*/.git'] },
270 // <plugin>, then two levels of the folder where a plugin keeps its installed dependencies.
271 { path: 'plugins/data', levels: 3, skip: [] },
272 // A local install of Claude Code.
273 { path: 'local', levels: 2, skip: [] },
274]
275
276// VS Code keeps the extensions it installs in the `.vscode` folder of HOME. They are too large to list whole, so the
277// walk lists the rest of that folder whole, and `extensions`: its top level, then two levels of each extension.
278const EDITOR_FOLDER = '.vscode'
279const EXTENSIONS_WALK: Walk = { path: 'extensions', levels: 3, skip: [] }
280
281/** Whether a walk skips the folder at `at`, the names from the walked place down to that folder. */
282export function isSkipped(skip: readonly string[], at: readonly string[]): boolean {
283 return skip.some(entry => {
284 const names = entry.split('/')
285 return names.length === at.length && names.every((name, index) => name === '*' || name === at[index])
286 })
287}
288
289/**
290 * The walks of the link check over guarded places. Each place is walked whole, except two. The config folder: its top
291 * level, its protected names (in `paths` already), and CONFIG_WALKS. The `.vscode` folder of HOME: the rest of it, and
292 * EXTENSIONS_WALK. A `.vscode` folder elsewhere holds settings only, so it is walked whole.
293 */
294export function guardedWalks(paths: readonly string[], { home, configDir }: Pick<Context, 'home' | 'configDir'>): Walk[] {
295 const inside = (folder: string, walk: Walk): Walk => ({ ...walk, path: `${folder === '/' ? '' : folder}/${walk.path}` })
296 const editor = home === undefined ? undefined : `${home === '/' ? '' : home}/${EDITOR_FOLDER}`
297 const walks = paths.map(path => ({ path, levels: path === configDir ? 1 : Infinity, skip: path === editor ? [EXTENSIONS_WALK.path] : [] }))
298 const config = configDir !== undefined && paths.includes(configDir) ? CONFIG_WALKS.map(walk => inside(configDir, walk)) : []
299 const extensions = editor !== undefined && paths.includes(editor) ? [inside(editor, EXTENSIONS_WALK)] : []
300 return [...walks, ...config, ...extensions]
301}
302
303/**
304 * Each folder that Allow opened that a real place lies inside or holds, and which of the two. As the Allow check does,
305 * it compares in any letter case: a file system that ignores case reads two spellings as one place.
306 */
307export function grantsMet<G extends { place: string }>(place: string, grants: readonly G[]): { grant: G; isInside: boolean }[] {
308 const folded = place.toLowerCase()
309 return grants.flatMap(grant => {
310 const opened = grant.place.toLowerCase()
311 const isInside = isUnder(folded, opened)
312 return isInside || isUnder(opened, folded) ? [{ grant, isInside }] : []
313 })
314}
315
316function isProtected(path: string, context: Context): boolean {
317 return context.protectedDirs.some(dir => isUnder(path, dir)) || isProtectedName(path)
318}
319
320function isCredential(record: SandboxTunerRecord, context: Context): boolean {
321 return [record.target, record.sample].some(path => context.credentialDirs.some(dir => isUnder(path, dir))) || isKeyFile(record.sample)
322}
323
324function isTooBroad(target: string, home: string | undefined): boolean {
325 return target.split('/').filter(part => part !== '').length < 2 || (home !== undefined && isUnder(home, target))
326}
327
328function isReadBlocked(path: string, context: Context): boolean {
329 const isOn = SOURCE_NAMES.some(name => at(context.sources[name], ['permissions', 'blockReadsOutsideWorkingDirectories']) === true)
330 const roots = context.home === undefined ? USER_ROOTS : [context.home, ...USER_ROOTS]
331 return isOn && roots.some(root => isUnder(path, root))
332}
333
334function noFix(reason: NoFixReason, detail: string): Verdict {
335 return { status: 'no-fix', reason, detail }
336}
337
338function withFile(context: Context, build: (file: FileInfo) => Verdict): Verdict {
339 return context.file === undefined ? noFix('no-settings-file', '') : build(context.file)
340}
341
342function byCount(record: SandboxTunerRecord, context: Context, allow: Change, deny: Change | null): Verdict {
343 return allowCount(record.counts) < context.threshold ? { status: 'waiting', deny } : { status: 'ready', allow, deny }
344}
345
346function hostVerdict(record: SandboxTunerRecord, context: Context): Verdict {
347 if (!isDnsName(record.target)) return noFix('not-dns', '')
348 const ports = record.ports.length === 0 ? [[]] : record.ports.map(port => [port])
349 const denied = SOURCE_NAMES.flatMap(name => hostEntries(context.sources[name], 'deniedDomains', 'deny')).find(entry => ports.some(port => hostMatches(entry, record.target, port)))
350 if (denied !== undefined) return noFix('denied-by-settings', denied)
351 const allowed = networkAllowSources(context.sources).map(source => ({ source, entries: hostEntries(context.sources[source], 'allowedDomains', 'allow') }))
352 const coverage = ports.map(port => allowed.find(({ entries }) => entries.some(entry => hostMatches(entry, record.target, port)))?.source)
353 const first = coverage[0]
354 if (first !== undefined && coverage.every(source => source !== undefined)) {
355 return { status: 'covered', source: coverage.every(source => source === first) ? first : 'combined' }
356 }
357 if (isManagedDomainsOnly(context.sources)) return noFix('managed-lock', record.target)
358 return withFile(context, file => {
359 // Deny only a host the person refused at the network prompt. Under strictAllowlist the sandbox refuses it already.
360 const canDeny = record.counts.refused > 0 && !isStrict(context.sources)
361 const deny: Change | null = canDeny ? { action: 'deny', settingsKey: 'sandbox.network.deniedDomains', entry: record.target, file } : null
362 return byCount(record, context, { action: 'allow', settingsKey: 'sandbox.network.allowedDomains', entry: record.target, file }, deny)
363 })
364}
365
366function pathVerdict(record: SandboxTunerRecord, context: Context): Verdict {
367 const { target, sample } = record
368 if (isProtected(target, context) || isProtected(sample, context)) return noFix('protected', '')
369 if (isCredential(record, context)) return noFix('credentials', '')
370 if (SYSTEM_FOLDERS.some(folder => isUnder(target, folder))) return noFix('system', '')
371 if (isTooBroad(target, context.home)) return noFix('too-broad', '')
372 if (GLOB.test(target)) return noFix('glob', '')
373 const pathDir = context.pathDirs.map(dir => absolutePath(dir, '/')).find(dir => isUnder(target, dir) || isUnder(dir, target))
374 if (pathDir !== undefined) return noFix('on-path', pathDir)
375 if (isUnder(target, context.cwd)) return noFix('inside-workspace', '')
376 if (target.toLowerCase().split('/').some(part => REPO_FOLDERS.has(part))) return noFix('protected', '')
377 const denied = denialVerdict([sample], denialEntries(context))
378 if (denied !== null) return denied
379 const credentials = credentialEntries(context).map(entry => ({ ...entry, path: absolutePath(entry.path, '/') }))
380 const readRule = credentials.find(entry => isUnder(sample, entry.path))
381 if (readRule !== undefined) return noFix('read-rule', readRule.text)
382 if (isReadBlocked(sample, context)) return noFix('read-rule', READ_BLOCK)
383 const coveredBy = honoredSources(context.sources).find(name =>
384 [...entriesOf(context, [name], ['sandbox', 'filesystem', 'allowWrite']), ...entriesOf(context, [name], ['permissions', 'additionalDirectories']), ...editGrants(context, name)]
385 .some(entry => isUnder(target, entry.path)),
386 )
387 if (coveredBy !== undefined) return { status: 'covered', source: coveredBy }
388 // A folder strictly inside /tmp that holds the denied file is a temp folder: the target that Sandbox Tuner 1.1.1 names.
389 const isTemp = context.tmp !== null && isTempFolder(target, context.home, context.tmp) && folderTarget(sample, context.home) === target
390 if (cacheFolder(sample, context.home) !== target && !isTemp) return noFix('not-a-cache', '')
391 // A temp folder in a system folder in another letter case, which a file system that ignores case treats as the same.
392 if (isTemp && SYSTEM_FOLDERS.some(folder => isUnder(target.toLowerCase(), folder.toLowerCase()))) return noFix('system', '')
393 // Guard the entire folder, including credentials elsewhere inside it.
394 if (context.protectedDirs.some(dir => isUnder(dir, target))) return noFix('protected', '')
395 if ([...context.credentialDirs, ...credentials.map(entry => entry.path)].some(path => isUnder(path, target))) return noFix('credentials', '')
396 if (record.counts.inferred > 0) return noFix('unverified-path', '')
397 return withFile(context, file => byCount(record, context, { action: 'allow', settingsKey: 'sandbox.filesystem.allowWrite', entry: displayPath(target, context.home), file }, null))
398}
399
400export function verdictOf(record: SandboxTunerRecord, context: Context): Verdict {
401 switch (record.kind) {
402 case 'host':
403 return hostVerdict(record, context)
404 case 'path':
405 return pathVerdict(record, context)
406 }
407}
408
409function compareRated(a: Rated, b: Rated): number {
410 const byTarget = a.record.target < b.record.target ? -1 : a.record.target > b.record.target ? 1 : 0
411 return (
412 STATUS_RANK[a.verdict.status] - STATUS_RANK[b.verdict.status] ||
413 sightingTotal(b.record.counts) - sightingTotal(a.record.counts) ||
414 b.record.lastSeen - a.record.lastSeen ||
415 byTarget
416 )
417}
418
419export function rateRecords(records: readonly SandboxTunerRecord[], context: Context): Rated[] {
420 return records.map(record => ({ record, verdict: verdictOf(record, context) })).sort(compareRated)
421}
422hooks/views.tsx 261 lines1// Drawings: the band, the pane, and the text report.
2
3import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, RenderNode, TextProps } from 'claude-code'
4
5import type { SandboxTunerApplied, SandboxTunerLatest, SandboxTunerNotice, SandboxTunerRecord } from '../types'
6import { cutText, plainText } from './names'
7import { isUndoUnresolved } from './records'
8import { fileInfo } from './settings-file'
9import type { Context, Rated, Verdict } from './verdicts'
10import {
11 APPLIED_NEXT, APPLIED_PREVIOUS, APPLY_HINT, NO_BLOCKS, SANDBOX_OFF, STILL_BLOCKED, appliedLine, countsSentence, countsSummary, displayTarget, explanation, lastProblem, moreRows,
12 openedLine, reformatSentence, reportHeadline, settingsHeader, undoSentence, verdictSentences,
13} from './words'
14
15type Kit = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
16type BandModel = { explanation: string; counts: string; sentences: string[]; error: string | null; canAllow: boolean; canDeny: boolean }
17type BandActions = { allow: () => void; deny: () => void; open: () => void; dismiss: () => void }
18
19// Sandbox Tuner's own cap on one problem text, so that a long cause leaves the pane lists their room in TEXT_BUDGET.
20const TEXT_LIMIT = 10_000
21
22/** A problem text that a drawing can show, cut to TEXT_LIMIT: its cause can hold any characters at any length. */
23export function drawable(text: string): string {
24 return cutText(plainText(text), TEXT_LIMIT)
25}
26
27function fixActions(verdict: Verdict): { canAllow: boolean; canDeny: boolean } {
28 return { canAllow: verdict.status === 'ready', canDeny: 'deny' in verdict && verdict.deny !== null }
29}
30
31function displaySentences(record: SandboxTunerRecord, verdict: Verdict, context: Context): string[] {
32 return [...verdictSentences(record, verdict, context), ...(verdict.status === 'covered' ? [STILL_BLOCKED] : [])]
33}
34
35export function buildBand(latest: SandboxTunerLatest, record: SandboxTunerRecord, verdict: Verdict, context: Context, notice: SandboxTunerNotice | null): BandModel {
36 return {
37 ...buildObservation(latest, notice),
38 counts: countsSentence(record),
39 sentences: displaySentences(record, verdict, context),
40 ...fixActions(verdict),
41 }
42}
43
44export function buildObservation(latest: SandboxTunerLatest, notice: SandboxTunerNotice | null): BandModel {
45 return {
46 explanation: explanation(latest), counts: '', sentences: [], canAllow: false, canDeny: false,
47 error: notice !== null && notice.isError && notice.at >= latest.at ? notice.text : null,
48 }
49}
50
51// No band Button has a hotkey: a bare digit typed into an empty prompt would press it (types ButtonProps.hotkey).
52export function bandTree(kit: Kit, model: BandModel, actions: BandActions, beneath: RenderElement): RenderElement {
53 const { Box, Text, Button } = kit
54 return (
55 <Box flexDirection="column">
56 <Box flexDirection="column" paddingX={1}>
57 <Text wrap="wrap">{model.explanation}</Text>
58 <Text dimColor>{model.counts}</Text>
59 {model.sentences.map(sentence => (
60 <Text wrap="wrap">{sentence}</Text>
61 ))}
62 {model.error === null ? null : (
63 <Text color="red" wrap="wrap">
64 {drawable(model.error)}
65 </Text>
66 )}
67 <Box gap={2}>
68 {model.canAllow ? <Button key="band-allow" label="Allow" variant="primary" onPress={actions.allow} /> : null}
69 {model.canDeny ? <Button key="band-deny" label="Deny" onPress={actions.deny} /> : null}
70 <Button key="band-open" label="Open Sandbox Tuner" onPress={actions.open} />
71 <Button key="band-dismiss" label="Dismiss" role="dismiss" onPress={actions.dismiss} />
72 </Box>
73 </Box>
74 {beneath}
75 </Box>
76 )
77}
78
79const ROW_LIMIT = 25
80// The engine refuses a drawing past 100,000 characters of text, keys, and labels, and draws its own in place of the
81// pane. A pane list ends with `+<n> more` past ROW_LIMIT rows, or once its next row would pass this budget.
82const TEXT_BUDGET = 90_000
83
84const SECTIONS = [
85 { kind: 'host', title: 'Hosts' },
86 { kind: 'path', title: 'Write folders' },
87] as const
88
89type RecordRow = { key: string; target: string; summary: string; sentences: string[]; canAllow: boolean; canDeny: boolean; canHide: boolean }
90type PaneSection = { title: string; rows: RecordRow[] }
91type AppliedRow = { id: string; line: string; undo: string | null; retry: boolean }
92
93// `reformatted`: the settings files of the journal that are not in the 2-space JSON format, so an Undo re-formats them.
94type PaneInput = { rated: readonly Rated[]; applied: readonly SandboxTunerApplied[]; reformatted: readonly string[]; appliedOffset: number; notice: SandboxTunerNotice | null; fault: string | null; isSandboxOn: boolean; context: Context; now: number }
95type PaneModel = { header: string; isSandboxOn: boolean; notice: SandboxTunerNotice | null; fault: string | null; sections: PaneSection[]; hidden: { key: string; target: string }[]; applied: AppliedRow[]; appliedOffset: number }
96type PaneActions = { allow: (key: string) => void; deny: (key: string) => void; hide: (key: string) => void; unhide: (key: string) => void; undo: (id: string) => void; moveApplied: (delta: number) => void }
97
98function visible(rated: readonly Rated[]): Rated[] {
99 return rated.filter(({ record }) => !record.isHidden)
100}
101
102/** A journal entry whose row offers Undo: a change still in place, or a removal not yet confirmed. */
103export function canUndo(entry: SandboxTunerApplied): boolean {
104 return entry.undoneAt === null || isUndoUnresolved(entry)
105}
106
107function recordRow({ record, verdict }: Rated, context: Context, now: number): RecordRow {
108 return {
109 key: record.key,
110 target: displayTarget(record, context.home),
111 summary: countsSummary(record, now),
112 sentences: displaySentences(record, verdict, context),
113 ...fixActions(verdict),
114 canHide: verdict.status !== 'covered',
115 }
116}
117
118export function buildPane(input: PaneInput): PaneModel {
119 const { context, now } = input
120 const shown = visible(input.rated)
121 return {
122 header: settingsHeader(context.file),
123 isSandboxOn: input.isSandboxOn,
124 notice: input.notice,
125 fault: input.fault,
126 appliedOffset: Math.max(0, Math.min(input.appliedOffset, input.applied.length - 1)),
127 sections: SECTIONS.map(({ kind, title }) => ({
128 title,
129 rows: shown.filter(({ record }) => record.kind === kind).map(rated => recordRow(rated, context, now)),
130 })).filter(section => section.rows.length > 0),
131 hidden: input.rated.filter(({ record }) => record.isHidden).map(({ record }) => ({ key: record.key, target: displayTarget(record, context.home) })),
132 applied: [...input.applied].reverse().map(entry => {
133 const file = fileInfo(entry.file, context.home)
134 const reformat = input.reformatted.includes(entry.file) ? [reformatSentence(file)] : []
135 return {
136 id: entry.id,
137 line: appliedLine(entry, now),
138 retry: isUndoUnresolved(entry),
139 undo: canUndo(entry) ? [undoSentence(entry, file), ...reformat].join(' ') : null,
140 }
141 }),
142 }
143}
144
145/** The characters of a drawing that count against the engine's text bound: its text, keys, and labels. */
146function textSize(node: RenderNode): number {
147 if (typeof node === 'string') return node.length
148 const props: Readonly<Record<string, unknown>> = 'props' in node ? (node.props ?? {}) : {}
149 const own = [props.key, props.label].reduce<number>((sum, value) => sum + (typeof value === 'string' ? value.length : 0), 0)
150 return 'children' in node ? (node.children ?? []).reduce((sum, child) => sum + textSize(child), own) : own
151}
152
153export function paneTree(kit: Kit, model: PaneModel, actions: PaneActions): RenderElement {
154 const { Box, Text, Button } = kit
155 const head = (
156 <Box flexDirection="column">
157 <Text bold>{model.header}</Text>
158 {model.isSandboxOn ? null : <Text color="yellow" wrap="wrap">{SANDBOX_OFF}</Text>}
159 {model.notice === null ? null : <Text color={model.notice.isError ? 'red' : 'green'} wrap="wrap">{drawable(model.notice.text)}</Text>}
160 {model.fault === null ? null : <Text color="red" wrap="wrap">{drawable(lastProblem(model.fault))}</Text>}
161 </Box>
162 )
163 let room = TEXT_BUDGET - textSize(head)
164 // Draws the rows that fit, in order, and counts the others as `+<n> more`.
165 const list = <T,>(key: string, title: string, rows: readonly T[], draw: (row: T) => RenderElement, offset = 0, move?: (delta: number) => void): RenderElement => {
166 const drawn: RenderElement[] = []
167 for (const row of rows.slice(offset, offset + ROW_LIMIT)) {
168 const element = draw(row)
169 const size = textSize(element)
170 if (size > room) break
171 room -= size
172 drawn.push(element)
173 }
174 return (
175 <Box key={key} flexDirection="column">
176 <Text bold>{title}</Text>
177 {drawn}
178 {rows.length > offset + drawn.length ? <Text dimColor>{moreRows(rows.length - offset - drawn.length)}</Text> : null}
179 {move === undefined ? null : (
180 <Box gap={2}>
181 {offset > 0 ? <Button key="applied-previous" label={APPLIED_PREVIOUS} onPress={() => move(-Math.min(offset, ROW_LIMIT))} /> : null}
182 {offset + drawn.length < rows.length ? <Button key="applied-next" label={APPLIED_NEXT} onPress={() => move(drawn.length)} /> : null}
183 </Box>
184 )}
185 </Box>
186 )
187 }
188 // Undo rows take their room first: they are the way back from every write.
189 const applied = model.applied.length === 0 ? null : list('applied', 'Applied', model.applied, row => (
190 <Box key={`applied:${row.id}`} flexDirection="column">
191 <Text>{row.line}</Text>
192 {row.undo === null ? null : (
193 <Box gap={2}>
194 <Text dimColor wrap="wrap">{row.undo}</Text>
195 <Button key={`undo:${row.id}`} label={row.retry ? 'Retry Undo' : 'Undo'} onPress={() => actions.undo(row.id)} />
196 </Box>
197 )}
198 </Box>
199 ), model.appliedOffset, actions.moveApplied)
200 const sections = model.sections.map(section => list(`section:${section.title}`, section.title, section.rows, row => (
201 <Box key={`row:${row.key}`} flexDirection="column" marginTop={1}>
202 <Text wrap="truncate-end">
203 <Text bold>{row.target}</Text>
204 <Text dimColor>{` ${row.summary}`}</Text>
205 </Text>
206 {row.sentences.map(sentence => (
207 <Text wrap="wrap">{sentence}</Text>
208 ))}
209 <Box gap={2}>
210 {row.canAllow ? <Button key={`allow:${row.key}`} label="Allow" variant="primary" onPress={() => actions.allow(row.key)} /> : null}
211 {row.canDeny ? <Button key={`deny:${row.key}`} label="Deny" onPress={() => actions.deny(row.key)} /> : null}
212 {row.canHide ? <Button key={`hide:${row.key}`} label="Hide" onPress={() => actions.hide(row.key)} /> : null}
213 </Box>
214 </Box>
215 )))
216 const hidden = model.hidden.length === 0 ? null : list('hidden', 'Hidden', model.hidden, row => (
217 <Box key={`hidden:${row.key}`} gap={2}>
218 <Text>{row.target}</Text>
219 <Button key={`unhide:${row.key}`} label="Unhide" onPress={() => actions.unhide(row.key)} />
220 </Box>
221 ))
222 const isEmpty = model.sections.length === 0 && model.hidden.length === 0 && model.applied.length === 0
223 return (
224 <Box flexDirection="column" gap={1}>
225 {head}
226 {isEmpty ? <Text dimColor>{NO_BLOCKS}</Text> : null}
227 {sections}
228 {hidden}
229 {applied}
230 </Box>
231 )
232}
233
234function statusCount(rated: readonly Rated[], status: Verdict['status']): number {
235 return rated.filter(({ verdict }) => verdict.status === status).length
236}
237
238export function reportText(rated: readonly Rated[], context: Context, isSandboxOn: boolean, now: number): string {
239 if (rated.length === 0) return NO_BLOCKS
240 const shown = visible(rated)
241 const lines = [
242 reportHeadline({ ready: statusCount(shown, 'ready'), waiting: statusCount(shown, 'waiting'), 'no-fix': statusCount(shown, 'no-fix'), covered: statusCount(shown, 'covered') }),
243 settingsHeader(context.file),
244 ...(isSandboxOn ? [] : [SANDBOX_OFF]),
245 ]
246 for (const { kind, title } of SECTIONS) {
247 const rows = shown.filter(({ record }) => record.kind === kind).slice(0, ROW_LIMIT)
248 if (rows.length === 0) continue
249 lines.push('', title)
250 for (const { record, verdict } of rows) {
251 lines.push(`- ${displayTarget(record, context.home)}: ${countsSummary(record, now)}`, ...displaySentences(record, verdict, context).map(sentence => ` ${sentence}`))
252 }
253 }
254 return [...lines, '', APPLY_HINT].join('\n')
255}
256
257export function summaryLine(rated: readonly Rated[]): string {
258 const shown = visible(rated)
259 return openedLine(statusCount(shown, 'ready'), shown.length)
260}
261hooks/words.ts 255 lines1// The sentences of the band, the pane, the report, the command, and the notices.
2
3import type { SandboxTunerApplied, SandboxTunerLatest, SandboxTunerRecord, SandboxTunerSettingsKey } from '../types'
4import { cutText, displayPath, isSharedFolder, plainText } from './names'
5import { allowCount, sightingTotal } from './records'
6import type { FileInfo, Refusal } from './settings-file'
7import type { Change, Context, NoFixReason, SourceName, Verdict } from './verdicts'
8
9const MINUTE = 60_000
10// A settings entry that a sentence quotes can come from a cloned project: a long one is cut.
11const QUOTE_LIMIT = 200
12const HOUR = 60 * MINUTE
13const DAY = 24 * HOUR
14
15export const NO_BLOCKS = 'Sandbox Tuner has not seen a sandbox block yet.'
16export const SANDBOX_OFF = 'The sandbox is off in this session. Sandbox Tuner has nothing to tune.'
17const NO_SETTINGS_FILE = 'Sandbox Tuner cannot find your user settings file because HOME and CLAUDE_CONFIG_DIR are not set.'
18export const NOTHING_TO_UNDO = 'Nothing to undo.'
19export const NOTHING_TO_CHANGE = 'Nothing to change: the target is no longer listed.'
20export const APPLY_HINT = 'To apply a fix, run /sandbox-tuner in an interactive session and press Allow or Deny.'
21export const COMMAND_DESCRIPTION = 'Review the hosts and write folders the sandbox keeps blocking.'
22export const STILL_BLOCKED = 'If the sandbox still blocks it, the cause is elsewhere, such as a tool that ignores the sandbox proxy, a read block, or file permissions. Check the client’s proxy settings and supported protocols. Run /status or /doctor for managed-settings warnings.'
23
24const SHARED_FOLDER = 'Every app you run keeps files in this folder, so sandboxed commands could change files that other apps read.'
25const SOURCE_LABELS: Readonly<Record<SourceName | 'combined', string>> = { user: 'user', project: 'project', local: 'local project', flag: '--settings', policy: 'managed', combined: 'combined' }
26const CHANGE_EFFECTS: Readonly<Record<SandboxTunerSettingsKey, string>> = {
27 'sandbox.network.allowedDomains': 'Sandboxed commands in every project can then reach it on any port, with no prompt and no per-command list, and can send it data.',
28 'sandbox.network.deniedDomains': 'The sandbox then refuses the host in every project, without a prompt.',
29 'sandbox.filesystem.allowWrite': 'Sandboxed commands can then write anywhere under it, and programs that run outside the sandbox may later load what they wrote.',
30}
31const NO_FIX: Readonly<Record<NoFixReason, (detail: string, target: string) => string>> = {
32 'unverified-path': () => 'This folder includes an unverified path inferred from error text. Punctuation can hide protected filenames. Sandbox Tuner cannot offer Allow.',
33 'not-dns': () => 'Sandbox Tuner does not suggest IP addresses or local host names.',
34 'denied-by-settings': detail => `Your setting "${detail}" already refuses this host.`,
35 'managed-lock': (_, target) => `Your organization's managed settings lock the network allowlist. Ask your administrator to allow ${target}.`,
36 protected: () =>
37 "Sandbox Tuner never opens this path to writes, because settings or code in it can run outside the sandbox. No setting opens such a path in your working directory or in Claude Code's config folder. If you expected this write, approve an unsandboxed retry or run the command yourself.",
38 credentials: () => 'This folder holds credentials. Sandbox Tuner never opens it to writes.',
39 system: () => 'This is a system or software-install folder. Sandbox Tuner never opens it to writes.',
40 'too-broad': (_, target) => `${target} is too broad to open to writes.`,
41 glob: (_, target) => `${target} holds *, ?, or [, which the sandbox reads as a pattern, so Sandbox Tuner cannot name it as a folder.`,
42 'on-path': (detail, target) => `${target} holds or sits inside ${detail}, a folder on your PATH, so a program written there could run in place of one you call.`,
43 'inside-workspace': () => 'Sandboxed commands can already write here. The block came from a protected file, a denyWrite rule, or a cause outside the sandbox.',
44 'deny-rule': detail => `Your write deny rule "${detail}" blocks it.`,
45 'write-pattern': detail => `Your write deny pattern "${detail}" may cover this path. Sandbox Tuner cannot safely suggest write access for this block.`,
46 'read-rule': detail => `Your read rule "${detail}" covers this path, so the block may be a read. Sandbox Tuner does not tune reads.`,
47 'read-pattern': detail => `Your read pattern "${detail}" may cover this path. Sandbox Tuner cannot safely suggest write access for this block.`,
48 'not-a-cache': () =>
49 'Sandbox Tuner suggests only cache and temp folders, because files in other folders can set up or start programs that run outside the sandbox. If you expected this write, approve an unsandboxed retry or run the command yourself.',
50 'no-settings-file': () => NO_SETTINGS_FILE,
51}
52
53function plural(count: number, one: string, many = `${one}s`): string {
54 return `${count} ${count === 1 ? one : many}`
55}
56
57function age(elapsedMs: number): string {
58 if (elapsedMs < MINUTE) return 'just now'
59 if (elapsedMs < HOUR) return `${Math.floor(elapsedMs / MINUTE)} min ago`
60 if (elapsedMs < DAY) return `${Math.floor(elapsedMs / HOUR)} h ago`
61 return `${Math.floor(elapsedMs / DAY)} d ago`
62}
63
64export function displayTarget(record: SandboxTunerRecord, home: string | undefined): string {
65 return record.kind === 'path' ? displayPath(record.target, home) : record.target
66}
67
68export function explanation(latest: SandboxTunerLatest): string {
69 const blocked = `Sandbox blocked ${latest.detail} for "${latest.command}".`
70 switch (latest.reason) {
71 case 'unlisted':
72 return `${blocked} The host is not on your sandbox allowlist, so Claude must list it on each command.`
73 case 'refused':
74 return `${blocked} You denied this host at the network prompt.`
75 case 'other':
76 return `${blocked} Reason: ${latest.reasonText}.`
77 case 'write':
78 return `Sandbox blocked a write to ${latest.detail} for "${latest.command}".`
79 case 'operation': {
80 const operation = latest.reasonText
81 const label = operation.startsWith('file-read-') ? 'a file read' : operation === 'mach-lookup' ? 'a Mach service lookup' : /socket|network-(?:bind|inbound)/.test(operation) ? 'a socket operation' : null
82 return label === null
83 ? `Sandbox Tuner cannot classify this sandbox operation: ${latest.detail}. No Allow action is available.`
84 : `The sandbox reported ${label} for "${latest.command}": ${latest.detail}. A write-folder grant cannot repair this operation. No Allow action is available.`
85 }
86 case 'inferred':
87 return `"${latest.command}" failed with a permission error on ${latest.detail}. The path and sandbox cause are unverified.`
88 }
89}
90
91export function countsSentence(record: SandboxTunerRecord): string {
92 return `Seen ${plural(sightingTotal(record.counts), 'time')} in ${plural(record.sessions.length, 'session')}.`
93}
94
95export function countsSummary(record: SandboxTunerRecord, now: number): string {
96 const { blocked, refused, listed, inferred } = record.counts
97 const head =
98 record.kind === 'path'
99 ? `blocked ${blocked}${inferred > 0 ? ` · from error text ${inferred}` : ''}`
100 : `blocked ${blocked}${refused > 0 ? ` (you denied ${refused})` : ''}${listed > 0 ? ` · listed on ${plural(listed, 'command')}` : ''}`
101 const programs = record.seenWith.length > 0 ? ` · with ${record.seenWith.join(', ')}` : ''
102 const ports = record.ports.length > 0 ? ` · ports ${record.ports.join(', ')}` : ''
103 return `${head} · ${plural(record.sessions.length, 'session')} · last ${age(now - record.lastSeen)}${programs}${ports}`
104}
105
106function changeSentence(change: Change): string {
107 const verb = change.action === 'allow' ? 'Allow' : 'Deny'
108 const shared = isSharedFolder(change.entry) ? ` ${SHARED_FOLDER}` : ''
109 return `${verb} adds "${change.entry}" to ${change.settingsKey} in ${change.file.display}. ${CHANGE_EFFECTS[change.settingsKey]}${shared}`
110}
111
112function refusalNote(record: SandboxTunerRecord): string[] {
113 return record.counts.refused > 0 ? [`You denied this host ${plural(record.counts.refused, 'time')} at the network prompt.`] : []
114}
115
116function statusSentences(record: SandboxTunerRecord, verdict: Verdict, context: Context): string[] {
117 switch (verdict.status) {
118 case 'ready':
119 return refusalNote(record)
120 case 'waiting':
121 return [...refusalNote(record), `Sandbox Tuner offers Allow after ${plural(context.threshold, 'sighting')} (${allowCount(record.counts)} so far).`]
122 case 'covered':
123 return [`Your ${SOURCE_LABELS[verdict.source]} settings already allow it.`]
124 case 'no-fix':
125 return [NO_FIX[verdict.reason](cutText(plainText(displayPath(verdict.detail, context.home)), QUOTE_LIMIT), displayTarget(record, context.home))]
126 }
127}
128
129/** The warning before a press saves a file that is not in the 2-space JSON format that Claude Code writes. */
130export function reformatSentence(file: FileInfo): string {
131 return `Saving also re-formats ${file.display} as 2-space JSON.`
132}
133
134export function verdictSentences(record: SandboxTunerRecord, verdict: Verdict, context: Context): string[] {
135 const changes = [verdict.status === 'ready' ? verdict.allow : null, 'deny' in verdict ? verdict.deny : null].filter((change): change is Change => change !== null)
136 const reformat = context.isCanonical ? [] : changes.slice(0, 1).map(change => reformatSentence(change.file))
137 return [...statusSentences(record, verdict, context), ...changes.map(changeSentence), ...reformat]
138}
139
140export function addedNotice(change: Change, hadFile: boolean): string {
141 const previous = hadFile ? `The previous file is saved at ${change.file.backupDisplay}.` : 'The file is new.'
142 return `Added "${change.entry}" to ${change.settingsKey} in ${change.file.display}. ${previous}`
143}
144
145export function removedNotice(entry: SandboxTunerApplied, file: FileInfo): string {
146 return `Removed "${entry.entry}" from ${entry.settingsKey} in ${file.display}. The previous file is saved at ${file.backupDisplay}.`
147}
148
149/** The cause when the file holds the entry, but the settings that Claude Code loaded do not. */
150export function notAppliedText(change: Change): string {
151 return `"${change.entry}" is already in ${change.settingsKey} in ${change.file.display}, but Claude Code has not applied it yet. If this stays, run /status to check that the session loads user settings, and look for a ConfigChange hook that blocks the change. Nothing was written.`
152}
153
154export function absentNotice(entry: SandboxTunerApplied): string {
155 return `"${entry.entry}" is no longer in ${entry.settingsKey}. Marked as undone.`
156}
157
158export function hiddenNotice(target: string): string {
159 return `${target} is hidden. Find it under Hidden.`
160}
161
162export function shownNotice(target: string): string {
163 return `${target} is shown again.`
164}
165
166export function nothingApplied(sentences: readonly string[]): string {
167 return `Nothing applied: ${sentences.join(' ')}`
168}
169
170function refusalCause(refusal: Refusal, file: FileInfo): string {
171 return refusal.refusal === 'invalid-json'
172 ? `${file.display} is not valid JSON (${refusal.message}).`
173 : `${refusal.path} in ${file.display} is ${refusal.found}, not ${refusal.expected}.`
174}
175
176export function refusalText(refusal: Refusal, file: FileInfo): string {
177 return `${refusalCause(refusal, file)} Nothing was written.${refusal.refusal === 'invalid-json' ? ' Fix the file, then press again.' : ''}`
178}
179
180export function verificationRefusalText(refusal: Refusal, file: FileInfo): string {
181 return `could not check ${file.display} after the write: ${refusalCause(refusal, file)} Check the file before trying again.`
182}
183
184export function appliedLine(entry: SandboxTunerApplied, now: number): string {
185 const undone = entry.applyState === 'pending' ? ', addition unconfirmed' : entry.undoState === 'superseded' ? ', a later change owns this entry' : entry.undoState === 'pending' || entry.undoState === 'retry' ? ', removal unconfirmed' : entry.undoneAt === null ? '' : `, undone ${age(now - entry.undoneAt)}`
186 return `${entry.entry} in ${entry.settingsKey}, ${age(now - entry.appliedAt)}${undone}`
187}
188
189export function undoSentence(entry: SandboxTunerApplied, file: FileInfo): string {
190 return `Undo removes "${entry.entry}" from ${entry.settingsKey} in ${file.display}.`
191}
192
193export function settingsHeader(file: FileInfo | undefined): string {
194 return file === undefined ? NO_SETTINGS_FILE : `Settings file: ${file.display}`
195}
196
197/** `a`, `a and b`, or `a, b, and c`. */
198function series(items: readonly string[]): string {
199 return items.length < 3 ? items.join(' and ') : `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`
200}
201
202/**
203 * A folder that Allow opened, as a warning of the link check at session start names it: whether a row of the pane can
204 * undo it, and each settings file that holds it with no such row, where only a hand edit removes it.
205 */
206type MetGrant = { grant: string; isInside: boolean; hasUndo: boolean; byHand: readonly string[] }
207
208/**
209 * The warning of the link check at session start: a link or a guarded place meets folders that Allow opened. It names
210 * each of them, because the exposure stays until each one is removed: with Undo where the pane offers it, else by hand.
211 */
212export function grantWarning(path: string, place: string, met: readonly MetGrant[]): string {
213 const isLink = path !== place
214 const subject = isLink ? `${path} leads to ${place}, which` : path
215 const quoted = (items: readonly MetGrant[]) => items.map(item => `"${item.grant}"`)
216 const relations = [[quoted(met.filter(item => item.isInside)), 'lies inside'], [quoted(met.filter(item => !item.isInside)), 'holds']] as const
217 const relation = relations.filter(([grants]) => grants.length > 0).map(([grants, verb]) => `${verb} ${series(grants)}`).join(' and ')
218 const folders = met.length === 1 ? 'a folder' : 'folders'
219 const undo = met.filter(item => item.hasUndo).map(item => `for "${item.grant}"`)
220 const steps = [
221 ...(undo.length > 0 ? [`press Undo ${series(undo)} in /sandbox-tuner`] : []),
222 ...[...new Set(met.flatMap(item => item.byHand))].map(file =>
223 `remove ${series(quoted(met.filter(item => item.byHand.includes(file))))} from sandbox.filesystem.allowWrite in ${file} by hand`),
224 ]
225 return `${subject} ${relation}, ${folders} that your settings open to sandboxed writes. So sandboxed commands can change what ${isLink ? 'the link' : 'it'} names. To close this, ${series(steps)}${isLink ? ', or remove the link' : ''}.`
226}
227
228export function lastProblem(fault: string): string {
229 return `Last background problem: ${fault}`
230}
231
232export function moreRows(count: number): string {
233 return `+${count} more`
234}
235
236export const APPLIED_PREVIOUS = 'Newer changes'
237export const APPLIED_NEXT = 'Older changes'
238
239export function reportHeadline(counts: Readonly<Record<Verdict['status'], number>>): string {
240 return `${plural(counts.ready, 'fix', 'fixes')} ready, ${counts.waiting} waiting, ${counts['no-fix']} without a settings fix, ${counts.covered} already allowed.`
241}
242
243export function openedLine(ready: number, targets: number): string {
244 return `${plural(ready, 'fix', 'fixes')} ready, ${plural(targets, 'target')}. The pane is open.`
245}
246
247export function notPlaced(reason: string): string {
248 return `The pane is not placed: ${reason}`
249}
250
251/** Command results and toasts already carry the plugin name. */
252export function hostText(text: string): string {
253 return text.replace(/^sandbox-tuner: /gm, '').replace(/^Sandbox Tuner has not seen/, 'Has not seen')
254}
255hooks/shapes.ts 18 lines1// JSON shape checks shared by saved records, the journal, and settings edits.
2
3export function isText(value: unknown): value is string {
4 return typeof value === 'string'
5}
6
7export function isCount(value: unknown): value is number {
8 return typeof value === 'number' && Number.isInteger(value) && value >= 0
9}
10
11export function isPlainObject(value: unknown): value is Record<string, unknown> {
12 return typeof value === 'object' && value !== null && !Array.isArray(value)
13}
14
15export function isListOf<T>(value: unknown, isItem: (item: unknown) => item is T): value is T[] {
16 return Array.isArray(value) && value.every(isItem)
17}
18types/index.d.ts 72 lines1export type SandboxTunerKind = 'host' | 'path'
2
3export type SandboxTunerCounts = {
4 blocked: number // reports of a block (network, or file write)
5 refused: number // host reports with reason "user denied"; part of blocked
6 listed: number // runs that named the host in allowed_domains
7 inferred: number // inferred blocks of a folder
8}
9
10export type SandboxTunerRecord = {
11 key: string // `${kind}:${target}`
12 kind: SandboxTunerKind
13 target: string // host (lowercase) or absolute folder
14 counts: SandboxTunerCounts
15 ports: number[] // hosts: distinct ports, newest last, at most 5
16 sample: string // the last host:port or absolute path seen; a folder keeps a protected or key file
17 seenWith: string[] // program names, newest last, at most 3
18 sessions: string[] // session ids, newest last, at most 20
19 firstSeen: number
20 lastSeen: number
21 isHidden: boolean
22}
23
24export type SandboxTunerAction = 'allow' | 'deny'
25
26export type SandboxTunerSettingsKey =
27 | 'sandbox.network.allowedDomains'
28 | 'sandbox.network.deniedDomains'
29 | 'sandbox.filesystem.allowWrite'
30
31export type SandboxTunerApplied = {
32 id: string // unique identifier for one settings addition
33 recordKey: string
34 action: SandboxTunerAction
35 settingsKey: SandboxTunerSettingsKey
36 entry: string
37 file: string // absolute path of the user settings file
38 backup: string | null // absolute path of the backup; null when the file was new
39 created: string[] // dotted key paths that the apply created, outermost first
40 appliedAt: number
41 undoneAt: number | null
42 applyState?: 'pending'
43 undoState?: 'pending' | 'retry' | 'superseded'
44}
45
46export type SandboxTunerLatest = {
47 recordKey: string
48 detail: string // host:port, or the absolute path
49 reason: 'unlisted' | 'refused' | 'other' | 'write' | 'inferred' | 'operation'
50 reasonText: string // the report's reason in parentheses; '' when none
51 command: string // one line, at most 120 characters
52 at: number
53}
54
55export type SandboxTunerNotice = { text: string; isError: boolean; at: number }
56
57declare module 'claude-code' {
58 interface PluginState {
59 'sandbox-tuner': {
60 backgroundTasks: string[]
61 evidenceIncomplete: boolean
62 records: SandboxTunerRecord[]
63 applied: SandboxTunerApplied[]
64 appliedOffset: number
65 latest: SandboxTunerLatest | null
66 dismissed: string | null
67 notice: SandboxTunerNotice | null
68 fault: string | null
69 }
70 }
71}
72