SLOPSHOPPER

sandbox-tuner

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

newpanebandguardcommandtoast
v1.1.2MITupdated 2026-10-14dot-agi/sandbox-tuner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sandbox-tuner
│ ┃ Sandbox Tuner ✕ › fix the failing auth test and add an audit log call │ ┃ Settings file: ~/.claude/settings.json │ ┃ The sandbox is off in this session. Sandbox ● sandbox-tuner: sandbox-tuner: tool.call (Bash): hook failed (throw) │ ┃ Tuner has nothing to tune. ● sandbox-tuner: sandbox-tuner: tool.call (Bash): hook failed (throw) │ ┃ ⏺ Read(src/auth.ts) │ ┃ Sandbox Tuner has not seen a sandbox block ⎿ Read 6 lines │ ┃ yet. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sandbox-tuner │ ⎿ sandbox-tuner: 0 fixes ready, 0 targets. The pane is open. │ ● sandbox-tuner: sandbox-tuner: tool.call (Bash): hook failed (throw) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sandbox Tuner
Settings file: ~/.claude/settings.json The sandbox is off in this session. Sandbox Tuner has nothing to tune. Sandbox Tuner has not seen a sandbox block yet.
README

<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">

Terminal: a sandboxed curl to example.org fails. The band names the block, counts the sightings, states the exact settings change, and shows Allow.

After a repeated network block, the band explains it and offers Allow.

Why

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.

Features

  • An explanation above the prompt. The band names the blocked target, the command, and the reason, in the terminal and the desktop app.
  • A ranking across sessions. /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.
  • A change you review. Allow shows the exact entry and its effect before you press it. Deny appears for a host that you refused at the network prompt, unless an allowlist lock applies.
  • Undo and a backup. Each settings edit gets an Undo record. A backup keeps the settings file as it was before the latest write.
  • Narrow fixes. Folder grants go only to eligible cache and temp folders. A folder strictly inside /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.

The Sandbox Tuner pane beside the transcript: example.org is ready with Allow and Hide, example.net waits at 1 of 3 sightings.

/sandbox-tuner ranks blocked hosts: a ready fix first, then waiting ones.

Terminal: a write to .git/hooks fails in the sandbox, and the band explains that no setting opens such a path.

Some blocks have no safe setting. Sandbox Tuner says so.

How it works

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]
  • A sighting is a reported block, a host that Claude lists in a command's 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.
  • Each press of Allow, Deny, or Undo changes one entry in sandbox.network.allowedDomains, sandbox.network.deniedDomains, or sandbox.filesystem.allowWrite of your user settings file: ~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json.
  • Grants apply to every project. An allowed host can receive data on any port. Programs outside the sandbox may later load files written into an allowed folder.
  • Each grant respects managed settings, and is checked against the loaded settings and a fresh read of the file. Folder checks follow symbolic links and refuse protected paths, credentials, system folders, and folders on PATH.
  • Before a write, Sandbox Tuner takes a lock file next to the settings file, so that presses in other sessions wait their turn. Under the lock, it reads the file, the sightings, and the Undo journal again, checks the press again, and applies it to the current text or refuses. Then the previous file goes to 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.
  • Undo removes only the entry that its press added. While a removal is unconfirmed, its row in the Applied list keeps Retry Undo. The usage guide has the full rules and the recovery steps.

Install

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

Agents and scripts

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
  • On a host with no SSH key for GitHub, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session.
  • New code loads in the next session, or after /reload-plugins. An agent cannot type /reload-plugins, so it starts a new session.
  • Running install again does not update. Use update.
  • The mv replaces the settings file, with mode 600. If settings.json is a symbolic link, edit its target instead.

Commands

CommandWhat it does
/sandbox-tunerOpens 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.

Options

OptionDefaultRangeMeaning
minimumSightings31–100How 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.
linkCheckMinutes150–1440How 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.

How it compares

Feature or projectWhat it doesWhat Sandbox Tuner adds
Built-in /sandboxConfigures Claude Code's sandbox and its operating mode.Explanations for observed blocks, counts across sessions, and reviewed edits with Undo.
Built-in network prompt approvalsApproves hosts for the current session.Persistent user-settings entries after you review a recurring target. Prompt approvals themselves are not counted.
Bash allowed_domainsLets 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 hookAutomatically 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.

Known limits

What it catches

  • Sources. Network and write blocks in Bash results, and the hosts that Claude lists in allowed_domains, across sessions.
  • Fixes. The band explains each block that it recognizes, also a block that no setting fixes.
  • Links. Links between a folder that Allow opens and protected, credentials, or PATH folders: before Allow, then at each session start and every 15 minutes by default.
  • Fake reports. It skips a sandbox report that more output follows, because a command printed it.

What it does not catch

  • Sources. Blocks in the later output of background commands and shell Monitors.
  • Fixes. No fix for read, Mach lookup, or socket blocks, for paths taken from error text, or for tools that ignore the sandbox proxy.
  • Links. Hard links, links in skipped or deep folders, and links made since the last check.
  • Fake reports. A fake report that a failing command prints last. Check the host or folder before you press Allow.

