SLOPSHOPPER

arrester

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…

newguardcommandstatustimer
v1.1.3MITupdated 2026-10-14dot-agi/arrester
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arrester
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /arrester ⎿ arrester: response: deny · a block stays open 30 min after it opens or after its last target or retry match ⎿ arrester: ⎿ arrester: Open blocks (0) ⎿ arrester: ⎿ arrester: Held calls (0) ⎿ arrester: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

Terminal: a deny rule blocks cat notes.txt. Claude tries head -n 5 notes.txt, and Arrester stops it as a workaround of block b1.

A deny rule blocks cat. Arrester stops the head detour.

Why

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.

Features

  • Follows the guards you have. Uses the blocks of your existing hooks, deny rules, and auto mode, so you write no new rules.
  • Matches across tools. Recognizes common shell rewrites and file-tool calls that reach a blocked path.
  • Protects your guards. While a block from a hook, rule, or classifier is open, stops recognized reads or changes of guard files, and claude commands that can turn guards off.
  • Checks subagents. Applies the same rules to tool calls from subagents in the conversation.
  • Explains each stop. Tells Claude which block the call matched and why. /arrester shows the open blocks and the recent decisions.
  • Denies or asks. Refuses a match at once, or holds it until you answer.

How it works

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]
  • Capture: Arrester records the blocked call's paths, the access to each (read, write, or delete), and the shape of its command. When the deny reason names some of the paths but not all, and each word in it that looks like a path names one of them, Arrester records only the named paths. A command that changes its folder with cd, pushd, or popd keeps every path.
  • Match: A later call matches when it reaches a blocked target, repeats the blocked command with changed flags or argument order, reads or changes a guard, or is opaque: it runs code or names paths that Arrester cannot read. A retry that only drops flags skips the retry rule; the other rules and your guards still apply.
  • Guards: The guard files are your user and project settings files, the hook scripts that your settings name by an absolute path or by a supported relative path, Arrester's own folder, and your installed plugins (changes only). A nested 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.
  • Your “No”: Arrester stops only an exact retry of the call you rejected, so a changed request can go on. The target, guard, and opaque rules do not apply to it.
  • Lifetime: A block closes after block_minutes (see Options), or when you run /arrester clear. /clear, /resume, and /branch start a fresh record.
  • No open block: Arrester passes calls through without analyzing them.

See the usage guide for access rules, supported shell forms, and script handling.

Install

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

Agents and scripts

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

Commands

CommandWhat it does
/arrester or /arrester statusShows 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 clearCloses every open block.
/arrester clear b2Closes 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.

Terminal: /arrester status shows open block b1 from deny rule Bash(cat notes.txt), its target, and three recent stops.

/arrester status lists the open block and recent stops.

Options

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.

OptionValuesDefaultWhat it does
responsedeny, holddenydeny 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_minutes1 to 144030How long a block stays open after it opened or after its last target or retry match.

How it compares

Arrester remembers supported guard blocks within the current conversation. These tools provide complementary checks and controls.

Tool or featureWhat it doesWhat Arrester adds
Built-in /permissions Recently denied and the PermissionDenied hookLists 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 pathsNever 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.
TermaxaApplies 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 guardrailsblock-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-guideUses 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.

Known limits

What it catches

  • A blocked file or folder reached another way, such as 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.
  • Files inside a folder after a blocked search of that folder by grep, rg, ag, ugrep, or the Grep tool, with no file filter, depth limit, or file listing, such as cat secrets/token.txt after a blocked grep -r . secrets.
  • Commands inside shell scripts, and literal paths in common file operations of interpreter code, such as 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.
  • While a block from a hook, rule, or classifier is open: reads and changes of settings and hook files, and nested claude runs.

What it does not catch

  • Paths that code builds at run time, in scripts, interpreter code, or command output, and code that Arrester does not read: functions and aliases from your shell profile, unlisted runners such as 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.
  • Reads inside a folder after a blocked call that records no folder: a search with a file filter, a depth limit, or a file listing, such as 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.
  • Copies into a folder from a glob, find, or xargs, such as cp /staging/* /work/, that replace a blocked file.
  • Hook denials in managed settings, a changed retry after your own “No”, which stops only the same call, and a path of a blocked call that the deny reason does not name, when the reason names another path of the call, such as 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.

  • Searches that name no folder: A search with no folder, such as 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.
  • Copies into a folder: For a copy, move, or link into a folder, such as 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.
  • A blocked search of a file or a folder by grep, egrep, fgrep, ugrep, rg, or ag, such as 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.
  • Arrester reads paths from Bash, Monitor, Read, Write, Edit, NotebookEdit, LSP, Grep, Glob, SendFile, and SendUserFile calls, from a 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.
  • Your “No” matches only an identical call: the same command text, working folder, and relevant tool arguments. A command that differs by one character goes on to your guards. Tool input past the serialization limits gets no exact identity, so your “No” on such a call matches no retry.
  • Managed-settings hook denials end before installed mods run, so Arrester cannot observe them. With managed settings on the machine, or with a Team or Enterprise sign-in, Arrester sees a hook that denies with JSON only through the permission verdict, which names the hook. There, a deny that Claude Code makes after a hook's answer can also open a hook block: the tool's own check or a safety check that denies after a hook's ask, or a hook's changed input that Claude Code refuses.
  • Arrester expands ~, $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.
  • In an interpreter script that last changed before the oldest open block, Arrester counts only a literal path that is a whole argument of a definite file operation, such as 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.
  • A nested 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.
  • Interpreter code can compute paths, use bare file names or names with spaces, or hide literals behind template expressions or comments that the scan reads as code. The scan recognizes common file operations but does not interpret functions or computed values. Some functions imported under a bare name are not recognized, such as 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.
  • Arrester walks a function's body at each call, with the call's arguments, an
Source 13 files
hooks/register.ts 508 lines
1// 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}
508
hooks/analyze/call.ts 324 lines
1// 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}
324
hooks/analyze/paths.ts 597 lines
1// 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}
597
hooks/analyze/shell.ts 3181 lines
1// 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 lines
1// 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 }
28
hooks/guards.ts 106 lines
1// 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}
106
hooks/journal.ts 236 lines
1// 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}
236
hooks/match.ts 225 lines
1// 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}
225
hooks/text.ts 352 lines
1// 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}
352
hooks/analyze/code.ts 316 lines
1// 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}
316
hooks/analyze/lexer.ts 465 lines
1// 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}
465
hooks/analyze/programs.ts 246 lines
1// 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