After a hook, deny rule, or auto mode blocks a tool call, Arrester stops later calls that reach the same result another way or read or change your guards…

<img src="https://img.shields.io/badge/Claude_Code-2.1.296%2B-D97757" alt="Claude Code 2.1.296+"> <img src="https://img.shields.io/badge/type-mod-555555" alt="mod"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555555" alt="License: MIT"></a> <img src="https://img.shields.io/badge/version-1.1.3-555555" alt="Version: 1.1.3">
A deny rule blocks cat. Arrester stops the head detour.
A guard can block one tool call while a different tool still reaches the same file, as reported in Claude Code issue #29709. Arrester remembers supported blocks from a settings hook, a deny rule, auto mode, or your own “No” in the permission dialog, within the capture limits, and checks later calls against them. A safe alternative that matches none of Arrester's rules goes on to your guards. A recognized detour to the blocked target stops.
claude commands that can turn guards off./arrester shows the open blocks and the recent decisions.flowchart TD
A[Tool call] --> B{Open block?}
B -->|No| D[Continue through existing guards]
B -->|Yes| C{Matches a block rule?}
C -->|No| D
C -->|Yes| E{Response option}
E -->|deny| F[Stop and explain]
E -->|hold| G[Ask the user]
G -->|Allow once| D
G -->|Deny or no answer| F
D -->|Observable guard block| H[Remember the block]
cd, pushd, or popd keeps every path.claude command with --settings, --bare, --safe-mode, --dangerously-skip-permissions, --allow-dangerously-skip-permissions, or --permission-mode bypassPermissions also counts, and so do claude plugin disable, claude plugin uninstall, and claude plugin configure … --values-stdin, also as claude plugins. Any other nested claude run counts too, except a version, help, or tool command; see Known limits.block_minutes (see Options), or when you run /arrester clear. /clear, /resume, and /branch start a fresh record.See the usage guide for access rules, supported shell forms, and script handling.
Works with Claude Code 2.1.296 and later. Tested with Claude Code 2.1.296.
Inside Claude Code:
/plugin marketplace add dot-agi/arrester
/plugin install arrester@arrester
From a shell:
claude plugin marketplace add dot-agi/arrester
claude plugin install arrester@arrester
To try it for one session from a clone:
git clone https://github.com/dot-agi/arrester
claude --plugin-dir ./arrester
Install from a shell. The part after the install turns on auto-update. It runs only when the install succeeds and the marketplace entry exists:
claude plugin install arrester --marketplace dot-agi/arrester --scope user --json \
&& f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
&& jq -e --arg name arrester '.extraKnownMarketplaces[$name].source' "$f" > /dev/null \
&& tmp=$(mktemp "$f.XXXXXX") \
&& { jq --arg name arrester '.extraKnownMarketplaces[$name].autoUpdate = true' "$f" > "$tmp" && mv "$tmp" "$f" || { rm -f "$tmp"; false; }; }
Auto-update runs only in interactive sessions. A headless job updates at its start, before the session that uses the mod:
claude plugin update arrester@arrester --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 == "arrester@arrester") | "\(.version) enabled=\(.enabled)"' \
&& git ls-remote --tags --refs https://github.com/dot-agi/arrester 'arrester--v*' | sed 's/.*--v//' | sort -V | tail -n 1
/reload-plugins.install again does not update. Use update.CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session.mv replaces the settings file, with mode 600. If settings.json is a symbolic link, edit its target instead.| Command | What it does |
|---|---|
/arrester or /arrester status | Shows the open blocks, the held calls, and the last ten decisions. |
/arrester history [1-5] | Shows ten retained decisions per page, newest page first, with clipped detail. |
/arrester clear | Closes every open block. |
/arrester clear b2 | Closes block b2. |
Only you can run clear. Arrester accepts it from your own prompt, from Remote Control, and from an SDK host: the desktop app, the VS Code panel, claude -p, or a program of your own on the Agent SDK. It refuses clear from every other origin, such as a scheduled task or another session.
/arrester status lists the open block and recent stops.
Set them in /config. From a shell, write the values to a JSON file and run claude plugin configure arrester@arrester --values-stdin < values.json.
| Option | Values | Default | What it does |
|---|---|---|---|
response | deny, hold | deny | deny refuses a match at once and tells Claude to stop and ask you. hold asks you first, with Deny and Allow once; Allow once still sends the call through every guard. Where nobody can answer (claude -p), hold refuses. |
block_minutes | 1 to 1440 | 30 | How long a block stays open after it opened or after its last target or retry match. |
Arrester remembers supported guard blocks within the current conversation. These tools provide complementary checks and controls.
| Tool or feature | What it does | What Arrester adds |
|---|---|---|
Built-in /permissions Recently denied and the PermissionDenied hook | Lists auto mode denials, which you can retry with your approval; the hook runs on each auto mode denial. | Checks later calls against a recorded block, including recognized routes through other tools. |
| Built-in protected paths | Never auto-approves writes to protected folders such as .git and .claude, except in bypassPermissions mode and in some sessions where that mode is available. | While a block from a hook, rule, or classifier is open, also stops recognized reads of guard files and recognized guard-changing commands. |
| Termaxa | Applies command policies, previews consequences, and uses a circuit breaker for repeated destructive intent across shells and sessions. | Follows supported blocks from other guards and matches their specific file targets. |
| melodic-software guardrails | block-hook-bypass checks Bash writes that bypass Write/Edit hooks; other guards check file changes. | Remembers a prior block and checks later calls against its targets across tools. |
| destructive_command_guard (dcg) | Uses rule packs to block destructive commands, with explanations and allow-once controls. | Follows observable hook blocks and stops common attempts to reach the blocked target another way. |
| guard-and-guide | Uses configurable regular-expression rules to deny commands and suggest safer alternatives. | Checks whether a later alternative matches an open block's target or another block rule. |
head after a blocked cat, the Read tool, an MCP call that names the path, or a search with a file filter or a depth limit that names it or a folder that holds it.cat secrets/token.txt after a blocked grep -r . secrets.python -c. A shell that starts with a printing option and runs a script file, such as bash -v check.sh, also reads the script.claude runs.bundle exec, compiled programs, and remote commands. Arrester reads printing options only on the command line of a shell that runs a script file. So it records no read of a script printed after set -x or bash -x -c, or through options from the environment or a #! line, as in set -x; . check.sh or SHELLOPTS=xtrace bash check.sh.rg -t py KEY src or rg --files src; a recursive copy, sync, archive, or compare, such as cp -r src /tmp or rsync -a src /tmp; a search of a broad folder, such as the project folder; or a search whose deny reason suggests another tool. A search with a file filter or a depth limit that names no folder, such as rg -t py KEY, counts as no read.find, or xargs, such as cp /staging/* /work/, that replace a blocked file.config/app.yaml after a hook denied cat config/secrets.env config/app.yaml for config/secrets.env. The guard still checks that path.Arrester is not a sandbox and cannot recognize every shell rewrite. The full list of known limits gives each limit with examples and workarounds.
Most of these limits apply while a block from a hook, deny rule, or auto mode classifier is open. In hold mode, Allow once still runs every guard. In deny mode, inspect the call and its block with /arrester; clear that specific block only if you intend to end its protection.
rg pattern, grep -r pattern, or a Grep call without path, counts as a read of the session's folder. For rg and ag, Arrester skips hidden names there, as they do, unless a flag such as --hidden shows them. A search with a file filter, such as rg -g '*.ts' pattern, rg -t py pattern, grep -r --include='*.ts' pattern, ag -G '\.ts$' pattern, or ag --js pattern, counts as no read, so it can read a blocked file without a stop or a warning. A search with a depth limit, such as rg --max-depth 1 pattern, ag --depth 1 pattern, or ugrep -r --depth=2 pattern, also counts as no read, so it can read a blocked file within that depth without a stop or a warning. Some other reads also pass without a stop or a warning: hidden files that a ripgrep config file or an rg flag that Arrester does not read shows, and a search by a subagent that runs in its own folder. An rg or ag search can also stop falsely on a hidden file below a visible folder, such as apps/.env, or on a file that .gitignore hides. A Grep or Glob call gets no target stop from a block of a Read( deny rule, because Claude Code leaves those files out of its search. Ask Claude to name the search folder while a read block is open.cp /work/staging/secret /work/, Arrester records the entry that it makes there, /work/secret. It cannot name that entry when the source's last name is a glob, as in cp /staging/* /work/, when the source comes from find -exec or from unknown xargs input, or for an rsync source that ends in /. Such a copy can replace a file under a write or deletion block without a stop or a warning. Under a deletion block, a recursive copy of a folder, such as cp -r /staging/d /work/ with /work/d/secret blocked, also passes. Arrester records the entry also when the destination is a file, which can cause a false stop. A copy, move, or link that cannot replace an entry or changes nothing adds no entry: cp -n, mv -n, --update=none, rsync -n, rsync --ignore-existing, and ln without a flag that replaces, such as -f. Such a call can still make a blocked file that does not exist yet without a stop or a warning. A call that gives -n together with -i or -f, such as cp -i -n, still adds the entry, so it can stop falsely. A copy of 128 or more named files into one folder passes the limit of 256 path effects, so it is opaque. Ask Claude to name the destination file, and check globbed or found copies into the folder of a protected file yourself.grep -r KEY /work/secrets, or a Grep call with that path, records it, so a later read of it, or of a file inside the folder, stops. A blocked search with a file filter or a depth limit records no folder, such as grep -r --include='*.ts' KEY /work/src, rg -g '*.ts' KEY /work/src, rg --max-depth 1 KEY /work/src, rg --ignore-file .ignore KEY /work/src, or a Grep call with glob or type. A blocked search with --files, --type-list, --help, or --version, such as rg --files /work/src, lists files or prints something else, so it records no folder either. A blocked recursive copy, sync, archive, or compare of a folder records no folder, such as cp -r /work/secrets /tmp/s, rsync -a --exclude='*.env' /work/src/ /tmp/b/, tar -czf /tmp/s.tgz /work/secrets, or diff -r /work/a /work/b. Reads inside such a folder pass without a stop or a warning, also of files that the blocked call would have read. Such a call on a regular file still records the file. A later search with a file filter or a depth limit still stops when it names a recorded file or folder, or a folder that holds one. The full list of known limits names each filter and depth flag. A blocked search records no folder that is /, the home folder, the session's folder, or the project folder, that holds one of them, or that holds a settings or hook file, so reads inside such a folder pass without a stop or a warning. A search that names no folder reads the session's folder, so it records none. A blocked search also records no folder when the deny reason suggests another tool: it says instead, prefer, please use, use rg, or ` try ` in any letter case, such as Use rg instead of grep. Then the suggested search of the folder, and reads inside it, pass without a stop or a warning, also when the reason says that the folder holds secrets too. A blocked recursive search of a glob path, such as rg KEY /work/config/*.env, and a blocked recursive listing, such as ls -R or a Glob call, record no target. A blocked listing or search that is not recursive records its path, also a glob path: after a blocked ls /work/secrets or grep KEY /work/config/*.env, a later read of /work/secrets/token.txt or /work/config/secret.env stops. A later search that holds a recorded folder reads it too, so it stops, such as rg KEY in the project. When a hook denies a search of a file only to suggest another tool, such as rg for grep`, that tool's search of the same file stops too.file: URI in a ReadMcpResourceTool call, and from MCP calls. An MCP call names a path only with a whole one-line string that is absolute, starts with ~/, or is a local file: URI. Arrester cannot know what the MCP tool does with it, so an MCP call that only reads a write-blocked or deletion-blocked file stops too. A named folder that exists matches no write or deletion block. An MCP call that changes or deletes a folder under a write or deletion block, a file inside such a folder, or a folder that holds a blocked file passes without a stop or a warning, and so do relative paths, paths inside longer text, and calls of other tools. An MCP call that names more than 256 paths is opaque.~, $HOME, and $CLAUDE_PROJECT_DIR in a hook command; a hook with args gets only $CLAUDE_PROJECT_DIR. A relative hook path counts only as a command's program, such as ./hooks/check.sh, as the first path after an interpreter or a shell, such as python3 .claude/hooks/guard.py, or under .claude/. Arrester reads it in the session's folder and in the project folder when the block opens. A bare name such as python3 guard.py and a script found through PATH add no guard. A cd in the hook command does not count, so the path after it guards the wrong file or none. Write python3 "$CLAUDE_PROJECT_DIR"/hooks/guard.py to guard such a file.shutil.rmtree('/w/d'). Paths that such a script passes to subprocess, exec, spawn, system, or popen, builds from parts, nests in another call, or keeps in a Path(…) that no file operation uses directly, such as p = Path('/w/d') before p.read_text(), pass without a stop or a warning. So does the whole script when the call has no work budget left for it. In an older shell script, code that Arrester cannot read does not count, including code past the command-length or nesting limit. At capture, every script read from disk counts as older, and an older interpreter script is not scanned, so a block on an interpreter script on disk records no targets from inside it.claude run does not inherit the open blocks, so while a block from a hook, rule, or classifier is open, it stops: also as claude-code, in any letter case, by a claude/versions/<version> path, or through a runner such as npx. Only a first word of -v, --version, -h, --help, or a tool subcommand (mcp, plugin, plugins, doctor, update, upgrade, install, auth, or setup-token) passes, but not mcp serve, an mcp add form, or a first word or subcommand that does not resolve. So claude -p "summarize" and claude --verbose mcp list stop too; in hold mode, choose Allow once. A session that another program starts still escapes: the Agent SDK, bun x, setsid, arch, Python subprocess, another agent CLI, a function or an alias from your shell profile, or a copy of the program under another name.copy after from shutil import copy. The scan skips Python # comments in code that python runs, and JavaScript // and /* */ comments in code that node, bun, or deno runs; a script run directly takes its language from its #! line. A comment counts only when it starts a line, after any indentation, and a /* only when its */ follows. Other comments are read as code, such as Ruby, Perl, and PHP comments, a comment after code on the same line, such as the # note in x = 1 # note, and a /* without its */. A file call in them can cause a false stop, and a quote in them can hide the calls after it. In a chain of methods on Path(…), Arrester stops following the chain at a subscript such as [0], at grouping parentheses, or at a parenthesis inside a string argument. A path after that point can take the write or deletion of the call around it, so while a block is open a call such as os.remove(Path('/w/d').read_text().split()[0].strip('/w/e')) can stop although it does not touch /w/e.hooks/register.ts 508 lines1// Arrester's hooks module: every hook and every `$` call. It keeps the journal
2// in `$.state`, reads the session and the file system through `$`, and leaves
3// every decision about paths and words to the pure modules.
4
5import { atom, read, update } from 'claude-code'
6import type { Caught, EngineInterface, EventName, EventResult, FsStat, Hook, PluginOptions, Register, Timer, TraceOutcome } from 'claude-code'
7
8import { analyzeCall, envelopeCall, inputCall } from './analyze/call'
9import type { Call } from './analyze/call'
10import { basenameOf, dirnameOf, joinPath } from './analyze/paths'
11import { OPAQUE } from './analyze/shell'
12import type { Effect, Io, ScriptRead } from './analyze/types'
13import { fixedGuards, hookPaths, SETTINGS_SOURCES } from './guards'
14import { cleared, EMPTY_JOURNAL, isRecorded, isTargetFolder, namedTargets, nextClose, openBlocks, pruned, renewed, targetsOf, withBlock, withDecision, withHeld, withoutHeld } from './journal'
15import { blocksOf, findings, renewable } from './match'
16import type { Finding } from './match'
17import { ALLOW_ONCE, ARGUMENT_HINT, blockOfText, causeOf, clearedText, clearRefused, COMMAND_DESCRIPTION, commandFailed, couldNotCheck, couldNotReadScript, couldNotReadSettings, couldNotRecord, couldNotRegister, couldNotResolve, couldNotSave, couldNotSync, decisionLine, evictionNotice, historyText, retryNotice, DENY, denyReason, failureOf, guardMessage, hasVerdict, HEADER, heldReason, holdEnded, holdUnanswered, homeUnset, hostText, incompleteCapture, hookFailedNoBlock, noVerdict, notCounted, phrase, question, statusLine, statusText, storedReason, summarize, uncheckedReason, USAGE, viewOf } from './text'
18import type { ArresterAction, ArresterGuard, ArresterJournal, ArresterSource } from '../types/index'
19
20/** The journal, the one `$.state` value. */
21const JOURNAL = atom({ plugin: 'arrester', key: 'journal' } as const, EMPTY_JOURNAL)
22
23/** The most filesystem placements in flight at once. */
24const PLACEMENT_WORKERS = 8
25
26/** The last known block state, used when re-entry forbids engine calls. */
27let hasOpenBlocks: boolean | undefined
28
29/** The origins whose `/arrester clear` is the user's own: the terminal, Remote Control, and the SDK host. */
30const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
31
32/** The longest script Arrester reads, in bytes. */
33const MAX_SCRIPT = 262_144
34
35/** The options as Arrester reads them. */
36type Config = { response: 'deny' | 'hold'; minutes: number }
37
38/** The arguments of a hook on event `N`. */
39type HookArgs<N extends EventName> = Parameters<Hook<N>>
40
41/** Where a path lands, and the kind of what is there when it exists. */
42type Placement = { real?: string; kind?: FsStat['kind'] }
43
44/** A path to place, and whether it names the entry itself. */
45type PlaceValue = { path: string; entry?: true }
46
47/** Placements by `placeKey`. */
48type Placements = Map<string, Placement>
49
50/** A `classic.PreToolUse` dispatch that Arrester passed on: the call as that event carried it, and the deny that settled beneath, if one did. */
51type PassedPreToolUse = { call: Call; deny: string | undefined }
52
53/** The most passed `classic.PreToolUse` dispatches kept for their `tool.call` hook; past it the oldest goes. */
54const MAX_PASSED = 64
55
56/** The passed `classic.PreToolUse` dispatches by tool use id, oldest first; the `tool.call` hook of the same call reads and drops each one. */
57const passedPreToolUses = new Map<string, PassedPreToolUse>()
58
59/** The timer that refreshes the status line at the next close; a reload drops it with the old module. */
60let timer: Timer | undefined
61
62/** How many syncs this module started: only the newest draws the status line and sets the timer, and the end of the session stops every sync in flight. */
63let syncs = 0
64
65/** Whether this module load reported that HOME is not set. */
66let isHomeReported = false
67
68/** Wires every hook. */
69export const register: Register = (on, options) => {
70 const config = configOf(options)
71
72 on('session.start', async ($, e, next) => {
73 await registerCommand($)
74 await sync($, config, 'session.start', true)
75 return next(e)
76 })
77
78 on('session.end', ($, e, next) => {
79 syncs += 1
80 timer?.cancel()
81 timer = undefined
82 $.ui.status(undefined)
83 return next(e)
84 })
85
86 on('classic.PreToolUse', async ($, e, next) => {
87 const passed = passPreToolUse(envelopeCall(e))
88 try {
89 return await next(e)
90 } finally {
91 passed.deny = settledOf(next.trace)?.deny
92 }
93 }).catch(($, e, next) => {
94 return next(e)
95 })
96
97 on('tool.check', async ($, e, next) => {
98 try {
99 return await next(e)
100 } finally {
101 const verdict = settledOf(next.trace)
102 if (verdict?.decision === 'deny' && verdict.rule !== undefined && e.tool_use_id !== undefined) await recordBlock($, config, 'tool.check', inputCall(e.tool, e.input, e.tool_use_id, e.agentId), 'rule', verdict.reason ?? verdict.rule, verdict.rule, next.signal)
103 else if (verdict?.decision === 'deny' && verdict.rule === undefined && verdict.hook === 'PreToolUse' && e.tool_use_id !== undefined && !passedPreToolUses.has(e.tool_use_id)) await recordBlock($, config, 'tool.check', inputCall(e.tool, e.input, e.tool_use_id, e.agentId), 'hook', verdict.reason ?? 'denied', undefined, next.signal)
104 }
105 }).catch(($, e, next) => {
106 if (next.error.kind === 're-entry') return next(e)
107 $.ui.log(hostText(couldNotRecord('tool.check', e.tool, failureOf(next.error))))
108 return next(e)
109 })
110
111 on('classic.PermissionDenied', async ($, e, next) => {
112 if (hasVerdict(e.reason)) await recordBlock($, config, 'classic.PermissionDenied', inputCall(e.tool_name, e.tool_input, e.tool_use_id), 'classifier', e.reason, undefined, next.signal)
113 else $.ui.log(noVerdict(e.reason), { to: 'debug' })
114 return next(e)
115 }).catch(($, e, next) => {
116 if (next.error.kind === 're-entry') return next(e)
117 $.ui.log(hostText(couldNotRecord('classic.PermissionDenied', e.tool_name, failureOf(next.error))))
118 return next(e)
119 })
120
121 on('tool.call', ($, e, next) => checkCall($, config, e, next)).catch(($, e, next) => checkFailed($, config, e, next))
122
123 on('command.run', { command: 'arrester' }, ($, e) => runCommand($, config, e)).catch(($, e, next) => ({ text: hostText(commandFailed(failureOf(next.error))) }))
124}
125
126/** The options the engine validated against plugin.json. */
127function configOf(options: PluginOptions): Config {
128 return { response: options.response === 'hold' ? 'hold' : 'deny', minutes: Number(options.block_minutes) }
129}
130
131/** Drops expired blocks, and after a reload the held calls, with a transcript line for each; redraws the status line; sets the timer for the next close. A sync that a newer one or the end of the session overtook draws nothing. A failure is logged to the debug log. */
132async function sync(engine: EngineInterface, config: Config, event: string, dropHeld = false): Promise<void> {
133 syncs += 1
134 const ticket = syncs
135 try {
136 const now = await engine.clock.now()
137 const before = await read(engine, JOURNAL)
138 const journal = pruned(before, now, config.minutes, dropHeld) === before ? before : await update(engine, JOURNAL, value => pruned(value, now, config.minutes, dropHeld))
139 if (dropHeld) for (const held of before.held) engine.ui.log(hostText(holdEnded(held)))
140 if (ticket !== syncs) return
141 hasOpenBlocks = journal.blocks.length > 0
142 engine.ui.status(statusLine(journal.blocks.length, journal.held.length, journal.eviction?.blockId))
143 timer?.cancel()
144 const due = nextClose(journal, config.minutes)
145 timer = due === undefined ? undefined : engine.clock.after(Math.max(1, due - now), () => void sync(engine, config, 'clock.after'))
146 } catch (error) {
147 engine.ui.log(couldNotSync(event, causeOf(error)), { to: 'debug' })
148 }
149}
150
151/** Opens a block for a call that a guard blocked, with its targets and the guard list placed on the file system; a recursive read whose root is a regular file counts as a read of that file, and a `fullSearch` whose root is a folder as a read of the folder, unless `isTargetFolder` spares it or the reason names another tool to use. Another recursive read of a folder, such as a filtered search, `cp -r`, `rsync`, `tar`, or `diff -r`, records no folder. When the call has two or more targets and the reason of a guard other than the user names some but not all of them, as `namedTargets` reads its `guardMessage`, the block keeps only the named ones. Every script on disk counts as older than the block, and an older interpreter script is not scanned. A call opens one block, also when that block was cleared since. */
152async function recordBlock(engine: EngineInterface, config: Config, event: string, call: Call, source: ArresterSource, reason: string, rule: string | undefined, signal: AbortSignal): Promise<void> {
153 const toolUseId = call.toolUseId
154 if (toolUseId === undefined || isRecorded(await read(engine, JOURNAL), toolUseId)) return
155 const home = await homeOf(engine, event)
156 const cwd = await engine.session.cwd()
157 const analysis = await analyzeCall(call, { cwd, home }, ioOf(engine, event, signal, Number.POSITIVE_INFINITY, true))
158 if (signal.aborted) return
159 const roots = await placeAll(engine, analysis.effects.filter(isSearchRoot), event, signal)
160 const guards = await guardPaths(engine, event, home, cwd)
161 const isFolderSearch = (effect: Effect): boolean => effect.fullSearch === true && isSearchRoot(effect) && roots.get(placeKey(effect))?.kind === 'dir'
162 const spared = analysis.effects.some(isFolderSearch) ? await sparedPaths(engine, event, signal, [home, cwd, await engine.session.root()], guards) : []
163 const effects = analysis.effects.map(effect => {
164 const found = isSearchRoot(effect) ? roots.get(placeKey(effect)) : undefined
165 const isTarget = found?.kind === 'file' || (found !== undefined && isFolderSearch(effect) && isTargetFolder(found.real === undefined ? [effect.path] : [effect.path, found.real], spared, reason))
166 return isTarget ? { ...effect, recursive: false } : effect
167 })
168 const all = targetsOf(effects)
169 const unplaced = all.filter(target => !roots.has(placeKey(target)))
170 const rest = await placeAll(engine, unplaced, event, signal)
171 const placed = withPlacements(all, new Map([...roots, ...rest]))
172 const targets = source === 'user' ? placed : namedTargets(placed, guardMessage(reason), typeof call.input.command === 'string' ? call.input.command : undefined, cwd, home)
173 if (signal.aborted) return
174 const at = await engine.clock.now()
175 const turn = await engine.session.turns()
176 const draft = { source, agentId: call.agentId, incomplete: analysis.opaque, tool: call.tool, summary: summarize(call), reason: storedReason(reason), ...(rule === undefined ? {} : { rule }), toolUseId, turn, at, exact: analysis.exact, skeleton: analysis.skeleton, flags: analysis.flags, segments: analysis.segments, targets }
177 let opened = false
178 const saved = await update(engine, JOURNAL, journal => {
179 const updated = withBlock(journal, draft, guards, config.minutes)
180 opened = updated !== journal
181 return updated
182 })
183 if (opened && saved.eviction?.toolUseId === toolUseId) engine.ui.log(hostText(evictionNotice(event, saved.eviction.blockId)))
184 const captured = saved.blocks.find(block => block.toolUseId === toolUseId)
185 if (opened && captured?.retryLimited !== undefined) engine.ui.log(hostText(retryNotice(event, captured.id, captured.retryLimited)))
186 hasOpenBlocks = true
187 if (opened && analysis.opaque !== undefined) engine.ui.log(hostText(incompleteCapture(event, analysis.opaque)))
188 await sync(engine, config, event)
189}
190
191/** The guards, placed on the file system: the fixed ones and each regular file that a command hook names, a relative name read in the session folder `cwd` and in the project folder. Working folders and paths whose kind is unknown add no guard. A settings source that cannot be read adds none and is logged. */
192async function guardPaths(engine: EngineInterface, event: string, home: string | undefined, cwd: string): Promise<ArresterGuard[]> {
193 const root = await engine.session.root()
194 const fixed = fixedGuards(home, root, engine.plugin.root)
195 const named = new Set<string>()
196 for (const source of SETTINGS_SOURCES) {
197 try {
198 for (const path of hookPaths(await engine.settings.read({ source }), home, root, cwd)) if (!fixed.some(guard => guard.path === path)) named.add(path)
199 } catch (error) {
200 engine.ui.log(couldNotReadSettings(event, source, causeOf(error)), { to: 'debug' })
201 }
202 }
203 const placedFixed = await Promise.all(fixed.map(async guard => withReal(guard, (await place(engine, event, guard.path)).real)))
204 const placedNamed = await Promise.all([...named].map(async path => ({ path, ...(await place(engine, event, path)) })))
205 return [...placedFixed, ...placedNamed.filter(found => found.kind === 'file').map(found => withReal({ path: found.path }, found.real))]
206}
207
208/** The paths that a folder target may not be or hold: each of `folders`, such as the home, session, and project folders, and each guard, also by its real path. */
209async function sparedPaths(engine: EngineInterface, event: string, signal: AbortSignal, folders: readonly (string | undefined)[], guards: readonly ArresterGuard[]): Promise<string[]> {
210 const values = folders.flatMap(path => (path === undefined ? [] : [{ path }]))
211 const placed = withPlacements(values, await placeAll(engine, values, event, signal))
212 return [...placed, ...guards].flatMap(value => (value.real === undefined ? [value.path] : [value.path, value.real]))
213}
214
215/** Where a path lands and what is there: its own real path and kind when it exists, also for a removed entry that is no symbolic link, so a case alias lands on the name on disk; for a removed link, a missing path, or an entry the file system refuses, its folder's real path plus its name; nothing when neither exists, or when the file system refuses the folder, which is logged to the debug log. */
216async function place(engine: EngineInterface, event: string, path: string, entry = false): Promise<Placement> {
217 if (entry) {
218 try {
219 const own = await engine.fs.stat(path, { resolve: true })
220 if (!own.isLink && own.realPath !== undefined) return { real: own.realPath, kind: own.kind }
221 } catch {
222 // A missing entry, or one the file system refuses, is placed by its folder below.
223 }
224 }
225 try {
226 if (!entry && await engine.fs.exists(path)) {
227 const stat = await engine.fs.stat(path, { resolve: true })
228 return { real: stat.realPath, kind: stat.kind }
229 }
230 const folder = dirnameOf(path)
231 if (folder === path || !(await engine.fs.exists(folder))) return {}
232 const real = (await engine.fs.stat(folder, { resolve: true })).realPath
233 return real === undefined ? {} : { real: joinPath(real, basenameOf(path)) }
234 } catch (error) {
235 engine.ui.log(couldNotResolve(event, path, causeOf(error)), { to: 'debug' })
236 return {}
237 }
238}
239
240/** The value with its real path added when the file system gave one that differs. */
241function withReal<T extends { path: string }>(placed: T, real: string | undefined): T & { real?: string } {
242 return real === undefined || real === placed.path ? placed : { ...placed, real }
243}
244
245/** The home folder from `HOME`, which expands `~`; when it is not set, the debug log says so once per module load. */
246async function homeOf(engine: EngineInterface, event: string): Promise<string | undefined> {
247 const home = await engine.env.get('HOME')
248 if (home !== undefined && home !== '') return home
249 if (!isHomeReported) {
250 isHomeReported = true
251 engine.ui.log(homeUnset(event), { to: 'debug' })
252 }
253 return undefined
254}
255
256/** The I/O the analysis may do: read the script a call runs, judged against `since`, and see the dispatch end; `capture` when it records a block. */
257function ioOf(engine: EngineInterface, event: string, signal: AbortSignal, since: number, capture = false): Io {
258 const io: Io = { readScript: path => readScript(engine, event, path, since), signal }
259 return capture ? { ...io, capture } : io
260}
261
262/** A script for the analysis: `missing`; its text when it is a regular file of at most MAX_SCRIPT bytes; else why it cannot be read, with a debug line for a refused read or a path that is no regular file. `isOld` when the file last changed before `since`. */
263async function readScript(engine: EngineInterface, event: string, path: string, since: number): Promise<ScriptRead> {
264 let isOld = false
265 let cause: string
266 try {
267 if (!(await engine.fs.exists(path))) return 'missing'
268 const stat = await engine.fs.stat(path)
269 isOld = stat.mtimeMs < since
270 if (stat.kind === 'file') return stat.size > MAX_SCRIPT ? { unreadable: OPAQUE.scriptTooLong, isOld } : { text: await engine.fs.read(path), isOld }
271 cause = `it is not a regular file but ${stat.kind}`
272 } catch (error) {
273 cause = causeOf(error)
274 }
275 engine.ui.log(couldNotReadScript(event, path, cause), { to: 'debug' })
276 return { unreadable: OPAQUE.scriptUnreadable, isOld }
277}
278
279/** Keeps a `classic.PreToolUse` dispatch as it starts, in place of an older one of the same call, for the `tool.call` hook of its call, and returns its entry, whose deny the dispatch sets once it settles; a call without a tool use id is not kept. */
280function passPreToolUse(call: Call): PassedPreToolUse {
281 const passed: PassedPreToolUse = { call, deny: undefined }
282 if (call.toolUseId === undefined) return passed
283 passedPreToolUses.delete(call.toolUseId)
284 passedPreToolUses.set(call.toolUseId, passed)
285 for (const id of passedPreToolUses.keys()) {
286 if (passedPreToolUses.size <= MAX_PASSED) break
287 passedPreToolUses.delete(id)
288 }
289 return passed
290}
291
292/** What the links beneath a hook settled to, read from its trace, nearest first: the answer of the first link whose own answer stood; nothing when a link rejected or none settled. */
293function settledOf<O>(trace: readonly { readonly outcome: TraceOutcome; readonly returned: O | undefined }[]): O | undefined {
294 for (const entry of trace) {
295 if (entry.outcome === 'rejected') return undefined
296 if ((entry.outcome === 'returned' || entry.outcome === 'caught') && entry.returned !== undefined) return entry.returned
297 }
298 return undefined
299}
300
301/** Opens a block when the hooks beneath Arrester's `classic.PreToolUse` hook denied the call, once the call settled; a block that cannot be recorded is logged to the transcript, and the deny stands. */
302async function observePreToolUse(engine: EngineInterface, config: Config, toolUseId: string | undefined, signal: AbortSignal): Promise<void> {
303 const passed = toolUseId === undefined ? undefined : passedPreToolUses.get(toolUseId)
304 if (toolUseId === undefined || passed === undefined) return
305 passedPreToolUses.delete(toolUseId)
306 const deny = passed.deny
307 if (typeof deny !== 'string') return
308 try {
309 await recordBlock(engine, config, 'classic.PreToolUse', passed.call, 'hook', deny, undefined, signal)
310 } catch (error) {
311 engine.ui.log(hostText(couldNotRecord('classic.PreToolUse', passed.call.tool, `throw: ${causeOf(error)}`)))
312 }
313}
314
315/** Opens a block when a call's result says a guard blocked it: a deny beneath Arrester's `classic.PreToolUse` hook, the user's rejection, or the text a hook or the classifier left. */
316async function observe(engine: EngineInterface, config: Config, call: Call, result: EventResult<'tool.call'>, signal: AbortSignal): Promise<void> {
317 await observePreToolUse(engine, config, call.toolUseId, signal)
318 if (result.isError !== true || typeof result.text !== 'string') return
319 const block = blockOfText(result.text)
320 if (block !== undefined) await recordBlock(engine, config, 'tool.call', call, block.source, block.reason, undefined, signal)
321}
322
323/** The `tool.call` hook: stops or holds a workaround of an open block; passes every other call on and reads its result. A path that an MCP call names is marked when it is a folder that exists. A dispatch abandoned during the check goes no further. */
324async function checkCall(engine: EngineInterface, config: Config, input: HookArgs<'tool.call'>[1], forward: HookArgs<'tool.call'>[2]): Promise<EventResult<'tool.call'>> {
325 const journal = await read(engine, JOURNAL)
326 const now = await engine.clock.now()
327 const open = openBlocks(journal, now, config.minutes)
328 hasOpenBlocks = open.length > 0
329 const call = envelopeCall(input)
330 if (open.length > 0) {
331 const since = Math.min(...open.map(block => block.at))
332 const analysis = await analyzeCall(call, { cwd: await engine.session.cwd(), home: await homeOf(engine, 'tool.call') }, ioOf(engine, 'tool.call', forward.signal, since))
333 if (analysis.ignored !== undefined) engine.ui.log(notCounted(analysis.ignored), { to: 'debug' })
334 const placements = await placeAll(engine, analysis.effects, 'tool.call', forward.signal)
335 const placed = withPlacements(analysis.effects, placements).map(effect => (effect.kind === 'name' && placements.get(placeKey(effect))?.kind === 'dir' ? { ...effect, folder: true as const } : effect))
336 if (forward.signal.aborted) return abandoned()
337 const found = findings(open, journal.guards, analysis, placed, call.tool)
338 const [first] = found
339 if (first !== undefined && config.response === 'hold') return hold(engine, config, input, forward, call, found, first, now)
340 if (first !== undefined) return stop(engine, config, call, found, first, now)
341 }
342 if (forward.signal.aborted) return abandoned()
343 const result = await forward(input)
344 await observe(engine, config, call, result, forward.signal)
345 return result
346}
347
348/** The `.catch` handler of the `tool.call` hook: replays a call that had settled and reports the block it could not record; refuses the call while blocks are open; passes it on while none is. */
349async function checkFailed(engine: EngineInterface, config: Config, input: HookArgs<'tool.call'>[1], forward: HookArgs<'tool.call'>[2] & Caught): Promise<EventResult<'tool.call'>> {
350 if (forward.error.kind === 're-entry') return hasOpenBlocks === false ? forward(input) : { deny: uncheckedReason('re-entry prevents checking this call') }
351 const failure = failureOf(forward.error)
352 const tool = String(input.tool)
353 if (forward.called) {
354 engine.ui.log(hostText(couldNotRecord('tool.call', tool, failure)))
355 return forward(input)
356 }
357 const open = await openCount(engine, config)
358 if (open.count === 0) {
359 engine.ui.log(hookFailedNoBlock(failure), { to: 'debug' })
360 return forward(input)
361 }
362 engine.ui.log(hostText(couldNotCheck(tool, failure, open.count, open.cause)))
363 return { deny: uncheckedReason(failure) }
364}
365
366/** The answer to a dispatch that was abandoned, which goes no further: nobody reads it, since the dispatch went on without this hook. */
367function abandoned(): { deny: string } {
368 return { deny: uncheckedReason(OPAQUE.interrupted) }
369}
370
371/** Places every distinct path and entry mode once, with bounded concurrency, and returns each placement by `placeKey`. */
372async function placeAll(engine: EngineInterface, values: readonly PlaceValue[], event: string, signal: AbortSignal): Promise<Placements> {
373 const unique = [...new Map(values.map(value => [placeKey(value), value])).entries()]
374 const placements: Placements = new Map()
375 let cursor = 0
376 await Promise.all(Array.from({ length: Math.min(PLACEMENT_WORKERS, unique.length) }, async () => {
377 while (cursor < unique.length && !signal.aborted) {
378 const [key, value] = unique[cursor++]!
379 const placement = await place(engine, event, value.path, value.entry)
380 placements.set(key, placement)
381 }
382 }))
383 return placements
384}
385
386/** The values, each with the real path of its placement when the file system gave one that differs. */
387function withPlacements<T extends PlaceValue>(values: readonly T[], placements: Placements): (T & { real?: string })[] {
388 return values.map(value => withReal(value, placements.get(placeKey(value))?.real))
389}
390
391/** The key of a path and its entry mode among placements. */
392function placeKey(value: PlaceValue): string {
393 return `${value.path}\0${value.entry === true}`
394}
395
396/** A recursive read of a plain path, with no glob and no find selection: a search that names its root. */
397function isSearchRoot(effect: Effect): boolean {
398 return effect.kind === 'read' && effect.recursive && effect.glob === undefined && effect.selection === undefined
399}
400
401/** Refuses a workaround: renews the blocks that a target or retry finding names, records the decision, and answers the reason Claude reads. */
402async function stop(engine: EngineInterface, config: Config, call: Call, found: readonly Finding[], first: Finding, now: number): Promise<{ deny: string }> {
403 await decide(engine, config, call, { action: 'stopped', blockIds: blocksOf(found).map(block => block.id), detail: phrase(first), now }, journal => renewed(journal, renewable(found), now))
404 return { deny: denyReason(found, now) }
405}
406
407/** Runs a journal write for a stop or a hold; a write that fails is logged to the transcript, and what Arrester does to the call stands. */
408async function saving(engine: EngineInterface, call: Call, action: string, write: () => Promise<unknown>): Promise<void> {
409 try {
410 await write()
411 } catch (error) {
412 engine.ui.log(hostText(couldNotSave(call.tool, action, causeOf(error))))
413 }
414}
415
416/** A decision about a call: what Arrester did, the blocks it was for, its detail, and when the check that led to it ran. */
417type Decided = { action: Exclude<ArresterAction, 'cleared'>; blockIds: string[]; detail: string; now: number }
418
419/** Records a decision after `change` in one write, logs its transcript line, and redraws the status line; a failed write is logged, and the transcript line still says what Arrester did. */
420async function decide(engine: EngineInterface, config: Config, call: Call, decided: Decided, change: (journal: ArresterJournal) => ArresterJournal): Promise<void> {
421 const view = viewOf(call)
422 const { action, blockIds, detail, now } = decided
423 await saving(engine, call, action, async () => {
424 const decision = { at: await engine.clock.now(), action, blockIds, call: view, detail }
425 await update(engine, JOURNAL, journal => withDecision(change(journal), decision))
426 })
427 engine.ui.log(decisionLine({ at: now, action, blockIds, call: view, detail }, view))
428 await sync(engine, config, 'tool.call')
429}
430
431/** Counts open blocks, retaining the cause when the journal or clock cannot be read. */
432async function openCount(engine: EngineInterface, config: Config): Promise<{ count?: number; cause?: string }> {
433 try {
434 return { count: openBlocks(await read(engine, JOURNAL), await engine.clock.now(), config.minutes).length }
435 } catch (error) {
436 return { cause: causeOf(error) }
437 }
438}
439
440/** Holds a workaround for the user: asks, runs the call through the guards once on "Allow once", and refuses it on any other answer or none. */
441async function hold(engine: EngineInterface, config: Config, input: HookArgs<'tool.call'>[1], forward: HookArgs<'tool.call'>[2], call: Call, found: readonly Finding[], first: Finding, now: number): Promise<EventResult<'tool.call'>> {
442 const ids = blocksOf(found).map(block => block.id)
443 const id = input.tool_use_id
444 const ended = (journal: ArresterJournal): ArresterJournal => withoutHeld(journal, id)
445 await saving(engine, call, 'held', () => update(engine, JOURNAL, journal => withHeld(renewed(journal, renewable(found), now), { ...viewOf(call), toolUseId: id, blockIds: ids, since: now })))
446 await sync(engine, config, 'tool.call')
447 let answer: string
448 try {
449 answer = await Promise.race([engine.ui.ask(question(call, first, now), { options: [DENY, ALLOW_ONCE], header: HEADER }), aborted(forward.signal)])
450 } catch (error) {
451 const cause = causeOf(error)
452 engine.ui.log(holdUnanswered(cause), { to: 'debug' })
453 await decide(engine, config, call, { action: 'unanswered', blockIds: ids, detail: cause, now }, ended)
454 return { deny: heldReason({ kind: 'unanswered', cause }, found, now) }
455 }
456 if (answer !== ALLOW_ONCE) {
457 await decide(engine, config, call, { action: 'refused', blockIds: ids, detail: answer, now }, ended)
458 return { deny: heldReason(answer === DENY ? { kind: 'refused' } : { kind: 'typed', text: answer }, found, now) }
459 }
460 await decide(engine, config, call, { action: 'allowed-once', blockIds: ids, detail: ALLOW_ONCE, now }, ended)
461 if (forward.signal.aborted) return abandoned()
462 const result = await forward(input)
463 await observe(engine, config, call, result, forward.signal)
464 return result
465}
466
467/** A promise that rejects when the dispatch is abandoned, so a hold stops waiting. */
468function aborted(signal: AbortSignal): Promise<never> {
469 return new Promise((_, reject) => {
470 const end = (): void => reject(new Error('the call was interrupted'))
471 if (signal.aborted) end()
472 else signal.addEventListener('abort', end, { once: true })
473 })
474}
475
476/** Declares `/arrester` to run at once, even while a turn is in flight; a failure is logged to the transcript, and Arrester keeps checking calls. */
477async function registerCommand(engine: EngineInterface): Promise<void> {
478 try {
479 await engine.command.register({ name: 'arrester', description: COMMAND_DESCRIPTION, argumentHint: ARGUMENT_HINT, immediate: true })
480 } catch (error) {
481 engine.ui.log(hostText(couldNotRegister(causeOf(error))))
482 }
483}
484
485/** `/arrester`: the status text, or `clear` from the user's own origins. */
486async function runCommand(engine: EngineInterface, config: Config, input: HookArgs<'command.run'>[1]): Promise<{ text: string }> {
487 const [verb, id, ...extra] = input.args.split(/\s+/).filter(word => word !== '')
488 const now = await engine.clock.now()
489 if (verb === undefined || (verb === 'status' && id === undefined)) {
490 const journal = await read(engine, JOURNAL)
491 return { text: statusText(journal, openBlocks(journal, now, config.minutes), config.response, config.minutes, now) }
492 }
493 if (verb === 'history' && extra.length === 0) {
494 const page = id === undefined ? 1 : /^[1-5]$/.test(id) ? Number(id) : 0
495 return { text: historyText(await read(engine, JOURNAL), page, now) }
496 }
497 if (verb !== 'clear' || extra.length > 0) return { text: USAGE }
498 if (!USER_ORIGINS.has(input.origin.kind)) return { text: clearRefused(input.origin.kind) }
499 let closed: string[] = []
500 await update(engine, JOURNAL, journal => {
501 const result = cleared(journal, id, now, config.minutes)
502 closed = result.closed
503 return result.journal
504 })
505 await sync(engine, config, 'command.run')
506 return { text: clearedText(closed, id) }
507}
508hooks/analyze/call.ts 324 lines1// One tool call as Arrester reads it: the call taken from the
2// input of any event that carries one, its effects on paths, and its retry
3// skeleton and flags. Pure apart from `io.readScript`, which the shell
4// analysis uses.
5
6import { absolute, expandHome, foldPath, MAX_PATH, PATH_TOO_LONG } from './paths'
7import { analyzeShell, MAX_EFFECTS, OPAQUE } from './shell'
8import type { Analysis, Context, Effect, EffectKind, Io } from './types'
9
10/** A tool call: the tool as the model named it, its arguments, the call's id, and the subagent that made it. */
11export type Call = { tool: string; input: Readonly<Record<string, unknown>>; toolUseId: string | undefined; agentId: string | undefined }
12
13/** What a call does, with its whole skeleton and its flags for the retry rule. */
14export type CallAnalysis = Analysis & { skeleton: string; flags: string[]; exact?: string }
15
16const MAX_SKELETON = 1_000
17const INPUT_LIMIT = 'tool input exceeds the serialization depth or work limit'
18const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent', 'requestMeta'])
19const NOISE = new Set(['description', 'timeout', 'timeout_ms', 'run_in_background', 'dangerouslyDisableSandbox', 'allowed_domains'])
20const SANDBOX_OFF = 'dangerouslyDisableSandbox'
21const FILE_TOOLS = new Map<string, { field: string; kind: EffectKind; recursive: boolean }>([
22 ['Read', { field: 'file_path', kind: 'read', recursive: false }],
23 ['Write', { field: 'file_path', kind: 'write', recursive: false }],
24 ['Edit', { field: 'file_path', kind: 'write', recursive: false }],
25 ['NotebookEdit', { field: 'notebook_path', kind: 'write', recursive: false }],
26 ['LSP', { field: 'filePath', kind: 'read', recursive: false }],
27 ['SendFile', { field: 'files', kind: 'read', recursive: false }],
28 ['SendUserFile', { field: 'files', kind: 'read', recursive: false }],
29 ['Grep', { field: 'path', kind: 'read', recursive: true }],
30 ['Glob', { field: 'path', kind: 'list', recursive: true }],
31])
32
33/** The start of a local `file:` URI, before its path: `file:/`, `file:///`, or `file://localhost/`, in any case. */
34const FILE_URI = /^file:(?:\/\/(?:localhost)?(?=\/)|(?=\/(?!\/)))/i
35
36/** True for a plain object, the shape tool arguments and settings values arrive in. */
37export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
38 return typeof value === 'object' && value !== null && !Array.isArray(value)
39}
40
41/** The call in a `tool.call` or `classic.PreToolUse` input, whose arguments sit beside `tool`, `tool_use_id`, `agentId`, `consent`, and `requestMeta`. */
42export function envelopeCall(e: Readonly<Record<string, unknown>>): Call {
43 const input: Record<string, unknown> = {}
44 for (const [key, value] of Object.entries(e)) if (!RESERVED.has(key)) input[key] = value
45 return { tool: String(e.tool), input, toolUseId: typeof e.tool_use_id === 'string' ? e.tool_use_id : undefined, agentId: typeof e.agentId === 'string' ? e.agentId : undefined }
46}
47
48/** The call in a `tool.check` or `classic.PermissionDenied` input, whose arguments arrive as one value of unknown shape. */
49export function inputCall(tool: string, input: unknown, toolUseId: string | undefined, agentId?: string): Call {
50 return { tool, input: isRecord(input) ? input : {}, toolUseId, agentId }
51}
52
53/** The effects, segments, opaque reason, whole skeleton, and flags of a call; a Bash call that leaves the sandbox carries the flag `dangerouslyDisableSandbox`. */
54export async function analyzeCall(call: Call, ctx: Context, io: Io): Promise<CallAnalysis> {
55 const identity = exactIdentity(call, ctx)
56 const command = call.tool === 'Bash' || call.tool === 'Monitor' ? call.input.command : undefined
57 if (typeof command !== 'string') {
58 const effects = toolEffects(call, ctx)
59 const skeleton = toolSkeleton(call)
60 return { ...identity, ...effects, opaque: effects.opaque ?? identity.opaque ?? (skeleton === undefined ? INPUT_LIMIT : undefined), segments: [], skeleton: skeleton === undefined ? '' : fingerprint(skeleton), flags: [] }
61 }
62 const shell = await analyzeShell(command, ctx, io)
63 const off = call.input.dangerouslyDisableSandbox === true ? [SANDBOX_OFF] : []
64 const segments = shell.segments.map(segment => ({ ...segment, skeleton: fingerprint(segment.skeleton), flags: [...segment.flags, ...off].sort() }))
65 const flags = [...new Set(segments.flatMap(segment => segment.flags))].sort()
66 return { ...identity, ...shell, opaque: shell.opaque ?? identity.opaque, segments, skeleton: fingerprint(segments.map(segment => segment.skeleton).sort().join(' ; ')), flags }
67}
68
69/** The effects of a file tool on the paths its input names, opaque past the distinct-effect, input-work, or supported path-length limits; a Grep or Glob call without a path searches the current folder, and a Grep call without a `glob` or `type` filter reads its path as a `fullSearch`. An MCP tool names the paths in its input, and a ReadMcpResourceTool call reads the file of a local `file:` URI. */
70function toolEffects(call: Call, ctx: Context): Pick<Analysis, 'effects' | 'opaque'> {
71 if (call.tool.startsWith('mcp__')) return namedPaths(call.input, ctx.home)
72 if (call.tool === 'ReadMcpResourceTool') {
73 const path = typeof call.input.uri === 'string' && FILE_URI.test(call.input.uri.trim()) ? namedPath(call.input.uri, ctx.home) : undefined
74 return { effects: path === undefined ? [] : [{ path, kind: 'read', recursive: false }] }
75 }
76 const touch = FILE_TOOLS.get(call.tool)
77 if (touch === undefined) return { effects: [] }
78 const given = call.input[touch.field]
79 const value = touch.recursive && (given === undefined || given === '') ? '.' : given
80 if (Array.isArray(value) && value.length > 16_384) return { effects: [], opaque: OPAQUE.tooMany }
81 const names = Array.isArray(value) ? value.filter((name): name is string => typeof name === 'string') : typeof value === 'string' ? [value] : []
82 const fullSearch = call.tool === 'Grep' && !['glob', 'type'].some(field => call.input[field] !== undefined && call.input[field] !== null && call.input[field] !== '')
83 const effects: Effect[] = []
84 const seen = new Set<string>()
85 let work = 0
86 for (const name of names) {
87 work += name.length
88 if (work > 4_000_000) return { effects, opaque: OPAQUE.tooMany }
89 const expanded = expandHome(name, ctx.home)
90 const path = expanded === undefined ? undefined : absolute(expanded, ctx.cwd)
91 if (path !== undefined && path.length > MAX_PATH) return { effects, opaque: PATH_TOO_LONG }
92 if (path !== undefined && !seen.has(path)) {
93 if (effects.length >= MAX_EFFECTS) return { effects, opaque: OPAQUE.tooMany }
94 seen.add(path)
95 effects.push({ path, kind: touch.kind, recursive: touch.recursive, ...(fullSearch ? { fullSearch: true as const } : {}) })
96 }
97 }
98 return { effects }
99}
100
101/** The `name` effects of an MCP tool's input: each string, value or key, that `namedPath` reads as a path, each once, in the order of the input; opaque past the serialization budgets of `sortedJson` or past MAX_EFFECTS paths. */
102function namedPaths(input: unknown, home: string | undefined): Pick<Analysis, 'effects' | 'opaque'> {
103 const effects: Effect[] = []
104 const seen = new Set<string>()
105 const pending: { value: unknown; depth: number }[] = [{ value: input, depth: 0 }]
106 let nodes = 0
107 let remaining = 4_000_000
108 const name = (text: string): string | undefined => {
109 remaining -= text.length
110 if (remaining < 0) return INPUT_LIMIT
111 const path = namedPath(text, home)
112 if (path === undefined || seen.has(path)) return undefined
113 if (effects.length >= MAX_EFFECTS) return OPAQUE.tooMany
114 seen.add(path)
115 effects.push({ path, kind: 'name', recursive: false })
116 return undefined
117 }
118 while (pending.length > 0) {
119 const { value, depth } = pending.pop()!
120 if (++nodes > 16_384 || depth > 64) return { effects, opaque: INPUT_LIMIT }
121 const children: unknown[] = []
122 if (typeof value === 'string') {
123 const opaque = name(value)
124 if (opaque !== undefined) return { effects, opaque }
125 } else if (Array.isArray(value)) {
126 if (value.length + nodes + pending.length > 16_384) return { effects, opaque: INPUT_LIMIT }
127 children.push(...value)
128 } else if (isRecord(value)) {
129 for (const key in value) if (Object.prototype.hasOwnProperty.call(value, key)) {
130 if (children.length + nodes + pending.length >= 16_384) return { effects, opaque: INPUT_LIMIT }
131 const opaque = name(key)
132 if (opaque !== undefined) return { effects, opaque }
133 children.push(value[key])
134 }
135 }
136 for (let i = children.length - 1; i >= 0; i -= 1) pending.push({ value: children[i], depth: depth + 1 })
137 }
138 return { effects }
139}
140
141/** The path that a string names, trimmed: an absolute path, a `~/` path, or a local `file:` URI with its `%` escapes decoded, folded; undefined for anything else, for text over more than one line, and for a path longer than MAX_PATH. */
142function namedPath(text: string, home: string | undefined): string | undefined {
143 const word = text.trim()
144 if (/[\r\n]/.test(word)) return undefined
145 const uri = FILE_URI.exec(word)
146 const path = uri !== null ? unescaped(word.slice(uri[0].length)) : word.startsWith('/') ? word : word.startsWith('~/') ? expandHome(word, home) : undefined
147 if (path === undefined || !path.startsWith('/')) return undefined
148 const folded = foldPath(path)
149 return folded.length > MAX_PATH ? undefined : folded
150}
151
152/** Text with each run of `%` escapes decoded as UTF-8; a run that does not decode stays as written. */
153function unescaped(text: string): string {
154 return text.replace(/(?:%[0-9A-Fa-f]{2})+/g, run => {
155 try {
156 return decodeURIComponent(run)
157 } catch {
158 return run
159 }
160 })
161}
162
163/** `tool:<name>:` and the input as JSON with sorted keys, less the fields that do not change what the call does. */
164function toolSkeleton(call: Call): string | undefined {
165 const kept = keptInput(call, key => !NOISE.has(key) && !(call.tool === 'WebFetch' && key === 'prompt'))
166 const text = kept === undefined ? undefined : sortedJson(kept)
167 return text === undefined ? undefined : `tool:${call.tool}:${text}`
168}
169
170/** Filters top-level input fields with bounded key enumeration before serialization. */
171function keptInput(call: Call, keep: (key: string) => boolean): Record<string, unknown> | undefined {
172 const entries: [string, unknown][] = []
173 let count = 0
174 let size = 0
175 for (const key in call.input) if (Object.prototype.hasOwnProperty.call(call.input, key)) {
176 size += key.length
177 if (++count > 16_384 || size > 4_000_000) return undefined
178 if (keep(key)) entries.push([key, call.input[key]])
179 }
180 return Object.fromEntries(entries)
181}
182
183/** Serializes sorted JSON iteratively, returning no identity when depth, entries, or text exceed the budget. */
184export function sortedJson(value: unknown): string | undefined {
185 type Task = { value: unknown; depth: number } | { text: string }
186 const pending: Task[] = [{ value, depth: 0 }]
187 const output: string[] = []
188 let nodes = 0
189 let remaining = 4_000_000
190 while (pending.length > 0) {
191 const task = pending.pop()!
192 if ('text' in task) {
193 remaining -= task.text.length
194 if (remaining < 0) return undefined
195 output.push(task.text)
196 continue
197 }
198 if (++nodes > 16_384 || task.depth > 64) return undefined
199 const item = task.value
200 if (Array.isArray(item)) {
201 if (item.length + nodes + pending.length > 16_384) return undefined
202 output.push('[')
203 remaining -= 1
204 pending.push({ text: ']' })
205 for (let i = item.length - 1; i >= 0; i -= 1) {
206 pending.push({ value: item[i], depth: task.depth + 1 })
207 if (i > 0) pending.push({ text: ',' })
208 }
209 } else if (isRecord(item)) {
210 const keys: string[] = []
211 for (const key in item) if (Object.prototype.hasOwnProperty.call(item, key)) {
212 if (keys.length + nodes + pending.length >= 16_384 || key.length > remaining) return undefined
213 remaining -= key.length
214 keys.push(key)
215 }
216 keys.sort()
217 output.push('{')
218 remaining -= 1
219 pending.push({ text: '}' })
220 for (let i = keys.length - 1; i >= 0; i -= 1) {
221 const key = keys[i]!
222 pending.push({ value: item[key], depth: task.depth + 1 }, { text: `${JSON.stringify(key)}:` })
223 if (i > 0) pending.push({ text: ',' })
224 }
225 } else {
226 if (typeof item === 'string' && item.length > remaining) return undefined
227 if (typeof item === 'bigint') return undefined
228 pending.push({ text: JSON.stringify(item) ?? 'null' })
229 }
230 }
231 return output.join('')
232}
233
234/** A skeleton as Arrester keeps it: whole up to 1,000 characters, else its first 200, its length, and a 32-bit FNV-1a hash of all of it. */
235function fingerprint(skeleton: string): string {
236 if (skeleton.length <= MAX_SKELETON) return skeleton
237 let hash = 0x811c9dc5
238 for (let i = 0; i < skeleton.length; i += 1) hash = Math.imul(hash ^ skeleton.charCodeAt(i), 0x01000193) >>> 0
239 return `${skeleton.slice(0, 200)}…#${skeleton.length}:${hash.toString(16)}`
240}
241
242/** Hashes complete syntax-preserving input and context into a bounded exact-retry identity. */
243function exactIdentity(call: Call, ctx: Context): { exact?: string; opaque?: string } {
244 const shell = call.tool === 'Bash' || call.tool === 'Monitor'
245 const kept = keptInput(call, key => key !== 'requestMeta' && !(shell && ['description', 'timeout', 'timeout_ms'].includes(key)))
246 const text = kept === undefined ? undefined : sortedJson({ tool: call.tool, cwd: ctx.cwd, home: ctx.home, input: kept })
247 return text === undefined ? { opaque: INPUT_LIMIT } : { exact: `sha256:${digest(text)}` }
248}
249
250/** The SHA-256 round constants. */
251const ROUNDS = [
252 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
253 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
254 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
255 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
256 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
257 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
258 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
259 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
260]
261
262/** Rotates one 32-bit word right. */
263function rotate(word: number, bits: number): number {
264 return word >>> bits | word << (32 - bits)
265}
266
267/** SHA-256 over every UTF-8 byte, using one fixed-size compression buffer. */
268function digest(text: string): string {
269 const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
270 const words = new Int32Array(64)
271 let offset = 0
272 let bytes = 0
273 const put = (byte: number): void => {
274 words[offset >>> 2] = words[offset >>> 2]! | byte << (24 - (offset % 4) * 8)
275 offset += 1
276 bytes += 1
277 if (offset !== 64) return
278 for (let i = 16; i < 64; i += 1) {
279 const a = words[i - 15]!
280 const b = words[i - 2]!
281 words[i] = words[i - 16]! + (rotate(a, 7) ^ rotate(a, 18) ^ a >>> 3) + words[i - 7]! + (rotate(b, 17) ^ rotate(b, 19) ^ b >>> 10)
282 }
283 let [a, b, c, d, e, f, g, h] = state as [number, number, number, number, number, number, number, number]
284 for (let i = 0; i < 64; i += 1) {
285 const first = h + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + ROUNDS[i]! + words[i]!
286 const second = (rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))
287 h = g
288 g = f
289 f = e
290 e = d + first | 0
291 d = c
292 c = b
293 b = a
294 a = first + second | 0
295 }
296 const next = [a, b, c, d, e, f, g, h]
297 for (let i = 0; i < 8; i += 1) state[i] = state[i]! + next[i]! | 0
298 words.fill(0, 0, 16)
299 offset = 0
300 }
301 for (const ch of text) {
302 const point = ch.codePointAt(0)!
303 if (point < 128) put(point)
304 else if (point < 2048) {
305 put(0xc0 | point >>> 6)
306 put(0x80 | point & 63)
307 } else if (point < 65536) {
308 put(0xe0 | point >>> 12)
309 put(0x80 | point >>> 6 & 63)
310 put(0x80 | point & 63)
311 } else {
312 put(0xf0 | point >>> 18)
313 put(0x80 | point >>> 12 & 63)
314 put(0x80 | point >>> 6 & 63)
315 put(0x80 | point & 63)
316 }
317 }
318 const length = bytes
319 put(128)
320 while (offset !== 56) put(0)
321 for (const half of [Math.floor(length / 0x20000000), length * 8 >>> 0]) for (const shift of [24, 16, 8, 0]) put(half >>> shift & 255)
322 return state.map(word => (word >>> 0).toString(16).padStart(8, '0')).join('')
323}
324hooks/analyze/paths.ts 597 lines1// Path arithmetic for Arrester: lexical folding, globs, braces, and how an
2// effect's path stands to a protected path. Pure: no `$`.
3
4import type { Effect } from './types'
5
6/** How an effect's path stands to a protected path. */
7export type Relation = 'same' | 'inside' | 'contains'
8
9/** What `relation` reads of an effect: a path, or a glob's prefix folder and remaining glob segments. */
10type Reach = Pick<Effect, 'path' | 'glob' | 'globOptions' | 'recursive' | 'selection'>
11
12/** The comparison work one check has done, in character steps. */
13export type Meter = { steps: number; uncertain?: true; limit?: number }
14
15/** The longest path operand that analysis supports. */
16export const MAX_PATH = 4_096
17
18/** A definite file operand exceeded the supported path length. */
19export const PATH_TOO_LONG = 'a file path exceeds the 4,096-character analysis limit'
20
21const REMOTE = /^[^/]*:/
22const GLOB_CHAR = /[*?[]/
23/** The most work one glob comparison takes before returning uncertainty. */
24const MAX_GLOB_STEPS = 65_536
25/** How deep brace groups may nest inside one another. */
26const MAX_BRACE_DEPTH = 32
27
28/** True when the text holds a glob character: `*`, `?`, or `[`. */
29export function hasGlob(text: string): boolean {
30 return GLOB_CHAR.test(text)
31}
32
33/** True for a URL or a remote `host:path` operand: a colon before any slash. */
34export function isRemote(word: string): boolean {
35 return REMOTE.test(word)
36}
37
38/** True when an operand of a program Arrester does not know looks like a path. */
39export function looksLikePath(word: string): boolean {
40 return !isRemote(word) && (word.includes('/') || word.startsWith('.') || word.startsWith('~'))
41}
42
43/** An absolute path with `.`, `..`, and repeated or trailing slashes folded. */
44export function foldPath(path: string): string {
45 const kept: string[] = []
46 for (const part of path.split('/')) {
47 if (part === '..') kept.pop()
48 else if (part !== '' && part !== '.') kept.push(part)
49 }
50 return `/${kept.join('/')}`
51}
52
53/** The absolute lexical path of `path` read in the absolute folder `dir`. */
54export function joinPath(dir: string, path: string): string {
55 return foldPath(path.startsWith('/') ? path : `${dir}/${path}`)
56}
57
58/** The absolute lexical path of `path` read in `cwd`; undefined for a relative path when the folder is unknown. */
59export function absolute(path: string, cwd: string | undefined): string | undefined {
60 if (path.startsWith('/')) return foldPath(path)
61 return cwd === undefined ? undefined : joinPath(cwd, path)
62}
63
64/** The folder that holds `path`; `/` for `/`. */
65export function dirnameOf(path: string): string {
66 return foldPath(`${path}/..`)
67}
68
69/** The last segment of `path`. */
70export function basenameOf(path: string): string {
71 return path.slice(path.lastIndexOf('/') + 1)
72}
73
74/** `~` and `~/…` with the home folder; undefined when the home folder is unknown; other words as they are. */
75export function expandHome(word: string, home: string | undefined): string | undefined {
76 if (word !== '~' && !word.startsWith('~/')) return word
77 return home === undefined ? undefined : `${home}${word.slice(1)}`
78}
79
80/** One piece of a brace expansion: literal text or a group of alternatives. */
81type Brace = { text: string } | { choices: Brace[][] }
82
83/** Every brace expansion of `text`, in order: comma lists, nested ones too; undefined past `max` words, which are counted before any is built, or past MAX_BRACE_DEPTH levels. */
84export function expandBraces(text: string, max: number): string[] | undefined {
85 if (!text.includes('{')) return [text]
86 const pieces = new BraceReader(text).read(0, text.length, 0)
87 if (pieces === undefined) return undefined
88 const count = countOf(pieces, max)
89 if (count > max || count * text.length > 4_000_000) return undefined
90 return build(pieces)
91}
92
93/** The brace pairs of a text, found in one pass, and the commas each pair holds at its own level. */
94class BraceReader {
95 private readonly text: string
96 private readonly closes = new Map<number, number>()
97 private readonly commas = new Map<number, number[]>()
98
99 constructor(text: string) {
100 this.text = text
101 const open: number[] = []
102 for (let i = 0; i < text.length; i += 1) {
103 const ch = text.charAt(i)
104 if (ch === '{') open.push(i)
105 else if (ch === '}' && open.length > 0) this.closes.set(open.pop() as number, i)
106 else if (ch === ',' && open.length > 0) {
107 const at = open[open.length - 1] as number
108 const commas = this.commas.get(at)
109 if (commas === undefined) this.commas.set(at, [i])
110 else commas.push(i)
111 }
112 }
113 }
114
115 /** The pieces of `text` from `start` to `end`; a pair with no comma at its level stays literal. */
116 read(start: number, end: number, depth: number): Brace[] | undefined {
117 if (depth > MAX_BRACE_DEPTH) return undefined
118 const pieces: Brace[] = []
119 let literal = ''
120 let i = start
121 while (i < end) {
122 const close = this.closes.get(i)
123 const group = close === undefined || close >= end ? undefined : this.group(i, close, depth)
124 if (group === null) return undefined
125 if (group === undefined) {
126 literal += this.text.charAt(i)
127 i += 1
128 continue
129 }
130 if (literal !== '') pieces.push({ text: literal })
131 literal = ''
132 pieces.push(group)
133 i = (close as number) + 1
134 }
135 if (literal !== '') pieces.push({ text: literal })
136 return pieces
137 }
138
139 /** The group of the pair from `open` to `close`; undefined when it is literal text; null when it nests too deep. */
140 private group(open: number, close: number, depth: number): Brace | undefined | null {
141 const commas = this.commas.get(open)
142 if (commas === undefined) return undefined
143 const choices: Brace[][] = []
144 const bounds = [open, ...commas, close]
145 for (let k = 0; k + 1 < bounds.length; k += 1) {
146 const choice = this.read((bounds[k] as number) + 1, bounds[k + 1] as number, depth + 1)
147 if (choice === undefined) return null
148 choices.push(choice)
149 }
150 return { choices }
151 }
152}
153
154/** How many words the pieces expand to, stopping once the count passes `max`. */
155function countOf(pieces: readonly Brace[], max: number): number {
156 let total = 1
157 for (const piece of pieces) {
158 if ('text' in piece) continue
159 let options = 0
160 for (const choice of piece.choices) options += countOf(choice, max)
161 total *= options
162 if (total > max) return max + 1
163 }
164 return total
165}
166
167/** The words the pieces expand to, in order. */
168function build(pieces: readonly Brace[]): string[] {
169 let words = ['']
170 for (const piece of pieces) {
171 const options = 'text' in piece ? [piece.text] : piece.choices.flatMap(build)
172 words = words.flatMap(word => options.map(option => word + option))
173 }
174 return words
175}
176
177/** A glob path as its literal prefix folder and remaining glob segments; undefined when no segment holds a glob character. */
178export function splitGlob(path: string): { path: string; glob: string } | undefined {
179 const segments = path.split('/').filter(segment => segment !== '')
180 const at = segments.findIndex(hasGlob)
181 const glob = segments[at]
182 return glob === undefined ? undefined : { path: `/${segments.slice(0, at).join('/')}`, glob: segments.slice(at).join('/') }
183}
184
185/** ASCII members of the standard POSIX character classes. */
186const CHARACTER_CLASSES: Readonly<Record<string, RegExp>> = {
187 alnum: /^[A-Za-z0-9]$/, alpha: /^[A-Za-z]$/, blank: /^[ \t]$/, cntrl: /^[\x00-\x1f\x7f]$/,
188 digit: /^[0-9]$/, graph: /^[!-~]$/, lower: /^[a-z]$/, print: /^[ -~]$/,
189 punct: /^[!-/:-@[-`{-~]$/, space: /^[ \t\r\n\v\f]$/, upper: /^[A-Z]$/, xdigit: /^[A-Fa-f0-9]$/,
190}
191
192/** The closing bracket of a glob expression, skipping named character classes and a leading literal bracket. */
193function bracketEnd(pattern: string, start: number): number {
194 let i = start + 1
195 if (pattern.charAt(i) === '!' || pattern.charAt(i) === '^') i += 1
196 if (pattern.charAt(i) === ']') i += 1
197 for (; i < pattern.length; i += 1) {
198 if (pattern.startsWith('[:', i)) {
199 const end = pattern.indexOf(':]', i + 2)
200 if (end < 0) return pattern.indexOf(']', i + 2)
201 i = end + 1
202 continue
203 }
204 if (pattern.charAt(i) === ']') return i
205 }
206 return -1
207}
208
209/** One step of a glob: the index after the token at `p` when it matches `ch`, else -1. */
210function stepGlob(pattern: string, p: number, ch: string): number {
211 const token = pattern.charAt(p)
212 if (token === '?') return p + 1
213 if (token !== '[') return token === ch ? p + 1 : -1
214 const end = bracketEnd(pattern, p)
215 if (end < 0) return ch === '[' ? p + 1 : -1
216 const negate = pattern.charAt(p + 1) === '!' || pattern.charAt(p + 1) === '^'
217 const body = pattern.slice(negate ? p + 2 : p + 1, end)
218 let hit = false
219 for (let i = 0; i < body.length; i += 1) {
220 if (body.startsWith('[:', i)) {
221 const end = body.indexOf(':]', i + 2)
222 if (end >= 0) {
223 hit = hit || CHARACTER_CLASSES[body.slice(i + 2, end)]?.test(ch) === true
224 i = end + 1
225 continue
226 }
227 }
228 if (body.charAt(i + 1) === '-' && i + 2 < body.length) {
229 hit = hit || (body.charAt(i) <= ch && ch <= body.charAt(i + 2))
230 i += 2
231 } else hit = hit || body.charAt(i) === ch
232 }
233 return hit === negate ? -1 : end + 1
234}
235
236/** The pattern with each run of `*` as one `*`, which matches the same names. */
237function compact(pattern: string): string {
238 return pattern.replace(/\*+/g, '*')
239}
240
241/** Charges actual comparison work without interpreting exhaustion as a match. */
242class Comparison {
243 private steps = 0
244
245 constructor(private readonly meter?: Meter) {}
246
247 /** True while this comparison remains inside its work budget. */
248 spend(units = 1): boolean {
249 this.steps += units
250 if (this.meter !== undefined) this.meter.steps += units
251 return this.steps <= MAX_GLOB_STEPS && (this.meter === undefined || this.meter.steps <= (this.meter.limit ?? Infinity))
252 }
253}
254
255/** Safe literal edges stop impossible comparisons before wildcard work. */
256function edges(pattern: string, tokens: readonly GlobToken[]): { prefix: string; suffix: string } {
257 const literal = (token: GlobToken): string | undefined => 'star' in token || pattern.charAt(token.at) === '?' || pattern.charAt(token.at) === '[' && token.end > token.at ? undefined : pattern.charAt(token.at)
258 let prefix = ''
259 let suffix = ''
260 for (const token of tokens) {
261 const ch = literal(token)
262 if (ch === undefined) break
263 prefix += ch
264 }
265 for (let i = tokens.length - 1; i >= 0; i -= 1) {
266 const ch = literal(tokens[i]!)
267 if (ch === undefined) break
268 suffix = ch + suffix
269 }
270 return { prefix, suffix }
271}
272
273/** Matches a complete name, or returns uncertainty when bounded comparison work expires. */
274export function globMatch(pattern: string, name: string, meter?: Meter, dot = false): boolean | 'uncertain' {
275 if (!dot && name.startsWith('.') && !pattern.startsWith('.')) return false
276 const work = new Comparison(meter)
277 if (!work.spend(pattern.length + name.length)) return 'uncertain'
278 const glob = compact(pattern)
279 const tokens = globTokens(glob, work)
280 if (tokens === undefined) return 'uncertain'
281 const edge = edges(glob, tokens)
282 if (!name.startsWith(edge.prefix) || !name.endsWith(edge.suffix)) return false
283 let p = 0
284 let n = 0
285 let star = -1
286 let mark = 0
287 while (n < name.length) {
288 if (!work.spend(glob.charAt(p) === '[' ? glob.length - p : 1)) return 'uncertain'
289 if (glob.charAt(p) === '*') {
290 star = p
291 mark = n
292 p += 1
293 continue
294 }
295 const next = p < glob.length ? stepGlob(glob, p, name.charAt(n)) : -1
296 if (next >= 0) {
297 p = next
298 n += 1
299 } else if (star >= 0) {
300 p = star + 1
301 mark += 1
302 n = mark
303 } else return false
304 }
305 while (glob.charAt(p) === '*') p += 1
306 return p === glob.length
307}
308
309/** One token of a glob segment: `*`, or one character position of the pattern (`?`, a class, or a literal). */
310type GlobToken = { star: true } | { at: number; end: number }
311
312/** The tokens of a compacted glob segment. */
313function globTokens(glob: string, work: Comparison): GlobToken[] | undefined {
314 const tokens: GlobToken[] = []
315 for (let p = 0; p < glob.length; ) {
316 if (!work.spend(glob.charAt(p) === '[' ? glob.length - p : 1)) return undefined
317 if (glob.charAt(p) === '*') {
318 tokens.push({ star: true })
319 p += 1
320 continue
321 }
322 const end = glob.charAt(p) === '[' ? bracketEnd(glob, p) : -1
323 tokens.push({ at: p, end: Math.max(p, end) })
324 p = end < 0 ? p + 1 : end + 1
325 }
326 return tokens
327}
328
329/** Finds one character accepted by every current token, including bracket intersections. */
330function sharedCharacter(globs: readonly string[], tokens: readonly (GlobToken | undefined)[], leadingDot: boolean, work: Comparison): boolean | 'uncertain' {
331 if (tokens.some(token => token === undefined)) return false
332 const fixed = tokens.flatMap((token, i) => {
333 if (token === undefined || 'star' in token) return []
334 const pattern = globs[i]!
335 const ch = pattern.charAt(token.at)
336 return ch === '?' || ch === '[' && token.end > token.at ? [] : [ch.charCodeAt(0)]
337 })
338 const candidates = new Set<number>(fixed.length > 0 ? [fixed[0]!] : [65535])
339 if (fixed.length === 0) for (let point = 1; point < 128; point += 1) candidates.add(point)
340 for (const [i, token] of (fixed.length > 0 ? [] : tokens).entries()) {
341 if (token === undefined || 'star' in token) continue
342 const pattern = globs[i]!
343 const end = token.end
344 if (!work.spend(Math.max(1, end - token.at + 1))) return 'uncertain'
345 for (let j = token.at; j <= Math.max(token.at, end); j += 1) {
346 const point = pattern.charCodeAt(j)
347 for (const value of [point - 1, point, point + 1]) if (value > 0 && value <= 65535) candidates.add(value)
348 }
349 }
350 for (const point of candidates) {
351 if (point === 47 || point === 46 && !leadingDot) continue
352 let matches = true
353 for (const [i, token] of tokens.entries()) {
354 if (token === undefined || 'star' in token) continue
355 const pattern = globs[i]!
356 if (!work.spend(pattern.charAt(token.at) === '[' ? pattern.length - token.at : 1)) return 'uncertain'
357 if (stepGlob(pattern, token.at, String.fromCharCode(point)) < 0) {
358 matches = false
359 break
360 }
361 }
362 if (matches) return true
363 }
364 return false
365}
366
367/** Requires one shared name across all patterns, with bounded product-state traversal. */
368function intersectGlobs(patterns: readonly string[], dots: readonly boolean[], meter?: Meter): boolean | 'uncertain' {
369 const work = new Comparison(meter)
370 if (!work.spend(patterns.reduce((sum, pattern) => sum + pattern.length + 1, 0))) return 'uncertain'
371 const globs = patterns.map(compact)
372 const tokens: GlobToken[][] = []
373 for (const glob of globs) {
374 const parsed = globTokens(glob, work)
375 if (parsed === undefined) return 'uncertain'
376 tokens.push(parsed)
377 }
378 const literalEdges = globs.map((glob, i) => edges(glob, tokens[i]!))
379 for (let i = 0; i < globs.length; i += 1) for (let j = 0; j < i; j += 1) {
380 const a = literalEdges[i]!
381 const b = literalEdges[j]!
382 if (!work.spend(a.prefix.length + b.prefix.length + a.suffix.length + b.suffix.length + 1)) return 'uncertain'
383 if (!a.prefix.startsWith(b.prefix) && !b.prefix.startsWith(a.prefix) || !a.suffix.endsWith(b.suffix) && !b.suffix.endsWith(a.suffix)) return false
384 if (globs[i]!.startsWith('.') && !globs[j]!.startsWith('.') && !dots[j] || globs[j]!.startsWith('.') && !globs[i]!.startsWith('.') && !dots[i]) return false
385 }
386 const pending = [{ at: globs.map(() => 0), started: false }]
387 const seen = new Set<string>()
388 while (pending.length > 0) {
389 if (!work.spend(globs.length + 1)) return 'uncertain'
390 const state = pending.pop()!
391 const key = `${state.started}:${state.at.join(',')}`
392 if (seen.has(key)) continue
393 seen.add(key)
394 if (state.at.every((at, i) => at === tokens[i]!.length)) return true
395 const current = state.at.map((at, i) => tokens[i]![at])
396 for (const [i, token] of current.entries()) if (token !== undefined && 'star' in token) {
397 if (!work.spend(globs.length)) return 'uncertain'
398 const next = [...state.at]
399 next[i] = next[i]! + 1
400 pending.push({ at: next, started: state.started })
401 }
402 const leadingDot = state.started || globs.every((glob, i) => dots[i] || glob.startsWith('.'))
403 const matches = sharedCharacter(globs, current, leadingDot, work)
404 if (matches === 'uncertain') return matches
405 if (matches) pending.push({ at: state.at.map((at, i) => current[i] !== undefined && 'star' in current[i]! ? at : at + 1), started: true })
406 }
407 return false
408}
409
410/** Whether one name matches both glob segments, preserving comparison uncertainty. */
411export function globsMeet(first: string, second: string, meter?: Meter, dot = false, otherDot = false): boolean | 'uncertain' {
412 return intersectGlobs([first, second], [dot, otherDot], meter)
413}
414
415/** The find names of a selection, all of which an entry must match. */
416export function selectionNames(selection: Reach['selection']): string[] {
417 return selection?.names ?? []
418}
419
420/** Requires all predicates to match the same literal name or glob candidate. */
421function namesMatch(names: readonly string[], name: string, glob: boolean, meter?: Meter, dot = false): boolean | 'uncertain' {
422 if (glob) return intersectGlobs([...names, name], [...names.map(() => true), dot], meter)
423 let uncertain = false
424 for (const pattern of names) {
425 const matches = globMatch(pattern, name, meter, true)
426 if (matches === false) return false
427 if (matches === 'uncertain') uncertain = true
428 }
429 return uncertain ? 'uncertain' : true
430}
431
432/** A path or a path segment as a case-insensitive comparison reads it. */
433export function foldCase(segment: string): string {
434 return segment.toLowerCase().replace(/\[:upper:\]/g, '[:lower:]')
435}
436
437/** How the effect stands to a protected path or glob, comparing each segment within the work budget. */
438export function relation(reach: Reach, target: string, meter?: Meter, targetGlob?: string, targetOptions?: Effect['globOptions']): Relation | 'uncertain' | undefined {
439 if (reach.selection !== undefined) return selectedRelation(reach, target, meter, targetGlob, targetOptions)
440 const dot = reach.globOptions?.dot === true
441 const targetDot = targetOptions?.dot === true
442 const nocase = reach.globOptions?.nocase || targetOptions?.nocase
443 const left = reach.path.split('/').filter(Boolean)
444 const right = target.split('/').filter(Boolean)
445 const leftPrefix = left.length
446 const rightPrefix = right.length
447 if (reach.glob !== undefined) left.push(...reach.glob.split('/'))
448 if (targetGlob !== undefined) right.push(...targetGlob.split('/'))
449 if (nocase) {
450 for (let i = 0; i < left.length; i += 1) left[i] = foldCase(left[i]!)
451 for (let i = 0; i < right.length; i += 1) right[i] = foldCase(right[i]!)
452 }
453 if (meter !== undefined) meter.steps += reach.path.length + target.length + (reach.glob?.length ?? 0) + (targetGlob?.length ?? 0)
454 if (left.slice(leftPrefix, -1).includes('**') || right.slice(rightPrefix, -1).includes('**')) return recursiveRelation(left, right, leftPrefix, rightPrefix, reach.recursive, meter, dot, targetDot)
455 let uncertain = false
456 for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
457 const a = left[i] as string
458 const b = right[i] as string
459 const aGlob = i >= leftPrefix && hasGlob(a)
460 const bGlob = i >= rightPrefix && hasGlob(b)
461 const matches = aGlob && bGlob ? globsMeet(a, b, meter, dot, targetDot) : aGlob ? globMatch(a, b, meter, dot) : bGlob ? globMatch(b, a, meter, targetDot) : a === b
462 if (matches === false) return undefined
463 if (matches === 'uncertain') uncertain = true
464 }
465 if (uncertain) return 'uncertain'
466 if (left.length === right.length) return 'same'
467 if (left.length > right.length) return 'inside'
468 return reach.recursive ? 'contains' : undefined
469}
470
471/** Compares a find selection to the target and its ancestors, without inventing deletion of the starting tree. */
472function selectedRelation(reach: Reach, target: string, meter?: Meter, targetGlob?: string, targetOptions?: Effect['globOptions']): Relation | 'uncertain' | undefined {
473 const selection = reach.selection as NonNullable<Reach['selection']>
474 const minDepth = selection.minDepth ?? 0
475 const maxDepth = selection.maxDepth ?? Infinity
476 if (minDepth > maxDepth) return undefined
477 const names = selectionNames(selection)
478 const possible = selectionPossible(selection, meter)
479 if (possible === false) return undefined
480 if (possible === 'uncertain') return possible
481 if (selection.parentOperand !== undefined) return parentRelation(reach, target, meter, targetGlob, targetOptions)
482 const base = relation({ ...reach, selection: undefined, recursive: true }, target, meter, targetGlob, targetOptions)
483 if (base === undefined || base === 'uncertain') return base
484 if (base === 'inside') return base
485 if (base === 'same') {
486 const candidate = targetGlob?.split('/').at(-1) ?? basenameOf(target)
487 const rootGlob = reach.glob?.split('/').at(-1)
488 const predicates = rootGlob === undefined ? names : [...names, rootGlob]
489 const matches = namesMatch(predicates, candidate, targetGlob !== undefined && hasGlob(candidate), meter, targetOptions?.dot)
490 if (matches === 'uncertain') return matches
491 return minDepth === 0 && matches ? 'same' : maxDepth > 0 ? 'inside' : undefined
492 }
493 const descend = selection.descend === true
494 const rootDepth = reach.path.split('/').filter(Boolean).length + (reach.glob?.split('/').length ?? 0)
495 const parts = `${target}${targetGlob === undefined ? '' : `/${targetGlob}`}`.split('/').filter(Boolean)
496 const rootName = parts[rootDepth - 1] ?? ''
497 if (descend && !selection.file && minDepth === 0) {
498 const matches = namesMatch(names, rootName, targetGlob !== undefined && rootDepth > target.split('/').filter(Boolean).length && hasGlob(rootName), meter, targetOptions?.dot)
499 if (matches === 'uncertain') return matches
500 if (matches) return 'contains'
501 }
502 const suffix = parts.slice(rootDepth)
503 for (let i = 0; i < suffix.length; i += 1) {
504 if (i + 1 < minDepth || i + 1 > maxDepth) continue
505 const last = i === suffix.length - 1
506 if (!last && (selection.file || !descend)) continue
507 const part = suffix[i] as string
508 const matches = namesMatch(names, part, targetGlob !== undefined && rootDepth + i >= target.split('/').filter(Boolean).length && hasGlob(part), meter, targetOptions?.dot)
509 if (matches === 'uncertain') return matches
510 if (matches) return last ? 'same' : 'contains'
511 }
512 return undefined
513}
514
515/** Compares an execdir sibling operand below each possible selected entry's parent, retaining selection depth. */
516function parentRelation(reach: Reach, target: string, meter?: Meter, targetGlob?: string, targetOptions?: Effect['globOptions']): Relation | 'uncertain' | undefined {
517 const selection = reach.selection!
518 const rootDepth = reach.path.split('/').filter(Boolean).length + (reach.glob?.split('/').length ?? 0)
519 const parts = target.split('/').filter(Boolean)
520 const min = Math.max(0, (selection.minDepth ?? 1) - 1)
521 const max = (selection.maxDepth ?? Infinity) - 1
522 for (let depth = min; depth <= max && rootDepth + depth <= parts.length; depth += 1) {
523 const parent = `/${parts.slice(0, rootDepth + depth).join('/')}`
524 const base = relation({ path: reach.path, glob: reach.glob, recursive: true }, parent, meter)
525 if (base === undefined) continue
526 if (base === 'uncertain') return base
527 const operand = joinPath(parent, selection.parentOperand!)
528 const glob = selection.parentGlob ? splitGlob(operand) : undefined
529 const found = relation({ path: glob?.path ?? operand, glob: glob?.glob, globOptions: reach.globOptions, recursive: selection.descend === true }, target, meter, targetGlob, targetOptions)
530 if (found !== undefined) return found
531 }
532 return undefined
533}
534
535/** Matches recursive directory segments with bounded iterative states, preserving ordinary wildcard and leading-dot rules. */
536function recursiveRelation(left: readonly string[], right: readonly string[], leftPrefix: number, rightPrefix: number, recursive: boolean, meter?: Meter, dot = false, targetDot = false): Relation | 'uncertain' | undefined {
537 const pending: [number, number][] = [[0, 0]]
538 const seen = new Set<number>()
539 const work: Meter = { steps: 0 }
540 let found: Relation | undefined
541 let uncertain = false
542 while (pending.length > 0) {
543 work.steps += 1
544 if (work.steps > MAX_GLOB_STEPS) {
545 uncertain = true
546 break
547 }
548 const [i, j] = pending.pop() as [number, number]
549 const key = i * (right.length + 1) + j
550 if (seen.has(key)) continue
551 seen.add(key)
552 const a = left[i]
553 const b = right[j]
554 const aStar = i >= leftPrefix && i + 1 < left.length && a === '**'
555 const bStar = j >= rightPrefix && j + 1 < right.length && b === '**'
556 if (aStar) pending.push([i + 1, j])
557 if (bStar) pending.push([i, j + 1])
558 if (a === undefined || b === undefined) {
559 if (a === undefined && b === undefined) {
560 found = 'same'
561 break
562 }
563 if (b === undefined) found = 'inside'
564 else if (recursive && found === undefined) found = 'contains'
565 continue
566 }
567 if (aStar || bStar) {
568 if (aStar && (dot || !b.startsWith('.'))) pending.push([i, j + 1])
569 if (bStar && (targetDot || !a.startsWith('.'))) pending.push([i + 1, j])
570 continue
571 }
572 const aGlob = i >= leftPrefix && hasGlob(a)
573 const bGlob = j >= rightPrefix && hasGlob(b)
574 const matches = aGlob && bGlob ? globsMeet(a, b, work, dot, targetDot) : aGlob ? globMatch(a, b, work, dot) : bGlob ? globMatch(b, a, work, targetDot) : a === b
575 if (work.steps > MAX_GLOB_STEPS) {
576 uncertain = true
577 break
578 }
579 if (matches === 'uncertain') uncertain = true
580 else if (matches) pending.push([i + 1, j + 1])
581 }
582 if (meter !== undefined) meter.steps += work.steps
583 return found ?? (uncertain ? 'uncertain' : undefined)
584}
585
586/** A stable equality key retains every selection constraint without depending on property order. */
587export function selectionKey(selection: Reach['selection']): string | undefined {
588 if (selection === undefined) return undefined
589 return JSON.stringify([selection.parentOperand, selection.parentGlob === true, [...new Set(selectionNames(selection))].sort(), selection.file === true, selection.minDepth ?? 0, selection.maxDepth, selection.descend])
590}
591
592/** Whether a find conjunction has any possible shared name, with explicit bounded uncertainty. */
593export function selectionPossible(selection: Reach['selection'], meter?: Meter): boolean | 'uncertain' {
594 const names = selectionNames(selection)
595 return intersectGlobs(names, names.map(() => true), meter)
596}
597hooks/analyze/shell.ts 3181 lines1// The shell analysis: what a command line does to which paths,
2// the retry skeleton of each command, and why some of it cannot be read.
3// Pure apart from io.readScript; it never runs anything.
4
5import { scanCode } from './code'
6import type { Dialect } from './code'
7import { heredocWord, lex, UNPARSED, wordText } from './lexer'
8import type { Part, Redirect, Token, Word } from './lexer'
9import { absolute, basenameOf, dirnameOf, expandBraces, globMatch, hasGlob, isRemote, joinPath, looksLikePath, MAX_PATH, PATH_TOO_LONG, selectionKey, selectionNames, selectionPossible, splitGlob } from './paths'
10import type { Meter } from './paths'
11import { ALWAYS_RECURSIVE, ARCHIVE, ATTACHED, CLAUDE_OFF, CLAUDE_TOOLS, CODE_FIRST, CODE_FLAGS, COPY, DESTROY, family, GIT_SUB_VALUES, hasFlag, INTERPRETER, isDecoder, isKnown, KEYWORDS, LIST, MCP_RUNS, MODE_FIRST, PASS_THROUGH, PATHY, PATTERN_FIRST, PLUGIN_OFF, READ, RECURSIVE_FLAGS, RUNNERS, RUNUSER, SHELLS, TRIVIAL, UNLINKERS, valuesOf, WRAPPERS, WRITE } from './programs'
12import type { Runner, Values } from './programs'
13import type { Analysis, Context, Effect, EffectKind, Ignored, Io, Segment } from './types'
14
15/** Why some code cannot be read. */
16export const OPAQUE = {
17 variableProgram: 'the command names its program with a variable',
18 unresolvedDeletion: 'a deletion names its target through a value that Arrester cannot read',
19 variableCode: 'code comes from a variable or a command substitution',
20 unreadableStdin: 'a shell or interpreter runs code from a command that Arrester cannot read',
21 decoded: 'a decoded or transformed text runs as code',
22 scriptUnreadable: 'a script cannot be read',
23 scriptTooLong: 'a script is too long to read',
24 tooDeep: 'code nests deeper than Arrester reads',
25 shellProgram: 'su or runuser names a shell that Arrester cannot read',
26 tooMany: 'the call names too many paths to read',
27 interrupted: 'the call was interrupted',
28} as const
29
30/** The bypass of a nested `claude` run, which does not inherit the open blocks. */
31export const NESTED_SESSION = 'a nested session'
32
33/** How deep `sh -c`, `eval`, command substitutions, and scripts may nest. */
34const MAX_DEPTH = 3
35
36/** The most values one word may resolve to, counted before they are built. */
37const MAX_VALUES = 256
38
39/** The most word values one analysis resolves in all. */
40const MAX_TOTAL_VALUES = 16_384
41
42/** The most effects one analysis keeps. */
43export const MAX_EFFECTS = 256
44
45/** The longest value one word may build, counted before it is built: the longest command Arrester reads. */
46const MAX_TEXT = 100_000
47
48/** The most work one analysis does, in characters read, characters built, and values copied; past it the analysis stops and is opaque. */
49const MAX_WORK = 4_000_000
50
51/** One word value: its text, whether it represents glob matches, and a pattern that preserves quoted wildcard positions. */
52type Value = { selected?: true; text: string; glob: boolean; pattern?: string; globOptions?: Effect['globOptions']; selection?: Effect['selection'] }
53
54/** A word as the walker sees it: its text as typed, and its values (undefined when it does not resolve); `upstream` marks an operand that reaches below its paths, as one that `xargs` or `find -exec` takes from what came before; `process` marks a process substitution, whose generated file name is unknown; `possible` holds, for a word that does not resolve, the values that the default or the alternate of a parameter operator gives it. */
55type Operand = { raw: string; values: Value[] | undefined; upstream?: true; process?: true; possible?: Value[] }
56
57/** The shell state one command line runs in; `reaching` names the variables bound to operands that reach below their paths; `possible` holds the possible values of a variable that has no known value, from an assignment of a parameter operator's default; `functions` holds the bodies of the functions defined so far and `named` the text of each alias. The links made and the files written are on disk, so every copy shares them. */
58type State = {
59 status?: boolean
60 terminated?: boolean
61 shell?: string
62 globOptions?: Effect['globOptions']
63 noglob?: boolean
64 globstar?: boolean
65 positional?: readonly Operand[]
66 execdir?: { root: string; selection: NonNullable<Effect['selection']> }
67 cwd: string | undefined
68 prev: string | undefined
69 stack: readonly (string | undefined)[]
70 vars: Map<string, Value[]>
71 reaching: Set<string>
72 exported: Set<string>
73 removed: Set<string>
74 emptyEnv: boolean
75 aliases: Map<string, string>
76 written: Map<string, string | undefined>
77 functions: Map<string, Simple>
78 named: Map<string, string>
79 possible: Map<string, Value[]>
80}
81
82/** The printing options on a shell's command line, in order: `verbose` prints the code that the shell reads, and `xtrace` the commands that it runs. */
83type Printing = { verbose?: boolean; xtrace?: boolean }
84
85/** One command with its redirections and optional compound body; `definition` names the function that the command defines, or is empty for a name that is not plain text, and `exits` marks a function body that names `return` or `exit`. */
86type Simple = { words: Word[]; redirects: Redirect[]; body?: Pipeline[]; subshell?: boolean; branches?: { condition?: Pipeline[]; body: Pipeline[] }[]; definition?: string; exits?: true; loop?: { condition?: Pipeline[]; until?: boolean; name?: string; words?: Word[] } }
87
88/** One pipeline and the operator that follows it. */
89type Pipeline = { commands: Simple[]; negate: boolean; after: string }
90
91/** A redirection, its resolved target, and for a heredoc or a here-string the text it gives. */
92type Redirected = { redirect: Redirect; target: Operand; text?: { text: string; uncertain?: true } }
93
94/** A descriptor's open destination, including inherited output streams and closed descriptors. */
95type Destination = Redirected | 'stdout' | 'stderr' | undefined
96
97/** What a command reads on stdin, and what it hands the next stage: nothing, literal text, a file's content, a listing of paths at or below `paths`, decoded text, or anything else, which `closed` marks as a stdin that `<&-` closed. */
98type Stdin = { kind: 'none' } | { kind: 'literal'; text: string; uncertain?: true; positions?: number[] } | { kind: 'script'; path: string } | { kind: 'paths'; paths: Operand[]; separator: string } | { kind: 'decoder' } | { kind: 'other'; closed?: true }
99
100const NONE: Stdin = { kind: 'none' }
101const OTHER: Stdin = { kind: 'other' }
102const CLOSED: Stdin = { kind: 'other', closed: true }
103const DECODER: Stdin = { kind: 'decoder' }
104
105/** A command's flags and the values of its value-taking flags. */
106type Options = { flags: string[]; given: Map<string, Operand[]>; ordered: { flag: string; value: Operand }[] }
107
108/** A command's flags, its operands, the values of its value-taking flags, and where `--` split the operands. */
109type Parsed = Options & { operands: Operand[]; split: number | undefined }
110
111/** The values that the command substitutions of one command gave, per part, so a word that brace expansion copies walks each one once. */
112type Substituted = Map<Part, string | undefined>
113
114/** What one analysis collects while it walks. */
115class Walk {
116 readonly effects: Effect[] = []
117 private readonly effectKeys = new Set<string>()
118 readonly segments: Segment[] = []
119 readonly home: string | undefined
120 readonly io: Io
121 opaque: string | undefined
122 ignored: Ignored | undefined
123 bypass: string | undefined
124 /** The older script being walked: code in it that cannot be read does not count. */
125 oldScript: string | undefined
126 depth = 0
127 /** How many word values the analysis has resolved so far. */
128 values = 0
129 /** How much work the analysis has done so far. */
130 work = 0
131 /** The aliases whose text is being walked, which do not expand again inside it. */
132 readonly expanding = new Set<string>()
133
134 constructor(home: string | undefined, io: Io) {
135 this.home = home
136 this.io = io
137 }
138
139 /** Marks the analysis opaque, or inside an older script records what was not counted; the first reason stays. */
140 fail(reason: string): void {
141 if (this.oldScript === undefined) this.opaque ??= reason
142 else this.ignored ??= { path: this.oldScript, reason }
143 }
144
145 /** Charges `units` of work; past MAX_WORK the analysis stops and is opaque, inside an older script too, since the rest of the call goes unread. */
146 charge(units: number): boolean {
147 if (this.io.signal.aborted) {
148 this.opaque ??= OPAQUE.interrupted
149 this.work = MAX_WORK + 1
150 return false
151 }
152 this.work += units
153 if (this.work <= MAX_WORK) return true
154 this.opaque ??= OPAQUE.tooMany
155 return false
156 }
157
158 /** True once the work passed MAX_WORK. */
159 isSpent(): boolean {
160 return this.work > MAX_WORK
161 }
162
163 /** Counts `count` more word values; past MAX_TOTAL_VALUES the analysis is opaque. */
164 count(count: number): boolean {
165 this.values += count
166 if (this.values <= MAX_TOTAL_VALUES) return true
167 this.fail(OPAQUE.tooMany)
168 return false
169 }
170
171 /** Keeps an effect, a `fullSearch` one apart from the same effect without the mark; past MAX_EFFECTS the analysis is opaque. */
172 add(effect: Effect): void {
173 const key = JSON.stringify([effect.path, effect.kind, effect.recursive, effect.entry, effect.glob, effect.globOptions?.dot === true, effect.globOptions?.nocase === true, selectionKey(effect.selection), ...(effect.fullSearch === true ? [true] : [])])
174 if (!this.charge(key.length) || this.effectKeys.has(key)) return
175 if (this.effects.length < MAX_EFFECTS) {
176 this.effectKeys.add(key)
177 this.effects.push(effect)
178 }
179 else this.fail(OPAQUE.tooMany)
180 }
181}
182
183/** Everything a program handler reads. */
184type Run = { walk: Walk; state: State; program: string; args: Operand[]; stdin: Stdin; find?: { starts: Operand[]; expression: FindTerm[] } }
185
186const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)(\+?)=/
187const ASSIGNERS = new Set(['export', 'declare', 'local', 'readonly'])
188const DIRECTORY_CHANGERS = new Set(['cd', 'pushd', 'popd'])
189const DEVICES = /^\/dev\/(null|stdout|stderr|tty|fd\/[0-9]+)$/
190const DUPLICATE = /^([0-9]+|-)$/
191const TRUNCATING = new Set(['>', '>|', '&>', '>&'])
192const SYMBOLIC_MODE = /^[ugoa]*[-+=][rwxXst]*$/
193const ESCAPED = /\\(x[0-9a-fA-F]|[0-7])|%b/
194const GIT_GLOBAL_VALUES = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
195const MARK = /\uE000([0-9]+)\uE001/g
196const MARKED = /[\uE000\uE001]/
197const IN_PLACE_CLUSTER = /^-[A-Za-z]*[iI]$/
198const CLAUDE_NAME = /^claude(?:-code)?$/i
199const CLAUDE_VERSION = /\/claude\/versions\/[^/]+$/i
200
201/** Analyzes one shell command line. */
202export async function analyzeShell(command: string, ctx: Context, io: Io): Promise<Analysis> {
203 const walk = new Walk(ctx.home, io)
204 await runText(walk, command, freshState(ctx.cwd))
205 return { effects: walk.effects, segments: walk.segments, opaque: walk.opaque, ignored: walk.ignored, bypass: walk.bypass }
206}
207
208/** A fresh shell with its folder and HOME inherited from the environment. */
209function freshState(cwd: string | undefined): State {
210 return { cwd, prev: undefined, stack: [], positional: [], vars: new Map(), reaching: new Set(), exported: new Set(['HOME']), removed: new Set(), emptyEnv: false, aliases: new Map(), written: new Map(), functions: new Map(), named: new Map(), possible: new Map() }
211}
212
213/** A copy of the state for a subshell or nested code; the links made and the files written stay shared, as on disk. */
214function fork(walk: Walk, state: State): State {
215 if (!walk.charge(8 * (state.vars.size + state.reaching.size + state.exported.size + state.removed.size + state.functions.size + state.named.size + state.possible.size + 1))) return freshState(state.cwd)
216 return { ...state, vars: new Map(state.vars), reaching: new Set(state.reaching), exported: new Set(state.exported), removed: new Set(state.removed), functions: new Map(state.functions), named: new Map(state.named), possible: new Map(state.possible) }
217}
218
219/** The state of a child process: exported variables and their possible values, the functions, and the shared files, in the current folder. */
220function sharedFresh(walk: Walk, state: State): State {
221 if (!walk.charge(state.exported.size + state.removed.size + state.functions.size + 1)) return freshState(state.cwd)
222 const child = { ...freshState(state.cwd), execdir: state.execdir, aliases: state.aliases, written: state.written, removed: new Set(state.removed), emptyEnv: state.emptyEnv, functions: new Map(state.functions) }
223 child.exported = new Set(state.exported)
224 if (!state.exported.has('HOME')) child.removed.add('HOME')
225 for (const name of state.exported) {
226 const value = state.vars.get(name)
227 if (value !== undefined) child.vars.set(name, value)
228 if (state.reaching.has(name)) child.reaching.add(name)
229 const possible = state.possible.get(name)
230 if (possible !== undefined) child.possible.set(name, possible)
231 }
232 return child
233}
234
235/** Lexes and walks a piece of shell text. */
236async function runText(walk: Walk, text: string, state: State, input: Stdin = NONE): Promise<void> {
237 if (!walk.charge(text.length)) return
238 const lexed = lex(text)
239 if ('error' in lexed) walk.fail(lexed.error)
240 else await runTokens(walk, lexed.tokens, state, input)
241}
242
243/** Walks code one level deeper; past MAX_DEPTH the analysis is opaque. */
244async function nested(walk: Walk, text: string, state: State, input: Stdin = NONE): Promise<void> {
245 if (walk.depth >= MAX_DEPTH) {
246 walk.fail(OPAQUE.tooDeep)
247 return
248 }
249 walk.depth += 1
250 await runText(walk, text, state, input)
251 walk.depth -= 1
252}
253
254/** An and-or list in progress: how many pipelines ran, whether only `&&` joined them so far, whether one that may not have run moved the folder. */
255type List = { count: number; isPure: boolean; isMoved: boolean; operator?: '&&' | '||' }
256
257/** A list that starts now. */
258function newList(): List {
259 return { count: 0, isPure: true, isMoved: false }
260}
261
262/** Keeps bounded variable alternatives when a conditional command may have been skipped. */
263function mergeVariables(walk: Walk, state: State, before: State): void {
264 if (state.terminated) {
265 Object.assign(state, before)
266 state.terminated = before.terminated
267 return
268 }
269 if (!walk.charge(state.vars.size + before.vars.size + state.removed.size + before.removed.size + state.exported.size + before.exported.size)) return
270 const names = new Set([...state.vars.keys(), ...before.vars.keys(), ...state.removed, ...before.removed])
271 for (const name of names) {
272 const previous = variable(walk, before, name)
273 const current = variable(walk, state, name)
274 if (previous === current) continue
275 const values = alternatives(walk, [...previous ?? [], ...current ?? []])
276 if (values.length > MAX_VALUES) {
277 walk.fail(OPAQUE.tooMany)
278 return
279 }
280 if (values.length > 0) state.vars.set(name, values)
281 if (before.reaching.has(name)) state.reaching.add(name)
282 }
283 for (const name of before.exported) state.exported.add(name)
284 for (const name of state.removed) if (!before.removed.has(name)) state.removed.delete(name)
285 if (!walk.charge(before.functions.size + before.named.size + before.possible.size)) return
286 for (const [name, body] of before.functions) if (!state.functions.has(name)) state.functions.set(name, body)
287 for (const [name, text] of before.named) if (!state.named.has(name)) state.named.set(name, text)
288 for (const [name, values] of before.possible) {
289 const merged = state.vars.has(name) ? [] : alternatives(walk, [...state.possible.get(name) ?? [], ...values])
290 if (merged.length > 0 && merged.length <= MAX_VALUES) state.possible.set(name, merged)
291 }
292 if (state.positional !== before.positional) mergePositional(walk, state, before)
293 state.globOptions = { dot: before.globOptions?.dot || state.globOptions?.dot, nocase: before.globOptions?.nocase || state.globOptions?.nocase }
294 state.noglob = state.noglob && before.noglob
295 state.globstar = state.globstar || before.globstar
296 state.status = undefined
297}
298
299/** Joins corresponding positional values; different argument counts stay unresolved until replaced. */
300function mergePositional(walk: Walk, state: State, before: State): void {
301 const current = state.positional
302 const previous = before.positional
303 state.positional = undefined
304 if (current === undefined || previous === undefined || current.length !== previous.length) return
305 if (!walk.charge(current.length)) return
306 const merged: Operand[] = []
307 for (const [index, arg] of current.entries()) {
308 const prior = previous[index]!
309 const values = arg.values === undefined || prior.values === undefined ? undefined : alternatives(walk, [...arg.values, ...prior.values])
310 if ((values?.length ?? 0) > MAX_VALUES) {
311 walk.fail(OPAQUE.tooMany)
312 return
313 }
314 merged.push({ raw: arg.raw, values, upstream: arg.upstream || prior.upstream })
315 }
316 setPositional(walk, state, merged)
317}
318
319/** Parses compound boundaries before execution, with a fixed nesting limit. */
320class Commands {
321 private at = 0
322 private depth = 0
323
324 constructor(private readonly tokens: readonly Token[]) {}
325
326 /** Returns an unquoted keyword or a control operator at the cursor. */
327 private text(offset = 0): string {
328 const token = this.tokens[this.at + offset]
329 if (token?.type === 'op') return token.op === '\n' ? ';' : token.op
330 return token?.type === 'word' && token.word.every(part => part.kind === 'text' && !part.quoted) ? wordText(token.word) : ''
331 }
332
333 /** Consumes a required delimiter, rejecting incomplete syntax. */
334 private take(text: string): void {
335 if (this.text() !== text) throw new Error(UNPARSED)
336 this.at += 1
337 }
338
339 /** Reads a command list up to a compound delimiter. */
340 list(stops: readonly string[] = []): Pipeline[] {
341 if (++this.depth > 32) throw new Error(OPAQUE.tooDeep)
342 const result: Pipeline[] = []
343 while (this.at < this.tokens.length && !stops.includes(this.text())) {
344 if (this.text() === ';') { this.at += 1; continue }
345 let negate = false
346 while (this.text() === '!') { negate = !negate; this.at += 1 }
347 const commands = [this.command()]
348 while (this.text() === '|' || this.text() === '|&') {
349 this.at += 1
350 while (this.tokens[this.at]?.type === 'op' && (this.tokens[this.at] as { op: string }).op === '\n') this.at += 1
351 commands.push(this.command())
352 }
353 let after = ''
354 if ([';', '&', '&&', '||'].includes(this.text())) {
355 after = this.text()
356 this.at += 1
357 } else if (this.at < this.tokens.length && !stops.includes(this.text())) throw new Error(UNPARSED)
358 result.push({ commands, negate, after })
359 }
360 this.depth -= 1
361 return result
362 }
363
364 /** Reads one simple command, group, condition, or function definition. */
365 private command(nesting = 0): Simple {
366 if (nesting >= 32) throw new Error(OPAQUE.tooDeep)
367 const command: Simple = { words: [], redirects: [] }
368 const text = this.text()
369 if ((text === 'function' && this.tokens[this.at + 1]?.type === 'word') || (text !== '' && this.text(1) === '(' && this.text(2) === ')')) {
370 this.at += text === 'function' ? 1 : 0
371 const name = this.text()
372 this.at += 1
373 if (this.text() === '(') { this.at += 1; this.take(')') }
374 const start = this.at
375 const body = this.command(nesting + 1)
376 if (body.body === undefined) throw new Error(UNPARSED)
377 return { ...body, definition: name, ...(this.tokens.slice(start, this.at).some(namesExit) ? { exits: true as const } : {}) }
378 }
379 if (text === '(' || text === '{') {
380 this.at += 1
381 command.body = this.list([text === '(' ? ')' : '}'])
382 command.subshell = text === '('
383 this.take(text === '(' ? ')' : '}')
384 } else if (text === 'while' || text === 'until') {
385 this.at += 1
386 command.loop = { condition: this.list(['do']), until: text === 'until' }
387 this.take('do')
388 command.body = this.list(['done'])
389 this.take('done')
390 } else if (text === 'for') {
391 this.at += 1
392 const name = this.text()
393 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(UNPARSED)
394 this.at += 1
395 while (this.text() === ';') this.at += 1
396 command.loop = { name }
397 if (this.text() === 'in') {
398 this.at += 1
399 command.loop.words = []
400 while (this.tokens[this.at]?.type === 'word') {
401 command.loop.words.push((this.tokens[this.at] as { type: 'word'; word: Word }).word)
402 this.at += 1
403 }
404 }
405 while (this.text() === ';') this.at += 1
406 this.take('do')
407 command.body = this.list(['done'])
408 this.take('done')
409 } else if (text === 'if') {
410 command.branches = []
411 do {
412 this.at += 1
413 const condition = this.list(['then'])
414 this.take('then')
415 command.branches.push({ condition, body: this.list(['elif', 'else', 'fi']) })
416 } while (this.text() === 'elif')
417 if (this.text() === 'else') {
418 this.at += 1
419 command.branches.push({ body: this.list(['fi']) })
420 }
421 this.take('fi')
422 } else {
423 while (this.at < this.tokens.length) {
424 const token = this.tokens[this.at] as Token
425 if (token.type === 'op') break
426 if (token.type === 'word') command.words.push(token.word)
427 else command.redirects.push(token.redirect)
428 this.at += 1
429 }
430 if (command.words.length === 0 && command.redirects.length === 0) throw new Error(UNPARSED)
431 }
432 while (this.tokens[this.at]?.type === 'redirect') {
433 command.redirects.push((this.tokens[this.at] as { type: 'redirect'; redirect: Redirect }).redirect)
434 this.at += 1
435 }
436 return command
437 }
438}
439
440/** Parses a token stream, then walks only the command bodies that can execute. */
441async function runTokens(walk: Walk, tokens: readonly Token[], state: State, input: Stdin): Promise<Stdin> {
442 let commands: Pipeline[]
443 try { commands = new Commands(tokens).list() }
444 catch (error) {
445 walk.fail(error instanceof Error ? error.message : UNPARSED)
446 return OTHER
447 }
448 return runList(walk, commands, state, input)
449}
450
451/** Walks and-or lists while preserving skipped status and bounded variable alternatives. */
452async function runList(walk: Walk, commands: readonly Pipeline[], state: State, input: Stdin): Promise<Stdin> {
453 let list = newList()
454 let output = NONE
455 let pipelines = 0
456 const parent = state
457 for (let index = 0; index < commands.length; index += 1) {
458 if (parent.terminated) break
459 const pipeline = commands[index]!
460 if (index === 0 || !['&&', '||'].includes(commands[index - 1]!.after)) {
461 let end = index
462 while (end + 1 < commands.length && ['&&', '||'].includes(commands[end]!.after)) end += 1
463 if (!walk.charge(end - index + 1)) return OTHER
464 state = commands[end]!.after === '&' ? fork(walk, parent) : parent
465 }
466 if (!walk.charge(1)) return OTHER
467 const wanted = list.operator === undefined ? undefined : list.operator === '&&'
468 if (!state.terminated && (wanted === undefined || state.status === undefined || state.status === wanted)) {
469 const alternative = wanted !== undefined && state.status === undefined ? fork(walk, state) : undefined
470 const before = state.cwd
471 const positions = input.kind === 'literal' ? input.positions ?? [0] : []
472 const produced = await runPipeline(walk, pipeline.commands, state, input)
473 if (pipeline.negate && state.status !== undefined) state.status = !state.status
474 if (alternative !== undefined) {
475 if (input.kind === 'literal' && state.terminated) input.positions = positions
476 else mergeInput(walk, input, positions)
477 mergeVariables(walk, state, alternative)
478 }
479 output = pipelines++ === 0 ? produced : OTHER
480 if (list.count > 0 && state.cwd !== before) {
481 if (list.isPure) list.isMoved = true
482 else state.cwd = undefined
483 }
484 list.count += 1
485 }
486 if (pipeline.after === '||') {
487 list.operator = '||'
488 list.isPure = false
489 if (list.isMoved) state.cwd = undefined
490 list.isMoved = false
491 } else if (pipeline.after === '&&') list.operator = '&&'
492 else {
493 if (pipeline.after === '&') parent.status = true
494 else if (list.isMoved) state.cwd = undefined
495 list = newList()
496 }
497 }
498 return output
499}
500
501/** Executes a group or an if branch, merging state only when a condition is unknown. */
502async function runCompound(walk: Walk, command: Simple, state: State, input: Stdin): Promise<Stdin> {
503 if (command.loop !== undefined) return runLoop(walk, command, state, input)
504 if (command.body !== undefined) {
505 const scope = command.subshell ? fork(walk, state) : state
506 const output = await runList(walk, command.body, scope, input)
507 state.status = scope.status
508 return output
509 }
510 const alternatives: State[] = []
511 const positions: number[] = []
512 let output = NONE
513 let executed = false
514 for (const branch of command.branches ?? []) {
515 if (!walk.charge(1)) return OTHER
516 if (branch.condition !== undefined) await runList(walk, branch.condition, state, input)
517 else state.status = true
518 if (state.terminated) break
519 if (state.status === false) continue
520 if (state.status === true) {
521 output = await runList(walk, branch.body, state, input)
522 executed = true
523 break
524 }
525 const scope = fork(walk, state)
526 const before = input.kind === 'literal' ? input.positions ?? [0] : []
527 await runList(walk, branch.body, scope, input)
528 if (input.kind === 'literal') {
529 if (!scope.terminated && walk.charge(input.positions?.length ?? 1)) positions.push(...input.positions ?? [0])
530 input.positions = before
531 }
532 alternatives.push(scope)
533 }
534 mergeInput(walk, input, positions)
535 if (!executed && !state.terminated) state.status = true
536 for (const alternative of alternatives) {
537 if (alternative.terminated) continue
538 mergeVariables(walk, state, alternative)
539 if (state.cwd !== alternative.cwd) state.cwd = undefined
540 output = OTHER
541 }
542 return output
543}
544
545/** Runs the stages of one pipeline, handing each stage what the one before produced. Each stage of a longer pipeline runs in a subshell, and a folder change in the last stage leaves the folder unknown, since some shells run that stage in this one. */
546async function runPipeline(walk: Walk, commands: readonly Simple[], state: State, input: Stdin): Promise<Stdin> {
547 const [only] = commands
548 if (commands.length === 1 && only !== undefined) {
549 return runSimple(walk, only, state, input)
550 }
551 let piped = input
552 let isDecoded = false
553 let last = state
554 for (const command of commands) {
555 if (!walk.charge(1)) return OTHER
556 last = fork(walk, state)
557 if (walk.isSpent()) return OTHER
558 piped = await runSimple(walk, command, last, isDecoded ? DECODER : piped)
559 isDecoded = isDecoded || piped.kind === 'decoder'
560 }
561 if (last.cwd !== state.cwd) state.cwd = undefined
562 state.status = last.status
563 return piped
564}
565
566/** How many words at the front of a command assign variables, read from their text as typed. */
567function leadingAssignments(words: readonly Word[]): number {
568 const index = words.findIndex(word => !ASSIGNMENT.test(wordText(word)))
569 return index < 0 ? words.length : index
570}
571
572/** `return` or `exit` as a name of its own anywhere in a text, also inside quotes or a substitution. */
573const EXITS = /(?:^|[^A-Za-z0-9_])(?:return|exit)(?![A-Za-z0-9_])/
574
575/** Whether a token names `return` or `exit` anywhere: in a word, a redirection's target, or a heredoc. */
576function namesExit(token: Token): boolean {
577 if (token.type === 'word') return EXITS.test(wordText(token.word))
578 return token.type === 'redirect' && (EXITS.test(wordText(token.redirect.target)) || EXITS.test(token.redirect.heredoc?.text ?? ''))
579}
580
581/** Runs one simple command: assignments, wrappers, the program, and the redirections; returns what it hands the next stage. A function definition is kept for its calls, unless its body names `return` or `exit`, which forgets the name; a command whose program word names an alias also walks the alias text with the rest of the command. */
582async function runSimple(walk: Walk, command: Simple, state: State, piped: Stdin): Promise<Stdin> {
583 if (command.definition !== undefined) {
584 if (command.exits) state.functions.delete(command.definition)
585 else if (command.definition !== '' && walk.charge(1)) state.functions.set(command.definition, { ...command, definition: undefined })
586 state.status = true
587 return NONE
588 }
589 const outer = state
590 const cache: Substituted = new Map()
591 let start = 0
592 while (KEYWORDS.has(wordText(command.words[start] ?? []))) start += 1
593 if (start > 0) command = { ...command, words: command.words.slice(start) }
594 const leading = leadingAssignments(command.words)
595 await expandAlias(walk, command, leading, state, piped)
596 const isDeclaration = ASSIGNERS.has(wordText(command.words[leading] ?? []))
597 const words: Operand[] = []
598 for (const word of command.words.slice(leading)) {
599 if (isDeclaration && ASSIGNMENT.test(wordText(word))) words.push(await resolveWhole(walk, word, state, cache))
600 else for (const field of await resolveFields(walk, word, state, cache)) words.push(field)
601 }
602 const redirects: Redirected[] = []
603 const descriptors = new Map<number, Destination>([[1, 'stdout'], [2, 'stderr']])
604 const redirectState = { ...state }
605 const redirectRun: Run = { walk, state: redirectState, program: textOf(words[0]), args: [], stdin: piped }
606 for (const redirect of command.redirects) {
607 const bound = await redirected(walk, redirect, state, cache)
608 if (state.execdir === undefined) bound.target = mapOperand(bound.target, text => absolute(text, redirectState.cwd))
609 // Descriptor numbers and device names are not relative filesystem paths.
610 if (redirect.op === '>&' || redirect.op === '<&') bound.target = await resolveWhole(walk, redirect.target, state, cache)
611 redirects.push(bound)
612 openRedirect(redirectRun, bound, descriptors)
613 }
614 if (leading > 0 && words.length > 0) state = fork(walk, state)
615 for (const word of command.words.slice(0, leading)) {
616 const setting = await resolveWhole(walk, word, state, cache)
617 assign(walk, state, setting)
618 if (words.length > 0) state.exported.add(ASSIGNMENT.exec(setting.raw)![1]!)
619 }
620 const stdin = stdinOf(redirects, state.cwd, piped) ?? piped
621 if (command.body !== undefined || command.branches !== undefined) {
622 const output = await runCompound(walk, command, state, stdin)
623 finishRedirects(redirectRun, descriptors, output)
624 return descriptors.get(1) === 'stdout' ? output : NONE
625 }
626 const result = await runCommand(walk, words, state, stdin)
627 if (state !== outer && ['read', 'printf'].includes(result.program)) {
628 const temporary = new Set(command.words.slice(0, leading).map(word => ASSIGNMENT.exec(wordText(word))![1]))
629 if (walk.charge(state.vars.size + outer.vars.size)) for (const name of new Set([...state.vars.keys(), ...outer.vars.keys()])) {
630 if (temporary.has(name)) continue
631 const values = state.vars.get(name)
632 if (values === undefined) outer.vars.delete(name)
633 else outer.vars.set(name, values)
634 if (state.reaching.has(name)) outer.reaching.add(name)
635 else outer.reaching.delete(name)
636 if (!state.removed.has(name)) outer.removed.delete(name)
637 }
638 }
639 outer.terminated = state.terminated
640 outer.status = result.status ?? (result.program === 'false' ? false : result.program === 'true' || result.program === ':' || (words.length === 0 && cache.size === 0 && redirects.length === 0) ? true : undefined)
641 redirectRun.program = result.program
642 finishRedirects(redirectRun, descriptors, ['', ':', 'true', 'false'].includes(result.program) ? { kind: 'literal', text: '' } : result.output)
643 return descriptors.get(1) === 'stdout' ? result.output : NONE
644}
645
646/** Walks the text of the alias that a command's program word names, unquoted, with the command's other words and its redirections after it, one level deeper, in a copy of the shell and of its input, so the command still reads as it does without the alias; an alias does not expand again inside its own text. */
647async function expandAlias(walk: Walk, command: Simple, leading: number, state: State, piped: Stdin): Promise<void> {
648 const word = command.words[leading]
649 const name = word?.length === 1 && word[0]?.kind === 'text' && !word[0].quoted ? word[0].text : undefined
650 const text = name === undefined || walk.expanding.has(name) ? undefined : state.named.get(name)
651 if (name === undefined || text === undefined) return
652 if (walk.depth >= MAX_DEPTH) {
653 walk.fail(OPAQUE.tooDeep)
654 return
655 }
656 if (!walk.charge(text.length)) return
657 const lexed = lex(text)
658 if ('error' in lexed) {
659 walk.fail(lexed.error)
660 return
661 }
662 const words = (list: readonly Word[]): Token[] => list.map(part => ({ type: 'word', word: part }))
663 const tokens: Token[] = [...words(command.words.slice(0, leading)), ...lexed.tokens, ...words(command.words.slice(leading + 1)), ...command.redirects.map(redirect => ({ type: 'redirect' as const, redirect }))]
664 walk.expanding.add(name)
665 walk.depth += 1
666 await runTokens(walk, tokens, fork(walk, state), copied(piped))
667 walk.depth -= 1
668 walk.expanding.delete(name)
669}
670
671/** Runs resolved command words, expanding each xargs invocation separately within the nesting and work limits. */
672async function runCommand(walk: Walk, words: readonly Operand[], state: State, stdin: Stdin): Promise<{ program: string; output: Stdin; status?: boolean }> {
673 const stripped = stripWrappers(walk, words, state)
674 const at = stripped.chdir === undefined ? stripped.state : { ...stripped.state, cwd: stripped.chdir.dir, execdir: undefined }
675 if (stripped.chdir?.symbolic || (stripped.chdir !== undefined && state.execdir !== undefined && stripped.args.some(arg => arg.values?.some(value => value.selected)))) walk.fail(OPAQUE.variableCode)
676 if (stripped.xargs !== undefined) {
677 const xargs = stripped.xargs
678 if (xargs.file !== undefined) addEffect({ walk, state: at, program: 'xargs', args: [], stdin }, xargs.file, 'read', false)
679 if (walk.depth >= MAX_DEPTH) walk.fail(OPAQUE.tooDeep)
680 else {
681 const input = inputOf(walk, xargs, stdin)
682 const batches = batchesOf(input, xargs)
683 walk.depth += 1
684 for (const batch of batches) {
685 if (!walk.charge(stripped.args.length + batch.length)) break
686 const args = xargsArgs(walk, stripped.args, xargs, batch)
687 if (args !== undefined) await runCommand(walk, args, fork(walk, at), NONE)
688 }
689 walk.depth -= 1
690 }
691 if (stripped.replaces) state.terminated = true
692 return { program: 'xargs', output: OTHER }
693 }
694 const [head, ...args] = stripped.args
695 const word = head === undefined ? undefined : soleText(head)
696 if (head !== undefined && word === undefined) walk.fail(OPAQUE.variableProgram)
697 const run: Run = { walk, state: at, program: word === undefined ? '' : programOf(word, at.cwd), args, stdin }
698 let status: boolean | undefined
699 const body = word === undefined ? undefined : at.functions.get(word)
700 if (body !== undefined) await callFunction(walk, body, args, at, stdin)
701 if (head !== undefined && word !== undefined) {
702 addSegment(run)
703 const result = await dispatch(run, head)
704 if (typeof result === 'boolean') status = result
705 }
706 if (stripped.replaces) state.terminated = true
707 return { program: run.program, output: produce(run), status }
708}
709
710/** Walks the body of a function that a command calls, one level deeper, in a copy of the caller's shell with the call's arguments as its positional parameters and a copy of its input, so the call still reads as it does without the function. */
711async function callFunction(walk: Walk, body: Simple, args: readonly Operand[], state: State, stdin: Stdin): Promise<void> {
712 if (walk.depth >= MAX_DEPTH) {
713 walk.fail(OPAQUE.tooDeep)
714 return
715 }
716 const scope = fork(walk, state)
717 setPositional(walk, scope, args)
718 walk.depth += 1
719 await runSimple(walk, body, scope, copied(stdin))
720 walk.depth -= 1
721}
722
723/** A copy of a command's input that a walk can read without moving the original's position. */
724function copied(stdin: Stdin): Stdin {
725 return stdin.kind === 'literal' ? { ...stdin } : stdin
726}
727
728/** The program a command word names: its base name, or for a path to a program Arrester does not know, the script's absolute path. `claude` in any letter case, `claude-code`, and a `claude/versions/<version>` path name `claude`. */
729function programOf(word: string, cwd: string | undefined): string {
730 const name = basenameOf(word)
731 const path = word.includes('/') ? (absolute(word, cwd) ?? word) : word
732 if (CLAUDE_NAME.test(name) || CLAUDE_VERSION.test(path)) return 'claude'
733 return word.includes('/') && !isKnown(name) ? path : name
734}
735
736/** Gives a command's operands their effects by the program's class, and walks or scans the code it runs. */
737async function dispatch(run: Run, head: Operand): Promise<boolean | void> {
738 const { program } = run
739 if (program.includes('/')) await runScript(run, head, 'direct', run.args)
740 else if (ASSIGNERS.has(program)) declare(run)
741 else if (DIRECTORY_CHANGERS.has(program)) changeDir(run)
742 else if (program === 'unset') {
743 const parsed = parse(run.args, {})
744 if (!hasFlag(parsed.flags, 'f')) for (const operand of parsed.operands) removeVariable(run.state, textOf(operand))
745 }
746 else if (program === 'shopt' || program === 'setopt' || program === 'unsetopt') globSettings(run)
747 else if (program === 'shift' || program === 'set') return positionalChange(run)
748 else if (program === 'alias') defineAliases(run)
749 else if (program === 'su' || program === 'runuser') return switchUser(run)
750 else if (program === 'read') return readVariables(run)
751 else if (program === 'printf' && textOf(run.args[0]) === '-v') return printfVariable(run)
752 else if (program === 'exit') {
753 if (run.args.length > 1) return false
754 run.state.terminated = true
755 const code = soleText(run.args[0])
756 return run.args.length === 0 ? run.state.status : code === undefined ? undefined : /^[+-]?\d+$/.test(code) && Number(code) % 256 === 0
757 }
758 else if (TRIVIAL.has(program)) return
759 else if (LIST.has(program)) plain({ ...run, args: unbracketed(run) }, 'list')
760 else if (READ.has(program)) plain(run, 'read')
761 else if (WRITE.has(program)) plain(run, 'write')
762 else if (DESTROY.has(program)) plain(run, 'destroy')
763 else if (CODE_FIRST.has(program) || PATTERN_FIRST.has(program)) codeOrPatternFirst(run)
764 else if (MODE_FIRST.has(program)) modeFirst(run)
765 else if (COPY.has(program) || program === 'mv') copyOrMove(run)
766 else if (program === 'ln') link(run)
767 else if (program === 'tee') tee(run)
768 else if (ARCHIVE.has(program)) archive(run)
769 else if (program === 'dd') dd(run)
770 else if (program === 'curl' || program === 'wget') fetch(run)
771 else if (program === 'find') await find(run)
772 else if (program === 'git') git(run)
773 else if (program === 'gh') github(run)
774 else if (program === 'claude') {
775 run.walk.bypass ??= guardChange(run.walk, run.args) ?? nestedSession(run.args)
776 unknownProgram(run)
777 }
778 else if (SHELLS.has(program)) return shell(run)
779 else if (program === 'eval') return evaluate(run)
780 else if (program === 'source' || program === '.') await sourced(run)
781 else if (program === 'perl') await perl(run)
782 else if (INTERPRETER.test(program)) await interpreter(run)
783 else unknownProgram(run)
784}
785
786/** The arguments of a command, without the `]` that ends `[` or the `]]` that ends `[[`, which is no path. */
787function unbracketed(run: Run): Operand[] {
788 const closing = run.program === '[' ? ']' : run.program === '[[' ? ']]' : undefined
789 return closing !== undefined && soleText(run.args.at(-1)) === closing ? run.args.slice(0, -1) : run.args
790}
791
792/** Consumes the remaining literal stream for programs that read to its end. */
793function takeInput(input: Stdin): Stdin {
794 if (input.kind !== 'literal') return input
795 const positions = input.positions ?? [0]
796 input.positions = [input.text.length]
797 return positions.length === 1 ? { kind: 'literal', text: input.text.slice(positions[0]), uncertain: input.uncertain } : OTHER
798}
799
800/** Retains bounded stream positions when a conditional command may have consumed input. */
801function mergeInput(walk: Walk, input: Stdin, positions: readonly number[]): void {
802 if (input.kind !== 'literal' || positions.length === 0) return
803 if (!walk.charge(positions.length + (input.positions?.length ?? 1))) return
804 const merged = [...new Set([...positions, ...input.positions ?? [0]])]
805 if (merged.length > MAX_VALUES) {
806 walk.fail(OPAQUE.tooMany)
807 input.uncertain = true
808 } else input.positions = merged
809}
810
811/** Binds reads at each possible stream position and retains their remaining input. */
812function readVariables(run: Run): boolean | undefined {
813 const input = run.stdin
814 if (input.kind !== 'literal' || (input.positions?.length ?? 1) < 2) return readRecord(run)
815 const positions = input.positions!
816 if (!run.walk.count(positions.length)) return
817 const before = fork(run.walk, run.state)
818 const remaining: number[] = []
819 let status: boolean | undefined
820 for (const [index, position] of positions.entries()) {
821 const state = index === 0 ? run.state : fork(run.walk, before)
822 const stdin = { ...input, positions: [position] }
823 const result = readRecord({ ...run, state, stdin })
824 remaining.push(...stdin.positions)
825 if (index === 0) status = result
826 else {
827 if (status !== result) status = undefined
828 mergeVariables(run.walk, run.state, state)
829 }
830 if (run.walk.isSpent()) return
831 }
832 input.positions = [...new Set(remaining)]
833 return status
834}
835
836/** Consumes one literal record and binds scalar destinations, preserving escaped field separators. */
837function readRecord(run: Run): boolean | undefined {
838 const parsed = parse(run.args, { '-d': 1, '-n': 1, '-N': 1, '-p': 1, '-t': 1, '-u': 1, '-a': 1 }, true)
839 const names = parsed.operands.length === 0 ? ['REPLY'] : parsed.operands.map(soleText)
840 if (names.some(name => name === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) return
841 const delimiter = parsed.given.has('-d') ? soleText(parsed.given.get('-d')?.[0])?.slice(0, 1) : '\n'
842 const supported = parsed.flags.every(flag => /^-[rs]+$/.test(flag) || ['-d', '-p'].includes(flag))
843 const input = run.stdin.kind === 'literal' && !run.stdin.uncertain && supported && delimiter !== undefined ? run.stdin.text : undefined
844 const raw = hasFlag(parsed.flags, 'r')
845 let line: string | undefined
846 let complete: boolean | undefined
847 const escaped = new Set<number>()
848 const start = run.stdin.kind === 'literal' ? run.stdin.positions?.[0] ?? 0 : 0
849 if (input !== undefined && run.walk.charge(input.length - start)) {
850 line = ''
851 complete = false
852 let i = start
853 for (; i < input.length; i += 1) {
854 const ch = input.charAt(i)
855 if (ch === '\\' && !raw) {
856 const next = input.charAt(++i)
857 if (next === '\n') continue
858 escaped.add(line.length)
859 line += next
860 } else if (ch === (delimiter || '\0')) {
861 complete = true
862 i += 1
863 break
864 } else line += ch
865 }
866 if (run.stdin.kind === 'literal') run.stdin.positions = [Math.min(i, input.length)]
867 }
868 const ifs = run.state.removed.has('IFS') ? undefined : variable(run.walk, run.state, 'IFS')
869 if (ifs !== undefined && ifs.length !== 1) line = undefined
870 if (!run.walk.charge(ifs?.[0]?.text.length ?? 0)) return
871 const separators = new Set(ifs?.[0]?.text ?? ' \t\n')
872 const fields: { start: number; end: number }[] = []
873 let lastEnd = 0
874 if (line !== undefined && parsed.operands.length > 0) {
875 splitFields(line, separators, (start, end, keep) => {
876 if (start !== end || keep) {
877 if (fields.length < names.length) fields.push({ start, end })
878 lastEnd = end
879 }
880 }, escaped)
881 }
882 for (let i = 0; i < names.length; i += 1) {
883 const name = names[i]!
884 const field = fields[i]
885 const text = line === undefined ? undefined : parsed.operands.length === 0 ? line : field === undefined ? '' : line.slice(field.start, i === names.length - 1 ? lastEnd : field.end)
886 assign(run.walk, run.state, { raw: `${name}=`, values: text === undefined ? undefined : fixed(`${name}=${text}`).values })
887 }
888 return complete
889}
890
891/** Assigns bounded literal printf output without sending it to stdout. */
892function printfVariable(run: Run): boolean | undefined {
893 const name = soleText(run.args[1])
894 if (name === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return
895 const args = run.args.slice(2).map(soleText)
896 const text = args.length > 0 && args.every((arg): arg is string => arg !== undefined) ? printed(run.walk, args) : undefined
897 assign(run.walk, run.state, { raw: `${name}=`, values: text === undefined ? undefined : fixed(`${name}=${text}`).values })
898 return text === undefined ? undefined : true
899}
900
901/** `alias NAME=TEXT`: records the text of each alias; a definition that does not resolve to one text forgets the alias. */
902function defineAliases(run: Run): void {
903 for (const arg of run.args) {
904 const definition = /^([^\s=/$`'"\\]+)=/.exec(soleText(arg) ?? arg.raw)
905 if (definition === null) continue
906 const text = soleText(arg)
907 if (text === undefined) run.state.named.delete(definition[1]!)
908 else run.state.named.set(definition[1]!, text.slice(definition[0].length))
909 }
910}
911
912/** Tracks explicit glob options and noexec in the current shell only, in order, and in `printing`, when it is given, the verbose and xtrace options of a shell's command line; a `set` cluster that holds `o`, such as `-eo`, takes the next word as an option name, unless that word starts with `-` or `+`, as in bash. `set` reads options up to `--`, and except in fish up to `-` or up to its first word that is no option and no option name; in zsh and ksh it also stops at `+`. */
913function globSettings(run: Run, printing?: Printing): void {
914 if (run.program === 'shopt' && !run.args.some(arg => ['-s', '-u'].includes(textOf(arg)))) return
915 let enabled = run.program !== 'unsetopt'
916 let named = false
917 const fish = run.state.shell === 'fish'
918 for (const arg of run.args) {
919 const text = textOf(arg)
920 if (text === '--') break
921 if (run.program === 'set' && !fish && text === '-') break
922 if (run.program === 'set' && text === '+' && (run.state.shell === 'zsh' || run.state.shell === 'ksh')) break
923 if (run.program === 'set' && /^[-+]/.test(text)) named = false
924 if (text === '-s' || text === '-u') {
925 enabled = text === '-s'
926 continue
927 }
928 if (text === '-o' || text === '+o') {
929 enabled = text === '-o'
930 named = true
931 continue
932 }
933 if (run.program === 'set' && /^[-+][a-zA-Z]+$/.test(text)) {
934 if (text.includes('f')) run.state.noglob = text.startsWith('-')
935 if (text.includes('n')) run.state.terminated = text.startsWith('-')
936 if (printing !== undefined && text.includes('v')) printing.verbose = text.startsWith('-')
937 if (printing !== undefined && text.includes('x')) printing.xtrace = text.startsWith('-')
938 if (text.includes('o')) {
939 enabled = text.startsWith('-')
940 named = true
941 }
942 continue
943 }
944 if (run.program === 'set' && !named) {
945 if (!fish && !/^[-+]/.test(text)) break
946 continue
947 }
948 const name = text.replace(/_/g, '').toLowerCase()
949 const options = { ...run.state.globOptions }
950 if (name === 'dotglob' || name === 'globdots') options.dot = enabled ? true : undefined
951 else if (name === 'nocaseglob') options.nocase = enabled ? true : undefined
952 else if (name === 'caseglob') options.nocase = enabled ? undefined : true
953 else if (name === 'globstar') run.state.globstar = enabled
954 else if (name === 'noglob') run.state.noglob = enabled
955 else if (name === 'glob') run.state.noglob = !enabled
956 else if (run.program === 'set' && name === 'noexec') run.state.terminated = enabled
957 else if (printing !== undefined && (name === 'verbose' || name === 'xtrace')) printing[name] = enabled
958 run.state.globOptions = options
959 named = false
960 }
961}
962
963/** Applies literal positional changes without changing the shell's command name. */
964function positionalChange(run: Run): boolean | undefined {
965 const { state, args, walk } = run
966 if (run.program === 'set') {
967 globSettings(run)
968 const split = args.findIndex(arg => soleText(arg) === '--')
969 if (split >= 0) setPositional(walk, state, args.slice(split + 1))
970 return split >= 0 ? true : undefined
971 }
972 const text = args.length === 0 ? '1' : soleText(args[0])
973 if (text === undefined) {
974 walk.fail(OPAQUE.variableCode)
975 return
976 }
977 if (!/^\d+$/.test(text)) return false
978 const count = Number(text)
979 const positional = state.positional
980 if (positional === undefined) {
981 walk.fail(OPAQUE.variableCode)
982 return
983 }
984 if (count <= positional.length) setPositional(walk, state, positional.slice(count))
985 return count <= positional.length
986}
987
988/** Skips inactive loops and scans other loop bodies once with bounded variable alternatives. */
989async function runLoop(walk: Walk, command: Simple, state: State, input: Stdin): Promise<Stdin> {
990 const loop = command.loop!
991 if (loop.condition !== undefined) {
992 await runList(walk, loop.condition, state, input)
993 if (state.terminated) return NONE
994 if (state.status === loop.until) {
995 state.status = true
996 return NONE
997 }
998 } else {
999 const words: Operand[] = []
1000 const cache: Substituted = new Map()
1001 if (loop.words === undefined) {
1002 if (state.positional === undefined) walk.fail(OPAQUE.variableCode)
1003 words.push(...state.positional ?? [])
1004 }
1005 else for (const word of loop.words) words.push(...await resolveFields(walk, word, state, cache))
1006 if (words.length === 0) {
1007 state.status = true
1008 return NONE
1009 }
1010 const count = words.reduce((sum, word) => sum + (word.values?.length ?? 1), 0)
1011 if (count > MAX_VALUES || !walk.charge(count)) {
1012 walk.fail(OPAQUE.tooMany)
1013 return OTHER
1014 }
1015 const name = loop.name!
1016 state.possible.delete(name)
1017 if (words.some(word => word.values === undefined)) state.vars.delete(name)
1018 else state.vars.set(name, words.flatMap(word => word.values ?? []))
1019 if (words.some(word => word.upstream)) state.reaching.add(name)
1020 else state.reaching.delete(name)
1021 }
1022 const before = fork(walk, state)
1023 const output = await runList(walk, command.body ?? [], state, input)
1024 if (loop.condition !== undefined && before.status === undefined) {
1025 mergeVariables(walk, state, before)
1026 if (state.cwd !== before.cwd) state.cwd = undefined
1027 }
1028 state.status = undefined
1029 return output
1030}
1031
1032/** `cd`, `pushd`, and `popd` move the current folder. */
1033function changeDir(run: Run): void {
1034 if (run.state.execdir !== undefined) {
1035 const destination = parse(run.args, {}).operands[0]
1036 run.state.execdir = undefined
1037 run.state.cwd = undefined
1038 if (!soleText(destination)?.startsWith('/')) {
1039 run.walk.fail(OPAQUE.variableCode)
1040 return
1041 }
1042 }
1043 const state = run.state
1044 run.walk.charge(state.stack.length)
1045 if (run.program === 'popd') {
1046 if (state.stack.length === 0) return
1047 state.prev = state.cwd
1048 state.cwd = state.stack[state.stack.length - 1]
1049 state.stack = state.stack.slice(0, -1)
1050 return
1051 }
1052 const target = run.args.find(operand => !textOf(operand).startsWith('-') || textOf(operand) === '-')
1053 if (run.program === 'pushd') state.stack = [...state.stack, state.cwd]
1054 if (target === undefined && (state.removed.has('HOME') || state.emptyEnv && !state.vars.has('HOME'))) return
1055 const next = target === undefined ? pathOf(state.cwd, { raw: '$HOME', values: variable(run.walk, state, 'HOME') }) : textOf(target) === '-' ? state.prev : pathOf(state.cwd, target)
1056 state.prev = state.cwd
1057 state.cwd = next
1058}
1059
1060/** The absolute path an operand names in `cwd`; undefined unless it resolves to one path that is no glob. */
1061function pathOf(cwd: string | undefined, operand: Operand | undefined): string | undefined {
1062 const value = operand?.values?.length === 1 ? operand.values[0] : undefined
1063 return value === undefined || value.glob ? undefined : absolute(value.text, cwd)
1064}
1065
1066/** A program whose operands all get one kind of effect; `base64` also reads its `-i` file, `base64` and `sort` write their `-o` file, other than `-`; `shuf` also writes its output file and reads its random source, and with `-e` its operands are words, not files. */
1067function plain(run: Run, kind: EffectKind): void {
1068 const parsed = parse(run.args, valuesOf(run.program))
1069 const recursive = isRecursive(run.program, parsed.flags)
1070 const isShuf = run.program === 'shuf'
1071 if (!isShuf || !hasFlag(parsed.flags, 'e', ['--echo'])) for (const operand of parsed.operands) addEffect(run, operand, kind, recursive)
1072 if (run.program === 'base64' || run.program === 'sort') {
1073 const files = (flags: readonly string[]): Operand[] => flags.flatMap(flag => parsed.given.get(flag) ?? []).filter(file => textOf(file) !== '-')
1074 if (run.program === 'base64') for (const file of files(['-i', '--input'])) addEffect(run, file, 'read', false)
1075 for (const file of files(['-o', '--output'])) addEffect(run, file, 'write', false)
1076 }
1077 if (!isShuf) return
1078 for (const file of [...parsed.given.get('-o') ?? [], ...parsed.given.get('--output') ?? []]) addEffect(run, file, 'write', false)
1079 for (const file of parsed.given.get('--random-source') ?? []) addEffect(run, file, 'read', false)
1080}
1081
1082/** A program whose first operand is code or a pattern unless a flag gives it; flag files are read; `sed -i` and BSD `sed -I`, also in a cluster such as `-Ei`, write. A search with a pattern and no file searches the current folder. A recursive search that `isPartialSearch` does not find reads its operands as a `fullSearch`. */
1083function codeOrPatternFirst(run: Run): void {
1084 const isSed = run.program === 'sed'
1085 const args = isSed ? run.args.filter((operand, index) => !(textOf(operand) === '' && IN_PLACE_CLUSTER.test(textOf(run.args[index - 1])))) : run.args
1086 const parsed = parse(args, valuesOf(run.program))
1087 const { files, operands, hasCode } = codeOperands(parsed)
1088 const inPlace = isSed && (parsed.given.has('-i') || parsed.given.has('-I') || parsed.flags.some(flag => flag.startsWith('-i') || flag.startsWith('--in-place')))
1089 const recursive = isRecursive(run.program, parsed.flags)
1090 const fullSearch = recursive && PATTERN_FIRST.has(run.program) && !isPartialSearch(run.program, parsed)
1091 for (const file of files) addEffect(run, file, 'read', false)
1092 for (const operand of operands) addEffect(run, operand, inPlace ? 'write' : 'read', recursive, true, true, fullSearch)
1093 if (PATTERN_FIRST.has(run.program) && hasCode && operands.length === 0) searchHere(run, parsed)
1094}
1095
1096/** The files a program whose first operand is code or a pattern reads through its flags, its operands after that first one, or all of them when a flag gives the code or the pattern, and whether it has the code or the pattern. */
1097function codeOperands(parsed: Parsed): { files: Operand[]; operands: Operand[]; hasCode: boolean } {
1098 const files = ['-f', '--file', '--from-file'].flatMap(flag => parsed.given.get(flag) ?? [])
1099 const isGiven = files.length > 0 || ['-e', '--regexp', '--expression'].some(flag => parsed.given.has(flag))
1100 return { files, operands: parsed.operands.slice(isGiven ? 0 : 1), hasCode: isGiven || parsed.operands.length > 0 }
1101}
1102
1103/** The grep programs that search the current folder only with `-r` or `-R`, also when stdin holds data. */
1104const GREPS = new Set(['grep', 'egrep', 'fgrep', 'ugrep'])
1105
1106/** Flags with which a search program prints something else and searches no file. */
1107const NO_SEARCH = ['--files', '--type-list', '--help', '--version']
1108
1109/** The rg flags that pick the files that a search reads by a glob or a type. */
1110const RG_FILTERS = ['-g', '--glob', '--iglob', '-t', '-T', '--type', '--type-not', '--type-add']
1111
1112/** The rg flags that leave files out by an ignore file or a size limit: a search with one reads only part of a folder that it names, and still counts as a read of the current folder when it names none. */
1113const RG_LIMITS = ['--ignore-file', '--ignore-file-case-insensitive', '--max-filesize']
1114
1115/** The long flags of grep and ugrep that pick the files that a search reads by a name, a glob, a type, or an ignore file; grep refuses the ones it does not have. */
1116const GREP_FILTERS = ['--include', '--exclude', '--exclude-dir', '--exclude-from', '--include-dir', '--include-from', '--include-fs', '--exclude-fs', '--glob', '--iglob', '--file-extension', '--file-type', '--file-magic', '--ignore-files']
1117
1118/** The long flags of ag that pick no files; every other long flag, such as `--ignore` or a file type such as `--js`, picks the files that a search reads. */
1119const AG_OPTIONS = new Set(['--ackmate', '--affinity', '--no-affinity', '--noaffinity', '--after', '--all-text', '--all-types', '--before', '--break', '--no-break', '--nobreak', '--case-sensitive', '--color', '--no-color', '--nocolor', '--color-line-number', '--color-match', '--color-path', '--color-win-ansi', '--column', '--context', '--count', '--debug', '--depth', '--filename', '--no-filename', '--nofilename', '--files-with-matches', '--files-without-matches', '--fixed-strings', '--follow', '--no-follow', '--nofollow', '--group', '--no-group', '--nogroup', '--heading', '--no-heading', '--noheading', '--help', '--hidden', '--ignore-case', '--invert-match', '--line-numbers', '--list-file-types', '--literal', '--match', '--max-count', '--mmap', '--no-mmap', '--nommap', '--multiline', '--no-multiline', '--nomultiline', '--numbers', '--no-numbers', '--nonumbers', '--pager', '--no-pager', '--nopager', '--no-recurse', '--norecurse', '--null', '--only-matching', '--one-device', '--parallel', '--passthrough', '--passthru', '--print0', '--print-all-files', '--print-long-lines', '--recurse', '--search-binary', '--search-files', '--search-zip', '--silent', '--skip-vcs-ignores', '--smart-case', '--stats', '--stats-only', '--unrestricted', '--version', '--vimgrep', '--width', '--word-regexp', '--workers'])
1120
1121/** The short flags that pick the files that a search reads, also in a cluster: `-O`, `-t`, `-g`, and `-M` for ugrep, and `-G`, `-g`, and `-p` for ag. */
1122const SHORT_FILTERS: ReadonlyMap<string, RegExp> = new Map([['ugrep', /^-[A-Za-z0-9]*[OtgM]/], ['ag', /^-[A-Za-z0-9]*[Ggp]/]])
1123
1124/** Every entry below the current folder whose path has no hidden name, which rg and ag search by default. */
1125const VISIBLE: Operand = { raw: '**/*', values: [{ text: '**/*', glob: true }] }
1126
1127/** A search with a pattern that names no file: grep, egrep, fgrep, and ugrep with `-r` read the whole current folder. rg and ag read it when their stdin holds no data, all of it when a flag shows hidden names, else only its visible entries. A narrow search reads no folder. The read is a `fullSearch` unless `isPartialSearch` finds the search. */
1128function searchHere(run: Run, parsed: Parsed): void {
1129 if (isNarrow(run.program, parsed)) return
1130 const fullSearch = !isPartialSearch(run.program, parsed)
1131 if (GREPS.has(run.program)) {
1132 if (hasFlag(parsed.flags, 'rR', ['--recursive', '--dereference-recursive'])) addEffect(run, fixed('.'), 'read', true, true, true, fullSearch)
1133 return
1134 }
1135 const { stdin } = run
1136 const isEmpty = stdin.kind === 'none' || (stdin.kind === 'script' && stdin.path === '/dev/null') || (stdin.kind === 'other' && stdin.closed === true)
1137 if (!isEmpty) return
1138 addEffect(run, showsHidden(run.program, parsed) ? fixed('.') : VISIBLE, 'read', true, true, true, fullSearch)
1139}
1140
1141/** Whether a search is narrow: a flag makes it print something else, such as `--files`, it has a file filter, or it limits its depth. */
1142function isNarrow(program: string, parsed: Parsed): boolean {
1143 return parsed.flags.some(flag => NO_SEARCH.includes(flag)) || isFiltered(program, parsed) || isShallow(program, parsed)
1144}
1145
1146/** Whether a search reads only some files below its folders, or none: it is narrow, or it is rg with an ignore file or a size limit. */
1147function isPartialSearch(program: string, parsed: Parsed): boolean {
1148 return isNarrow(program, parsed) || (program === 'rg' && parsed.flags.some(flag => RG_LIMITS.includes(flag)))
1149}
1150
1151/** Whether a search picks the files that it reads: rg by a glob or a type, grep and ugrep by a name, a glob, a type, or an ignore file, and ag by a pattern, an ignore rule, or a file type. */
1152function isFiltered(program: string, parsed: Parsed): boolean {
1153 if (program === 'rg') return RG_FILTERS.some(flag => parsed.given.has(flag))
1154 const short = SHORT_FILTERS.get(program)
1155 return parsed.flags.some(flag => (flag.startsWith('--') ? (program === 'ag' ? !AG_OPTIONS.has(flag) : GREP_FILTERS.includes(flag)) : short?.test(flag) === true))
1156}
1157
1158/** A ugrep digit flag that limits the depth, such as `-1`, `-rn2`, or `-3,5`, with no letter before it that takes the rest of the word as a value, such as `-J4`. */
1159const UGREP_DEPTH = /^-[A-IL-MO-PR-Ya-z]*[0-9]/
1160
1161/** Whether a search limits how deep it goes: rg with `-d`, `--max-depth`, or `--maxdepth`, ag with `-n`, `--norecurse`, or a last `--depth` of zero or more, as `-1` sets no limit, and ugrep with `--depth` or a digit flag; grep's digit flags give context lines. */
1162function isShallow(program: string, parsed: Parsed): boolean {
1163 if (program === 'rg') return parsed.given.has('-d') || parsed.flags.some(flag => flag === '--max-depth' || flag === '--maxdepth')
1164 if (program === 'ag') return hasFlag(parsed.flags, 'n', ['--norecurse', '--no-recurse']) || /^[0-9]+$/.test(textOf(parsed.given.get('--depth')?.slice(-1)[0]))
1165 return program === 'ugrep' && parsed.flags.some(flag => flag === '--depth' || UGREP_DEPTH.test(flag))
1166}
1167
1168/** Whether rg or ag searches hidden names: `--hidden`, `-.` for rg, and two `-u` for rg, one for ag. */
1169function showsHidden(program: string, parsed: Parsed): boolean {
1170 const short = parsed.flags.filter(flag => /^-[^-]/.test(flag))
1171 const unrestricted = short.reduce((count, flag) => count + [...flag].filter(letter => letter === 'u').length, 0) + parsed.flags.filter(flag => flag === '--unrestricted').length
1172 if (parsed.flags.includes('--hidden') || unrestricted >= (program === 'rg' ? 2 : 1)) return true
1173 return program === 'rg' && short.some(flag => flag.includes('.'))
1174}
1175
1176/** `chmod`, `chown`, `chgrp`: the first operand is the mode or owner unless `--reference` gives it. */
1177function modeFirst(run: Run): void {
1178 const isSymbolic = run.program === 'chmod' && SYMBOLIC_MODE.test(textOf(run.args[0]))
1179 const parsed = parse(isSymbolic ? run.args.slice(1) : run.args, valuesOf(run.program))
1180 const recursive = isRecursive(run.program, parsed.flags)
1181 for (const operand of parsed.operands.slice(isSymbolic || parsed.given.has('--reference') ? 0 : 1)) addEffect(run, operand, 'write', recursive)
1182}
1183
1184/** `cp`, `install`, `rsync`, `scp`, and `mv`: sources and one destination. */
1185function copyOrMove(run: Run): void {
1186 const parsed = parse(run.args, valuesOf(run.program))
1187 const directory = parsed.given.get('-t')?.[0] ?? parsed.given.get('--target-directory')?.[0]
1188 const sources = directory === undefined ? parsed.operands.slice(0, -1) : parsed.operands
1189 const destination = directory ?? (parsed.operands.length > 1 ? parsed.operands[parsed.operands.length - 1] : undefined)
1190 const isMove = run.program === 'mv'
1191 const recursive = isMove || isRecursive(run.program, parsed.flags)
1192 for (const source of sources) addEffect(run, source, isMove ? 'destroy' : 'read', recursive)
1193 if (destination === undefined) return
1194 const deletes = run.program === 'rsync' && parsed.flags.some(flag => flag.startsWith('--delete'))
1195 addEffect(run, destination, deletes ? 'destroy' : 'write', recursive)
1196 if ((COPY_AS_FILE.has(run.program) && hasFlag(parsed.flags, 'T', ['--no-target-directory'])) || (run.program === 'install' && hasFlag(parsed.flags, 'd', ['--directory'])) || keepsEntries(run.program, parsed)) return
1197 const isRsync = run.program === 'rsync'
1198 const whole = (run.program === 'cp' && parsed.flags.includes('--parents')) || (isRsync && hasFlag(parsed.flags, 'R', ['--relative']))
1199 for (const source of sources) addEffect(run, entryIn(run.walk, destination, source, isRsync, whole), 'write', recursive, false, false)
1200}hooks/analyze/types.ts 28 lines1// Types the analysis modules share. Pure: no `$`.
2
3import type { ArresterTarget } from '../../types/index'
4
5/** What a tool call does to one path; `name` is a path that an MCP call names, whose access Arrester cannot know. */
6export type EffectKind = 'list' | 'read' | 'write' | 'destroy' | 'name'
7
8/** One path effect: its glob or find selection, access, reach below the path, and final-entry removal; `fullSearch` when grep, egrep, fgrep, ugrep, rg, ag, or a Grep call searches below the path with no file filter, ignore file, size limit, depth limit, or listing flag. */
9export type Effect = { path: string; glob?: string; globOptions?: ArresterTarget['globOptions']; kind: EffectKind; recursive: boolean; entry?: true; selection?: ArresterTarget['selection']; fullSearch?: true }
10
11/** One simple command's retry skeleton and its flags, sorted and each once. */
12export type Segment = { skeleton: string; flags: string[]; preview?: true }
13
14/** Code in an older script that Arrester could not read, and so did not count. */
15export type Ignored = { path: string; reason: string }
16
17/** What the analysis of one call or one piece of code found; `bypass` names a launch flag or a plugin command that can turn guards off, or a nested `claude` run. */
18export type Analysis = { effects: Effect[]; segments: Segment[]; opaque?: string; ignored?: Ignored; bypass?: string }
19
20/** Where a call runs: the session's current folder and the home folder. */
21export type Context = { cwd: string; home: string | undefined }
22
23/** How a script read went: no such file, its text, or the reason it cannot be read; `isOld` when the file last changed before the oldest open block opened. */
24export type ScriptRead = 'missing' | { text: string; isOld: boolean } | { unreadable: string; isOld: boolean }
25
26/** The only I/O the analysis does: reading a script that a call runs, and noticing an abandoned dispatch; `capture` when the analysis records a block, where an older interpreter script is not scanned. */
27export type Io = { readScript: (path: string) => Promise<ScriptRead>; signal: AbortSignal; capture?: true }
28hooks/guards.ts 106 lines1// Guard paths and guard names: the files whose read or change
2// counts as touching a guard while a block is open. Pure: no `$`.
3
4import { isRecord } from './analyze/call'
5import { lex } from './analyze/lexer'
6import type { Word } from './analyze/lexer'
7import { basenameOf, foldPath, joinPath, looksLikePath } from './analyze/paths'
8import { INTERPRETER, KEYWORDS, SHELLS } from './analyze/programs'
9import type { ArresterGuard } from '../types/index'
10
11/** The settings sources whose command hooks name guard files, lowest precedence first. */
12export const SETTINGS_SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
13
14/** The fixed guard paths: the settings files of the user and of the project, and Arrester's own folder, for any touch; the installed plugins, for a change only. */
15export function fixedGuards(home: string | undefined, root: string, pluginRoot: string): ArresterGuard[] {
16 const userPaths = home === undefined ? [] : [`${home}/.claude/settings.json`]
17 const projectPaths = ['settings.json', 'settings.local.json'].map(name => `${root}/.claude/${name}`)
18 const guards: ArresterGuard[] = [...new Set([...userPaths, ...projectPaths, pluginRoot].map(foldPath))].map(path => ({ path }))
19 return home === undefined ? guards : [...guards, { path: foldPath(`${home}/.claude/plugins`), changeOnly: true }]
20}
21
22/** The options after which an interpreter or a shell takes code or a module, not a script file. */
23const CODE_OPTIONS = new Set(['-c', '-e', '-m'])
24
25/** A shell assignment word, such as `MODE=strict`. */
26const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
27
28/** The paths that the command hooks of one settings source name: each absolute word, and each relative path-like word that is a command's program, the script operand of an interpreter or a shell, or a path under `.claude/`, read in the session folder `cwd` and in the project folder `root`. A hook with `args` runs `command` as one program with each argument as written, so only `$CLAUDE_PROJECT_DIR` is replaced there; a hook without them is a shell command, split into commands and words with its quotes, where `~` and `$HOME` expand too. */
29export function hookPaths(settings: Readonly<Record<string, unknown>>, home: string | undefined, root: string, cwd: string): string[] {
30 const paths: string[] = []
31 for (const hook of commandHooks(settings.hooks)) {
32 const args = hook.args
33 const commands = Array.isArray(args) ? [[hook.command, ...args].map(word => (typeof word === 'string' ? word.replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, () => root) : undefined))] : typeof hook.command === 'string' ? shellCommands(hook.command, home, root) : []
34 for (const words of commands) paths.push(...commandPaths(words, root, cwd))
35 }
36 return paths
37}
38
39/** The paths that one command's words name, its program first: each absolute word, and a relative path-like word that is the program, the script operand, or under `.claude/`, joined to `cwd` and to `root`; an unreadable word is undefined and names none. */
40function commandPaths(words: readonly (string | undefined)[], root: string, cwd: string): string[] {
41 const script = scriptIndex(words)
42 return words.flatMap((word, index) => {
43 if (word === undefined) return []
44 if (word.startsWith('/')) return [foldPath(word)]
45 if (!looksLikePath(word) || (index !== 0 && index !== script && !word.split('/').slice(0, -1).includes('.claude'))) return []
46 return [...new Set([cwd, root])].map(base => joinPath(base, word))
47 })
48}
49
50/** Where the script operand of an interpreter or a shell is: its first path-like word that is no option; -1 for another program, or when `-c`, `-e`, `-m`, or an unreadable word comes first. */
51function scriptIndex(words: readonly (string | undefined)[]): number {
52 const program = words[0] === undefined ? undefined : basenameOf(words[0])
53 if (program === undefined || !(INTERPRETER.test(program) || SHELLS.has(program))) return -1
54 for (const [index, word] of words.entries()) {
55 if (index === 0) continue
56 if (word === undefined || CODE_OPTIONS.has(word)) return -1
57 if (!word.startsWith('-') && looksLikePath(word)) return index
58 }
59 return -1
60}
61
62/** The words of each simple command of a shell command, with supported variables expanded, respecting quoting; leading assignments and keywords are dropped, so the program comes first. An unreadable word is undefined; redirections give no word; a command that does not parse gives none. */
63function shellCommands(command: string, home: string | undefined, root: string): (string | undefined)[][] {
64 const lexed = lex(command)
65 if ('error' in lexed) return []
66 const commands: (string | undefined)[][] = [[]]
67 for (const token of lexed.tokens) {
68 const words = commands[commands.length - 1]!
69 if (token.type === 'op') commands.push([])
70 else if (token.type === 'word' && !(words.length === 0 && isPrefix(token.word))) words.push(literal(token.word, home, root))
71 }
72 return commands
73}
74
75/** True for a word that comes before a command's program: an assignment or a shell keyword. */
76function isPrefix(word: Word): boolean {
77 const [first] = word
78 return first?.kind === 'text' && !first.quoted && (ASSIGNMENT.test(first.text) || (word.length === 1 && KEYWORDS.has(first.text)))
79}
80
81/** A literal shell word with supported variables expanded, respecting quoting; undefined when it cannot be read. */
82function literal(word: Word, home: string | undefined, root: string): string | undefined {
83 let text = ''
84 for (const [index, part] of word.entries()) {
85 if (part.kind === 'var') {
86 const value = part.name === 'CLAUDE_PROJECT_DIR' ? root : part.name === 'HOME' ? home : undefined
87 if (value === undefined) return undefined
88 text += value
89 } else if (part.kind === 'text') {
90 if (index === 0 && !part.quoted && /^~(?:\/|$)/.test(part.text)) {
91 if (home === undefined) return undefined
92 text += home + part.text.slice(1)
93 } else text += part.text
94 } else return undefined
95 }
96 return text
97}
98
99/** The command hooks of a settings `hooks` value: each entry of `type: "command"` in each matcher group of each event. */
100function commandHooks(hooks: unknown): Readonly<Record<string, unknown>>[] {
101 if (!isRecord(hooks)) return []
102 const groups = Object.values(hooks).flatMap(list => (Array.isArray(list) ? list : []))
103 const entries = groups.flatMap(group => (isRecord(group) && Array.isArray(group.hooks) ? group.hooks : []))
104 return entries.filter((hook): hook is Readonly<Record<string, unknown>> => isRecord(hook) && hook.type === 'command')
105}
106hooks/journal.ts 236 lines1// The journal transforms: pure functions over the one `$.state` value,
2// which `update` applies inside the host's versioned write. Pure: no `$`.
3
4import { basenameOf, foldCase, foldPath, globMatch, joinPath, relation, selectionKey } from './analyze/paths'
5import type { Meter } from './analyze/paths'
6import type { Effect, Segment } from './analyze/types'
7import type { ArresterAccess, ArresterBlock, ArresterDecision, ArresterGuard, ArresterHeld, ArresterJournal, ArresterTarget } from '../types/index'
8
9/** A block as the capture gathers it, before the journal gives it an id and bounds its flags. */
10type BlockDraft = Omit<ArresterBlock, 'id' | 'lastHitAt' | 'flagsComplete' | 'segments'> & { segments: Segment[] }
11
12/** The journal of a session where no block has opened. */
13export const EMPTY_JOURNAL: ArresterJournal = { seq: 0, blocks: [], held: [], decisions: [], guards: [], recorded: [] }
14
15const MAX_BLOCKS = 50
16const MAX_DECISIONS = 50
17const MAX_SEGMENTS = 16
18const MAX_FLAGS = 256
19const MAX_FLAG_BYTES = 65_536
20const MAX_TARGETS = 256
21const MAX_RECORDED = 100
22const MINUTE = 60_000
23const STRENGTH: Readonly<Record<ArresterAccess, number>> = { read: 1, write: 2, destroy: 3 }
24/** A deny reason that names another tool to use, such as `Use rg instead of grep`: the guard objected to the tool, not to the folder. */
25const SUGGESTS_TOOL = /\binstead\b|\bprefer(?:s|red)?\b|\bplease use\b|\buse `?rg\b|\btry `/i
26/** What a word of a deny reason loses at its two ends: quotes, backticks, and brackets. */
27const WORD_EDGES = new Set([...'\'"`‘’“”()[]{}'])
28/** What one form of a word of a deny reason also loses at its end. */
29const WORD_ENDS = new Set([...'.,;:!?'])
30/** Where a command splits into the words that spell its paths: blanks, quotes, and shell operators. */
31const SPELLING_BREAKS = /[\s'"`;&|()<>=]+/
32/** The programs that change the folder of a shell; a blocked command that names one keeps every target. */
33const FOLDER_CHANGERS = new Set(['cd', 'pushd', 'popd'])
34/** What a command loses before Arrester looks for a program that changes its folder: its quotes, the `$` before a quote, and its backslashes. */
35const QUOTING = /\$?['"]|\\/g
36/** Where a command without its quoting splits into the names of the programs that it can run. */
37const NAME_BREAKS = /[^A-Za-z0-9_.-]+/
38/** The most distinct words of a deny reason that naming reads; a reason with more keeps every target. */
39const MAX_REASON_WORDS = 256
40/** The most glob steps that naming targets takes; past it, the reason keeps every target. */
41const MAX_NAME_STEPS = 1_000_000
42
43/** When a block closes: `block_minutes` after it opened or after its last caught workaround. */
44function closesAt(block: ArresterBlock, minutes: number): number {
45 return Math.max(block.at, block.lastHitAt) + minutes * MINUTE
46}
47
48/** The blocks still open at `now`, oldest first. */
49export function openBlocks(journal: ArresterJournal, now: number, minutes: number): ArresterBlock[] {
50 return journal.blocks.filter(block => now < closesAt(block, minutes))
51}
52
53/** True when the call `toolUseId` opened a block already, even one that has closed since. */
54export function isRecorded(journal: ArresterJournal, toolUseId: string): boolean {
55 return journal.recorded.includes(toolUseId) || journal.blocks.some(block => block.toolUseId === toolUseId)
56}
57
58/** The journal with a block opened at `draft.at`: the next id, its segments and flags capped, expired blocks pruned, the oldest past 50 closed, the guards replaced, and the call recorded; unchanged when the call opened a block already. */
59export function withBlock(journal: ArresterJournal, draft: BlockDraft, guards: readonly ArresterGuard[], minutes: number): ArresterJournal {
60 if (isRecorded(journal, draft.toolUseId)) return journal
61 const seq = journal.seq + 1
62 let bytes = 0
63 const bounded = (flags: string[]): { flags: string[]; flagsComplete: boolean } => {
64 let size = 0
65 for (const flag of flags) {
66 size += 4
67 for (const ch of flag) {
68 const point = ch.codePointAt(0)!
69 size += point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4
70 }
71 if (size + bytes > MAX_FLAG_BYTES) break
72 }
73 if (flags.length > MAX_FLAGS || size + bytes > MAX_FLAG_BYTES) return { flags: [], flagsComplete: false }
74 bytes += size
75 return { flags, flagsComplete: true }
76 }
77 const whole = bounded(draft.flags)
78 const segments = draft.segments.slice(0, MAX_SEGMENTS).map(segment => ({ ...segment, ...bounded(segment.flags) }))
79 const limited = !whole.flagsComplete || segments.some(segment => !segment.flagsComplete)
80 const retryLimited = [draft.retryLimited, ...(limited ? ['flag storage exceeds 256 flags or 65,536 total bytes; fuzzy retries with incomplete flags are unavailable'] : [])].filter(Boolean).join('; ') || undefined
81 const block: ArresterBlock = { ...draft, id: `b${seq}`, lastHitAt: draft.at, ...whole, segments, retryLimited }
82 const recorded = [...journal.recorded, draft.toolUseId].slice(-MAX_RECORDED)
83 const blocks = [...openBlocks(journal, draft.at, minutes), block]
84 const evicted = blocks.length > MAX_BLOCKS ? blocks[0] : undefined
85 const eviction = evicted === undefined ? journal.eviction : { blockId: evicted.id, at: draft.at, toolUseId: draft.toolUseId }
86 return { ...journal, seq, blocks: blocks.slice(-MAX_BLOCKS), guards: [...guards], recorded, eviction }
87}
88
89/** The journal with the blocks a workaround matched renewed: their window starts again at `now`, or stays at a later match that a parallel check saved first. */
90export function renewed(journal: ArresterJournal, ids: readonly string[], now: number): ArresterJournal {
91 return { ...journal, blocks: journal.blocks.map(block => (ids.includes(block.id) ? { ...block, lastHitAt: Math.max(block.lastHitAt, now) } : block)) }
92}
93
94/** The journal with one more decision; the oldest past 50 is dropped. */
95export function withDecision(journal: ArresterJournal, decision: ArresterDecision): ArresterJournal {
96 return { ...journal, decisions: [...journal.decisions, decision].slice(-MAX_DECISIONS) }
97}
98
99/** The journal with a call held for the user. */
100export function withHeld(journal: ArresterJournal, held: ArresterHeld): ArresterJournal {
101 return { ...journal, held: [...journal.held, held] }
102}
103
104/** The journal without the held call `toolUseId`: its hold has ended. */
105export function withoutHeld(journal: ArresterJournal, toolUseId: string): ArresterJournal {
106 return { ...journal, held: journal.held.filter(held => held.toolUseId !== toolUseId) }
107}
108
109/** The journal with the open block `id` closed, or every open block when `id` is undefined, and the ids it closed; a clear that closes something is recorded. */
110export function cleared(journal: ArresterJournal, id: string | undefined, now: number, minutes: number): { journal: ArresterJournal; closed: string[] } {
111 const open = openBlocks(journal, now, minutes)
112 const closed = open.filter(block => id === undefined || block.id === id).map(block => block.id)
113 if (closed.length === 0) return { journal, closed }
114 const kept = { ...journal, blocks: open.filter(block => !closed.includes(block.id)) }
115 return { journal: withDecision(kept, { at: now, action: 'cleared', blockIds: closed, detail: 'by the user' }), closed }
116}
117
118/** The journal at `now` with expired blocks dropped, and every held call too when `dropHeld` (a reload ends the hooks that held them); the same object when nothing changes. */
119export function pruned(journal: ArresterJournal, now: number, minutes: number, dropHeld: boolean): ArresterJournal {
120 const blocks = openBlocks(journal, now, minutes)
121 const isHeldDropped = dropHeld && journal.held.length > 0
122 if (blocks.length === journal.blocks.length && !isHeldDropped) return journal
123 return { ...journal, blocks, held: isHeldDropped ? [] : journal.held }
124}
125
126/** When the next open block closes; undefined when none is open. */
127export function nextClose(journal: ArresterJournal, minutes: number): number | undefined {
128 const times = journal.blocks.map(block => closesAt(block, minutes))
129 return times.length === 0 ? undefined : Math.min(...times)
130}
131
132/** Whether a blocked full search of a folder may keep the folder as a read target: its deny `reason` names no other tool to use, and no spelling of it is `/` or is or holds one of the `spared` paths, such as the home, session, and project folders and the guard files. */
133export function isTargetFolder(spellings: readonly string[], spared: readonly string[], reason: string): boolean {
134 return !SUGGESTS_TOOL.test(reason) && spellings.every(folder => folder !== '/' && !spared.some(path => path === folder || path.startsWith(`${folder}/`)))
135}
136
137/** The targets of a blocked call: the first 256 paths its effects touch, each with the strongest access; recursive reads and lists, and paths that an MCP call names, give none. */
138export function targetsOf(effects: readonly Effect[]): ArresterTarget[] {
139 const targets = new Map<string, ArresterTarget>()
140 for (const effect of effects) {
141 if (effect.kind === 'name' || ((effect.kind === 'read' || effect.kind === 'list') && effect.recursive)) continue
142 const access: ArresterAccess = effect.kind === 'list' ? 'read' : effect.kind
143 const key = JSON.stringify([effect.path, effect.glob, effect.globOptions?.dot === true, effect.globOptions?.nocase === true, effect.entry === true, selectionKey(effect.selection)])
144 const found = targets.get(key)
145 if (found !== undefined) {
146 if (STRENGTH[access] > STRENGTH[found.access]) found.access = access
147 } else if (targets.size < MAX_TARGETS) targets.set(key, { path: effect.path, access, ...(effect.globOptions === undefined ? {} : { globOptions: effect.globOptions }), ...(effect.glob === undefined ? {} : { glob: effect.glob }), ...(effect.entry === true ? { entry: true } : {}), ...(effect.selection === undefined ? {} : { selection: effect.selection }) })
148 }
149 return [...targets.values()]
150}
151
152/** What naming a target reads besides the word: the words of the blocked command, and the shared glob budget. */
153type Naming = { spelled: ReadonlySet<string>; meter: Meter }
154
155/** The targets of a blocked call that its deny `reason` names, when there are two or more and the reason names some but not all of them; else every target. Every target stays when the blocked `command` names `cd`, `pushd`, or `popd`. Each word of the reason, cut at blanks, is read in the two forms of `formsOf`, and names a target when one of its forms does: when the form is the target's absolute path or real path, its spelling in the blocked `command`, its path relative to the session folder `cwd`, or its file name, or one of these for a folder that holds the target; a relative form is read in `cwd` and `~` in `home`. A form with `*` names a target with no glob whose file name or path it matches as a glob; a glob target is named only by its glob text or a folder that holds it. Every target stays when the reason has more than MAX_REASON_WORDS distinct words, when a form holds `**`, when a word with a form that looks like a path names no target in either form, when a glob target can match a form of a word that does not name it, or past the glob budget. */
156export function namedTargets(targets: readonly ArresterTarget[], reason: string, command: string | undefined, cwd: string, home: string | undefined): ArresterTarget[] {
157 if (targets.length < 2 || (command !== undefined && movesFolder(command))) return [...targets]
158 const words = new Map<string, string[]>()
159 for (const word of reason.split(/\s+/)) {
160 const forms = [...new Set(formsOf(word))].filter(form => form !== '')
161 if (forms.length > 0) words.set(forms.join(' '), forms)
162 if (words.size > MAX_REASON_WORDS) return [...targets]
163 }
164 const naming: Naming = { spelled: new Set(command?.split(SPELLING_BREAKS) ?? []), meter: { steps: 0, limit: MAX_NAME_STEPS } }
165 const named = new Set<ArresterTarget>()
166 for (const forms of words.values()) {
167 if (forms.some(form => form.includes('**'))) return [...targets]
168 const read = forms.map(form => ({ form, resolved: form === '~' || form.startsWith('~/') ? (home === undefined ? undefined : foldPath(`${home}${form.slice(1)}`)) : joinPath(cwd, form) }))
169 const hits = new Set(targets.filter(target => read.some(({ form, resolved }) => names(form, resolved, target, naming))))
170 if (hits.size === 0 && forms.some(form => isPathWord(form, naming))) return [...targets]
171 if (targets.some(target => !hits.has(target) && read.some(({ form, resolved }) => globCanMatch(form, resolved, target, naming.meter)))) return [...targets]
172 if (naming.meter.steps > MAX_NAME_STEPS) return [...targets]
173 for (const hit of hits) named.add(hit)
174 }
175 const kept = targets.filter(target => named.has(target))
176 return kept.length > 0 && kept.length < targets.length ? kept : [...targets]
177}
178
179/** Whether a blocked command can change its folder: without its quoting, it names `cd`, `pushd`, or `popd` as a word, in any part. */
180function movesFolder(command: string): boolean {
181 return command.replace(QUOTING, '').split(NAME_BREAKS).some(word => FOLDER_CHANGERS.has(word))
182}
183
184/** Whether a form of a word of a deny reason looks like a path: it holds `/` or `*`, or it is a word of the blocked command. */
185function isPathWord(word: string, naming: Naming): boolean {
186 return word.includes('/') || word.includes('*') || naming.spelled.has(word)
187}
188
189/** The two forms of a word of a deny reason, both without the quotes, backticks, and brackets at its two ends: the first also without the `.`, `,`, `;`, `:`, `!`, and `?` at its end; the second keeps those that come before the last closing quote or bracket of the word, or all of them when the word ends in none, so `` `secret.`, `` gives `secret` and `secret.`. */
190function formsOf(word: string): [string, string] {
191 let start = 0
192 let end = word.length
193 let kept = end
194 while (start < end && WORD_EDGES.has(word.charAt(start))) start += 1
195 while (end > start && (WORD_EDGES.has(word.charAt(end - 1)) || WORD_ENDS.has(word.charAt(end - 1)))) {
196 end -= 1
197 if (WORD_EDGES.has(word.charAt(end))) kept = end
198 }
199 return [word.slice(start, end), word.slice(start, kept)]
200}
201
202/** Whether a form of a word of a deny reason, `resolved` as a path, names a target, or a folder that holds it, by its path or real path: as an absolute path, or a relative one read in the session folder, or `~` read in the home folder; as a file or folder name; as a spelling of the blocked command that the path ends with; or, with `*`, as a glob. A glob target's glob text names it, in the same forms, but no other part of the glob does, and no glob word. */
203function names(word: string, resolved: string | undefined, target: ArresterTarget, naming: Naming): boolean {
204 const paths = target.real === undefined ? [target.path] : [target.path, target.real]
205 const spelling = naming.spelled.has(word) ? word.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '') : ''
206 return paths.some(path => {
207 const place = target.glob === undefined ? path : `${path === '/' ? '' : path}/${target.glob}`
208 if (resolved !== undefined && (place === resolved || path === resolved || path.startsWith(resolved === '/' ? '/' : `${resolved}/`))) return true
209 if (!word.includes('/') && (word === target.glob || path.split('/').includes(word))) return true
210 if (spelling !== '' && !spelling.startsWith('/') && (place.endsWith(`/${spelling}`) || path.endsWith(`/${spelling}`) || path.includes(`/${spelling}/`))) return true
211 return target.glob === undefined && word.includes('*') && globNames(word, resolved, place, naming.meter)
212 })
213}
214
215/** Whether a glob target can match a form of a word of a deny reason as a file: the form `resolved` as a path, or a form without `/` as a file name that the last segment of the glob matches; past the budget, or when the comparison is uncertain, it can. */
216function globCanMatch(word: string, resolved: string | undefined, target: ArresterTarget, meter: Meter): boolean {
217 const glob = target.glob
218 if (glob === undefined) return false
219 if (meter.steps > MAX_NAME_STEPS) return true
220 const paths = target.real === undefined ? [target.path] : [target.path, target.real]
221 if (resolved !== undefined && paths.some(path => relation({ path: resolved, recursive: false }, path, meter, glob, target.globOptions) !== undefined)) return true
222 if (word.includes('/')) return false
223 const last = glob.slice(glob.lastIndexOf('/') + 1)
224 const nocase = target.globOptions?.nocase === true
225 return globMatch(nocase ? foldCase(last) : last, nocase ? foldCase(word) : word, meter, target.globOptions?.dot === true) !== false
226}
227
228/** Whether a word with `*` matches the file name of a place, or the place as a path when it is read as `resolved`, with each name a glob; past the budget it counts as a match. */
229function globNames(word: string, resolved: string | undefined, place: string, meter: Meter): boolean {
230 if (globMatch(word, basenameOf(place), meter, true) !== false) return true
231 if (resolved === undefined) return false
232 const pattern = resolved.split('/')
233 const parts = place.split('/')
234 return pattern.length === parts.length && pattern.every((part, i) => globMatch(part, parts[i] ?? '', meter, true) !== false)
235}
236hooks/match.ts 225 lines1// Which open blocks a call matches, and why, by four rules: target, retry,
2// guard, and opaque. Pure: no `$`.
3
4import type { CallAnalysis } from './analyze/call'
5import { foldCase, relation } from './analyze/paths'
6import type { Meter, Relation } from './analyze/paths'
7import { OPAQUE } from './analyze/shell'
8import type { Effect, EffectKind } from './analyze/types'
9import type { ArresterAccess, ArresterBlock, ArresterGuard, ArresterTarget } from '../types/index'
10
11/** An effect with the real path of its path, when the file system gave one that differs; `folder` marks a `name` effect on a folder that exists. */
12export type PlacedEffect = Effect & { real?: string; folder?: true }
13
14/** One reason a call matches a block; paths are written for people, a glob as its folder and segment. A guard finding names the guard the effect touched, the launch flag or plugin command that can turn guards off, or a nested `claude` run. */
15export type Finding =
16 | { rule: 'target'; block: ArresterBlock; kind: EffectKind; path: string; relation: Relation; target: string; access: ArresterAccess }
17 | { rule: 'retry'; block: ArresterBlock }
18 | { rule: 'guard'; block: ArresterBlock; kind: EffectKind; path: string; relation: Relation; guard: string }
19 | { rule: 'guard'; block: ArresterBlock; bypass: string }
20 | { rule: 'opaque'; block: ArresterBlock; reason: string }
21
22/** The most comparison steps one check takes; past them it stops comparing and the call is opaque, so a crafted call cannot hold the check. */
23const MAX_STEPS = 20_000_000
24
25/** Every finding of a call of `tool` against the open blocks: target findings for each block that is not a user rejection and retry findings for each block, oldest first, then guard and opaque findings against the newest block that is not a user rejection. A Grep or Glob call gets no target finding from a block of a `Read(` rule, since Claude Code leaves the files of Read deny rules out of those searches. */
26export function findings(open: readonly ArresterBlock[], guards: readonly ArresterGuard[], call: CallAnalysis, effects: readonly PlacedEffect[], tool = ''): Finding[] {
27 const found: Finding[] = []
28 const meter: Meter = { steps: 0 }
29 const candidates = targetCandidates(open, effects, meter)
30 const isSearchTool = tool === 'Grep' || tool === 'Glob'
31 for (const block of open) {
32 for (const effect of block.source === 'user' || (isSearchTool && block.rule?.startsWith('Read(') === true) ? [] : effects) {
33 if (meter.steps > MAX_STEPS) break
34 const hit = targetHit(block, effect, meter, candidates.get(effect))
35 if (hit !== undefined) found.push({ rule: 'target', block, kind: effect.kind, path: shown(effect.path, effect.glob), relation: hit.relation, target: shown(hit.target.path, hit.target.glob), access: hit.target.access })
36 }
37 if (isRetry(block, call)) found.push({ rule: 'retry', block })
38 }
39 const newest = open.filter(block => block.source !== 'user').at(-1)
40 if (newest === undefined) return found
41 for (const effect of effects) {
42 if (meter.steps > MAX_STEPS) break
43 const hit = guardHit(effect, guards, meter)
44 if (hit !== undefined) found.push({ rule: 'guard', block: newest, kind: effect.kind, path: shown(effect.path, effect.glob), relation: hit.relation, guard: hit.guard })
45 }
46 if (call.bypass !== undefined) found.push({ rule: 'guard', block: newest, bypass: call.bypass })
47 const reason = call.opaque ?? (meter.uncertain ? 'path comparison exceeded its work budget; target overlap is uncertain' : undefined) ?? (meter.steps > MAX_STEPS ? OPAQUE.tooMany : undefined)
48 if (reason !== undefined) found.push({ rule: 'opaque', block: newest, reason })
49 return found
50}
51
52/** Literal-prefix index of retained targets, including their real spellings. */
53type TargetNode = { children: Map<string, TargetNode>; targets: Set<ArresterTarget> }
54
55/** Selects only targets whose literal prefixes overlap an effect, charging index work to the shared budget. A case-insensitive glob on either side compares its folders without case, so case-insensitive targets sit in an index of case-folded folders, and a case-insensitive effect looks up the other targets by their folded folders too. */
56function targetCandidates(open: readonly ArresterBlock[], effects: readonly PlacedEffect[], meter: Meter): Map<PlacedEffect, Set<ArresterTarget>> {
57 const retained = open.flatMap(block => (block.source === 'user' ? [] : block.targets))
58 const sensitive = retained.filter(target => target.globOptions?.nocase !== true)
59 const loose = retained.filter(target => target.globOptions?.nocase === true)
60 const insensitive = targetIndex(loose, true, meter)
61 if (insensitive === undefined) return new Map()
62 let exact: TargetNode | undefined
63 let folded: TargetNode | undefined
64 const cache = new Map<string, Set<ArresterTarget>>()
65 const result = new Map<PlacedEffect, Set<ArresterTarget>>()
66 for (const effect of effects) {
67 const nocase = effect.globOptions?.nocase === true
68 const key = `${effect.path}\0${effect.real ?? ''}\0${nocase}`
69 let candidates = cache.get(key)
70 if (candidates === undefined) {
71 const root = nocase ? (folded ??= targetIndex(sensitive, true, meter)) : (exact ??= targetIndex(sensitive, false, meter))
72 if (root === undefined) return new Map()
73 candidates = new Set()
74 for (const path of spellings(effect.path, effect.real)) {
75 meter.steps += path.length
76 gather(root, path, nocase, candidates, meter)
77 if (loose.length > 0) gather(insensitive, path, true, candidates, meter)
78 }
79 cache.set(key, candidates)
80 }
81 result.set(effect, candidates)
82 }
83 return result
84}
85
86/** The index of the targets by the folders of each spelling, case-folded when `fold` is set; undefined past the step budget. */
87function targetIndex(targets: readonly ArresterTarget[], fold: boolean, meter: Meter): TargetNode | undefined {
88 const root: TargetNode = { children: new Map(), targets: new Set() }
89 for (const target of targets) {
90 for (const path of spellings(target.path, target.real)) {
91 meter.steps += path.length
92 if (meter.steps > MAX_STEPS) return undefined
93 let node = root
94 for (const part of folders(path, fold)) {
95 let child = node.children.get(part)
96 if (child === undefined) {
97 child = { children: new Map(), targets: new Set() }
98 node.children.set(part, child)
99 }
100 node = child
101 }
102 node.targets.add(target)
103 }
104 }
105 return root
106}
107
108/** Adds to `candidates` the indexed targets at or above the path and every target below it. */
109function gather(root: TargetNode, path: string, fold: boolean, candidates: Set<ArresterTarget>, meter: Meter): void {
110 let node: TargetNode | undefined = root
111 for (const part of folders(path, fold)) {
112 meter.steps += node.targets.size
113 if (meter.steps > MAX_STEPS) break
114 for (const target of node.targets) candidates.add(target)
115 node = node.children.get(part)
116 if (node === undefined) break
117 }
118 const pending = node === undefined ? [] : [node]
119 while (pending.length > 0 && meter.steps <= MAX_STEPS) {
120 const next = pending.pop()!
121 meter.steps += 1 + next.targets.size + next.children.size
122 for (const target of next.targets) candidates.add(target)
123 pending.push(...next.children.values())
124 }
125}
126
127/** The folders of a path, each case-folded as a case-insensitive comparison reads it when `fold` is set. */
128function folders(path: string, fold: boolean): string[] {
129 return (fold ? foldCase(path) : path).split('/').filter(Boolean)
130}
131
132/** The blocks that findings name, in order, each once. */
133export function blocksOf(found: readonly Finding[]): ArresterBlock[] {
134 return [...new Set(found.map(finding => finding.block))]
135}
136
137/** The ids of the blocks that a target or retry finding names: the blocks a caught workaround renews; a guard or opaque finding alone renews none. */
138export function renewable(found: readonly Finding[]): string[] {
139 return blocksOf(found.filter(finding => finding.rule === 'target' || finding.rule === 'retry')).map(block => block.id)
140}
141
142/** A path as people read it: a glob as its folder and its glob segment. */
143export function shown(path: string, glob: string | undefined): string {
144 if (glob === undefined) return path
145 return path === '/' ? `/${glob}` : `${path}/${glob}`
146}
147
148/** The target rule: the first target of the block that the effect meets in a way its access counts. */
149function targetHit(block: ArresterBlock, effect: PlacedEffect, meter: Meter, candidates: ReadonlySet<ArresterTarget> | undefined): { target: ArresterTarget; relation: Relation } | undefined {
150 if (candidates === undefined || candidates.size === 0) return undefined
151 for (const target of block.targets) {
152 if (!candidates.has(target)) continue
153 if (meter.steps > MAX_STEPS) break
154 const found = meet(effect, target.path, target.real, target.glob, meter, target.selection, target.globOptions)
155 if (found === 'uncertain') {
156 meter.uncertain = true
157 continue
158 }
159 if (found !== undefined && counts(target.access, effect.kind, found, effect.folder === true)) return { target, relation: found }
160 }
161 return undefined
162}
163
164/** Whether an effect of `kind` that stands in `found` to a target of `access` gets the result the guard blocked; a path that an MCP call names counts for a read target, and for another target when it is the same path and no folder that exists. */
165function counts(access: ArresterAccess, kind: EffectKind, found: Relation, folder: boolean): boolean {
166 if (kind === 'name') return access === 'read' || (found === 'same' && !folder)
167 if (kind === 'destroy') return true
168 if (kind === 'write') return found === 'same' || (access === 'write' && found === 'contains')
169 if (kind === 'read') return access === 'read'
170 return access === 'read' && found !== 'contains'
171}
172
173/** The retry rule: the call repeats the blocked command as a whole, or holds the one command the block had, unless it only drops some of the blocked flags. A block from the user's own rejection matches only the same call again: the complete syntax-preserving input and context identity. */
174function isRetry(block: ArresterBlock, call: CallAnalysis): boolean {
175 const isWhole = call.skeleton !== '' && call.skeleton === block.skeleton
176 const exact = call.exact !== undefined && call.exact === block.exact
177 if (block.source === 'user') return exact
178 const preview = call.segments.some(segment => segment.preview)
179 if (exact && !preview) return true
180 if (block.flagsComplete && isWhole) {
181 if (!preview && !dropsFlags(call.flags, block.flags)) return true
182 if (preview && call.segments.some(segment => !segment.preview && block.segments.some(blocked => blocked.flagsComplete && blocked.skeleton === segment.skeleton && !dropsFlags(segment.flags, blocked.flags)))) return true
183 }
184 const [only, ...others] = block.segments
185 return only !== undefined && others.length === 0 && call.segments.some(segment => !segment.preview && only.flagsComplete && segment.skeleton === only.skeleton && !dropsFlags(segment.flags, only.flags))
186}
187
188/** True when the later flags are some, not all, of the blocked ones: the retry drops flags, and a guard that objected to the command itself sees it again. */
189function dropsFlags(later: readonly string[], blocked: readonly string[]): boolean {
190 return later.length < blocked.length && later.every(flag => blocked.includes(flag))
191}
192
193/** The guard rule compares only recorded guard paths; containing reads and change-only guard reads do not count. */
194function guardHit(effect: PlacedEffect, guards: readonly ArresterGuard[], meter: Meter): { guard: string; relation: Relation } | undefined {
195 const changes = effect.kind === 'write' || effect.kind === 'destroy'
196 for (const guard of guards) {
197 if (guard.changeOnly === true && !changes) continue
198 const found = meet(effect, guard.path, guard.real, undefined, meter)
199 if (found === 'uncertain') meter.uncertain = true
200 if (found === 'same' || found === 'inside' || (found === 'contains' && changes)) return { guard: guard.path, relation: found }
201 }
202 return undefined
203}
204
205/** How an effect stands to a protected path: the first relation among the pairs of {written, real} spellings, effect first. */
206function meet(effect: PlacedEffect, path: string, real: string | undefined, glob: string | undefined, meter: Meter, selection?: ArresterTarget['selection'], globOptions?: ArresterTarget['globOptions']): Relation | 'uncertain' | undefined {
207 for (const spelling of spellings(effect.path, effect.real)) {
208 for (const place of spellings(path, real)) {
209 if (selection !== undefined) {
210 const selected = relation({ path: place, glob, globOptions, recursive: true, selection }, spelling, meter, effect.glob, effect.globOptions)
211 if (selected === undefined) continue
212 if (selected === 'uncertain') return selected
213 }
214 const found = relation({ path: spelling, glob: effect.glob, globOptions: effect.globOptions, recursive: effect.recursive, selection: effect.selection }, place, meter, glob, globOptions)
215 if (found !== undefined) return found
216 }
217 }
218 return undefined
219}
220
221/** A path, and its real path when that differs. */
222function spellings(path: string, real: string | undefined): string[] {
223 return real === undefined || real === path ? [path] : [path, real]
224}
225hooks/text.ts 352 lines1// Every text Arrester shows or sends: the reasons Claude reads, the question,
2// the transcript lines, the status line, the /arrester output, the
3// diagnostics, and the reading of block messages in tool results. Pure: no `$`.
4
5import type { HookFailure } from 'claude-code'
6
7import type { Call } from './analyze/call'
8import { sortedJson } from './analyze/call'
9import { NESTED_SESSION } from './analyze/shell'
10import type { EffectKind, Ignored } from './analyze/types'
11import { blocksOf, shown } from './match'
12import type { Finding } from './match'
13import type { ArresterBlock, ArresterCall, ArresterDecision, ArresterHeld, ArresterJournal, ArresterSource } from '../types/index'
14
15/** The menu line of `/arrester`. */
16export const COMMAND_DESCRIPTION = 'Show the calls Arrester keeps blocked, or close blocks with clear'
17/** The hint after `/arrester` in the menu. */
18export const ARGUMENT_HINT = '[history [page] | clear [id]]'
19/** What `/arrester` prints for input it does not take. */
20export const USAGE = 'Usage: /arrester, /arrester history [1-5], /arrester clear, or /arrester clear <id>'
21/** The options and the chip of the hold question. */
22export const DENY = 'Deny'
23export const ALLOW_ONCE = 'Allow once'
24export const HEADER = 'Arrester'
25
26/** How a hold ended without "Allow once". */
27type HoldEnd = { kind: 'refused' } | { kind: 'typed'; text: string } | { kind: 'unanswered'; cause: string }
28
29const MAX_SUMMARY = 120
30const MAX_REASON = 300
31const MAX_QUESTION = 600
32const LABELS: Readonly<Record<ArresterSource, string>> = { hook: 'settings hook', rule: 'deny rule', classifier: 'auto mode classifier', user: 'user rejection' }
33const VERBS: Readonly<Record<EffectKind, string>> = { destroy: 'deletes or discards', write: 'writes', read: 'reads', list: 'lists', name: 'names' }
34const REJECTED = "The user doesn't want to proceed with this tool use."
35const HOOK_ERROR = /^PreToolUse:\S+ hook error: \[/
36const HOOK_PREFIX = /^PreToolUse:\S+ hook error: /
37const COMMAND_END = ']: '
38const CLASSIFIER = 'Permission for this action was denied by the Claude Code auto mode classifier. Reason: '
39const ADVICE = '. If you have other tasks'
40const STOP = 'Do not try another way to get the same result.'
41
42/** `text` on one line, cut to `max` characters with `…`. */
43function line(text: string, max: number): string {
44 const flat = text.replace(/\s+/g, ' ').trim()
45 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
46}
47
48/** A call in one line of at most 120 characters: its command or path, else its input as JSON. */
49export function summarize(call: Call): string {
50 const input = call.input
51 const main = [input.command, input.file_path, input.notebook_path, input.pattern, input.url].find(value => typeof value === 'string')
52 const text = typeof main === 'string' ? `${main} ${typeof input.path === 'string' ? input.path : ''}` : sortedJson(input) ?? '[input exceeds analysis limit]'
53 return line(text, MAX_SUMMARY)
54}
55
56/** A guard's reason without the hook error prefix, and without the hook command when the prefix names one. */
57export function guardMessage(reason: string): string {
58 const hook = blockOfText(reason)
59 return hook?.source === 'hook' ? hook.reason : reason.replace(HOOK_PREFIX, '')
60}
61
62/** A guard's reason as a block keeps it: its `guardMessage` on one line of at most 300 characters. */
63export function storedReason(reason: string): string {
64 return line(guardMessage(reason), MAX_REASON)
65}
66
67/** The tool, summary, and subagent of a call, as the journal shows it. */
68export function viewOf(call: Call): ArresterCall {
69 const view = { tool: call.tool, summary: summarize(call) }
70 return call.agentId === undefined ? view : { ...view, agentId: call.agentId }
71}
72
73/** The message of something thrown or rejected. */
74export function causeOf(error: unknown): string {
75 return error instanceof Error ? error.message : String(error)
76}
77
78/** A hook failure as the diagnostics name it: its kind and its message. */
79export function failureOf(failure: HookFailure): string {
80 return failure.message === undefined ? failure.kind : `${failure.kind}: ${failure.message}`
81}
82
83/** Whether an auto mode denial carries a classifier verdict; the two no-verdict reasons do not. */
84export function hasVerdict(reason: string): boolean {
85 return !reason.startsWith('Classifier unavailable') && !reason.startsWith('Auto mode could not evaluate this action')
86}
87
88/** The block a tool result's text reports, read from the stable prefixes only: the user's rejection; a settings hook's exit-code-2 error, whose reason follows the first `]: ` after the hook command; or a classifier verdict, which ends before the advice that follows it. */
89export function blockOfText(text: string): { source: ArresterSource; reason: string } | undefined {
90 if (text.startsWith(REJECTED)) return { source: 'user', reason: text }
91 const hook = HOOK_ERROR.exec(text)
92 if (hook !== null) {
93 const rest = text.slice(hook[0].length)
94 const end = rest.indexOf(COMMAND_END)
95 return { source: 'hook', reason: end < 0 ? rest : rest.slice(end + COMMAND_END.length) }
96 }
97 if (!text.startsWith(CLASSIFIER)) return undefined
98 const rest = text.slice(CLASSIFIER.length)
99 const end = rest.indexOf(ADVICE)
100 const verdict = end < 0 ? rest : rest.slice(0, end)
101 return hasVerdict(verdict) ? { source: 'classifier', reason: verdict } : undefined
102}
103
104/** One finding in words. */
105export function phrase(finding: Finding): string {
106 if (finding.rule === 'retry' && finding.block.source === 'user') return 'it repeats the call the user rejected'
107 if (finding.rule === 'retry') return 'it repeats the blocked command with other flags or argument order'
108 if (finding.rule === 'opaque') return `Arrester cannot fully analyze this call (${finding.reason})`
109 if (finding.rule === 'guard') {
110 if ('bypass' in finding) return finding.bypass === NESTED_SESSION ? 'it runs a nested claude, which does not inherit the open blocks' : `it runs claude ${finding.bypass}, which can turn off guards`
111 if (finding.relation === 'contains') return `it ${VERBS[finding.kind]} ${finding.path}, which contains the guard file ${finding.guard}`
112 return `it ${finding.kind === 'name' ? 'names' : finding.kind === 'read' || finding.kind === 'list' ? 'reads or lists' : 'changes'} ${finding.path}, a guard file`
113 }
114 const where = finding.relation === 'same' ? '' : `, which ${finding.relation === 'inside' ? 'is inside' : 'contains'} ${finding.target}`
115 return `it ${VERBS[finding.kind]} ${finding.path}${where} (blocked: ${finding.access})`
116}
117
118/** Who blocked a call: the guard, with the rule for a deny rule. */
119function label(block: ArresterBlock): string {
120 return block.rule === undefined ? LABELS[block.source] : `${LABELS[block.source]} ${block.rule}`
121}
122
123/** How long ago a moment was, in whole minutes. */
124function ago(ms: number): string {
125 return `${Math.max(0, Math.floor(ms / 60_000))} min ago`
126}
127
128/** A call in a line: its tool, the subagent that made it, and its summary. */
129function who(call: ArresterCall): string {
130 return `${call.tool}${call.agentId === undefined ? '' : ` in agent ${call.agentId}`}: ${call.summary}`
131}
132
133/** The Blocked and Match lines of a reason: the blocks the first three findings name, and those findings. */
134function evidence(found: readonly Finding[], now: number): string[] {
135 const shownFindings = found.slice(0, 3)
136 return [
137 ...blocksOf(shownFindings).map(block => `Blocked: ${block.id}, ${label(block)}, ${ago(now - block.at)}: ${block.tool} \`${block.summary}\`. Reason: ${block.reason}`),
138 `Match: ${shownFindings.map(finding => line(phrase(finding), 600)).join('; ')}.`,
139 ]
140}
141
142/** The reason Claude reads when Arrester stops a workaround. */
143export function denyReason(found: readonly Finding[], now: number): string {
144 const blocks = blocksOf(found.slice(0, 3))
145 const close = blocks.length === 1 ? `The user can close this block with /arrester clear ${blocks.map(block => block.id).join('')}.` : 'The user can close these blocks with /arrester clear.'
146 return [
147 'Arrester stopped this call. It looks like a workaround of a call that a guard blocked.',
148 ...evidence(found, now),
149 `${STOP} Stop, tell the user what was blocked and why, and wait for their instructions. ${close}`,
150 ].join('\n')
151}
152
153/** The reason Claude reads when a hold ends in a refusal. */
154export function heldReason(end: HoldEnd, found: readonly Finding[], now: number): string {
155 const first =
156 end.kind === 'refused'
157 ? 'Arrester held this call and the user chose Deny.'
158 : end.kind === 'typed'
159 ? `Arrester held this call and the user did not allow it. The user answered: "${line(end.text, MAX_REASON)}".`
160 : `Arrester held this call and got no answer (${end.cause}), so it refused it.`
161 return [first, ...evidence(found, now), `${STOP} Wait for the user's instructions.`].join('\n')
162}
163
164/** The reason Claude reads when the check itself failed while blocks are open. */
165export function uncheckedReason(cause: string): string {
166 return `Arrester could not check this call against its open blocks (${cause}), so it refused it. Tell the user. The user can close the blocks with /arrester clear.`
167}
168
169/** The hold question, at most 600 characters, the summaries cut first; it ends with its question mark. */
170export function question(call: Call, first: Finding, now: number): string {
171 const block = first.block
172 const ask = (room: number): string => [
173 `Arrester held a ${call.tool} call in ${call.agentId === undefined ? 'the main session' : `agent ${call.agentId}`}. It may get the same result as block ${block.id} (${label(block)}, ${ago(now - block.at)}: ${line(block.summary, room)}).`,
174 `Call: ${line(summarize(call), room)}`,
175 `Why: ${phrase(first)}.`,
176 'Allow this call once?',
177 ].join('\n')
178 const fits = [MAX_SUMMARY, 60, 20].map(ask).find(text => text.length <= MAX_QUESTION)
179 if (fits !== undefined) return fits
180 const tail = '\nAllow this call once?'
181 return `${ask(20).slice(0, MAX_QUESTION - tail.length - 1)}…${tail}`
182}
183
184/** The transcript line of a stop or of the end of a hold; Claude does not read it. */
185export function decisionLine(decision: ArresterDecision, call: ArresterCall): string {
186 const ids = decision.blockIds.join(', ')
187 if (decision.action === 'stopped') return `stopped ${who(call)} (workaround of ${ids}: ${line(decision.detail, 1500)})`
188 if (decision.action === 'allowed-once') return `allowed once ${who(call)} (held for ${ids})`
189 if (decision.action === 'refused') return `refused ${who(call)} (held for ${ids}; the user answered: ${line(decision.detail, 1500)})`
190 return `refused ${who(call)} (held for ${ids}; no answer: ${line(decision.detail, 1500)})`
191}
192
193/** The status line: open blocks and held calls, or nothing when there are none. */
194export function statusLine(open: number, held: number, evicted?: string): string | undefined {
195 if (open === 0 && held === 0 && evicted === undefined) return undefined
196 const blocks = `${open} open block${open === 1 ? '' : 's'}`
197 const current = held === 0 ? blocks : `${blocks} · ${held} held`
198 return evicted === undefined ? current : `${current} · ${evicted} evicted, protection lost`
199}
200
201/** The `/arrester` text: the options, the open blocks, the held calls, and the last ten decisions. */
202export function statusText(journal: ArresterJournal, open: readonly ArresterBlock[], response: string, minutes: number, now: number): string {
203 const recent = journal.decisions.slice(-10)
204 return [
205 `response: ${response} · a block stays open ${minutes} min after it opens or after its last target or retry match`,
206 '',
207 ...(journal.eviction === undefined ? [] : [`Capacity: ${evictionText(journal.eviction.blockId)} (${ago(now - journal.eviction.at)})`, '']),
208 `Open blocks (${open.length})`,
209 ...open.flatMap(block => [
210 ` ${block.id} ${ago(now - block.at)}, turn ${block.turn} ${label(block)} ${block.tool}${block.agentId === undefined ? '' : ` in agent ${block.agentId}`}: ${block.summary}`,
211 ` reason: ${block.reason}`,
212 ...(block.retryLimited === undefined ? [] : [` retry coverage: ${block.retryLimited}`]),
213 ...(block.source === 'user' && block.exact === undefined ? [' exact retry identity unavailable: the tool input exceeds the serialization limits, so this block matches no retry'] : []),
214 ...(block.incomplete === undefined ? [] : [` incomplete capture: ${block.incomplete}`]),
215 ` targets: ${block.targets.length === 0 ? 'none (retries only)' : block.targets.slice(0, 32).map(target => `${line(shown(target.path, target.glob), 240)} (${target.access})`).join(', ') + (block.targets.length > 32 ? `; ${block.targets.length - 32} more retained targets` : '')}`,
216 ]),
217 '',
218 `Held calls (${journal.held.length})`,
219 ...journal.held.map(held => ` ${ago(now - held.since)} ${held.blockIds.join(', ')} ${who(held)}`),
220 '',
221 `Recent decisions (${recent.length})`,
222 ...recent.map(decision => ` ${ago(now - decision.at)} ${decision.action.padEnd(12)} ${decision.blockIds.join(', ')} ${decision.call === undefined ? '' : `${who(decision.call)} `}${line(decision.detail, 600)}`),
223 '',
224 'Close blocks with /arrester clear [id].',
225 ].join('\n')
226}
227
228/** What `/arrester clear` prints. */
229export function clearedText(closed: readonly string[], id: string | undefined): string {
230 if (id !== undefined) return closed.length === 0 ? `No open block ${id}.` : `Closed block ${id}.`
231 return `Closed ${closed.length} block${closed.length === 1 ? '' : 's'}.`
232}
233
234/** What `/arrester clear` prints for an origin that is not the user's own. */
235export function clearRefused(kind: string): string {
236 return `/arrester clear needs the user at the prompt; this request came from ${kind}.`
237}
238
239/** Diagnostic: the check failed while blocks are open. */
240export function couldNotCheck(tool: string, failure: string, open: number | undefined, cause?: string): string {
241 const because = open === undefined ? `the open blocks could not be read${cause === undefined ? '' : ` (${cause})`}` : `${open} block${open === 1 ? ' is' : 's are'} open`
242 return `arrester: tool.call: could not check ${tool} (${failure}); refused it because ${because}`
243}
244
245/** Diagnostic: the hook failed and no block was open. */
246export function hookFailedNoBlock(failure: string): string {
247 return `arrester: tool.call: hook failed (${failure}); no block was open, so the call went on`
248}
249
250/** Diagnostic: a capture point could not record a block. */
251export function couldNotRecord(event: string, tool: string, failure: string): string {
252 return `arrester: ${event}: could not record the block for ${tool} (${failure}); workarounds of it will not be caught`
253}
254
255/** Diagnostic: a settings source could not be read while guards were computed. */
256export function couldNotReadSettings(event: string, source: string, message: string): string {
257 return `arrester: ${event}: could not read ${source} settings (${message}); hook files named there are not guarded`
258}
259
260/** Diagnostic: a script exists but could not be read. */
261export function couldNotReadScript(event: string, path: string, message: string): string {
262 return `arrester: ${event}: could not read the script ${path} (${message})`
263}
264
265/** Diagnostic: a path could not be placed on the file system. */
266export function couldNotResolve(event: string, path: string, message: string): string {
267 return `arrester: ${event}: could not resolve ${path} (${message}); compared as written`
268}
269
270/** Diagnostic: the `/arrester` hook failed. */
271export function commandFailed(failure: string): string {
272 return `arrester: command.run: /arrester failed (${failure})`
273}
274
275/** Diagnostic: `/arrester` could not be registered. */
276export function couldNotRegister(message: string): string {
277 return `arrester: session.start: could not register /arrester (${message})`
278}
279
280/** Diagnostic: the status line could not be refreshed. */
281export function couldNotSync(event: string, message: string): string {
282 return `arrester: ${event}: could not refresh the status line (${message})`
283}
284
285/** Diagnostic: the hold question got no answer. */
286export function holdUnanswered(message: string): string {
287 return `arrester: tool.call: the hold got no answer (${message}); the call was refused`
288}
289
290/** Diagnostic: an auto mode denial without a classifier verdict. */
291export function noVerdict(reason: string): string {
292 return `arrester: classic.PermissionDenied: no classifier verdict (${reason}); no block opened`
293}
294
295/** Diagnostic: HOME is not set, so `~` paths resolve to nothing. */
296export function homeUnset(event: string): string {
297 return `arrester: ${event}: HOME is not set; ~ paths do not resolve; shell deletions may be stopped as unreadable`
298}
299
300/** Diagnostic: a stop or a hold could not be saved in the journal; what Arrester did to the call stands. */
301export function couldNotSave(tool: string, action: string, message: string): string {
302 return `arrester: tool.call: could not save the ${action} record for ${tool} (${message}); /arrester and the status line may not show it`
303}
304
305/** Diagnostic: code in a script older than the open blocks could not be read, so it was not counted. */
306export function notCounted(ignored: Ignored): string {
307 return `arrester: tool.call: ${ignored.path} changed before the open blocks, so code in it that Arrester cannot read (${ignored.reason}) was not counted`
308}
309
310/** Diagnostic: a reload ended a hold before the user answered it. */
311export function holdEnded(held: ArresterHeld): string {
312 return `arrester: session.start: a reload ended the hold of ${who(held)} (held for ${held.blockIds.join(', ')}); Arrester did not decide that call`
313}
314
315/** Omits the name supplied by the host on transcript and command rows. */
316export function hostText(text: string): string {
317 return text.replace(/^arrester: /, '')
318}
319
320/** Diagnostic: capture retained only the effects that analysis could read. */
321export function incompleteCapture(event: string, reason: string): string {
322 return `arrester: ${event}: incomplete capture (${reason}); some targets may be missing`
323}
324
325/** A bounded capacity notice names the block whose protection ended. */
326function evictionText(id: string): string {
327 return `${id} evicted at the 50-block capacity; its protection is lost`
328}
329
330/** Ten clipped decisions per page, newest page first, covering all retained history. */
331export function historyText(journal: ArresterJournal, page: number, now: number): string {
332 const decisions = journal.decisions.slice(-50).reverse()
333 const pages = Math.max(1, Math.ceil(decisions.length / 10))
334 if (!Number.isInteger(page) || page < 1 || page > pages) return USAGE
335 const rows = decisions.slice((page - 1) * 10, page * 10)
336 return [
337 `Decision history: page ${page}/${pages} (${decisions.length} retained)`,
338 ...rows.map(decision => line(`${ago(now - decision.at)} ${decision.action} ${decision.blockIds.join(', ')} ${decision.call === undefined ? '' : who(decision.call)} ${decision.detail}`, 160)),
339 'Use /arrester history <page>. Older evicted decisions are unavailable.',
340 ].join('\n')
341}
342
343/** Diagnostic: a successful capture evicted a block before its normal expiry. */
344export function evictionNotice(event: string, id: string): string {
345 return `arrester: ${event}: ${evictionText(id)}`
346}
347
348/** Diagnostic: a successful capture could not retain complete fuzzy retry coverage. */
349export function retryNotice(event: string, id: string, reason: string): string {
350 return `arrester: ${event}: ${id}: ${reason}`
351}
352hooks/analyze/code.ts 316 lines1// The interpreter code scan associates literal paths with file operations.
2// Pure: no `$`. Bounded lookahead keeps each scan linear.
3
4import { absolute, hasGlob, isRemote, looksLikePath, MAX_PATH, PATH_TOO_LONG, splitGlob } from './paths'
5import type { Effect, EffectKind } from './types'
6
7/** Words that make recognized file paths in the code a deletion: removal, moves, and running other programs, whose effects Arrester cannot see. */
8const DESTROY_WORDS: readonly string[] = ['rmtree', 'removedirs', 'remove_tree', 'os.remove', 'os.replace', 'unlink', 'rmdir', 'rmSync', '.rm(', 'rimraf', 'FileUtils.rm', 'File.delete', 'Deno.remove', 'rename', 'shutil.move', 'truncate', 'system(', 'subprocess', 'child_process', 'execSync', 'exec(', 'spawn', 'popen', 'Deno.Command', 'Deno.run']
9
10/** Words that make recognized file paths in the code a write. */
11const WRITE_WORDS: readonly string[] = ['write_text', 'write_bytes', 'writeFile', 'appendFile', 'createWriteStream', 'mkdir', 'makedirs', 'chmod', 'chown', 'copyfile', 'copytree', 'copyFile', 'shutil.copy', 'cpSync', 'symlink', 'File.write']
12
13/** Names of calls that read a file without a word in the lists above. */
14const READ_CALL = /(?:open|glob|readFile(?:Sync)?|read_text|read_bytes|readdir(?:Sync)?|readlink|stat|lstat|access|exists|read)$/
15
16/** A Python `shutil` copy, whose `src=` and `dst=` arguments are keywords. In other languages, `src = …` in a call is an assignment. */
17const SHUTIL_COPY = /(?:^|[^.]|shutil\.)(?:copy2?|copyfile|copytree|copymode|copystat)$/
18
19/** One method called on a path object, up to its `(`. */
20const METHOD = /\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/y
21/** The next method called in a chain on a path object, past attributes such as `.parent`, up to its `(`. */
22const LINK = /\s*(?:\.\s*[A-Za-z_][A-Za-z0-9_]*\s*)*?\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/y
23const METHOD_WINDOW = 120
24
25/** Path methods that return the same file, so a file operation after them still acts on the path. */
26const SAME_FILE = new Set(['resolve', 'absolute', 'expanduser'])
27
28/** The end of the path expression after such a method: the end of an argument, a bracket, or a statement. */
29const CHAIN_END = /\s*(?:[)\]},;]|$)/y
30
31const MODE = /^[rwxabt+<>]{1,4}$/
32const PERL_MODE = /^\+?[<>]{1,2}/
33const WRITE_MODE = /[wax+>]/
34const SPECIFIER = /(?:\brequire\(|\bimport\(|\bfrom|\bimport)\s*$/
35const SPECIFIER_WINDOW = 16
36const BARE = /^\.{0,2}\/?$/
37
38/** What comes before a whole literal argument: `(`, `,`, a keyword and its `=`, or a bare `unlink`, `rmdir`, or `open`. */
39const WHOLE_BEFORE = /(?:[(,]|[A-Za-z_][A-Za-z0-9_]*\s*=|\b(?:unlink|rmdir|open))\s*$/
40/** What comes after a whole literal argument: `)` or `,`. */
41const WHOLE_AFTER = /^\s*[),]/
42const ARGUMENT_WINDOW = 64
43
44/** One string literal of the code: its text and source bounds; `cut` marks a template literal cut at its first `${`. */
45type Literal = { text: string; start: number; end: number; cut?: true }
46
47/** The comment syntax that the scan blanks: Python `#`, or JavaScript `//` and `/* … *\/`. */
48export type Dialect = 'python' | 'js'
49
50/** How to scan: the comment dialect of the code, and whether only definite file operations with a whole literal path count. */
51type ScanOptions = { dialect?: Dialect; definiteOnly?: boolean }
52
53/** The string literal that opens at `i`: `'…'`, `"…"`, tripled quotes, or a template literal, whose text stops at its first `${`. */
54function literalAt(code: string, i: number): Literal {
55 const quote = code.charAt(i)
56 const close = quote !== '`' && code.startsWith(quote.repeat(3), i) ? quote.repeat(3) : quote
57 let j = i + close.length
58 let text = ''
59 let isCut = false
60 while (j < code.length && !code.startsWith(close, j)) {
61 if (quote === '`' && code.startsWith('${', j)) isCut = true
62 const step = code.charAt(j) === '\\' && j + 1 < code.length ? 2 : 1
63 if (!isCut) text += code.charAt(j + step - 1)
64 j += step
65 }
66 return isCut ? { text, start: i, end: j + close.length, cut: true } : { text, start: i, end: j + close.length }
67}
68
69/** True when a string literal opens at `i`. */
70function isQuote(code: string, i: number): boolean {
71 const ch = code.charAt(i)
72 return ch === "'" || ch === '"' || ch === '`'
73}
74
75/** The string literals of the code: `'…'`, `"…"`, tripled quotes, and a template literal up to its first `${`. */
76function literals(code: string): Literal[] {
77 const found: Literal[] = []
78 let i = 0
79 while (i < code.length) {
80 if (!isQuote(code, i)) {
81 i += 1
82 continue
83 }
84 const literal = literalAt(code, i)
85 found.push(literal)
86 i = literal.end
87 }
88 return found
89}
90
91/** Where the comment of `dialect` that opens at `i`, the start of a line after its indentation, ends; undefined when none opens there. A `/*` opens a comment only when a `*\/` closes it at or before `lastClose`, the start of the last `*\/` in the code. */
92function commentEnd(code: string, i: number, dialect: Dialect, lastClose: number): number | undefined {
93 const ch = code.charAt(i)
94 if (ch !== '#' && ch !== '/') return undefined
95 const line = (): number => {
96 const end = code.indexOf('\n', i)
97 return end < 0 ? code.length : end
98 }
99 if (dialect === 'python') return ch === '#' ? line() : undefined
100 if (code.startsWith('//', i)) return line()
101 if (!code.startsWith('/*', i) || lastClose < i + 2) return undefined
102 return code.indexOf('*/', i + 2) + 2
103}
104
105/** The code with the text of each comment of `dialect` that starts a line, after any indentation, replaced by spaces, every index and line break kept; strings are skipped as `literals` reads them. A comment after code on the same line stays code. */
106function blankComments(code: string, dialect: Dialect): string {
107 const parts: string[] = []
108 const lastClose = code.lastIndexOf('*/')
109 let kept = 0
110 let i = 0
111 let isLineStart = true
112 while (i < code.length) {
113 if (isQuote(code, i)) {
114 i = literalAt(code, i).end
115 isLineStart = false
116 continue
117 }
118 const end = isLineStart ? commentEnd(code, i, dialect, lastClose) : undefined
119 if (end === undefined) {
120 const ch = code.charAt(i)
121 isLineStart = ch === '\n' || (isLineStart && (ch === ' ' || ch === '\t'))
122 i += 1
123 continue
124 }
125 parts.push(code.slice(kept, i), code.slice(i, end).replace(/[^\r\n]/g, ' '))
126 kept = end
127 i = end
128 isLineStart = false
129 }
130 parts.push(code.slice(kept))
131 return parts.join('')
132}
133
134/** The mode a literal gives `open(`: a short mode string, or the prefix of a two-argument Perl open. */
135function modeOf(literal: Literal): string | undefined {
136 return MODE.test(literal.text) ? literal.text : PERL_MODE.exec(literal.text)?.[0]
137}
138
139/** The access of a recognized operation, independent of neighboring calls. */
140function operationKind(name: string): EffectKind {
141 if (DESTROY_WORDS.some(word => name.includes(word.replace(/\($/, '')))) return 'destroy'
142 return WRITE_WORDS.some(word => name.includes(word)) ? 'write' : 'read'
143}
144
145/** True when a call or a method of this name reads, writes, or deletes a file: a name that READ_CALL or one of the word lists names. */
146function isFileOperation(name: string): boolean {
147 return READ_CALL.test(name) || operationKind(name) !== 'read'
148}
149
150/** Access shared by a file call and its path literals, including a later open mode; `bare` marks a path object that no file operation uses, such as a lone `Path(…)` or one that `.with_suffix(` changes. */
151type Access = { kind: EffectKind; glob: boolean; potential?: boolean; bare?: true }
152
153/** One literal path and the operation that accesses it. */
154type FileLiteral = { literal: Literal; access: Access; definite: boolean }
155
156/** One open call or bracket: the access of its arguments, the index and keyword of its current argument, and its kind. `named` marks a Python `shutil` copy. `twoPaths` marks `cpSync`, whose arguments after its destination, such as its options, name no file. `found` counts the literals found before it opened. `pathOpen` is the access of the path object whose `.open(` this is. `link` marks a method chained on a path object, with the access kind of the arguments of each method in that chain. */
157type CallState = { access?: Access; multiple: boolean; argument: number; keyword: string; open: boolean; constructor: boolean; copy: boolean; named: boolean; twoPaths: boolean; pair: boolean; found: number; pathOpen?: Access; link?: EffectKind }
158
159/** The keyword of the current argument of a Python `shutil` copy, or `''`. */
160function copyKeyword(call: CallState): string {
161 return call.named ? call.keyword : ''
162}
163
164/** The access of the current argument: a copy reads its source, named by `src=` in a Python `shutil` copy or else first, and gives the call's access to the rest, up to the destination of `cpSync`. */
165function argumentAccess(call: CallState): Access | undefined {
166 if (!call.copy || call.access === undefined) return call.access
167 if (call.twoPaths && call.argument > 1) return undefined
168 const keyword = copyKeyword(call)
169 return keyword === 'src' || (keyword !== 'dst' && call.argument === 0) ? { kind: 'read', glob: false } : call.access
170}
171
172/** The method that `pattern` finds at `at`, and the index of its `(`. */
173function nextMethod(code: string, at: number, pattern: RegExp): { name: string; open: number } | undefined {
174 pattern.lastIndex = 0
175 const match = pattern.exec(code.slice(at, at + METHOD_WINDOW))
176 return match === null ? undefined : { name: match[1]!, open: at + pattern.lastIndex - 1 }
177}
178
179/** The first method chained after a path object, past methods that keep the same file, such as `.resolve()`, and the index of its `(`. A file operation, such as `.unlink()`, gives its own access. A method that changes the path, such as `.with_name()`, reads, whatever follows it. So does the last method that keeps the same file when the path goes on after it, as in `.resolve().parent`. */
180function chainedOperation(code: string, at: number): { name: string; open: number } | undefined {
181 const rest = code.slice(at, at + METHOD_WINDOW)
182 let kept: { name: string; open: number } | undefined
183 let k = 0
184 for (;;) {
185 METHOD.lastIndex = k
186 const match = METHOD.exec(rest)
187 if (match === null) break
188 const method = { name: match[1]!, open: at + METHOD.lastIndex - 1 }
189 if (!SAME_FILE.has(method.name)) return method
190 let depth = 1
191 for (k = METHOD.lastIndex; k < rest.length && depth > 0; k += 1) depth += rest.charAt(k) === '(' ? 1 : rest.charAt(k) === ')' ? -1 : 0
192 if (depth > 0) return undefined
193 kept = method
194 }
195 CHAIN_END.lastIndex = k
196 return kept === undefined || CHAIN_END.test(rest) ? undefined : kept
197}
198
199/** True when the literal names a module (`require('…')`, `import('…')`, `from '…'`): code to load, not a file the code changes. */
200function isSpecifier(code: string, literal: Literal): boolean {
201 return SPECIFIER.test(code.slice(Math.max(0, literal.start - SPECIFIER_WINDOW), literal.start))
202}
203
204/** Selects literals within file operations, leaving printed text and other data out of the effects. */
205function fileLiterals(code: string): FileLiteral[] {
206 const strings = literals(code)
207 const found: FileLiteral[] = []
208 const calls: CallState[] = []
209 // The indices in `found` of the literals that hold each access object, in order.
210 const holders = new Map<Access, number[]>()
211 let pathOpen: { at: number; access: Access } | undefined
212 // The `(` of the next method in a chain on a path object, and the access kind of the arguments of that chain.
213 let chain: { at: number; kind: EffectKind } | undefined
214 let next = 0
215 let name = ''
216 for (let i = 0; i < code.length; i += 1) {
217 const literal = strings[next]
218 if (literal?.start === i) {
219 const call = calls.at(-1)
220 const bare = /(?:unlink|rmdir|open)\s*$/.exec(code.slice(Math.max(0, i - 32), i))?.[0].trim()
221 if (call?.open && call.access !== undefined) {
222 const mode = modeOf(literal)
223 if (mode !== undefined && WRITE_MODE.test(mode)) call.access.kind = 'write'
224 }
225 if (call?.pathOpen !== undefined && (call.keyword === 'mode' || (call.keyword === '' && call.argument === 0)) && MODE.test(literal.text) && WRITE_MODE.test(literal.text)) call.pathOpen.kind = 'write'
226 const access = bare === undefined ? call && argumentAccess(call) : { kind: operationKind(bare), glob: false }
227 if (access !== undefined && (bare !== undefined || call?.multiple || call?.argument === 0)) {
228 found.push({ literal, definite: !access.potential && (bare !== undefined || call?.argument === 0 || call?.pair === true && (call.argument < 2 || call.keyword === 'src' || call.keyword === 'dst') || call?.open === true && /(?:my\s+\$|FH\b)/.test(code.slice(Math.max(0, literal.start - 80), literal.start))), access })
229 const held = holders.get(access)
230 if (held === undefined) holders.set(access, [found.length - 1])
231 else held.push(found.length - 1)
232 }
233 i = literal.end - 1
234 next += 1
235 name = ''
236 continue
237 }
238 const ch = code.charAt(i)
239 if (/[A-Za-z0-9_.$]/.test(ch)) name = (name + ch).slice(-80)
240 else if (ch === '(' || ch === '[') {
241 const parent = calls.at(-1)
242 const inherited = parent !== undefined && (parent.multiple || parent.argument === 0) ? argumentAccess(parent) : undefined
243 const kind = operationKind(name)
244 const operation = READ_CALL.test(name) || /Path$/.test(name)
245 || kind !== 'read' && !/(?:write_text|write_bytes)$/.test(name)
246 const constructor = /(?:^|[^A-Za-z0-9_$])Path\s*$/.test(code.slice(Math.max(0, i - 16), i))
247 const link = chain?.at === i ? chain.kind : undefined
248 const bare = /Path$/.test(name) && !isFileOperation(name)
249 const access = constructor && inherited !== undefined ? inherited : operation ? { kind, glob: /glob$/.test(name), potential: /(?:system|subprocess|child_process|exec|spawn|popen|Deno\.(?:Command|run))/.test(name), ...(bare ? { bare } : {}) } : inherited === undefined || name === '' ? inherited : { ...inherited, kind: link ?? inherited.kind, potential: true }
250 calls.push({ access, multiple: !operation && inherited !== undefined || /(?:open|copy|cpSync|move|rename|replace|symlink|subprocess|exec|spawn|system|popen)/i.test(name), argument: 0, keyword: '', open: /open$/.test(name), constructor, copy: /(?:copy|cpSync)/.test(name), named: SHUTIL_COPY.test(name), twoPaths: /cpSync/.test(name), pair: /(?:copy|cpSync|move|rename|replace|symlink)/.test(name), found: found.length, pathOpen: pathOpen?.at === i ? pathOpen.access : undefined, link })
251 name = ''
252 } else if (ch === ')' || ch === ']') {
253 const call = calls.pop()
254 const method = call?.constructor && call.access !== undefined ? chainedOperation(code, i + 1) : undefined
255 if (call?.access !== undefined && method !== undefined) {
256 // The method decides the access of the path, apart from the call around it, which can share the access object.
257 // Each literal leaves the shared object once, so the work stays linear.
258 const own: Access = { kind: operationKind(method.name), glob: call.access.glob, ...(isFileOperation(method.name) ? {} : { bare: true as const }) }
259 const held = holders.get(call.access) ?? []
260 while (held.length > 0 && held.at(-1)! >= call.found) {
261 const k = held.pop()!
262 found[k] = { ...found[k]!, access: own }
263 }
264 if (method.name === 'open') pathOpen = { at: method.open, access: own }
265 }
266 // The arguments of each method chained on the path, such as `'.x'` in `.with_suffix('.x')`, take the kind of the method
267 // right after `Path(…)`, never the access of the call around the path. A file operation in those arguments keeps its own access.
268 const after = call?.link !== undefined ? nextMethod(code, i + 1, LINK) : call?.constructor && call.access !== undefined ? nextMethod(code, i + 1, METHOD) : undefined
269 if (after !== undefined) chain = { at: after.open, kind: call?.link ?? operationKind(after.name) }
270 name = ''
271 } else if (ch === ',') {
272 const call = calls.at(-1)
273 if (call !== undefined) {
274 call.argument += 1
275 call.keyword = ''
276 }
277 name = ''
278 } else if (ch === '=') {
279 const call = calls.at(-1)
280 if (call !== undefined && code.charAt(i + 1) !== '=' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) call.keyword = name
281 name = ''
282 } else if (!/\s/.test(ch)) name = ''
283 }
284 return found
285}
286
287/** True when the literal is a whole argument: after `(`, `,`, a keyword `=`, or a bare `unlink`, `rmdir`, or `open`, and before `)` or `,`. */
288function isWholeArgument(code: string, literal: Literal): boolean {
289 const before = code.slice(Math.max(0, literal.start - ARGUMENT_WINDOW), literal.start)
290 return WHOLE_BEFORE.test(before) && WHOLE_AFTER.test(code.slice(literal.end, literal.end + ARGUMENT_WINDOW))
291}
292
293/** The effects of a piece of interpreter code run in `cwd`; module names and the bare `.`, `..`, and `/` name no file. Comments of `dialect` are blanked first. With `definiteOnly`, only a whole literal argument of a definite file operation counts, and no literal of a bare path object, cut at `${`, or that folds to `/` does. */
294export function scanCode(code: string, cwd: string | undefined, options: ScanOptions = {}): { effects: Effect[]; opaque?: string } {
295 const text = options.dialect === undefined ? code : blankComments(code, options.dialect)
296 const effects: Effect[] = []
297 let opaque: string | undefined
298 for (const { literal, access, definite } of fileLiterals(text)) {
299 const { kind } = access
300 if (isSpecifier(text, literal)) continue
301 if (options.definiteOnly && (!definite || access.bare || literal.cut || !isWholeArgument(text, literal))) continue
302 const operand = literal.text.replace(PERL_MODE, '')
303 const resolved = absolute(operand, cwd)
304 if (definite && !isRemote(operand) && (resolved?.length ?? operand.length) > MAX_PATH) opaque = PATH_TOO_LONG
305 for (const word of literal.text.split(/\s+/)) {
306 const token = word.replace(PERL_MODE, '')
307 if (BARE.test(token) || !looksLikePath(token)) continue
308 const path = absolute(token, cwd)
309 if (path === undefined || path.length > MAX_PATH || (options.definiteOnly && path === '/')) continue
310 const globbed = access.glob && hasGlob(path) ? splitGlob(path) : undefined
311 effects.push({ path: globbed?.path ?? path, glob: globbed?.glob, kind, recursive: kind === 'destroy' })
312 }
313 }
314 return { effects, opaque }
315}
316hooks/analyze/lexer.ts 465 lines1// The shell lexer: words as parts, control operators, redirections with
2// their file descriptors, and heredoc bodies, in one left-to-right scan with
3// no backtracking regular expression. Pure: no `$`.
4
5/** One piece of a shell word; `quoted` marks an expansion inside double quotes, which is neither split nor globbed. A `param` part is a parameter operator: `-`, `=`, or `+`, each also with `:`, `#`, `##`, `%`, or `%%` with its word, or `length` for `${#name}`. */
6export type Part =
7 | { kind: 'text'; text: string; quoted: boolean }
8 | { kind: 'var'; name: string; quoted: boolean }
9 | { kind: 'param'; name: string; op: string; word: Word; quoted: boolean }
10 | { kind: 'sub'; command: string; quoted: boolean }
11 | { kind: 'process'; command: string; isOutput: boolean }
12 | { kind: 'unknown' }
13
14/** A shell word: its parts in order. */
15export type Word = Part[]
16
17/** A redirection: its operator, the file descriptor it applies to, its target, and its heredoc body once the lexer has read it. */
18export type Redirect = { op: string; fd: number; target: Word; heredoc?: { text: string; quoted: boolean } }
19
20/** One token of a command line. */
21export type Token =
22 | { type: 'word'; word: Word }
23 | { type: 'op'; op: string }
24 | { type: 'redirect'; redirect: Redirect }
25
26/** The tokens of a command line, or why it cannot be read. */
27type Lexed = { tokens: Token[] } | { error: string }
28
29/** The longest command Arrester reads. */
30const MAX_COMMAND = 100_000
31/** How deep command substitutions may nest inside one another before the command counts as unreadable. */
32const MAX_NESTING = 32
33const TOO_LONG = 'the command is too long to read'
34/** Why a command that does not parse cannot be read. */
35export const UNPARSED = 'the command does not parse'
36
37const CONTROL = ['&&', '||', ';;', '|&', '&', '|', ';', '(', ')']
38const REDIRECTS = ['&>>', '<<<', '<<-', '&>', '>>', '>|', '>&', '<&', '<>', '<<', '>', '<']
39const WORD_END = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>'])
40const NAME = /^[A-Za-z_][A-Za-z0-9_]*/
41/** A parameter name inside `${…}`, read at a position: a name, digits, or one special character. */
42const BRACED_NAME = /[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!-]/y
43/** The operators of a `${…}` that the analysis resolves, longest first. */
44const OPERATORS = [':-', ':=', ':+', '##', '%%', '-', '=', '+', '#', '%']
45const FD = /^[0-9]+(?=[<>])/
46const SPECIAL = new Set(['@', '*', '#', '?', '$', '!', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
47const ESCAPES: Readonly<Record<string, string>> = { n: '\n', t: '\t', '\\': '\\', "'": "'", '"': '"' }
48const IN_DOUBLE_QUOTES = '"\\$`\n'
49const IN_HEREDOC = '\\$`\n'
50
51/** Thrown inside the lexer when the command does not parse; `lex` turns it into an error. */
52class Unparsed extends Error {}
53
54/** Lexes one command line. */
55export function lex(command: string): Lexed {
56 if (command.length > MAX_COMMAND) return { error: TOO_LONG }
57 const lexer = new Lexer(command, 0, 0)
58 return lexer.run() ? { tokens: lexer.tokens } : { error: UNPARSED }
59}
60
61/** The parts of a heredoc body whose delimiter is unquoted, which the shell expands like double-quoted text with `"` plain; undefined when an expansion in it does not parse. */
62export function heredocWord(text: string): Word | undefined {
63 return new Lexer(text, 0, 0).expandable()
64}
65
66/** The text of a part: literal text as it is, expansions written as typed. */
67function partText(part: Part): string {
68 if (part.kind === 'text') return part.text
69 if (part.kind === 'var') return `$${part.name}`
70 if (part.kind === 'param') return part.op === 'length' ? `\${#${part.name}}` : `\${${part.name}${part.op}${wordText(part.word)}}`
71 if (part.kind === 'sub') return `$(${part.command})`
72 return part.kind === 'process' ? `${part.isOutput ? '>' : '<'}(${part.command})` : '$(( ))'
73}
74
75/** The text of a word with its quotes removed; what a skeleton's unresolved operand uses. */
76export function wordText(word: Word): string {
77 return word.map(partText).join('')
78}
79
80/** Adds text to the word, joined to the last part when that part is text with the same quoting. */
81function pushText(parts: Word, text: string, quoted: boolean): void {
82 const last = parts[parts.length - 1]
83 if (last !== undefined && last.kind === 'text' && last.quoted === quoted) last.text += text
84 else parts.push({ kind: 'text', text, quoted })
85}
86
87/** One scan of one command line, or of the command inside a substitution that starts at `start`. */
88class Lexer {
89 readonly tokens: Token[] = []
90 private readonly pending: { redirect: Redirect; raw: string }[] = []
91 /** The source spelling of each expansion part, which a heredoc delimiter keeps as written. */
92 private readonly spellings = new Map<Part, string>()
93 private readonly src: string
94 private readonly nesting: number
95 private i: number
96 /** How deep the `${…}` being read nests in other ones. */
97 private braces = 0
98
99 constructor(src: string, start: number, nesting: number) {
100 this.src = src
101 this.i = start
102 this.nesting = nesting
103 }
104
105 /** Scans the command; false when it does not parse or a heredoc has no end. */
106 run(): boolean {
107 try {
108 this.scan(false)
109 } catch (error) {
110 if (error instanceof Unparsed) return false
111 throw error
112 }
113 return this.pending.length === 0
114 }
115
116 /** The whole source read as a heredoc body that the shell expands; undefined when it does not parse. */
117 expandable(): Word | undefined {
118 const parts: Word = []
119 try {
120 this.quoted(parts, undefined, IN_HEREDOC)
121 } catch (error) {
122 if (error instanceof Unparsed) return undefined
123 throw error
124 }
125 return parts
126 }
127
128 /** The main loop: blanks, newlines, comments, redirections, operators, and words. Inside a substitution it stops at the `)` that closes it and returns that index. */
129 private scan(isInner: boolean): number {
130 let depth = 0
131 while (this.i < this.src.length) {
132 const ch = this.src.charAt(this.i)
133 if (ch === ' ' || ch === '\t') {
134 this.i += 1
135 continue
136 }
137 if (ch === '\\' && this.src.charAt(this.i + 1) === '\n') {
138 this.i += 2
139 continue
140 }
141 if (ch === '\n') {
142 this.i += 1
143 this.tokens.push({ type: 'op', op: '\n' })
144 this.heredocs()
145 continue
146 }
147 if (ch === '#') {
148 const end = this.src.indexOf('\n', this.i)
149 this.i = end < 0 ? this.src.length : end
150 continue
151 }
152 if ((ch === '<' || ch === '>') && this.src.charAt(this.i + 1) === '(') {
153 this.tokens.push({ type: 'word', word: this.word() })
154 continue
155 }
156 const fd = FD.exec(this.src.slice(this.i, this.i + 10))?.[0]
157 const at = this.i + (fd?.length ?? 0)
158 const redirect = REDIRECTS.find(op => this.src.startsWith(op, at))
159 if (redirect !== undefined) {
160 this.i = at
161 this.redirect(redirect, fd === undefined ? undefined : Number(fd))
162 continue
163 }
164 const control = CONTROL.find(op => this.src.startsWith(op, this.i))
165 if (control !== undefined) {
166 if (isInner && control === ')' && depth === 0) {
167 if (this.pending.length > 0) throw new Unparsed()
168 return this.i
169 }
170 if (control === '(') depth += 1
171 else if (control === ')') depth -= 1
172 this.i += control.length
173 this.tokens.push({ type: 'op', op: control })
174 continue
175 }
176 this.tokens.push({ type: 'word', word: this.word() })
177 }
178 if (isInner) throw new Unparsed()
179 return this.i
180 }
181
182 /** A redirection and its target word; `<<` and `<<-` wait for their body after the next newline. Without a number, `<` forms read descriptor 0 and the others write descriptor 1. */
183 private redirect(op: string, fd: number | undefined): void {
184 this.i += op.length
185 while (this.src.charAt(this.i) === ' ' || this.src.charAt(this.i) === '\t') this.i += 1
186 const ch = this.src.charAt(this.i)
187 const isProcess = (ch === '<' || ch === '>') && this.src.charAt(this.i + 1) === '('
188 if (this.i >= this.src.length || (WORD_END.has(ch) && !isProcess)) throw new Unparsed()
189 const start = this.i
190 const redirect: Redirect = { op, fd: fd ?? (op.startsWith('<') ? 0 : 1), target: this.word() }
191 if (op === '<<' || op === '<<-') this.pending.push({ redirect, raw: this.src.slice(start, this.i) })
192 this.tokens.push({ type: 'redirect', redirect })
193 }
194
195 /** One word, up to an unquoted blank or operator. */
196 private word(): Word {
197 const parts: Word = []
198 while (this.i < this.src.length) {
199 const ch = this.src.charAt(this.i)
200 const isProcess = (ch === '<' || ch === '>') && this.src.charAt(this.i + 1) === '('
201 if (WORD_END.has(ch) && !isProcess) break
202 const at = this.i
203 if (isProcess) parts.push({ kind: 'process', command: this.inner(this.i + 1), isOutput: ch === '>' })
204 else if (ch === "'") pushText(parts, this.single(), true)
205 else if (ch === '"') this.double(parts)
206 else if (ch === '\\') {
207 if (this.src.charAt(this.i + 1) !== '\n') pushText(parts, this.src.charAt(this.i + 1), true)
208 this.i += 2
209 } else if (ch === '$') this.dollar(parts, false)
210 else if (ch === '`') parts.push({ kind: 'sub', command: this.backtick(), quoted: false })
211 else {
212 pushText(parts, ch, false)
213 this.i += 1
214 }
215 this.spell(parts, at)
216 }
217 return parts
218 }
219
220 /** Keeps the source spelling, from `at`, of an expansion part that the last step added. */
221 private spell(parts: Word, at: number): void {
222 const part = parts[parts.length - 1]
223 if (part !== undefined && part.kind !== 'text' && !this.spellings.has(part)) this.spellings.set(part, this.src.slice(at, this.i))
224 }
225
226 /** The text of a single-quoted string. */
227 private single(): string {
228 const end = this.src.indexOf("'", this.i + 1)
229 if (end < 0) throw new Unparsed()
230 const text = this.src.slice(this.i + 1, end)
231 this.i = end + 1
232 return text
233 }
234
235 /** A double-quoted string, from its opening quote to its closing one. */
236 private double(parts: Word): void {
237 this.i += 1
238 this.quoted(parts, '"', IN_DOUBLE_QUOTES)
239 this.i += 1
240 }
241
242 /** Quoted text and expansions up to `end`, or to the end of the source when `end` is undefined; a backslash escapes only the characters in `escapable`. */
243 private quoted(parts: Word, end: string | undefined, escapable: string): void {
244 const start = parts.length
245 for (;;) {
246 if (this.i >= this.src.length) {
247 if (end === undefined) {
248 if (parts.length === start) pushText(parts, '', true)
249 return
250 }
251 throw new Unparsed()
252 }
253 const ch = this.src.charAt(this.i)
254 if (ch === end) {
255 if (parts.length === start) pushText(parts, '', true)
256 return
257 }
258 const next = this.src.charAt(this.i + 1)
259 const at = this.i
260 if (ch === '\\' && next !== '' && escapable.includes(next)) {
261 if (next !== '\n') pushText(parts, next, true)
262 this.i += 2
263 } else if (ch === '$') this.dollar(parts, true)
264 else if (ch === '`') parts.push({ kind: 'sub', command: this.backtick(), quoted: true })
265 else {
266 pushText(parts, ch, true)
267 this.i += 1
268 }
269 this.spell(parts, at)
270 }
271 }
272
273 /** A `$` expansion: arithmetic, a command substitution, a braced or plain variable, ANSI-C text, or a translated string. */
274 private dollar(parts: Word, quoted: boolean): void {
275 const next = this.src.charAt(this.i + 1)
276 if (next === '(' && this.src.charAt(this.i + 2) === '(') {
277 this.arithmetic(this.i + 1)
278 parts.push({ kind: 'unknown' })
279 } else if (next === '(') parts.push({ kind: 'sub', command: this.inner(this.i + 1), quoted })
280 else if (next === '{') parts.push(this.braced(quoted))
281 else if (next === "'" && !quoted) pushText(parts, this.ansiC(), true)
282 else if (next === '"' && !quoted) {
283 this.i += 1
284 this.double(parts)
285 } else if (SPECIAL.has(next)) {
286 this.i += 2
287 parts.push({ kind: 'var', name: next, quoted })
288 } else {
289 const name = NAME.exec(this.src.slice(this.i + 1, this.i + 257))?.[0]
290 if (name === undefined) {
291 pushText(parts, '$', quoted)
292 this.i += 1
293 } else {
294 this.i += 1 + name.length
295 parts.push({ kind: 'var', name, quoted })
296 }
297 }
298 }
299
300 /** `${…}`, read to the `}` that closes it: the parameter it names when the body is a name and nothing more, a parameter part for `${#name}` and for an operator in OPERATORS with its word, else an unknown part. */
301 private braced(quoted: boolean): Part {
302 if (this.braces >= MAX_NESTING) throw new Unparsed()
303 this.braces += 1
304 this.i += 2
305 const counted = this.src.charAt(this.i) === '#' ? this.parameterName(this.i + 1) : undefined
306 let part: Part
307 if (counted !== undefined && this.src.charAt(this.i + 1 + counted.length) === '}') {
308 this.i += counted.length + 2
309 part = { kind: 'param', name: counted, op: 'length', word: [], quoted }
310 } else {
311 const name = this.parameterName(this.i)
312 const op = name === undefined ? undefined : OPERATORS.find(operator => this.src.startsWith(operator, this.i + name.length))
313 if (name !== undefined && this.src.charAt(this.i + name.length) === '}') {
314 this.i += name.length + 1
315 part = { kind: 'var', name, quoted }
316 } else {
317 this.i += (name?.length ?? 0) + (op?.length ?? 0)
318 const word = this.operand(quoted)
319 part = name === undefined || op === undefined ? { kind: 'unknown' } : { kind: 'param', name, op, word, quoted }
320 }
321 }
322 this.braces -= 1
323 return part
324 }
325
326 /** The parameter name that starts at `at`: a name, digits, or one special character; undefined when none does. */
327 private parameterName(at: number): string | undefined {
328 BRACED_NAME.lastIndex = at
329 return BRACED_NAME.exec(this.src)?.[0]
330 }
331
332 /** The word of a parameter operator, up to the `}` that closes its `${`: quotes, escapes, and expansions as in a word, blanks and operators as text. In double quotes, a `'` is plain text and a `"` opens quoted text whose quotes are removed. */
333 private operand(quoted: boolean): Word {
334 const parts: Word = []
335 for (;;) {
336 if (this.i >= this.src.length) throw new Unparsed()
337 const ch = this.src.charAt(this.i)
338 if (ch === '}') {
339 this.i += 1
340 return parts
341 }
342 const at = this.i
343 const next = this.src.charAt(this.i + 1)
344 if (ch === "'" && !quoted) pushText(parts, this.single(), true)
345 else if (ch === '"') this.double(parts)
346 else if (ch === '\\' && next === '') throw new Unparsed()
347 else if (ch === '\\' && (!quoted || `${IN_DOUBLE_QUOTES}}`.includes(next))) {
348 if (next !== '\n') pushText(parts, next, true)
349 this.i += 2
350 } else if (ch === '$') this.dollar(parts, quoted)
351 else if (ch === '`') parts.push({ kind: 'sub', command: this.backtick(), quoted })
352 else {
353 pushText(parts, ch, quoted)
354 this.i += 1
355 }
356 this.spell(parts, at)
357 }
358 }
359
360 /** `$'…'` decoded: `\n`, `\t`, `\\`, `\'`, `\"`, `\xHH`, and `\NNN` (octal). */
361 private ansiC(): string {
362 let text = ''
363 let j = this.i + 2
364 while (j < this.src.length && this.src.charAt(j) !== "'") {
365 if (this.src.charAt(j) !== '\\') {
366 text += this.src.charAt(j)
367 j += 1
368 continue
369 }
370 const escape = this.src.slice(j + 1, j + 4)
371 const hex = /^x[0-9a-fA-F]{1,2}/.exec(escape)?.[0]
372 const octal = /^[0-7]{1,3}/.exec(escape)?.[0]
373 if (hex !== undefined) {
374 text += String.fromCharCode(parseInt(hex.slice(1), 16))
375 j += 1 + hex.length
376 } else if (octal !== undefined) {
377 text += String.fromCharCode(parseInt(octal, 8))
378 j += 1 + octal.length
379 } else {
380 const ch = this.src.charAt(j + 1)
381 text += ESCAPES[ch] ?? ch
382 j += 2
383 }
384 }
385 if (j >= this.src.length) throw new Unparsed()
386 this.i = j + 1
387 return text
388 }
389
390 /** Skips `$((…))`, nested parentheses and quotes respected. */
391 private arithmetic(open: number): void {
392 let depth = 0
393 let j = open
394 while (j < this.src.length) {
395 const ch = this.src.charAt(j)
396 if (ch === '\\') j += 2
397 else if (ch === "'" || ch === '"') j = this.closeQuote(j) + 1
398 else {
399 if (ch === '(') depth += 1
400 if (ch === ')') depth -= 1
401 if (depth === 0) {
402 this.i = j + 1
403 return
404 }
405 j += 1
406 }
407 }
408 throw new Unparsed()
409 }
410
411 /** The command inside `$(…)`, `<(…)`, or `>(…)` whose parenthesis opens at `open`, found with a lexer of its own, so quotes, comments, and heredocs inside it count. */
412 private inner(open: number): string {
413 if (this.nesting >= MAX_NESTING) throw new Unparsed()
414 const close = new Lexer(this.src, open + 1, this.nesting + 1).scan(true)
415 this.i = close + 1
416 return this.src.slice(open + 1, close)
417 }
418
419 /** The index of the quote that closes the one at `at`. */
420 private closeQuote(at: number): number {
421 const quote = this.src.charAt(at)
422 let j = at + 1
423 while (j < this.src.length && this.src.charAt(j) !== quote) j += quote === '"' && this.src.charAt(j) === '\\' ? 2 : 1
424 if (j >= this.src.length) throw new Unparsed()
425 return j
426 }
427
428 /** The command inside backticks. */
429 private backtick(): string {
430 let text = ''
431 let j = this.i + 1
432 while (j < this.src.length && this.src.charAt(j) !== '`') {
433 const next = this.src.charAt(j + 1)
434 if (this.src.charAt(j) === '\\' && next !== '' && '`\\$'.includes(next)) {
435 text += next
436 j += 2
437 } else {
438 text += this.src.charAt(j)
439 j += 1
440 }
441 }
442 if (j >= this.src.length) throw new Unparsed()
443 this.i = j + 1
444 return text
445 }
446
447 /** Reads the bodies of the heredocs started on the line that just ended. The delimiter is the target word with its quotes removed and its expansions as written; quote removal that changes the word means some part of it is quoted. */
448 private heredocs(): void {
449 for (const { redirect, raw } of this.pending.splice(0)) {
450 const delimiter = redirect.target.map(part => (part.kind === 'text' ? part.text : this.spellings.get(part) ?? partText(part))).join('')
451 const lines: string[] = []
452 for (;;) {
453 if (this.i >= this.src.length) throw new Unparsed()
454 const end = this.src.indexOf('\n', this.i)
455 const raw = this.src.slice(this.i, end < 0 ? this.src.length : end)
456 this.i = end < 0 ? this.src.length : end + 1
457 const line = redirect.op === '<<-' ? raw.replace(/^\t+/, '') : raw
458 if (line === delimiter) break
459 lines.push(`${line}\n`)
460 }
461 redirect.heredoc = { text: lines.join(''), quoted: delimiter.replaceAll('\\\n', '') !== raw.replaceAll('\\\n', '') }
462 }
463 }
464}
465hooks/analyze/programs.ts 246 lines1// The program tables of the shell analysis. Pure: no `$`.
2
3/** Program classes. */
4export const LIST = new Set(['ls', 'stat', 'du', 'file', 'test', '[', '[[', 'tree', 'realpath', 'readlink', 'dirname', 'basename', 'pwd', 'which'])
5/** Readers whose words are paths, so a retry skeleton makes them absolute. */
6const PATH_READ = ['cat', 'head', 'tail', 'less', 'more', 'bat', 'wc', 'cut', 'sort', 'uniq', 'cmp', 'diff', 'xxd', 'od', 'hexdump', 'strings', 'base64', 'md5', 'md5sum', 'shasum', 'sha256sum']
7/** Readers: also `tac` and `shuf`, whose `tac -s` separator and `shuf -e` words are data, so `PATHY` leaves them out. */
8export const READ = new Set([...PATH_READ, 'tac', 'shuf'])
9export const CODE_FIRST = new Set(['awk', 'jq', 'yq', 'sed'])
10export const PATTERN_FIRST = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ugrep'])
11export const WRITE = new Set(['touch', 'mkdir', 'mkfifo'])
12export const MODE_FIRST = new Set(['chmod', 'chown', 'chgrp'])
13export const COPY = new Set(['cp', 'install', 'rsync', 'scp'])
14export const DESTROY = new Set(['rm', 'rmdir', 'unlink', 'shred', 'srm', 'trash', 'trash-put', 'truncate', 'rimraf'])
15export const ARCHIVE = new Set(['tar', 'zip', 'unzip'])
16export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
17export const INTERPRETER = /^(python[0-9.]*|pypy3?|node|nodejs|tsx|ts-node|bun|deno|ruby|php|perl)$/
18
19/** Programs that add no skeleton; of these only the list class adds effects. `dirname` and `basename` are in both: they only change text, so they touch no path, and their output lists their operands. */
20export const TRIVIAL = new Set(['cd', 'pushd', 'popd', 'export', 'declare', 'local', 'readonly', 'set', 'unset', 'true', 'false', ':', 'sleep', 'echo', 'printf', 'for', 'case', 'esac', 'done', 'fi', 'in', 'function', 'select', 'return', 'exit', 'shift', 'wait', 'trap', 'alias', 'hash', 'type', 'dirname', 'basename'])
21
22/** Shell keywords removed from the front of a command. */
23export const KEYWORDS = new Set(['!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until', '{', '}'])
24
25/** Programs whose operands are paths, so a skeleton writes them as absolute paths. */
26export const PATHY = new Set([...LIST, ...PATH_READ, ...CODE_FIRST, ...PATTERN_FIRST, ...WRITE, ...MODE_FIRST, ...COPY, ...DESTROY, ...ARCHIVE, 'mv', 'ln', 'tee'])
27
28/** Programs that always reach below their operands. */
29export const ALWAYS_RECURSIVE = new Set(['du', 'tree', 'rg', 'ag', 'ugrep', 'srm', 'trash', 'trash-put', 'rimraf'])
30
31/** Programs that remove a symbolic link itself, not what it leads to, when they do not reach below it. */
32export const UNLINKERS = new Set(['rm', 'unlink', 'rmdir'])
33
34/** Programs that pass on the lines they read, or some of them, when they name no file. */
35export const PASS_THROUGH = new Set(['sort', 'uniq', 'head', 'tail', 'tac', 'shuf'])
36
37const GREP_RECURSIVE = { letters: 'rR', longs: ['--recursive', '--dereference-recursive'] }
38const MODE_RECURSIVE = { letters: 'R', longs: ['--recursive'] }
39
40/** The short letters and long names that make a program reach below its operands. */
41export const RECURSIVE_FLAGS: ReadonlyMap<string, { letters: string; longs: readonly string[] }> = new Map([
42 ['ls', { letters: 'R', longs: ['--recursive'] }],
43 ['grep', GREP_RECURSIVE],
44 ['egrep', GREP_RECURSIVE],
45 ['fgrep', GREP_RECURSIVE],
46 ['diff', { letters: 'r', longs: ['--recursive'] }],
47 ['rm', { letters: 'rR', longs: ['--recursive'] }],
48 ['chmod', MODE_RECURSIVE],
49 ['chown', MODE_RECURSIVE],
50 ['chgrp', MODE_RECURSIVE],
51 ['cp', { letters: 'rRa', longs: ['--recursive', '--archive'] }],
52 ['rsync', { letters: 'ra', longs: ['--recursive', '--archive'] }],
53 ['scp', { letters: 'r', longs: [] }],
54])
55
56/** The value-taking flags of a program, by how many words each takes; ATTACHED marks a flag whose value can only join its own word (`-i.bak`). */
57export type Values = Readonly<Record<string, number>>
58
59/** A flag whose value can only join its own word, as `sed -i.bak` and `perl -pi.orig` take theirs, and is empty otherwise. */
60export const ATTACHED = -1
61
62/** A wrapper that runs the rest of the line: its value-taking flags, the operands before the command, and the flags that set the folder the command runs in. */
63type Wrapper = { values: Values; operands: number; chdir: readonly string[] }
64
65/** Wrappers that run the rest of the line. */
66export const WRAPPERS: ReadonlyMap<string, Wrapper> = new Map<string, Wrapper>([
67 ['sudo', { values: { '-u': 1, '-g': 1, '-C': 1, '-D': 1, '-h': 1, '-p': 1, '-r': 1, '-t': 1, '-T': 1, '-U': 1, '--chdir': 1 }, operands: 0, chdir: ['-D', '--chdir'] }],
68 ['doas', { values: { '-u': 1, '-C': 1 }, operands: 0, chdir: [] }],
69 ['env', { values: { '-u': 1, '-C': 1, '-S': 1, '--unset': 1, '--chdir': 1, '--split-string': 1 }, operands: 0, chdir: ['-C', '--chdir'] }],
70 ['command', { values: {}, operands: 0, chdir: [] }],
71 ['builtin', { values: {}, operands: 0, chdir: [] }],
72 ['exec', { values: { '-a': 1 }, operands: 0, chdir: [] }],
73 ['nohup', { values: {}, operands: 0, chdir: [] }],
74 ['time', { values: { '-f': 1, '-o': 1 }, operands: 0, chdir: [] }],
75 ['timeout', { values: { '-s': 1, '-k': 1, '--signal': 1, '--kill-after': 1 }, operands: 1, chdir: [] }],
76 ['nice', { values: { '-n': 1, '--adjustment': 1 }, operands: 0, chdir: [] }],
77 ['ionice', { values: { '-c': 1, '-n': 1, '-p': 1, '--class': 1, '--classdata': 1 }, operands: 0, chdir: [] }],
78 ['stdbuf', { values: { '-i': 1, '-o': 1, '-e': 1 }, operands: 0, chdir: [] }],
79 ['caffeinate', { values: { '-t': 1, '-w': 1 }, operands: 0, chdir: [] }],
80 ['watch', { values: { '-n': 1, '--interval': 1 }, operands: 0, chdir: [] }],
81 ['xargs', { values: { '-n': 1, '-P': 1, '-I': 1, '-J': 1, '-L': 1, '-s': 1, '-d': 1, '-E': 1, '--eof': 1, '-a': 1, '-i': ATTACHED, '--arg-file': 1, '--max-args': 1, '--max-lines': 1, '--max-procs': 1, '--delimiter': 1 }, operands: 0, chdir: [] }],
82])
83
84/** The value-taking flags of `su` and of `runuser` without `-u`. */
85const SU_VALUES = { '-c': 1, '--command': 1, '--session-command': 1, '-s': 1, '--shell': 1, '-g': 1, '--group': 1, '-G': 1, '--supp-group': 1, '-w': 1, '--whitelist-environment': 1 }
86
87/** `runuser -u USER`, which runs the command after it directly, as `sudo` does. */
88export const RUNUSER: Wrapper = { values: { ...SU_VALUES, '-u': 1, '--user': 1 }, operands: 0, chdir: [] }
89
90/** A program that runs the command after it, behind one of its subcommands when it names any, each of one or more words split by a blank: its value-taking flags, the flags that set the folder, the flags whose value is shell code, the flags that run the command through a shell, and whether the command is a package whose program has the package's name. */
91export type Runner = { subs: readonly string[]; values: Values; chdir: readonly string[]; code: readonly string[]; shell: readonly string[]; isPackage: boolean }
92
93const PACKAGE_VALUES = { '-p': 1, '--package': 1 }
94
95/** The value-taking flags of `uv run`, `uv tool run`, and `uvx`. */
96const UV_VALUES = { '--with': 1, '-w': 1, '--with-editable': 1, '--with-requirements': 1, '--python': 1, '-p': 1, '--project': 1, '--directory': 1, '--package': 1, '--extra': 1, '--group': 1, '--env-file': 1, '--index': 1, '--default-index': 1, '--index-url': 1, '--extra-index-url': 1, '--from': 1 }
97
98/** What `pnpm exec`, `pnpm dlx`, and `pnpx` share: their value-taking flags, folder flags, and shell flags. */
99const PNPM = { values: { '-C': 1, '--dir': 1, '--filter': 1, '-F': 1, '--package': 1 }, chdir: ['-C', '--dir'], code: [], shell: ['-c', '--shell-mode'], isPackage: true }
100
101/** Package and environment runners: `uv run`, `uvx`, `npx`, `npm exec`, `pnpm exec`, `pnpm dlx`, and the like. */
102export const RUNNERS: ReadonlyMap<string, Runner> = new Map<string, Runner>([
103 ['uv', { subs: ['run', 'tool run'], values: UV_VALUES, chdir: ['--directory'], code: [], shell: [], isPackage: false }],
104 ['poetry', { subs: ['run'], values: { '-C': 1, '--directory': 1, '-P': 1, '--project': 1 }, chdir: ['-C', '--directory'], code: [], shell: [], isPackage: false }],
105 ['npx', { subs: [], values: { ...PACKAGE_VALUES, '-c': 1, '--call': 1 }, chdir: [], code: ['-c', '--call'], shell: [], isPackage: true }],
106 ['bunx', { subs: [], values: PACKAGE_VALUES, chdir: [], code: [], shell: [], isPackage: true }],
107 ['pnpm', { subs: ['exec', 'dlx'], ...PNPM }],
108 ['uvx', { subs: [], values: UV_VALUES, chdir: ['--directory'], code: [], shell: [], isPackage: true }],
109 ['pipenv', { subs: ['run'], values: {}, chdir: [], code: [], shell: [], isPackage: false }],
110 ['npm', { subs: ['exec'], values: { '--package': 1, '-c': 1, '--call': 1, '-w': 1, '--workspace': 1 }, chdir: [], code: ['-c', '--call'], shell: [], isPackage: true }],
111 ['yarn', { subs: ['dlx'], values: PACKAGE_VALUES, chdir: [], code: [], shell: [], isPackage: true }],
112 ['pnpx', { subs: [], ...PNPM }],
113])
114
115const GREP_VALUES = { '-e': 1, '-f': 1, '-m': 1, '-A': 1, '-B': 1, '-C': 1, '-d': 1, '-D': 1, '--regexp': 1, '--file': 1, '--max-count': 1, '--after-context': 1, '--before-context': 1, '--context': 1, '--include': 1, '--exclude': 1, '--exclude-dir': 1, '--label': 1 }
116const COPY_VALUES = { '-t': 1, '-S': 1, '--target-directory': 1, '--suffix': 1 }
117const SHELL_VALUES = { '-o': 1, '-O': 1, '--rcfile': 1, '--init-file': 1 }
118
119/** Flags whose value is the next word (two words for jq's `--arg NAME VALUE`), per program; a flag not listed takes no value. */
120const VALUE_FLAGS: ReadonlyMap<string, Values> = new Map<string, Values>([
121 ['grep', GREP_VALUES],
122 ['egrep', GREP_VALUES],
123 ['fgrep', GREP_VALUES],
124 ['ugrep', GREP_VALUES],
125 ['rg', { '-e': 1, '-f': 1, '-g': 1, '-t': 1, '-T': 1, '-m': 1, '-A': 1, '-B': 1, '-C': 1, '-j': 1, '-M': 1, '-E': 1, '-d': 1, '-r': 1, '--regexp': 1, '--file': 1, '--glob': 1, '--iglob': 1, '--type': 1, '--type-not': 1, '--type-add': 1, '--type-clear': 1, '--max-count': 1, '--max-depth': 1, '--sort': 1, '--sortr': 1, '--pre': 1, '--pre-glob': 1, '--dfa-size-limit': 1, '--encoding': 1, '--engine': 1, '--regex-size-limit': 1, '--threads': 1, '--ignore-file': 1, '--max-filesize': 1, '--after-context': 1, '--before-context': 1, '--context': 1, '--color': 1, '--colors': 1, '--context-separator': 1, '--field-context-separator': 1, '--field-match-separator': 1, '--hostname-bin': 1, '--hyperlink-format': 1, '--max-columns': 1, '--path-separator': 1, '--replace': 1, '--generate': 1 }],
126 ['ag', { '-G': 1, '-A': 1, '-B': 1, '-C': 1, '-m': 1, '--ignore': 1, '--depth': 1 }],
127 ['sed', { '-e': 1, '-f': 1, '-l': 1, '-i': ATTACHED, '-I': ATTACHED, '--expression': 1, '--file': 1 }],
128 ['awk', { '-f': 1, '-v': 1, '-F': 1 }],
129 ['jq', { '-f': 1, '--from-file': 1, '--arg': 2, '--argjson': 2, '--slurpfile': 2, '--rawfile': 2, '--indent': 1 }],
130 ['yq', { '-f': 1, '--from-file': 1, '-o': 1, '-p': 1, '--output-format': 1, '--input-format': 1 }],
131 ['head', { '-n': 1, '-c': 1 }],
132 ['tail', { '-n': 1, '-c': 1 }],
133 ['tac', { '-s': 1, '--separator': 1 }],
134 ['shuf', { '-n': 1, '-o': 1, '-i': 1, '--head-count': 1, '--output': 1, '--input-range': 1, '--random-source': 1 }],
135 ['cut', { '-d': 1, '-f': 1, '-c': 1, '-b': 1 }],
136 ['sort', { '-k': 1, '-t': 1, '-o': 1, '-T': 1, '-S': 1, '--output': 1 }],
137 ['uniq', { '-f': 1, '-s': 1, '-w': 1 }],
138 ['xxd', { '-l': 1, '-s': 1, '-c': 1, '-g': 1 }],
139 ['od', { '-A': 1, '-t': 1, '-j': 1, '-N': 1 }],
140 ['hexdump', { '-n': 1, '-s': 1, '-e': 1, '-f': 1 }],
141 ['strings', { '-n': 1, '-t': 1 }],
142 ['base64', { '-b': 1, '-w': 1, '-i': 1, '-o': 1, '--input': 1, '--output': 1 }],
143 ['shasum', { '-a': 1 }],
144 ['du', { '-d': 1, '-B': 1, '-t': 1, '--max-depth': 1 }],
145 ['stat', { '-f': 1, '-c': 1, '-t': 1 }],
146 ['tree', { '-L': 1, '-P': 1, '-I': 1, '-o': 1 }],
147 ['ls', { '-I': 1, '-w': 1, '-T': 1 }],
148 ['touch', { '-r': 1, '-t': 1, '-d': 1 }],
149 ['mkdir', { '-m': 1 }],
150 ['mkfifo', { '-m': 1 }],
151 ['chmod', { '--reference': 1 }],
152 ['chown', { '--reference': 1 }],
153 ['chgrp', { '--reference': 1 }],
154 ['cp', COPY_VALUES],
155 ['mv', COPY_VALUES],
156 ['ln', COPY_VALUES],
157 ['install', { ...COPY_VALUES, '-m': 1, '-o': 1, '-g': 1, '--mode': 1, '--owner': 1, '--group': 1 }],
158 ['rsync', { '-e': 1, '-f': 1, '--rsh': 1, '--exclude': 1, '--include': 1, '--filter': 1, '--files-from': 1, '--chmod': 1, '--exclude-from': 1, '--include-from': 1 }],
159 ['scp', { '-i': 1, '-P': 1, '-o': 1, '-F': 1, '-c': 1, '-l': 1, '-S': 1, '-J': 1 }],
160 ['truncate', { '-s': 1, '-r': 1, '--size': 1, '--reference': 1 }],
161 ['shred', { '-n': 1, '-s': 1, '--iterations': 1, '--size': 1 }],
162 ['tar', { '-f': 1, '-C': 1, '-T': 1, '-X': 1, '--file': 1, '--directory': 1, '--exclude': 1, '--files-from': 1 }],
163 ['zip', { '-x': 1, '-i': 1, '-b': 1, '-P': 1 }],
164 ['unzip', { '-d': 1, '-x': 1, '-P': 1 }],
165 ['curl', { '-d': 1, '--data': 1, '--data-binary': 1, '--data-urlencode': 1, '--data-raw': 1, '--data-ascii': 1, '--json': 1, '-F': 1, '--form': 1, '-T': 1, '--upload-file': 1, '-K': 1, '--config': 1, '-o': 1, '--output': 1, '--output-dir': 1, '-D': 1, '--dump-header': 1, '-c': 1, '--cookie-jar': 1, '-X': 1, '--request': 1, '-H': 1, '--header': 1, '-u': 1, '--user': 1, '-A': 1, '--user-agent': 1, '-e': 1, '--referer': 1, '-b': 1, '--cookie': 1, '-m': 1, '--max-time': 1, '-w': 1, '--write-out': 1, '-x': 1, '--proxy': 1 }],
166 ['wget', { '-O': 1, '-P': 1, '-o': 1, '-a': 1, '-U': 1, '-t': 1, '-T': 1, '-e': 1, '-i': 1, '--output-document': 1, '--directory-prefix': 1, '--output-file': 1, '--post-file': 1, '--body-file': 1, '--post-data': 1, '--body-data': 1, '--header': 1, '--input-file': 1 }],
167 ['sh', SHELL_VALUES],
168 ['bash', SHELL_VALUES],
169 ['zsh', SHELL_VALUES],
170 ['dash', SHELL_VALUES],
171 ['ksh', SHELL_VALUES],
172 ['fish', SHELL_VALUES],
173 ['python', { '-c': 1, '-m': 1, '-W': 1, '-X': 1 }],
174 ['node', { '-e': 1, '-p': 1, '-r': 1, '-C': 1, '--eval': 1, '--print': 1, '--require': 1, '--import': 1, '--loader': 1 }],
175 ['bun', { '-e': 1, '-p': 1, '--eval': 1, '--print': 1 }],
176 ['ruby', { '-e': 1, '-I': 1, '-r': 1 }],
177 ['php', { '-r': 1, '-f': 1, '-d': 1 }],
178 ['perl', { '-e': 1, '-E': 1, '-I': 1, '-i': ATTACHED, '-M': ATTACHED, '-m': ATTACHED, '-F': ATTACHED }],
179 ['su', SU_VALUES],
180 ['runuser', SU_VALUES],
181])
182
183/** The value-taking flags of git's subcommands that Arrester reads. */
184export const GIT_SUB_VALUES: ReadonlyMap<string, Values> = new Map<string, Values>([
185 ['clean', { '-e': 1, '--exclude': 1 }],
186 ['rm', { '--pathspec-from-file': 1 }],
187 ['checkout', { '-b': 1, '-B': 1, '--orphan': 1 }],
188 ['restore', { '-s': 1, '--source': 1 }],
189 ['stash', { '-m': 1, '--message': 1 }],
190])
191
192/** The flags whose value is code, per interpreter family. */
193export const CODE_FLAGS: ReadonlyMap<string, readonly string[]> = new Map([
194 ['python', ['-c']],
195 ['node', ['-e', '--eval', '-p', '--print']],
196 ['bun', ['-e', '--eval', '-p', '--print']],
197 ['ruby', ['-e']],
198 ['php', ['-r']],
199 ['perl', ['-e', '-E']],
200])
201
202/** `claude` launch flags that load other settings or turn off permission checks, hooks, or mods. */
203export const CLAUDE_OFF = new Set(['--settings', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--bare', '--safe-mode'])
204
205/** `claude plugin` subcommands that turn a plugin off. */
206export const PLUGIN_OFF = new Set(['disable', 'uninstall'])
207
208/** The first words of a `claude` command line that start no session: version and help flags, and tool subcommands. */
209export const CLAUDE_TOOLS = new Set(['-v', '--version', '-h', '--help', 'mcp', 'plugin', 'plugins', 'doctor', 'update', 'upgrade', 'install', 'auth', 'setup-token'])
210
211/** `claude mcp` subcommands that start a server, or add one whose command runs later. */
212export const MCP_RUNS = new Set(['serve', 'add', 'add-json', 'add-from-claude-desktop'])
213
214const DECODERS = new Set(['uudecode', 'gunzip', 'zcat', 'bzcat', 'xzcat'])
215const HANDLED = new Set(['find', 'git', 'gh', 'curl', 'wget', 'dd', 'eval', 'source', '.', 'openssl', 'gzip', 'claude', 'su', 'runuser', ...DECODERS])
216
217/** The value-taking flags of a program; none when the table does not list it. */
218export function valuesOf(program: string): Values {
219 return VALUE_FLAGS.get(program) ?? {}
220}
221
222/** True when the flags hold one of `letters` in a short cluster (`-rf`) or one of the long names. */
223export function hasFlag(flags: readonly string[], letters: string, longs: readonly string[] = []): boolean {
224 return flags.some(flag => longs.includes(flag) || (/^-[A-Za-z]+$/.test(flag) && [...letters].some(letter => flag.includes(letter))))
225}
226
227/** True when the program decodes or unpacks text. */
228export function isDecoder(program: string, flags: readonly string[]): boolean {
229 if (program === 'base64') return hasFlag(flags, 'dD', ['--decode'])
230 if (program === 'xxd') return hasFlag(flags, 'r', ['--revert'])
231 if (program === 'openssl') return flags.includes('-d')
232 if (program === 'gzip') return hasFlag(flags, 'd', ['--decompress'])
233 return DECODERS.has(program)
234}
235
236/** The interpreter table key: Python variants share `python`; Node and TypeScript runners share `node`. */
237export function family(program: string): string {
238 if (/^(python|pypy)/.test(program)) return 'python'
239 return ['nodejs', 'tsx', 'ts-node'].includes(program) ? 'node' : program
240}
241
242/** True when Arrester reads the program itself, so a path to it is not a script run. */
243export function isKnown(program: string): boolean {
244 return [PATHY, TRIVIAL, SHELLS, HANDLED].some(set => set.has(program)) || INTERPRETER.test(program) || WRAPPERS.has(program) || RUNNERS.has(program)
245}
246