The usage guide has every limit in full, also rare ones such as races, size caps, and unusual setups.

  • Wildcard rules and rules with no known anchor make Sandbox Tuner cautious. It counts an 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.
  • Paths from error text can be wrong. When no sandbox report names a file, Sandbox Tuner takes the path from a permission error such as 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.
  • A failed command can forge a sandbox report. Claude Code adds the sandbox report of a failed command at the end of the command's output, and can add a proxy note after it. So Sandbox Tuner reads only the <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.
  • Read, Mach lookup, and socket blocks get no fix. When a sandbox report names such an operation, the band explains it for the current session only. Sandbox Tuner saves no count for it and offers no Allow. For an operation that it does not know, the band says that it cannot classify it.
  • Later background output goes unseen. For a background Bash command or a shell Monitor launch, Sandbox Tuner counts the hosts that the launch declared, once, when it starts. It does not read the later output, so blocks there give no sighting.
  • A tool that ignores the sandbox proxy can still fail for an allowed host. Covered rows in the band, the pane, and the report suggest checking the tool's proxy settings and supported protocols. Sandbox Tuner offers no command exclusion.

Privacy

Sandbox Tuner makes no network request and starts no process. Nothing leaves your machine.

  • Hooks. Sandbox Tuner reads each Bash call and each shell Monitor launch after Claude Code has handled it. It changes no tool call and no tool result, adds no instructions for Claude, and hooks no permission event. If a hook fails, it writes a 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.
  • Reads. The command, its listed hosts, and its output and error output, only to find sandbox blocks. Your settings from every source (user, project, local, command line, and managed), and the text of your user settings file. The variables 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.
  • Writes. Sandbox Tuner writes three files itself, and only when you press Allow, Deny, or Undo: your user settings file, its backup, and the lock file next to the settings file. An edit changes one entry under 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.
  • The backup. It is a full copy of your user settings file, which can hold secrets in 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.
  • The store. Sandbox Tuner keeps its data in Claude Code's plugin store ($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.
  • Problems. A problem that Sandbox Tuner shows or writes to the debug log quotes only 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.

Troubleshooting

  • No band. The sandbox is off, the session runs in VS Code or the mobile app (no band there), a survey holds the band, you dismissed that block, or you already fixed it. Run /sandbox-tuner.
  • No Allow. The target has not recurred enough, managed settings lock the allowlist, your settings already allow it, the folder is no cache or temp folder, or a rule of yours refuses it. The row says which.
  • "Claude Code has not applied it yet". Your settings file holds the entry, but the session did not load it. Run /status to check that the session loads user settings, and look for a ConfigChange hook that blocks the change.
  • "Your settings already allow it", and the sandbox still blocks it. The tool may ignore the sandbox proxy, the block may be a read, or file permissions may refuse it. Check the tool's proxy settings and supported protocols. Run /status or /doctor for managed-settings warnings.
  • "is not valid JSON". Fix your user settings file ($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.
  • "could not confirm that ... is unchanged". Another session or program saved your settings file during the press. Sandbox Tuner left the file as it is. Press again.
  • "another Sandbox Tuner session holds the lock". A press in another session is writing your settings file. Press again. If it stays for 30 seconds, a press stopped while it held the lock, and the next press takes the lock over.
  • "... after this press read it ...". Another session or program changed your settings file during the press, and the rest of the line says why the change no longer fits the new text. Nothing was written. Read the file, then press again if you still want the change.
  • "... leads to ...". The folder is a link, and its real place is no cache or temp folder, or a rule refuses it there. Nothing was written.
  • "guarded directory traversal limit exceeded" after Allow on a folder. A guarded folder is too large for the link check, so nothing was written. If you trust the folder, add it to sandbox.filesystem.allowWrite in your user settings file by hand. See Known limits.
  • "This folder holds credentials" after Allow on a cache folder. A link puts a credentials folder inside it, such as ~/.ssh linked into ~/.cache. Nothing was written.
  • "... name one place on a file system that ignores letter case". A guarded folder or a PATH folder differs from the folder only in letter case, and on macOS that is the same place. Nothing was written.
  • "... is no longer in ...: another session or program saved the file at the same moment". Two writes crossed, and the other one took your change back. Press again.
  • A host that you approved at the network prompt has no count. No event reports those approvals. In auto mode, Claude lists hosts on its commands, so counts grow fastest there.
  • Debug lines. Run 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.

License

MIT.

Source 11 files
hooks/register.tsx 1228 lines
1// 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 lines
1// 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}
12
hooks/names.ts 225 lines
1// 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}
225
hooks/records.ts 156 lines
1// 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}
156
hooks/settings-file.ts 193 lines
1// 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}
193
hooks/sightings.ts 230 lines
1// 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}
230
hooks/verdicts.ts 422 lines
1// 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}
422
hooks/views.tsx 261 lines
1// 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}
261
hooks/words.ts 255 lines
1// 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}
255
hooks/shapes.ts 18 lines
1// 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}
18
types/index.d.ts 72 lines
1export 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