SLOPSHOPPER

guard

Agent guardrails for Claude Code, Copilot CLI, VS Code, Codex, opencode and pi: skill enforcer, Conventional Commit title guard, pip deny, host guard…

guardprocessnetworktimeragents
v0.62.0MITupdated 2026-10-25giocaizzi/harness/plugins/guard
A shopper browsing a rack in a slop shop
README

guard plugin

Agent guardrails as hooks: one rule engine that runs under Claude Code, GitHub Copilot CLI, VS Code Copilot and Codex as command hooks (hooks/hooks.json, .generated/codex/hooks.json) and under opencode and pi as in-process plugins (hooks/opencode.mjs, hooks/pi.mjs), on macOS, Linux and Windows. Per-harness differences: docs/compatibility.md. No skills or agents. Architecture and the install/verify runbook: docs/guard.md. The UI moved to hud and toasts and sounds to sounds.

Hooks

HookEventHarnessWhat it does
skill-enforcerPreToolUse (shell)allBefore git commit, gh pr, glab mr, az repos pr, worktree and release commands, denies once per session until the matching skill is loaded.
title-guardPreToolUse (shell)allDenies git commit -m (heredoc and -F - included), `gh pr createedit --title, glab mr createupdate --title or az repos pr createupdate --title` whose subject does not match the org's PR-title check, every time, for every titled command in the chain.
host-guardPreToolUse (shell)allDenies a heavy build or test run once when load, CPU or free disk is past its cap, pointing to host-hygiene's heavy.sh and lease.sh; commands already run through a heavy or lease wrapper pass.
risky-askPreToolUse (shell)all; Codex and opencode ask through their own prompt rules for plain forms (rm -rf build, git push --force origin x: plain words, no $, ~, globs or quotes) and deny the restAsks before rm -r (every spelling), find -delete, force-push, git push --mirror or deleting main, git reset --hard, git clean -f, git checkout ., git stash drop, git branch -D, also behind git -C dir and inside bash -c, eval, ssh, docker exec, cmd /c and powershell -Command, terraform/tofu apply, git update-ref -d/filter-repo, data-loss deletes outside git (docker volumes, gh … delete, gh api -X DELETE, kubectl delete, helm uninstall, gcloud/az … delete, dd, mkfs), gh pr merge --admin, az repos pr --bypass-policy, and secret reads (bw/bws, security -w, op read, pass show, cloud secret managers, gh auth token and other token printers, a bare env dump, printenv/`envgrep TOKEN, echo $X_TOKEN, any reader of .env, ~/.aws/credentials or ssh keys; a quoted VAR="$(…)"` injection passes), with a plain-words summary of the risky step, where it runs, the rule, and a blast-radius preview (a risky step buried in a chain of more than 3 commands is denied with "run it on its own"); deletes under a temp dir, of a clean worktree or a branch that is pushed or has a merged PR, or of git-ignored build output pass.
pip-denyPreToolUse (shell)allIn a uv repo, denies pip, pip3, pipx, python -m pip and poetry every time; uv pip passes. In a poetry, pipenv or pip repo they run, with one note per session.
stop-checkStopall; opencode and pi send the failure back as a messageRuns make check before the agent stops, only when the repo changed since the last green run; on failure blocks once with the output tail. make runs with no terminal; a timeout (default 300 s, GUARD_STOP_TIMEOUT_MS) defers it to the next stop, never blocks; GUARD_STOP_CHECK=off disables it, force runs it on a strained host too. In a factory run it first holds the stop, at most twice, while the run's result.json is missing or invalid (the result gate; GUARD_RESULT_GATE=off disables it).
shell-edit-hintPreToolUse (shell)allOnce per session, after sed -i, perl -pi, inline Python or Node writing a file, or echo/printf/cat/tee/Set-Content into one (not /dev/*, an fd or a temp path), tells the agent to use its edit tool, which the lint and docs hints watch.
post-edit-lintPostToolUse (edit tools)allshellcheck on edited .sh/.bash (not zsh or fish scripts, not temp files), PSScriptAnalyzer on .ps1; findings go back to the agent. Missing linter: skip.
keep-awakeSessionStartallKeeps the machine awake while the harness runs (VS Code: while the window is open): caffeinate -w (macOS), systemd-inhibit (Linux), SetThreadExecutionState (Windows).
usage-throttlemod agent.spawnClaudeRefuses new subagents at 90% of the worst plan window. Copilot exposes no rate-limit windows.
ask-timeoutmod tool.checkClaude (pi, opencode: #100; others cannot: #101)Puts each permission ask that would reach the person in an Allow/Deny dialog and parks it after 60 seconds without an answer (GUARD_ASK_TIMEOUT_MS; 0 turns it off), telling the agent "no answer, carry on or park it", never "the user denied". Logs each outcome as guard.ask_result. Auto mode's own asks stay with its classifier; dontAsk stays with the engine.
decision-logPreToolUse (shell), mod agent.spawnall (throttle: Claude)Sends each deny, ask and throttle denial as an OTLP log record to the collector in OTEL_EXPORTER_OTLP_ENDPOINT (or its GUARD_ copy, since Claude Code and Copilot strip OTEL_* from hooks); nothing without it. See docs/guard.md.
routing-hintPreToolUse (subagent spawns: Agent, Task, task, spawn_agent, runSubagent)Claude, Copilot CLI, VS Code, Codex (opencode, pi: no case can occur)Once per session per case, a hint (never a deny) when a spawn bypasses the typed agents: the built-in Plan agent, an untyped spawn on an Opus, Fable, GPT Sol or Astra model or with no model, or a typed agent spawned with a model or effort the skill does not allow.
stale-pluginsPreToolUse (every tool the guard sees)allOnce per session and newer version, tells the user (and the agent) that this marketplace's plugins were updated since the session loaded them, with the harness's reload command, which only the user can run.
skill-trackerPreToolUse (Skill, a read of SKILL.md), UserPromptSubmit (/skill, /skill:name, $skill)allRecords loaded skills and typed slash commands for the skill enforcer.

Install

/plugin install guard@giocaizzi-harness          # Claude Code
copilot plugin install guard@giocaizzi-harness   # Copilot CLI
codex plugin add guard@giocaizzi-harness         # Codex, then trust its hooks in /hooks
make install HARNESS=codex                       # Codex prompt rules for risky commands, from a clone
make install HARNESS=opencode                    # opencode, from a clone
pi install git:github.com/giocaizzi/harness      # pi

VS Code and the details per harness: docs/install.md.

Source 5 files
hooks/mod.mjs 166 lines
1// Claude Code mod (hooks.json `modules`; Copilot ignores the key): the usage throttle and the ask timeout.
2// The command guards run as command hooks for both harnesses; this holds only what needs the mods API.
3import { cmdClass, evaluateSpawn } from '../core/engine.mjs'
4import { logRequest, otelOff, repoSlug } from '../core/otel.mjs'
5import { rules } from '../core/rules.mjs'
6
7// The window closest to its limit (a gateway spend limit is not a plan window).
8const worstWindow = (limits) => limits.filter((r) => r.kind !== 'spend_limit').sort((a, b) => b.percentUsed - a.percentUsed)[0]
9
10// The OTel export settings: each OTEL_* variable, else its GUARD_-prefixed copy, as for the command hooks
11// (core/otel.mjs); null when GUARD_OTEL=off.
12async function otelConfig($) {
13  if (otelOff({ GUARD_OTEL: await $.env.get('GUARD_OTEL') })) return null
14  return {
15    endpoint: (await $.env.get('OTEL_EXPORTER_OTLP_ENDPOINT')) || (await $.env.get('GUARD_OTEL_EXPORTER_OTLP_ENDPOINT')),
16    headers: (await $.env.get('OTEL_EXPORTER_OTLP_HEADERS')) || (await $.env.get('GUARD_OTEL_EXPORTER_OTLP_HEADERS')),
17    resource: (await $.env.get('OTEL_RESOURCE_ATTRIBUTES')) || (await $.env.get('GUARD_OTEL_RESOURCE_ATTRIBUTES')),
18  }
19}
20
21// The installed guard's version (read once per load) and the session repo's `owner/name` (its `origin` remote, else the
22// first remote, as the command hooks read it; once per working directory), null where unreadable. Never throws.
23const attempt = async (f) => {
24  try {
25    return await f()
26  } catch {
27    return null
28  }
29}
30let version
31const repos = new Map()
32async function whereAttrs($) {
33  version ??= attempt(async () => JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)).version ?? null)
34  const cwd = (await attempt(() => $.session.cwd())) ?? ''
35  if (!repos.has(cwd)) {
36    const git = async (...args) => {
37      const r = await $.process.run(['git', ...args], { timeoutMs: 2000, ...(cwd ? { cwd } : {}) })
38      return r.exitCode === 0 ? r.stdout.trim() : null
39    }
40    repos.set(cwd, attempt(async () => {
41      const first = async () => {
42        const name = (await git('remote'))?.split(/\r?\n/)[0]
43        return name ? git('remote', 'get-url', name) : null
44      }
45      return repoSlug((await git('remote', 'get-url', 'origin')) ?? (await first()))
46    }))
47  }
48  return { guard_version: await version, repo: await repos.get(cwd) }
49}
50
51// One guard record to the OTel collector, when the session exports to one; never fails the caller.
52async function emitRecord($, event, attrs) {
53  try {
54    const config = await otelConfig($)
55    const req = config && logRequest(config, event, { ...attrs, harness: 'claude', ...(await whereAttrs($)) })
56    if (req) await $.http.fetch(req.url, { method: 'POST', headers: req.headers, body: req.body })
57  } catch {
58    // collector unreachable: the decision stands
59  }
60}
61
62const emitThrottle = ($, w) => emitRecord($, 'guard.throttle', { action: 'deny', rule: 'usage-throttle', cmd_class: 'spawn', window: w?.kind, percent: w?.percentUsed })
63// The class of an asked call: its shell command's, else `tool`.
64const askClass = (e) => (typeof e.input?.command === 'string' ? cmdClass(null, e.input.command) : 'tool')
65
66// Permission asks are parked (denied, with "no answer, carry on") after this long without an answer, in every mode:
67// the user may be working elsewhere, so a session never waits on a dialog. GUARD_ASK_TIMEOUT_MS overrides it; 0 turns
68// it off (the engine's dialog waits for an answer).
69export const ASK_TIMEOUT_MS = 60_000
70// Tools whose own dialog is the question (askUserQuestionTimeout covers AskUserQuestion); left to the engine.
71const PERSON_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
72// The session's permission mode, as the last classic hook input carried it. ponytail: a mode switched with
73// shift+tab is seen at the next prompt or tool result, so one ask in between can go the old way. SessionStart carries
74// no `permission_mode` (Claude Code 2.1.310), so before the first prompt or tool result the mode is unknown, and a
75// reload (`/reload-plugins`) starts the module over with it unknown again.
76let mode
77
78function trackMode($, e, next) {
79  mode = e.permission_mode ?? mode
80  return next(e)
81}
82
83const summary = (input) => String(input?.command ?? input?.file_path ?? input?.url ?? input?.pattern ?? '').slice(0, 500)
84
85// GUARD_ASK_TIMEOUT_MS when it is a whole number of ms >= 0, else the default; unreadable env: the default.
86async function askTimeoutMs($) {
87  try {
88    const v = await $.env.get('GUARD_ASK_TIMEOUT_MS')
89    return /^\d+$/.test(v ?? '') ? Number(v) : ASK_TIMEOUT_MS
90  } catch {
91    return ASK_TIMEOUT_MS
92  }
93}
94
95// GUARD_UNATTENDED in the environment Claude Code was launched with: nobody is there to answer, so an ask is parked at
96// once instead of after the timeout. Only the environment: a switch a command set lives in the command hooks' state.
97async function unattendedEnv($) {
98  try {
99    return /^(1|true|on)$/i.test((await $.env.get('GUARD_UNATTENDED')) ?? '')
100  } catch {
101    return false
102  }
103}
104
105// What the agent is told when nobody answered: the user may be busy elsewhere, so the call is parked, not refused.
106// It names the call, not the tool: "Bash was not run" read as "Bash is unavailable", and an agent gave up the task.
107const noAnswer = (input, why) => {
108  const call = summary(input).slice(0, 120)
109  return `No answer: ${why}, so this call${call ? ` (${call})` : ''} was not run; other calls still run. This is not a refusal. Carry on with the rest of the task without it, or park it: say in your report that it is waiting for the user's approval.`
110}
111
112// Puts the ask in the plugin's own dialog and denies when nobody answers within `ms`. Every outcome is reported as
113// guard.ask_result (allow, deny, timeout, unavailable) with how long it waited.
114async function askWithTimeout($, e, reason, ms) {
115  const started = await $.clock.now().catch(() => NaN)
116  let expire
117  const timedOut = new Promise((resolve) => (expire = resolve))
118  const timer = $.clock.after(ms, () => expire('timeout'))
119  // A dialog that cannot show (headless, a host without one) or is dismissed rejects: nobody answered.
120  const asked = $.ui.ask(`Allow ${e.tool}? ${summary(e.input)}\n${reason ?? ''}\nParked after ${ms / 1000} s without an answer.`, ['Allow', 'Deny']).catch(() => 'unavailable')
121  const answer = await Promise.race([asked, timedOut])
122  timer.cancel()
123  const result = answer === 'Allow' ? 'allow' : answer === 'Deny' ? 'deny' : answer
124  const waited = (await $.clock.now().catch(() => NaN)) - started
125  // Not awaited: a slow collector never delays the decision.
126  void emitRecord($, 'guard.ask_result', { result, wait_ms: Number.isFinite(waited) ? waited : undefined, tool: e.tool, cmd_class: askClass(e), tool_use_id: e.tool_use_id })
127  if (result === 'allow') return { decision: 'allow', reason: 'allowed in the guard dialog' }
128  if (result === 'timeout') return { decision: 'deny', reason: noAnswer(e.input, `nobody answered the permission prompt within ${ms / 1000} seconds`) }
129  if (result === 'unavailable') return { decision: 'deny', reason: noAnswer(e.input, 'the permission prompt could not be shown or was dismissed') }
130  return { decision: 'deny', reason: `The user denied this ${e.tool} call. Do not retry it or a respelling of it; ask the user what they want instead.` }
131}
132
133export function register(on) {
134  on('classic.UserPromptSubmit', trackMode)
135  on('classic.PostToolUse', trackMode)
136
137  // Ask timeout: a dialog nobody answers would hold the session forever. Only a real call whose ask reaches the
138  // person: auto mode's own asks go to its classifier, a hosted session's to its host. Fails to the engine's dialog.
139  on('tool.check', async ($, e, next) => {
140    const r = await next(e)
141    if (r.decision !== 'ask' || !e.tool_use_id || r.decider || PERSON_TOOLS.has(e.tool)) return r
142    // Claude Code's own ask with the mode unknown (a session's first call, right after a reload): auto mode's classifier
143    // or dontAsk may own it, so it stays with Claude Code. Parking it denied every call of an auto session after a
144    // reload, and a denied call fires no PostToolUse to bring the mode back. A hook's ask (the guard's) is still parked.
145    if ((mode === 'auto' || mode === undefined) && !r.hook) return r
146    // dontAsk denies anything not pre-approved itself; there is no one to ask.
147    if (mode === 'dontAsk') return r
148    if (await unattendedEnv($)) {
149      void emitRecord($, 'guard.ask_result', { result: 'unavailable', via: 'unattended', wait_ms: 0, tool: e.tool, cmd_class: askClass(e), tool_use_id: e.tool_use_id })
150      return { decision: 'deny', reason: noAnswer(e.input, 'this is an unattended run (GUARD_UNATTENDED), so nobody can answer the permission prompt') }
151    }
152    const ms = await askTimeoutMs($)
153    return ms ? askWithTimeout($, e, r.reason, ms) : r
154  }).catch(($, e, next) => next(e))
155
156  // Usage throttle: no new subagents at the throttle level.
157  on('agent.spawn', async ($, e, next) => {
158    const { rateLimits } = await $.session.usage()
159    const w = worstWindow(rateLimits)
160    const d = evaluateSpawn({ usagePercent: w?.percentUsed, resetsAt: w?.resetsAt, rules })
161    if (d.action !== 'deny') return next(e)
162    await emitThrottle($, w)
163    return { deny: d.reason }
164  }).catch(($, e, next) => next(e)) // usage unreadable: fail open
165}
166
core/engine.mjs 1584 lines
1// Shared rule engine for the Claude Code mod and the Copilot adapters.
2// Pure functions: adapters gather facts (command, loaded skills, host, usage) and
3// apply the decision. `state.reminded` (a Set) is mutated: each rule fires once
4// per session ("remind once, then let it through").
5
6import { analyze, commandWords, scan, units, wrapped } from './shell.mjs';
7
8const bare = (skill) => skill.split(':').pop();
9
10// Rules match what a command would run, not text it carries: quoted strings (commit messages, echo, grep patterns)
11// and heredoc bodies are blanked. What runs although it is written as data (a shell's -c string, eval, ssh's remote
12// command, docker exec, a heredoc or echo piped into a shell, $(…) even inside double quotes) is read again by
13// core/shell.mjs and added, one line per level. `remote: false` leaves out what runs on another host or in a
14// container; `injected: false` leaves out a $(…) whose output only becomes a variable's value (`T="$(…)" cmd`).
15// A command the guard cannot read (an encoded PowerShell command, base64 decoded into a shell) adds OPAQUE.
16const HEREDOC = /(<<-?\s*(['"]?)(\w+)\2[^\n]*)\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g;
17export const OPAQUE = '<guard:opaque>';
18const cache = new Map();
19export function analysis(command) {
20  if (!cache.has(command)) {
21    if (cache.size > 200) cache.clear();
22    cache.set(command, analyze(command));
23  }
24  return cache.get(command);
25}
26const levelCode = (a) => a.commands.map((c, i) => `${i && c.sep ? (c.sep === '\n' ? '\n' : ` ${c.sep} `) : ''}${c.code.trim()}`).join('');
27function levels(a, opts, ctx = { remote: false, injected: false }) {
28  const out = [levelCode(a)];
29  for (const c of a.commands)
30    for (const p of c.payloads) {
31      const sub = { remote: ctx.remote || Boolean(p.remote), injected: ctx.injected || Boolean(p.injected) };
32      if ((sub.remote && !opts.remote) || (sub.injected && !opts.injected)) continue;
33      out.push(...levels(p.analysis, opts, sub));
34    }
35  return out;
36}
37export function executable(command, { remote = true, injected = true } = {}) {
38  const a = analysis(command);
39  return [...levels(a, { remote, injected }), ...(a.opaque ? [OPAQUE] : [])].join('\n');
40}
41
42// Git's global options sit between `git` and the subcommand (`git -C dir`, `-c k=v`, `--git-dir=…`, `--no-pager`) and
43// would hide it from every `git <subcommand>` rule, so rules match with them removed; `gitDirs` keeps the `-C` dirs.
44const GIT_VAL = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s;&|"'])+`;
45const GIT_GLOBALS = String.raw`(?<![\w./:@-])git((?:\s+(?:(?:-C|-c|--git-dir|--work-tree|--namespace|--config-env|--attr-source)(?:=|\s+)${GIT_VAL}|-[\w-]+(?:=${GIT_VAL})?))+)(?=\s+[^\s-])`;
46export const gitPlain = (code) => code.replace(new RegExp(GIT_GLOBALS, 'g'), 'git');
47const gitDirs = (seg) => [...(new RegExp(GIT_GLOBALS).exec(seg)?.[1] ?? '').matchAll(/(?:^|\s)-C\s+(\S+)/g)].map((m) => m[1]);
48// What rules match: the executable text with git's global options removed. A double-quoted word holding only a
49// variable or a plain word (`"$TOKEN"`, `".env"`) is that word, so a rule can see it (SIMPLE_QUOTED, below).
50const matchable = (command, opts) => gitPlain(executable(command.replace(SIMPLE_QUOTED, '$1'), opts));
51
52// Every pipeline of a command at every level, with its code and its text as written: { code, raw, depth,
53// siblings, host, top }, where `siblings` counts the commands at its level and `top` those of the whole command.
54const KEYWORD = /^(?:do|done|then|fi|else|esac|\{|\})?$/;
55function pipelines(command, { injected = true } = {}) {
56  const a = analysis(command.replace(SIMPLE_QUOTED, '$1'));
57  const count = (level) => level.commands.filter((c) => !KEYWORD.test(c.code.trim())).length;
58  const top = count(a);
59  const out = [];
60  const walk = (level, ctx) => {
61    const siblings = count(level);
62    let pipe = [];
63    const groups = [];
64    for (const c of level.commands) {
65      if (pipe.length && c.sep !== '|' && c.sep !== '|&') (groups.push(pipe), (pipe = []));
66      pipe.push(c);
67    }
68    if (pipe.length) groups.push(pipe);
69    for (const p of groups) {
70      const parts = p.map((c) => ({ code: gitPlain(c.code.trim()), vals: commandWords(c.words).map((w) => w.val) }));
71      out.push({ code: parts.map((x) => x.code).join(' | '), raw: p.map((c) => c.raw).join(' | '), parts, depth: ctx.depth, siblings, host: ctx.host, top });
72      for (const c of p)
73        for (const pl of c.payloads) {
74          if (pl.injected && !injected) continue;
75          walk(pl.analysis, { depth: ctx.depth + 1, host: pl.host ?? ctx.host });
76        }
77    }
78  };
79  walk(a, { depth: 0, host: undefined });
80  return out;
81}
82// A `pipeline` rule fires on one pipeline whose code matches, whose text matches `raw` and not `unless`. A `help` rule
83// leaves out a secret CLI that only prints its usage (`bws secret get --help`, `op help`, `gh auth token -h`): the help
84// flag last and not after an option (where it could be that option's value), or a `help` subcommand. A file reader is never left out: BSD `cat .env --help`
85// prints the file.
86const HELP_CLI = /^(?:bws?|op|pass|security|chezmoi|gcloud|az|gh|printenv)$/;
87const helpOnly = ({ vals }) => HELP_CLI.test(vals[0]?.replace(/^.*[\\/]/, '') ?? '') && ((/^(?:--help|-h)$/.test(vals.at(-1)) && !vals.at(-2).startsWith('-')) || vals[1] === 'help');
88const pipelineCode = (r, p) => (r.help ? p.parts.filter((x) => !helpOnly(x)).map((x) => x.code).join(' | ') : p.code);
89const pipelineHit = (r, p) => riskyRe(r).test(pipelineCode(r, p)) && (!r.raw || new RegExp(r.raw, 'i').test(p.raw)) && !(r.unless && new RegExp(r.unless, 'i').test(p.raw));
90
91// Commands that set a commit or PR/MR title: [command words, title flags, flags that read the message from a file].
92// az's -t is --target-branch, so az has --title only.
93const TITLED = [
94  [['git', 'commit'], /^(?:-[a-zA-Z]*m|--message)$/, /^(?:-[a-zA-Z]*F|--file)$/],
95  [['gh', 'pr', /^(?:create|edit)$/], /^(?:--title|-t)$/],
96  [['glab', 'mr', /^(?:create|update)$/], /^(?:--title|-t)$/],
97  [['az', 'repos', 'pr', /^(?:create|update)$/], /^--title$/],
98];
99// git's global options before the subcommand (`git -C dir`, `-c k=v`, `--no-pager`): the ones that take a value.
100const GIT_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source']);
101
102// The message a word passes, or null when it cannot be read statically: a variable, a substitution other than
103// `"$(cat <<'EOF' … EOF)"`, or a backtick. Single-quoted text is literal.
104function literal(word) {
105  const sub = /^"\$\(([\s\S]*)\)"$/.exec(word.raw);
106  if (sub && word.substs.length === 1) {
107    const [cat, ...rest] = scan(sub[1]);
108    return !rest.length && commandWords(cat?.words ?? [])[0]?.val === 'cat' && cat.heredocs.length === 1 ? cat.heredocs[0] : null;
109  }
110  return /[$`]/.test(word.raw.replace(/'[^']*'/g, '')) ? null : word.val;
111}
112
113// The title one simple command sets, or null: the first title flag's value (`-m "x"`, `-mx`, `--title=x`), or the
114// first line of a heredoc passed as `-F -` / `--file=-`. Only a command at command position counts, so
115// `echo git commit -m x` sets none; a message read from a file or stdin without a heredoc is not read.
116function titleIn(cmd) {
117  let words = commandWords(cmd.words);
118  if (words[0]?.val === 'git') {
119    let k = 1;
120    while (words[k]?.val.startsWith('-')) k += GIT_VALUE.has(words[k].val) ? 2 : 1;
121    words = [words[0], ...words.slice(k)];
122  }
123  const vals = words.map((w) => w.val);
124  const titled = TITLED.find(([name]) => name.every((n, i) => (typeof n === 'string' ? vals[i] === n : n.test(vals[i] ?? ''))));
125  if (!titled) return null;
126  const [name, flag, file] = titled;
127  for (let i = name.length; i < words.length; i++) {
128    const v = vals[i];
129    if (v === '--') break;
130    const eq = /^(--[\w-]+)=([\s\S]*)$/.exec(v);
131    const joined = !v.startsWith('--') && /^(-[a-zA-Z]*?[mF])(.+)$/.exec(v);
132    const [f, value] = eq ? [eq[1], { ...words[i], val: eq[2], raw: words[i].raw.slice(words[i].raw.indexOf('=') + 1) }] : joined && !flag.test(v) && !file?.test(v) ? [joined[1], { ...words[i], val: joined[2], raw: words[i].raw.slice(joined[1].length) }] : [v, words[i + 1]];
133    if (file?.test(f)) return value?.val === '-' && cmd.heredocs.length ? cmd.heredocs[0] : null;
134    if (flag.test(f)) return value ? literal(value) : null;
135  }
136  return null;
137}
138
139// Every title a command sets, in order: each simple command at command position, payloads (bash -c, ssh) included.
140export function titlesOf(command) {
141  return units(analysis(command))
142    .map((u) => titleIn(u.cmd))
143    .filter((t) => t !== null)
144    .map((t) => t.split('\n')[0]);
145}
146
147// The first title that does not match the org's PR-title check (giocaizzi/.github `pr-title.yml`), or null. Git's own
148// merge commit subjects ("Merge branch …") are skipped.
149export function badTitle(command, rules) {
150  const re = new RegExp(`^(${rules.titles.types.join('|')})(\\([a-z0-9._/-]+\\))?!?: [a-z].*$`);
151  return titlesOf(command).find((t) => !re.test(t) && !/^Merge (?:branch|pull request|remote-tracking branch|tag|commit) /.test(t)) ?? null;
152}
153
154const hit = (rules, command, opts) => rules.find((r) => new RegExp(r.match, 'i').test(matchable(command, opts)));
155
156// A risky rule's `raw` must also match the command as written (quoted text included). `case` rules match
157// case-sensitively; `skipInjected` rules do not look inside a value injected into a variable.
158const riskyRe = (r) => new RegExp(r.match, r.case ? '' : 'i');
159const riskyMatch = (r, command) =>
160  r.pipeline
161    ? pipelines(command, { injected: !r.skipInjected }).some((p) => pipelineHit(r, p))
162    : riskyRe(r).test(matchable(command, { injected: !r.skipInjected })) && (!r.raw || new RegExp(r.raw, 'i').test(command));
163
164// The deny rule a command hits, so adapters gather repo facts (the Python manager) only when one applies.
165export const deniedBy = (command, rules) => hit(rules.deny ?? [], command);
166// Where the denied command runs, relative to the cwd ('' for the cwd itself): `cd <uv repo> && pip install x` is
167// judged by that repo's manager. Null when it cannot be known (an unfollowed cd) or it runs on another host.
168export function deniedDir(command, rules, env = {}) {
169  const rule = deniedBy(command, rules);
170  if (!rule) return null;
171  const at = segments(command, env).find(({ seg }) => new RegExp(rule.match, 'i').test(seg));
172  if (!at || at.remote || at.dir.startsWith(UNKNOWN_DIR)) return null;
173  return at.dir;
174}
175
176// `for VAR in w1 w2; do BODY; done` with only plain words in the list becomes BODY once per word, $VAR and ${VAR}
177// replaced, so each removal target can be judged. The loop is found with quotes and heredoc bodies masked (same
178// length), so a `for` or `done` inside a string never counts. Not expanded, so `$VAR` stays and asks: a list with
179// globs, quotes or expansions, a body that names VAR otherwise (assigns, reads, unsets it) or holds another loop.
180const LOOP = /(?<=^|[\s;&|(])for\s+([A-Za-z_]\w*)\s+in\s+([^;\n]*?)\s*[;\n]\s*do\s([\s\S]*?)[;\n]\s*done(?=[\s;&|)<>]|$)/dg;
181const PLAIN_LIST = /^[\w./@%+=:,-]+(?:\s+[\w./@%+=:,-]+)*$/;
182const LOOP_WORD = /(?:^|[\s;&|(])(?:for|while|until|select|do|done)(?=[\s;]|$)/;
183export function expandLoops(command) {
184  const masked = command
185    .replace(HEREDOC, (m, head) => head + m.slice(head.length).replace(/\S/g, '_'))
186    .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, (m) => m[0] + '_'.repeat(m.length - 2) + m[0]);
187  let out = '';
188  let at = 0;
189  for (const m of masked.matchAll(LOOP)) {
190    const [, name, list] = m;
191    const [start, end] = m.indices[3];
192    const body = command.slice(start, end);
193    const ref = new RegExp(`(?<!\\\\)\\$(?:${name}(?!\\w)|\\{${name}\\})`, 'g');
194    if (!PLAIN_LIST.test(list) || LOOP_WORD.test(masked.slice(start, end)) || new RegExp(`\\b${name}\\b`).test(body.replace(ref, ''))) continue;
195    out += command.slice(at, m.index) + list.split(/\s+/).map((w) => body.replace(ref, () => w)).join('\n');
196    at = m.index + m[0].length;
197  }
198  return out + command.slice(at);
199}
200
201// The simple commands of a command (after loop expansion, redirections dropped, git's global options removed), each
202// with the directory it runs in. A relative path after `cd DIR` is in DIR, and after `git -C DIR` in DIR too. `cd` is
203// followed only through plain `;`/`&&` chains with a plain DIR, up to the first subshell, group, `||` or conditional:
204// a `cd` at or after one, or a `pushd`, makes the directory UNKNOWN_DIR, which no check accepts, and so does the first
205// of them once a `cd` was followed (the `cd` may not have run). What runs before it keeps its directory, so a
206// `(make test)` at the end of a cleanup chain no longer hides where an earlier delete ran.
207// Paths are resolved the way the shell would, by position (owner decision D5): a leading `~` or `$HOME` from the
208// environment, `$PWD` from the directory a followed `cd` reached (an absolute one only), and `$NAME` from an
209// assignment earlier in the same command (`NAME=/abs/path`, `NAME=$(mktemp -d)`, `export NAME=…`), the latest one
210// winning. A prefix assignment (`NAME=v cmd`) holds for that command only, so it is never followed. An unset or
211// external variable, `~user` or a value that is not a plain path stays as written and asks.
212// A double-quoted word with no spaces or other expansions ("$TMPDIR/x", "$S") is that word: unquote it so the
213// temp check can resolve it instead of seeing the blanked string.
214const SIMPLE_QUOTED = /"((?:\$\{?\w+\}?)?[^"\s$`\\]*)"/g;
215const UNKNOWN_DIR = '<cd?>';
216const REMOTE_DIR = '<remote>';
217const NO_TARGET = '<none>';
218const CD = /(?:^|[\s({])(?:cd|chdir|set-location|sl|pushd|popd|push-location|pop-location)(?=\s|$)/i;
219const PLAIN_CD = /^\s*(?:cd|chdir|set-location|sl)(?:\s+(\S+))?\s*$/i;
220const PLAIN_DIR = /^[\w.@%+=:,\\/~][\w.@%+=:,\\/~-]*$/;
221// Where `cd` stops being followed: a separator that opens a subshell or a branch, or a group or keyword that starts one.
222const BRANCH_SEP = new Set(['(', ')', '||', ';;']);
223const BRANCH_CODE = /^\s*[{}]|(?:^|[\s;&|])(?:if|then|else|elif|fi|case|esac|for|while|until|select|do|done)(?=[\s;]|$)/i;
224// A `cd` piped or sent to the background runs in a subshell and changes nothing after it.
225const SUBSHELL_SEP = new Set(['|', '|&', '&']);
226const ABSOLUTE = /^(?:[\\/]|[a-z]:|~|\$|%)/i;
227const inDir = (dir, p) => (!dir || ABSOLUTE.test(p) ? p : `${dir}/${p}`);
228const MKTEMP = /^\$\(mktemp(\s+-[dqu]+)*(\s+(-t|--tmpdir(=\S+)?)(\s+[\w.-]+)?)?\)$/;
229const DECLARE = /^(?:export|declare|typeset|local|readonly)$/;
230// Words before an assignment that still make it one: `export A=1`, `env A=1 cmd`.
231const DECLARE_WORDS = /^(?:export|declare|typeset|local|readonly|env)$/;
232const VAR_REF = /^\$(?:\{(\w+)\}|(\w+))(?=[\\/]|$)/;
233// `word` with a leading `~`, `$HOME`, `$PWD` or `$NAME` resolved from `vars` (assignments so far), `env` and `pwd`
234// (the absolute directory a followed `cd` reached); else as written.
235function expandPath(word, vars, env, pwd) {
236  if (/^~(?=[\\/]|$)/.test(word)) return env.HOME ? env.HOME.replace(/[\\/]+$/, '') + word.slice(1) : word;
237  const m = VAR_REF.exec(word);
238  if (!m) return word;
239  const name = m[1] ?? m[2];
240  const value = name in vars ? vars[name] : name === 'HOME' ? env.HOME?.replace(/[\\/]+$/, '') : name === 'PWD' ? pwd : undefined;
241  return value ? value + word.slice(m[0].length) : word;
242}
243// A command made only of assignments (`A=1 B=2`, `export A=1`): they persist, so later commands see them.
244function assign(cmd, vars, env, pwd) {
245  const words = cmd.words.filter((w, i) => !(i === 0 && DECLARE.test(w.val)));
246  if (!words.length || !words.every((w) => /^[A-Za-z_]\w*=/.test(w.val))) return false;
247  for (const w of words) {
248    const name = w.val.slice(0, w.val.indexOf('='));
249    const raw = w.raw.slice(w.raw.indexOf('=') + 1).replace(/^"([\s\S]*)"$/, '$1');
250    const value = expandPath(w.val.slice(name.length + 1), vars, env, pwd);
251    if (MKTEMP.test(raw)) vars[name] = '/tmp/mktemp.placeholder';
252    else if (/^(?:[\\/]|[a-z]:[\\/])/i.test(value) && PLAIN_PATH.test(value)) vars[name] = value;
253    else if (isTempPath(value, env)) vars[name] = value;
254    else vars[name] = null;
255  }
256  return true;
257}
258// One level of commands (the top level, or a payload), each with its directory; payloads follow their command, in
259// the directory it runs in (a remote payload starts in no known directory, without this machine's HOME). A command
260// that hands its words to another host or a container (`ssh host rm -rf x`) is judged only through its payload.
261// Each entry: { seg (redirections dropped), code, words, redirs (output redirection targets), dir, remote, resolve(path) }.
262// A `cd` holds past the end of its chain (`;`, a newline, `&`) only when it surely ran: nothing but assignments and
263// other sure `cd`s ran before it in the whole command (`ran`, inherited by payloads), so nothing could have removed or
264// moved its directory first, and that directory exists (`env.dirs`, which the adapter gathers through `cdDirs`). Else
265// a failed `cd x; rm -rf y` (or `rm -rf x; cd x; rm -rf y`) would delete `./y`, so the directory becomes UNKNOWN_DIR
266// after the break. Within a chain
267// (`cd x && rm -rf y`) a failed `cd` stops what follows, so it always holds there.
268// A payload whose text the outer shell expands first (`bash -c "…$X…"`, an ssh command, a heredoc: `p.expanded`)
269// resolves `$NAME` and `$PWD` with the outer values (`frozen`), not with its own assignments and `cd`s; one that also
270// escapes a `\$` for its own shell (`bash -c "D=x; rm -rf \$D"`) resolves neither, so its variables ask.
271// `cds`, when given, collects every directory a `cd` reaches, each taken to exist (see cdDirs).
272const CHAIN_BREAK = new Set([';', '\n', '&']);
273const ABS_DIR = /^(?:\/|[a-z]:[\\/])/i;
274function levelSegments(a, baseDir, remote, env, outer = {}, frozen = null, cds = null, ranBefore = false) {
275  const vars = { ...outer };
276  let ran = ranBefore;
277  let dir = baseDir;
278  let followCd = true;
279  let sure = true;
280  let chainSure = !ranBefore;
281  const out = [];
282  for (const [i, cmd] of a.commands.entries()) {
283    const seg = cmd.code.replace(/\d*[<>]+(&\d+|\s*[^\s;&|]+)/g, ' ');
284    if (i > 0 && CHAIN_BREAK.has(cmd.sep)) {
285      if (dir !== baseDir && (!sure || cmd.sep === '&')) dir = UNKNOWN_DIR;
286      chainSure = !ran;
287    }
288    // The first subshell, group, `||` or conditional: a later `cd` is not followed, nor an earlier one past it.
289    if (followCd && (BRANCH_SEP.has(cmd.sep) || BRANCH_CODE.test(cmd.code))) {
290      followCd = false;
291      if (dir !== baseDir) dir = UNKNOWN_DIR;
292    }
293    const pwd = frozen ? frozen.pwd : ABS_DIR.test(dir) && PLAIN_PATH.test(dir) ? dir : undefined;
294    const snapshot = frozen ? frozen.vars : { ...vars };
295    const resolve = (p) => expandPath(p, snapshot, env, pwd);
296    const ranHere = ran;
297    if (assign(cmd, vars, env, pwd)) {
298      // nothing runs
299    } else if (CD.test(seg)) {
300      const m = followCd && !SUBSHELL_SEP.has(cmd.sep) && !SUBSHELL_SEP.has(a.commands[i + 1]?.sep) && PLAIN_CD.exec(seg);
301      const to = m && (m[1] === undefined ? env.HOME : resolve(m[1]));
302      dir = to && PLAIN_DIR.test(to) && !/^~/.test(to) ? inDir(dir, to) : UNKNOWN_DIR;
303      if (cds && dir !== UNKNOWN_DIR) cds.push(dir);
304      sure = chainSure = chainSure && dir !== UNKNOWN_DIR && (cds !== null || env.dirs?.has(dir) === true);
305      // bash resets $PWD and $OLDPWD on every cd.
306      delete vars.PWD;
307      delete vars.OLDPWD;
308    } else {
309      chainSure = false;
310      ran = true;
311      if (!cmd.payloads.some((p) => p.remote && p.kind !== 'subst')) out.push({ seg: gitPlain(seg), code: cmd.code, words: cmd.words, redirs: cmd.redirs, dir: gitDirs(seg).map(resolve).reduce(inDir, dir), remote, resolve });
312    }
313    for (const p of cmd.payloads) {
314      const inner = p.expanded === 'escaped' ? { vars: {}, pwd: undefined } : p.expanded ? { vars: snapshot, pwd } : frozen;
315      out.push(...(p.remote ? levelSegments(p.analysis, REMOTE_DIR, true, {}, {}, inner) : levelSegments(p.analysis, dir, remote, env, snapshot, inner, cds, ranHere)));
316    }
317  }
318  return out;
319}
320const prepared = (command) => analysis(expandLoops(command).replace(SIMPLE_QUOTED, '$1'));
321function segments(command, env = {}) {
322  return levelSegments(prepared(command), '', false, env);
323}
324// Every directory a `cd` in the command reaches on this machine, as the engine names it (relative to the cwd when it
325// does not start with `/`): the adapter checks which exist and passes them back as `env.dirs`.
326export function cdDirs(command, env = {}) {
327  const cds = [];
328  levelSegments(prepared(command), '', false, env, {}, null, cds);
329  return [...new Set(cds)];
330}
331const plainArgs = (rest) => rest.split(/\s+/).filter((t) => t && !t.startsWith('-'));
332function findRoots(rest) {
333  const roots = [];
334  for (const t of rest.split(/\s+/).filter(Boolean)) {
335    if (/^[-(!\\]/.test(t)) break;
336    roots.push(t);
337  }
338  return roots;
339}
340
341// Targets of every recursive delete (`rm -r`, `Remove-Item -Recurse`, `find -delete`, `git worktree remove --force`)
342// in a command, as written, each in its directory. Quoted, $(...) or glob targets come through as-is and are judged
343// unsafe later; a delete with no target (`xargs rm -rf`) yields NO_TARGET, so it never rides on a safe one. A find's
344// targets are its start paths, before the first option or expression.
345const REMOVAL = new Set(['rm', 'find', 'worktree-rm']);
346export function removalEntries(command, rules, env = {}) {
347  const risky = (id) => riskyRe(rules.risky.find((r) => r.id === id));
348  const removes = [...REMOVAL].map(risky);
349  const entries = [];
350  for (const { seg, dir, remote, resolve } of segments(command, env)) {
351    if (!removes.some((re) => re.test(seg))) continue;
352    const found = risky('find').test(seg)
353      ? findRoots(/\bfind\b(.*)$/i.exec(seg)?.[1] ?? '')
354      : plainArgs(/\b(?:rm|remove-item|ri|del|erase|rd|rmdir|worktree\s+remove)\b(.*)$/i.exec(seg)?.[1] ?? '');
355    entries.push(...(found.length ? found : [NO_TARGET]).map((t) => ({ target: inDir(dir, resolve(t)), remote })));
356  }
357  return entries;
358}
359export const removalTargets = (command, rules, env) => removalEntries(command, rules, env).map((e) => e.target);
360// Targets on this machine: facts (git, gh) are never gathered for another host's or a container's paths.
361const localTargets = (command, rules, env) => removalEntries(command, rules, env).filter((e) => !e.remote).map((e) => e.target);
362
363// Branches every `git branch -D` in a command deletes: { dir, name }, `dir` from `cd`/`git -C` ('' for the cwd). One
364// on another host or in a container has dir REMOTE_DIR: no fact is gathered for it, so it never rides on a safe one.
365export function branchTargets(command, rules, env = {}) {
366  const re = riskyRe(rules.risky.find((r) => r.id === 'branch-delete'));
367  return segments(command, env)
368    .filter(({ seg }) => re.test(seg))
369    .flatMap(({ seg, dir, remote }) => {
370      const names = plainArgs(/\bbranch\b(.*)$/.exec(seg)?.[1] ?? '');
371      return (names.length ? names : [NO_TARGET]).map((name) => ({ dir: remote ? REMOTE_DIR : dir, name }));
372    });
373}
374
375// A temp path: strictly under /tmp, /private/tmp, the macOS per-user temp dir or a Windows user's AppData\Local\Temp.
376// $TMPDIR, ${TMPDIR}, $env:TEMP, %TEMP% (and TMP) resolve through `env`; an unset variable, other glob, `~`, `.`/`..`
377// or shell syntax is not resolvable statically, so it is not a temp path. The temp dir itself is not either.
378// ponytail: `/tmp/link/` with a trailing slash follows a planted symlink; resolve with realpath if that matters.
379const TEMP_VAR = /^(?:\$\{?(TMPDIR|TEMP|TMP)\}?|\$env:(TEMP|TMP)|%(TEMP|TMP)%)(?=[\\/]|$)/i;
380// Under /var/folders only the per-user temp dir counts (`/var/folders/<a>/<b>/T/…`, its `C/` caches do not), and under
381// /tmp not the sockets of tmux, X11 and ssh agents or macOS's own folders.
382const UNIX_TEMP = /^\/(?:private\/)?(?:tmp\/(?!(?:tmux-|\.X11-unix|\.ICE-unix|ssh-|com\.apple\.|launchd-))[^/]|var\/folders\/[^/]+\/[^/]+\/T\/[^/])/;
383const WINDOWS_TEMP = /^[a-z]:\/users\/[^/]+\/appdata\/local\/temp\/[^/]/i;
384const norm = (p) => p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
385export function isTempPath(target, env = {}) {
386  const path = target.replace(TEMP_VAR, (m, a, b, c) => env[(a || b || c).toUpperCase()] || m);
387  // A `*`/`?` glob in the last segment stays inside its parent, so it is temp when the parent is. A leading dot is
388  // refused: an old bash expands `.*` to `..`.
389  const glob = /^(.+)[\\/]([\w*?-][\w.*?-]*)$/.exec(path);
390  if (glob && /[*?]/.test(glob[2])) return isTempPath(glob[1], env);
391  if (/^~|[*?[\]{}$%`"'(),;<>|&!]/.test(path)) return false;
392  const p = norm(path);
393  if (p.split('/').some((s) => s === '.' || s === '..')) return false;
394  const tempDirs = ['TMPDIR', 'TEMP', 'TMP'].map((k) => env[k] && norm(env[k]).toLowerCase());
395  // Strictly under the environment's own temp dir counts too, whatever its layout (Linux, a CI runner).
396  const underTemp = tempDirs.some((d) => d && p.toLowerCase().startsWith(`${d.replace(/\/$/, '')}/`));
397  return (UNIX_TEMP.test(p) || WINDOWS_TEMP.test(p) || underTemp) && !tempDirs.includes(p.toLowerCase());
398}
399
400// Git facts for one removal target, through an injected `run(argv, cwd?) -> stdout | null` (null on failure or timeout).
401// Undefined when the target is not the root of a linked worktree (the main checkout never qualifies).
402// "Safe" = clean tree and either every commit on some remote ref (pushed, or merged into origin's default branch)
403// or HEAD contained in the head of a merged PR of its branch (squash merge with the remote branch deleted).
404export async function worktreeFact(path, run) {
405  const sh = (argv, cwd) => Promise.resolve().then(() => run(argv, cwd)).catch(() => null);
406  const git = (...args) => sh(['git', '-C', path, ...args]);
407  const rp = await git('rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-prefix', '--show-toplevel');
408  if (rp == null) return undefined;
409  const [gitDir, commonDir, prefix = '', top] = rp.split(/\r?\n/);
410  if (!gitDir || gitDir === commonDir || prefix !== '') return undefined;
411  const [all, ahead] = await Promise.all([git('status', '--porcelain', '--ignored'), git('rev-list', '--count', 'HEAD', '--not', '--remotes')]);
412  if (all == null || ahead == null || !/^\d+\s*$/.test(ahead)) return { safe: false, why: 'git check failed' };
413  // Ignored files are normally regenerable, but a local secret (`.env`, `*.key`) exists nowhere else.
414  const lines = all.split(/\r?\n/).filter(Boolean);
415  const status = lines.filter((l) => !l.startsWith('!!')).join('\n');
416  const secrets = lines.filter((l) => l.startsWith('!!')).map((l) => l.slice(3).replace(/\/$/, '')).filter((f) => f.split('/').some((seg) => NEVER_REGEN.test(seg)));
417  if (secrets.length) return { safe: false, why: `ignored secrets: ${secrets.slice(0, 3).join(', ')}` };
418  const n = Number(ahead);
419  if (n && !status.trim()) {
420    const merged = await mergedPrHead(git, sh, top || path, (await git('branch', '--show-current'))?.trim());
421    if (merged === true) return { safe: true, why: '' };
422    return { safe: false, why: `${unpushed(n)} (${merged})` };
423  }
424  const why = [n && unpushed(n), status.trim() && 'dirty tree'].filter(Boolean);
425  return { safe: !why.length, why: why.join(', ') };
426}
427const unpushed = (n) => `${n} unpushed commit${n > 1 ? 's' : ''}`;
428
429// True when `rev` is contained in the head of a merged PR of `branch` (gh, run in the repo at `cwd`), else why not.
430// Any gh failure (missing, unauthenticated, offline, timeout) is a reason, so the delete asks.
431async function mergedPrHead(git, sh, cwd, branch, rev = 'HEAD') {
432  if (!branch) return 'no branch';
433  const oid = await sh(['gh', 'pr', 'list', '--head', branch, '--state', 'merged', '--json', 'headRefOid', '--limit', '1', '--jq', '.[0].headRefOid // empty'], cwd);
434  if (oid == null) return 'gh check failed';
435  if (!oid.trim()) return 'no merged PR';
436  return (await git('merge-base', '--is-ancestor', rev, oid.trim())) != null || `${rev === 'HEAD' ? 'HEAD' : 'tip'} not in the merged PR`;
437}
438
439const PLAIN_PATH = /^[\w./\\@+=:,-]+$/;
440// Worktree facts for every non-temp removal target of a command, checked in parallel: { [target]: { safe, why } }.
441export async function worktreeFacts(command, env, rules, run) {
442  const targets = [...new Set(localTargets(command, rules, env))].filter((t) => !isTempPath(t, env) && PLAIN_PATH.test(t));
443  const facts = await Promise.all(targets.map((t) => worktreeFact(t, run)));
444  return Object.fromEntries(targets.map((t, i) => [t, facts[i]]).filter(([, f]) => f));
445}
446
447// Facts for one branch a `git branch -D` deletes, in the repo at `dir`: safe when every commit on it is on a remote
448// ref, or its tip is in the head of a merged PR of that name (squash merge, remote branch deleted).
449export async function branchFact(dir, name, run) {
450  if (!/^[\w./@+-]+$/.test(name) || name.startsWith('-')) return { safe: false, why: 'branch name not plain' };
451  const sh = (argv, cwd) => Promise.resolve().then(() => run(argv, cwd)).catch(() => null);
452  const git = (...args) => sh(['git', '-C', dir || '.', ...args]);
453  const ahead = await git('rev-list', '--count', name, '--not', '--remotes');
454  if (ahead == null || !/^\d+\s*$/.test(ahead)) return { safe: false, why: 'git check failed' };
455  const n = Number(ahead);
456  if (!n) return { safe: true, why: '' };
457  const top = (await git('rev-parse', '--show-toplevel'))?.trim();
458  const merged = top ? await mergedPrHead(git, sh, top, name, name) : 'git check failed';
459  return merged === true ? { safe: true, why: '' } : { safe: false, why: `${unpushed(n)} (${merged})` };
460}
461const branchKey = ({ dir, name }) => `${dir}|${name}`;
462
463// Tags a `git tag -f <name> [<commit>]` or `git tag -d <name>…` moves or deletes: { dir, name }. Any other release step
464// (creating a tag, an annotated or signed one, a tag named like a version, release-please, npm version, another
465// host's) has no name, so it never rides on a local one.
466const VERSION_TAG = /^v?\d+(?:\.\d+)+/i;
467export function tagTargets(command, rules, env = {}) {
468  const re = new RegExp(rules.skills.find((r) => r.id === 'release').match, 'i');
469  return segments(command, env)
470    .filter(({ seg }) => re.test(seg))
471    .flatMap(({ words, dir, remote }) => {
472      let vals = commandWords(words).map((w) => w.val);
473      if (vals[0] === 'git') {
474        let k = 1;
475        while (vals[k]?.startsWith('-')) k += GIT_VALUE.has(vals[k]) ? 2 : 1;
476        vals = vals.slice(k);
477      }
478      const opts = vals.slice(1).filter((v) => v.startsWith('-'));
479      const names = vals.slice(1).filter((v) => !v.startsWith('-'));
480      const del = opts.length > 0 && opts.every((o) => /^(?:-d|--delete)$/.test(o));
481      const force = opts.length > 0 && opts.every((o) => /^(?:-f|--force)$/.test(o));
482      const named = del ? names : force ? names.slice(0, 1) : [];
483      const ok = vals[0] === 'tag' && !remote && named.length > 0 && named.every((n) => /^[\w./@+-]+$/.test(n) && !VERSION_TAG.test(n));
484      return ok ? named.map((name) => ({ dir, name })) : [{ dir, name: NO_TARGET }];
485    });
486}
487// A tag no remote has: `git remote` lists none, or `git ls-remote --tags <remote> refs/tags/<name>` is empty on each.
488// A failed lookup (offline, auth) counts as on a remote.
489export async function tagFact(dir, name, run) {
490  const sh = (argv) => Promise.resolve().then(() => run(argv)).catch(() => null);
491  const remotes = await sh(['git', '-C', dir || '.', 'remote']);
492  if (remotes == null) return { local: false, why: 'git check failed' };
493  const found = await Promise.all(remotes.split(/\r?\n/).filter(Boolean).map((r) => sh(['git', '-C', dir || '.', 'ls-remote', '--tags', r, `refs/tags/${name}`])));
494  if (found.some((f) => f == null)) return { local: false, why: 'ls-remote failed' };
495  return found.every((f) => !f.trim()) ? { local: true, why: '' } : { local: false, why: 'on a remote' };
496}
497// Tag facts for every tag a command moves or deletes: { [dir|name]: { local, why } }.
498export async function tagFacts(command, rules, run, env = {}) {
499  const targets = [...new Map(tagTargets(command, rules, env).map((t) => [branchKey(t), t])).values()].filter((t) => t.name !== NO_TARGET && PLAIN_PATH.test(t.dir || '.'));
500  const facts = await Promise.all(targets.map((t) => tagFact(t.dir, t.name, run)));
501  return Object.fromEntries(targets.map((t, i) => [branchKey(t), facts[i]]));
502}
503// Never with a push, a release or a `gh api` write in the same command: `git tag -f foo && git push origin foo`
504// publishes it, and a ref write can sit in a quoted field or an `--input` file the text does not show (#208).
505const GH_API_WRITE = /\bgh\s+api\b[^|;&]*(?:(?:-X|--method)\s*=?\s*(?:POST|PATCH|PUT|DELETE)\b|(?:^|\s)(?:-[fF]|--field|--raw-field|--input)(?:[\s=]|\S)|refs\/tags)/i;
506const localTagsOnly = (command, rules, tags, env) => {
507  const targets = tagTargets(command, rules, env);
508  if (segments(command, env).some(({ seg }) => /\bgit\b[^|;&]*\bpush\b|\bgh\s+release\b/.test(seg) || GH_API_WRITE.test(seg.replace(/['"\\]/g, '')))) return false;
509  return targets.length > 0 && targets.every((t) => tags[branchKey(t)]?.local === true);
510};
511
512// Branch refs every `gh api -X DELETE …/git/refs/heads/<b>` deletes: { dir, repo, name }, `repo` null for gh's
513// `{owner}/{repo}` placeholder, which gh fills from the repo in `dir`. The endpoint is gh's first operand, read from
514// the words with every option's value skipped (a `--jq` or `--template` value naming a ref is not the endpoint). Any
515// other GitHub delete (a repo, a tag, a release), another host's (`--hostname`, GH_HOST) or another repo's (GH_REPO
516// set in the command) has no name, so it never rides on a safe ref.
517const GH_API_VALUE = new Set(['-X', '--method', '-H', '--header', '-f', '--raw-field', '-F', '--field', '-q', '--jq', '-t', '--template', '--input', '-p', '--preview', '--cache', '--hostname']);
518const REF_ENDPOINT = /^\/?repos\/([\w.-]+\/[\w.-]+|\{owner\}\/\{repo\})\/git\/refs\/heads\/(\S+)$/;
519function refEndpoint(words) {
520  const vals = commandWords(words).map((w) => w.val);
521  if (vals[0] !== 'gh' || vals[1] !== 'api') return null;
522  let endpoint = null;
523  for (let k = 2; k < vals.length; k++) {
524    const v = vals[k];
525    if (v === '--') {
526      endpoint ??= vals[k + 1] ?? null;
527      break;
528    }
529    if (/^--hostname(?:=|$)/.test(v)) return null;
530    if (GH_API_VALUE.has(v)) k++;
531    else if (!v.startsWith('-')) endpoint ??= v;
532  }
533  return endpoint && REF_ENDPOINT.exec(endpoint);
534}
535// A variable a command sets anywhere (prefix, export, env, a plain assignment), at any level on this machine.
536const setsVariable = (command, re) =>
537  units(analysis(command)).some(({ cmd, remote }) => !remote && cmd.words.some((w, i, ws) => re.test(w.val) && ws.slice(0, i).every((x) => /^[A-Za-z_]\w*=/.test(x.val) || DECLARE_WORDS.test(x.val))));
538export function refTargets(command, rules, env = {}) {
539  const re = riskyRe(rules.risky.find((r) => r.id === 'gh-delete'));
540  const elsewhere = setsVariable(command, /^GH_(?:REPO|HOST)=/);
541  return segments(command, env)
542    .filter(({ seg }) => re.test(seg))
543    .map(({ words, dir, remote }) => {
544      const m = !remote && !elsewhere && refEndpoint(words);
545      return m ? { dir, repo: m[1].startsWith('{') ? null : m[1], name: m[2] } : { dir: remote ? REMOTE_DIR : dir, repo: null, name: NO_TARGET };
546    });
547}
548const refKey = ({ dir, repo, name }) => `${dir}|${repo ?? ''}|${name}`;
549
550// Facts for one branch ref a `gh api` DELETE removes on GitHub: safe when the ref's tip is the head of a merged PR of
551// that branch, so the merge holds every commit the ref has. The default branch never is.
552export async function refFact({ dir, repo, name }, run) {
553  if (!/^[\w./@+-]+$/.test(name) || name.startsWith('-')) return { safe: false, why: 'branch name not plain' };
554  if (/^(?:main|master)$/.test(name)) return { safe: false, why: 'the default branch' };
555  const sh = (argv) => Promise.resolve().then(() => run(argv, dir || undefined)).catch(() => null);
556  const [oid, tip] = await Promise.all([
557    sh(['gh', 'pr', 'list', ...(repo ? ['-R', repo] : []), '--head', name, '--state', 'merged', '--json', 'headRefOid', '--limit', '1', '--jq', '.[0].headRefOid // empty']),
558    sh(['gh', 'api', `repos/${repo ?? '{owner}/{repo}'}/git/ref/heads/${name}`, '--jq', '.object.sha']),
559  ]);
560  if (oid == null || tip == null) return { safe: false, why: 'gh check failed' };
561  if (!oid.trim()) return { safe: false, why: 'no merged PR' };
562  return oid.trim() === tip.trim() ? { safe: true, why: '' } : { safe: false, why: 'its tip is not the head of the merged PR' };
563}
564
565// Ref facts for every branch ref a command deletes through `gh api`, in parallel: { [dir|repo|name]: { safe, why } }.
566export async function refFacts(command, rules, run, env = {}) {
567  const targets = [...new Map(refTargets(command, rules, env).map((r) => [refKey(r), r])).values()].filter((r) => r.name !== NO_TARGET && PLAIN_PATH.test(r.dir || '.'));
568  const facts = await Promise.all(targets.map((r) => refFact(r, run)));
569  return Object.fromEntries(targets.map((r, i) => [refKey(r), facts[i]]));
570}
571
572// Branch facts for every `git branch -D` of a command, in parallel: { [dir|name]: { safe, why } }.
573export async function branchFacts(command, rules, run, env = {}) {
574  const targets = [...new Map(branchTargets(command, rules, env).map((b) => [branchKey(b), b])).values()].filter((b) => b.name !== NO_TARGET && PLAIN_PATH.test(b.dir || '.'));
575  const facts = await Promise.all(targets.map((b) => branchFact(b.dir, b.name, run)));
576  return Object.fromEntries(targets.map((b, i) => [branchKey(b), facts[i]]));
577}
578
579// Build output and caches a tool regenerates. A delete of one passes when git ignores it (`git check-ignore`, so it
580// is inside a repo and untracked): nothing there is lost that a build or install cannot make again. `results` only
581// under `evals/` (`claude plugin eval` output). No glob, no secret-looking segment, ever.
582const REGENERABLE = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.terraform', 'dist', 'build', '.next', 'target', 'coverage', '.turbo', '.cache', '.wrangler', '.vitest-cache', '.vite', '.gradle', '.parcel-cache', '.svelte-kit', '.nuxt']);
583const NEVER_REGEN = /^\.env|secret|\.key$/i;
584export function regenerableName(target) {
585  const segs = target.replace(/\\/g, '/').replace(/\/+$/, '').split('/');
586  const last = segs.at(-1);
587  if (!PLAIN_PATH.test(target) || segs.some((s) => NEVER_REGEN.test(s))) return false;
588  return REGENERABLE.has(last) || (last === 'results' && segs.at(-2) === 'evals');
589}
590
591// { [target]: true } for every removal target that is a regenerable name git ignores.
592export async function regenFacts(command, env, rules, run) {
593  const targets = [...new Set(localTargets(command, rules, env))].filter((t) => !isTempPath(t, env) && regenerableName(t));
594  const ignored = await Promise.all(
595    targets.map((t) => {
596      const p = t.replace(/\\/g, '/').replace(/\/+$/, '');
597      const i = p.lastIndexOf('/');
598      const argv = ['git', '-C', i < 0 ? '.' : p.slice(0, i) || '/', 'check-ignore', '-q', '--', p.slice(i + 1)];
599      return Promise.resolve().then(() => run(argv)).then((out) => out != null, () => false);
600    }),
601  );
602  return Object.fromEntries(targets.filter((t, i) => ignored[i]).map((t) => [t, true]));
603}
604
605// A removal is exempt when it has targets and every one is a temp path, a safe worktree or regenerable output.
606// A remote target (ssh, a container) passes only as a temp path: no fact about this machine applies to it.
607function safeRemoval(command, rules, env, worktrees, regenerable) {
608  const entries = removalEntries(command, rules, env);
609  return entries.length > 0 && entries.every(({ target: t, remote }) => isTempPath(t, env) || (!remote && (worktrees[t]?.safe === true || regenerable[t] === true)));
610}
611
612// GUARD_UNATTENDED as a command sets it for this machine (`export GUARD_UNATTENDED=1`, a prefix
613// `GUARD_UNATTENDED=1 cmd`, `env GUARD_UNATTENDED=1 cmd`): true when on (1, true, on), false for any other value or
614// `unset GUARD_UNATTENDED`, null when the command does not set it. Only words in assignment position count, so a commit message or an echo
615// naming it does not; another host's or a container's setting is not this session's.
616export function unattendedSet(command) {
617  let set = null;
618  for (const { cmd, remote } of units(analysis(command))) {
619    if (remote) continue;
620    const words = cmd.words.map((w) => w.val);
621    if (words[0] === 'unset' && words.includes('GUARD_UNATTENDED')) set = false;
622    for (let k = DECLARE_WORDS.test(words[0] ?? '') ? 1 : 0; k < words.length && /^[A-Za-z_]\w*=/.test(words[k]); k++) {
623      const m = /^GUARD_UNATTENDED=([\s\S]*)$/.exec(words[k]);
624      if (m) set = /^(?:1|true|on)$/i.test(m[1]);
625    }
626  }
627  return set;
628}
629
630// The pipeline a risky rule fires on, at whatever level of the command it sits: { raw, depth, siblings, host, top }.
631// The whole command when no single pipeline matches (an opaque payload).
632function riskyStep(command, rule) {
633  const all = pipelines(command, { injected: !rule.skipInjected });
634  const hit = all.find((p) => (rule.pipeline ? pipelineHit(rule, p) : riskyRe(rule).test(p.code) && (!rule.raw || new RegExp(rule.raw, 'i').test(p.raw))));
635  return hit ?? { raw: command.trim(), depth: 0, siblings: all[0]?.top ?? 1, host: undefined, top: all[0]?.top ?? 1 };
636}
637
638// What the risky part does, in plain words, with the targets the guard resolved.
639const shown = (t) => (t.startsWith(`${REMOTE_DIR}/`) ? t.slice(REMOTE_DIR.length + 1) : t.startsWith(`${UNKNOWN_DIR}/`) ? `${t.slice(UNKNOWN_DIR.length + 1)} (after a cd the guard cannot follow)` : t === NO_TARGET ? 'paths it cannot read (from a pipe or xargs)' : t);
640function summary(command, rule, step, rules, env) {
641  const list = (xs) => [...new Set(xs)].join(', ');
642  if (REMOVAL.has(rule.id)) {
643    const targets = removalTargets(command, rules, env);
644    if (rule.id === 'worktree-rm') return `Force-removes worktree${targets.length > 1 ? 's' : ''} ${list(targets.map(shown))}`;
645    return `Deletes${targets.length > 1 ? ` ${targets.length} paths` : ''} recursively: ${list(targets.map(shown))}`;
646  }
647  if (rule.id === 'branch-delete') {
648    const names = branchTargets(command, rules, env).map((b) => (b.dir === REMOTE_DIR ? `${b.name} (on another host)` : b.dir ? `${b.name} (in ${b.dir})` : b.name));
649    return `Deletes branch${names.length > 1 ? 'es' : ''} ${list(names.length ? names : ['(unread)'])}`;
650  }
651  if (rule.id === 'force') {
652    const args = plainArgs(/\bpush\b(.*)$/.exec(gitPlain(step.raw))?.[1] ?? '');
653    return `Force-pushes${args.length ? ` ${args.join(' ')}` : ' the current branch'}`;
654  }
655  return rule.say ?? rule.why[0].toUpperCase() + rule.why.slice(1);
656}
657
658// A branch delete is exempt when every branch it names is safe (merged or pushed).
659function safeBranchDelete(command, rules, branches, env) {
660  const targets = branchTargets(command, rules, env);
661  return targets.length > 0 && targets.every((b) => branches[branchKey(b)]?.safe === true);
662}
663
664// A GitHub delete is exempt when every one in the command is a branch ref whose tip a merged PR holds.
665function safeRefDelete(command, rules, refs, env) {
666  const targets = refTargets(command, rules, env);
667  return targets.length > 0 && targets.every((r) => refs[refKey(r)]?.safe === true);
668}
669
670// Shell writes to a file the agent could make with its edit tool, which the post-edit lint and the docs and skill hints
671// never see: `sed -i`, `perl -pi`, inline Python or Node that writes a file, and `echo`, `printf`, `cat` (a heredoc
672// too), `tee` or PowerShell's Set-Content, Add-Content, Out-File into a file. A write to /dev/*, an fd, `$null` or a
673// temp path is not an edit.
674const IN_PLACE = /(?:^|[\s;&|(])(?:sed\s(?:[^;&|\n]*\s)?(?:-[Enrsuz]*[iI]|--in-place)|perl\s(?:[^;&|\n]*\s)?-[0-9aelnpsw]*i)/;
675const PY_INLINE = /(?:^|[\s;&|(/])python[\d.]*(?:\s+-\w+)*\s+(?:-c\b|-?\s*<<)/;
676const PY_WRITE = /\bopen\(((?:[^()]|\([^()]*\))*?),\s*(?:mode\s*=\s*)?['"][rbtU]*[wax+]|\bPath\(((?:[^()]|\([^()]*\))*?)\)\s*\.\s*(?:write_text|write_bytes|open\(\s*['"][rbtU]*[wax+])/g;
677const NODE_INLINE = /(?:^|[\s;&|(/])(?:(?:node|bun)(?:\s+-[\w-]+)*\s+(?:-e\b|--eval\b|-p\b|--print\b|-?\s*<<)|deno\s+eval\b)/;
678const NODE_WRITE = /\b(?:writeFileSync|appendFileSync|writeFile|appendFile|createWriteStream)\(\s*((?:[^(),]|\([^()]*\))+?)\s*[,)]/g;
679const WRITERS = /^(?:echo|printf|cat|write-output)$/i;
680const PS_WRITERS = /^(?:set-content|add-content|out-file)$/i;
681const NOT_A_FILE = /^(?:\/dev\/\w+|\$null|nul)$/i;
682const IN_PLACE_CMD = /^(?:sudo\s+)?(sed|perl)\b(.*)$/;
683// The files an in-place `sed -i`/`perl -pi` edits: its operands without the script (the first operand, unless the
684// script came with -e/-f). An empty list means "unread", which counts as an edit.
685function inPlaceFiles(tool, rest) {
686  const words = rest.trim().split(/\s+/).filter(Boolean);
687  const operands = [];
688  let script = false;
689  for (let i = 0; i < words.length; i++) {
690    const w = words[i];
691    if (/^-(?:e|f|-expression|-file)$/.test(w) || (tool === 'perl' && /^-[\w]*e$/.test(w))) (script = true), i++;
692    else if (!w.startsWith('-') && w !== '""') operands.push(w);
693  }
694  return script ? operands : operands.slice(1);
695}
696export function shellEdit(command, env = {}) {
697  const code = executable(command);
698  const fileTarget = (t) => !NOT_A_FILE.test(t) && !isTempPath(t, env);
699  for (const [inline, write] of [[PY_INLINE, PY_WRITE], [NODE_INLINE, NODE_WRITE]]) {
700    if (!inline.test(code)) continue;
701    for (const m of command.matchAll(write)) {
702      const lit = /^\s*(['"`])([^'"`$]*)\1\s*$/.exec(m[1] ?? m[2]);
703      if (!lit || fileTarget(expandPath(lit[2], {}, env))) return true;
704    }
705  }
706  // Every write target in the directory and with the variables of its own command (`cd /tmp/x && cat > f`).
707  for (const { code: c, dir, remote, resolve } of segments(command, env)) {
708    if (remote) continue;
709    const at = (t) => inDir(dir, resolve(t));
710    const seg = c.replace(/\d*[<>]&\d+/g, ' ').replace(/&>/g, '>').replace(/<<-?\S*/g, ' ').trim();
711    const inPlace = IN_PLACE.test(` ${seg}`) && IN_PLACE_CMD.exec(seg);
712    if (inPlace) {
713      const files = inPlaceFiles(inPlace[1], inPlace[2]);
714      if (!files.length || files.map(at).some(fileTarget)) return true;
715      continue;
716    }
717    const words = seg.split(/\s+/).filter((w) => !/^\w+=/.test(w));
718    const cmd = words[0] === 'sudo' ? words[1] : words[0];
719    if (!cmd) continue;
720    let targets = [];
721    if (WRITERS.test(cmd)) targets = [...seg.matchAll(/(?:^|\s)1?>>?\|?\s*([^\s;&|<>]+)/g)].map((m) => m[1]);
722    else if (/^tee$/i.test(cmd)) targets = plainArgs(seg.replace(/^.*?\btee\b/i, ''));
723    else if (PS_WRITERS.test(cmd)) {
724      const rest = seg.replace(new RegExp(`^.*?\\b${cmd}\\b`, 'i'), '');
725      const named = /\s-(?:file)?path\s+(\S+)|\s-literalpath\s+(\S+)/i.exec(rest);
726      targets = named ? [named[1] ?? named[2]] : plainArgs(rest).slice(0, 1);
727    }
728    if (targets.map(at).some(fileTarget)) return true;
729  }
730  return false;
731}
732
733// A shell write, in a factory run, to a protected path (#213): `protect.globs` (`rules.factory.protectedPaths`, always,
734// plus FACTORY_PROTECTED_PATHS), each over paths relative to the repo root (`protect.root`; the cwd when unknown). Writes are output redirections of any command, `tee`, `sed -i`/`perl -pi`, Set-Content, Add-Content,
735// Out-File and Tee-Object, the operands of rm, Remove-Item and their aliases, the sources and destination of mv and
736// Move-Item, and the destination of cp and Copy-Item (a destination also as `<dest>/<source name>`, a directory it may
737// be). Removing or moving away a directory above a protected path counts (`rm -rf .github`, `rm -rf .`), along a
738// glob's literal leading segments only (`**/*.tf` puts no directory above it). Letter case is ignored, as macOS and
739// Windows do. A target the shell globs is matched as a glob (`.g*` may name `.github`; a leading `*` or `?` never names
740// a dot file), a brace list is expanded (one group a level, 64 words at most), and one whose start is a variable or a
741// `cd` not followed is matched by the rest of its path anywhere (`$ROOT/.github/x`). A default glob is waived for
742// `protect.exempt` (`.factory/runs/*/*/**`: files inside a run directory) when the normalised path is spelled out;
743// a glob in FACTORY_PROTECTED_PATHS (`protect.globs`) is never waived. A command that may write fails
744// closed past the limits (`rules.factory.protectedLimits`: targets, characters, milliseconds) or when the check throws,
745// since a hook that times out or emits nothing lets the command through; globs past the limits are ignored. Returns { target } (the first protected target as written), { unsure } (a limit it hit) or null.
746const PROTECT_RM = /^(?:rm|rmdir|unlink|remove-item|ri|del|erase|rd)$/;
747const PROTECT_MV = /^(?:mv|move-item|mi|move)$/;
748const PROTECT_CP = /^(?:cp|copy-item|ci|copy|cpi)$/;
749const PROTECT_PS = /^(?:set-content|add-content|out-file|tee-object|sc|ac)$/;
750const SEG_GLOB = /[*?[]/;
751const posixPath = (p) => p.replace(/\\/g, '/');
752function normalizeSegments(path) {
753  const out = [];
754  for (const s of path.split('/')) {
755    if (!s || s === '.') continue;
756    if (s === '..') {
757      if (!out.length || out.at(-1) === '..') out.push('..');
758      else out.pop();
759    } else out.push(s);
760  }
761  return out;
762}
763// The repo-relative segments of a target: { segs } from the root, { segs, suffix: true } when its start is unknown,
764// or null outside the repo.
765function repoSegments(target, dir, protect) {
766  const p = posixPath(target);
767  const cwdRel = protect.root && protect.cwd ? relativeTo(posixPath(protect.root), posixPath(protect.cwd)) : [];
768  // A first segment that is only a variable or `~user` is dropped; one with text after the variable stays (`$P.github`).
769  if (/^[$~%`]/.test(p)) {
770    const segs = normalizeSegments(p);
771    return { segs: /^(?:\$(?:\{[^}]*\}|\w+|\([^)]*\))|%\w+%|~[\w.-]*|`[^`]*`)$/.test(segs[0] ?? '') ? segs.slice(1) : segs, suffix: true };
772  }
773  // An absolute target is where it says, whatever directory a `cd` reached.
774  if (dir === UNKNOWN_DIR && !ABS_DIR.test(p)) return { segs: normalizeSegments(p), suffix: true };
775  const full = dir && !ABS_DIR.test(p) ? `${posixPath(dir).replace(/\/+$/, '')}/${p}` : p;
776  if (ABS_DIR.test(full)) {
777    if (!protect.root) return { segs: normalizeSegments(full), suffix: true };
778    return relativeTo(posixPath(protect.root), full.replace(/^\/(?=[a-z]:)/i, ''), true);
779  }
780  const segs = normalizeSegments([...cwdRel, ...full.split('/')].join('/'));
781  // Above the root (`..`) holds the whole repo; beside it (`../other`) is outside.
782  if (segs[0] === '..') return segs.every((x) => x === '..') ? { segs: [] } : null;
783  return { segs };
784}
785// `path` relative to `root` as segments (case ignored); with `wrap`, { segs }, `{ segs: [] }` for a directory above the
786// root (it holds the whole repo), or null when outside.
787function relativeTo(root, path, wrap = false) {
788  const r = normalizeSegments(root);
789  const s = normalizeSegments(path);
790  const same = (a, b) => a.slice(0, Math.min(a.length, b.length)).every((x, i) => x.toLowerCase() === b[i].toLowerCase());
791  const inside = r.length <= s.length && same(r, s);
792  if (!wrap) return inside ? s.slice(r.length) : [];
793  return inside ? { segs: s.slice(r.length) } : same(s, r) ? { segs: [] } : null;
794}
795// Whether two globs may name one word: the text before the first wildcard of each, and after the last, agree (`*.log`
796// never meets `*.tf`).
797function globsMayMeet(a, b) {
798  const head = (g) => g.slice(0, g.search(/[*?[]/)).toLowerCase();
799  const tail = (g) => g.slice(Math.max(g.lastIndexOf('*'), g.lastIndexOf('?'), g.lastIndexOf(']')) + 1).toLowerCase();
800  const agree = (x, y, end) => (end ? x.endsWith(y) || y.endsWith(x) : x.startsWith(y) || y.startsWith(x));
801  return agree(head(a), head(b), false) && agree(tail(a), tail(b), true);
802}
803// Whether one target segment may be one protected-pattern segment.
804// A variable may hold a leading dot, so only a written `*`, `?` or `[` at the start keeps a dot file out.
805function segMayMatch(pat, seg) {
806  const glob = seg.replace(/\$(?:\{[^}]*\}|\w+|\([^)]*\))|%\w+%|`[^`]*`/g, '*');
807  if (SEG_GLOB.test(glob)) {
808    if (/^[*?[]/.test(seg) && pat.startsWith('.')) return false;
809    return SEG_GLOB.test(pat) ? globsMayMeet(glob, pat) : globMatches(glob, pat.toLowerCase());
810  }
811  return SEG_GLOB.test(pat) ? globMatches(pat, glob.toLowerCase()) : pat.toLowerCase() === glob.toLowerCase();
812}
813// Whether pattern segments `pat` match target segments `segs`; `above` lets the target end above a match, when every
814// segment it has matched one literal leading segment of the pattern (no `**` took any). `tick` counts the work.
815function patternHits(pat, segs, above, suffix, tick) {
816  const memo = new Map();
817  const f = (i, j) => {
818    const key = i * (segs.length + 1) + j;
819    if (memo.has(key)) return memo.get(key);
820    tick();
821    let r;
822    if (j === segs.length) r = (above && i === j && !pat.slice(0, i).includes('**')) || pat.slice(i).every((p) => p === '**');
823    else if (i === pat.length) r = false;
824    else if (pat[i] === '**') r = f(i + 1, j) || f(i, j + 1);
825    else r = segMayMatch(pat[i], segs[j]) && f(i + 1, j + 1);
826    memo.set(key, r);
827    return r;
828  };
829  if (!suffix) return f(0, 0);
830  // An unknown start (a variable, a `cd` not followed, an absolute path with no known root): any tail of the path.
831  return segs.some((_, j) => f(0, j));
832}
833// The words a brace list expands to, innermost list first, a range kept as a glob and a group that is no list left as
834// written; null past `limit` words or with more than `limit` groups. Iterative: a word of thousands of groups must not
835// overflow the stack.
836function expandBraces(text, limit = 64) {
837  if ((text.match(/\{/g) ?? []).length > limit) return null;
838  const done = [];
839  const todo = [text];
840  while (todo.length) {
841    const word = todo.pop();
842    const m = [...word.matchAll(/\{([^{}]*)\}/g)].find((g) => /,|\.\./.test(g[1]));
843    if (!m) {
844      done.push(word);
845      continue;
846    }
847    const head = word.slice(0, m.index);
848    const tail = word.slice(m.index + m[0].length);
849    for (const part of m[1].includes(',') ? m[1].split(',') : ['*']) todo.push(head + part + tail);
850    if (done.length + todo.length > limit) return null;
851  }
852  return done;
853}
854const LIMITED = Symbol('limited');
855// A command that may write at all: an output redirection or a writer's name. Only such a command can fail closed.
856const MAY_WRITE = /[>]|(?:^|[^\w-])(?:tee|sed|perl|rm|rmdir|unlink|mv|cp|move|copy|set-content|add-content|out-file|tee-object|remove-item|move-item|copy-item|ri|mi|ci|cpi|del|erase|rd|sc|ac)(?![\w-])/i;
857export function protectedWrite(command, protect, env = {}, limits = { globs: 256, targets: 512, chars: 1024, ms: 2000 }) {
858  // A setting past the limits is cut, not failed: globs over `chars` are dropped, then all past `globs` (defaults first).
859  // `protect.defaults` come first and may be exempted (`protect.exempt`); `protect.globs` are explicit and never are.
860  const segsOf = (g) => normalizeSegments(posixPath(g).replace(/\/$/, '/**'));
861  const defaults = protect.defaults ?? [];
862  const tagged = [...defaults, ...protect.globs].map((g, i) => ({ g, base: i < defaults.length })).filter((x) => x.g.length <= limits.chars).slice(0, limits.globs);
863  const globs = tagged.map((x) => ({ segs: segsOf(x.g), base: x.base }));
864  const exempt = (protect.exempt ?? []).filter((g) => g.length <= limits.chars).map(segsOf);
865  const until = Date.now() + limits.ms;
866  let work = 0;
867  let targets = 0;
868  const tick = () => {
869    if ((++work & 255) === 0 && Date.now() > until) throw LIMITED;
870  };
871  // One write target as written: counted, and checked for length, once however many spellings it has.
872  const count = (w) => {
873    if (++targets > limits.targets || w.val.length > limits.chars) throw LIMITED;
874  };
875  const hits = (target, dir, resolve, above) => {
876    const at = repoSegments(resolve(target), dir, protect);
877    if (at === null) return false;
878    const hit = globs.filter((g) => patternHits(g.segs, at.segs, above, at.suffix, tick));
879    if (hit.some((g) => !g.base)) return true;
880    // A default glob is waived for the run's own files: the whole normalised path is spelled out (no wildcard, variable
881    // or brace, which could stand for `..`, nor a segment ending in a dot or space, which Windows strips: `12\.. ` is `12\..`) and starts at the root, so `.factory/runs/../config.yaml` is no exemption.
882    return hit.length > 0 && !(at.segs.every((x) => !/[*?[\]{}$`%~]|[. ]$/.test(x)) && exempt.some((g) => patternHits(g, at.segs, false, false, tick)));
883  };
884  try {
885    const target = scanWrites(command, env, count, hits);
886    return target === null ? null : { target };
887  } catch (e) {
888    // Any failure (a limit, a stack overflow, a bug) denies a command that may write, and lets the rest through.
889    if (!MAY_WRITE.test(command)) return null;
890    return { unsure: e === LIMITED ? `more than ${limits.targets} write targets, one over ${limits.chars} characters, a brace list of more than 64 words, or over ${limits.ms} ms to check` : 'the check failed' };
891  }
892}
893// PowerShell and cmd keep a backslash (`.github\ci.yml`) that a POSIX shell reads as an escape: both spellings count. A
894// brace list the shell expands counts as each word it yields; past the limit, the command fails closed.
895function spellings(w) {
896  const out = [w.val, ...(w.raw.includes('\\') ? [w.raw.replace(/['"]/g, '')] : [])];
897  if (!/\{/.test(w.val) || !dynamicText(w.raw)) return out;
898  const words = expandBraces(w.val);
899  if (!words) throw LIMITED;
900  return [...out, ...words];
901}
902const POSIX_MOVERS = /^(?:cp|mv)$/;
903function scanWrites(command, env, count, hits) {
904  for (const { words, redirs = [], dir, remote, resolve } of segments(command, env)) {
905    if (remote) continue;
906    for (const w of redirs) {
907      if (NOT_A_FILE.test(w.val)) continue;
908      count(w);
909      for (const t of spellings(w)) if (!NOT_A_FILE.test(t) && hits(t, dir, resolve, false)) return t;
910    }
911    // Past reserved words (`then rm …`, `{ cp …`, `! tee …`), as the hidden-gh check reads them.
912    const cw = commandWords(dropReserved(words));
913    if (!cw.length) continue;
914    const name = cw[0].val.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
915    const inPlace = (name === 'sed' && cw.some((w) => /^(?:-[Enrsuz]*[iI]|--in-place)/.test(w.val))) || (name === 'perl' && cw.some((w) => /^-[0-9aelnpsw]*i/.test(w.val)));
916    if (!(name === 'tee' || inPlace || PROTECT_PS.test(name) || PROTECT_RM.test(name) || PROTECT_MV.test(name) || PROTECT_CP.test(name))) continue;
917    // Only a writer's words are counted and expanded: a read-only command never fails closed.
918    for (const w of cw.slice(1)) if (!w.val.startsWith('-')) count(w);
919    const args = cw.slice(1).flatMap(spellings);
920    const operands = args.filter((a) => !a.startsWith('-'));
921    let writes = [];
922    let removes = [];
923    if (name === 'tee') writes = operands;
924    else if (inPlace) {
925      const files = inPlaceFiles(name, args.join(' '));
926      writes = files.length ? files : operands;
927    } else if (PROTECT_PS.test(name)) {
928      const k = args.findIndex((a) => /^-(?:path|filepath|literalpath)$/i.test(a));
929      const end = args.findIndex((x, i) => i > k && x.startsWith('-'));
930      writes = k >= 0 ? args.slice(k + 1, end < 0 ? undefined : end) : operands.slice(0, 2);
931    } else if (PROTECT_RM.test(name)) removes = operands;
932    else {
933      let dest = null;
934      const sources = [];
935      for (let k = 0; k < args.length; k++) {
936        const a = args[k];
937        // GNU: `--target-directory` or any prefix of it down to `--t`, glued with `=` or not; a short-flag cluster
938        // ending in `t` (`-rt DIR`) or holding it with its value glued (`-tDIR`, `-rtDIR`).
939        const long = /^(--[a-z-]+)(?:=(.*))?$/.exec(a);
940        const short = POSIX_MOVERS.test(name) && /^-([A-Za-z]*?)t(.*)$/.exec(a);
941        if (long && long[1].length >= 3 && '--target-directory'.startsWith(long[1])) dest = long[2] ?? args[++k] ?? null;
942        else if (short && /^[a-su-zA-Z]*$/.test(short[1])) dest = short[2] || (args[++k] ?? null);
943        else if (/^-destination$/i.test(a)) dest = args[++k] ?? null;
944        else if (!a.startsWith('-')) sources.push(a);
945      }
946      if (dest === null) dest = sources.pop() ?? null;
947      if (dest !== null) writes = [dest, ...sources.map((src) => `${dest}/${posixPath(src).replace(/\/+$/, '').split('/').pop()}`)];
948      if (PROTECT_MV.test(name)) removes = sources;
949    }
950    for (const t of writes) if (hits(t, dir, resolve, false)) return t;
951    for (const t of removes) if (hits(t, dir, resolve, true)) return t;
952  }
953  return null;
954}
955
956// `python`: the Python manager of the repo in cwd ('uv', 'poetry', 'pipenv', 'pip'; null for none), for deny rules with `when`.
957// A whole command that only prints files (`cat f`, `head -n 5 f`, `sed -n 1,20p f`): the harness's read tool does it better.
958const SHELL_READ = /^\s*(?:(?:cat|head|tail|less|more)(?:\s+-[\w-]+(?:\s+\d+)?)*|sed\s+-n\s+\S+)(?:\s+[^\s|;&<>()`$]+)+\s*$/;
959// A command that only prints project files: `cat`, `head`, `tail`, `less`, `more` or `sed -n <range>p` of plain relative
960// paths (no leading `/` or `~`, no `:` or `\` (a Windows drive, a PowerShell `env:`), no `..`, no empty or `.` segment), optionally after one `cd <dir> &&` of the same form.
961// No quotes, variables, globs, redirections or pipes, no sed script but a line range, and no path that looks like a
962// secret (`.env*`, keys, tokens, `.git/config`, Terraform state, ...). The guard allows these outright, so a harness
963// prompt never stalls a read; anything outside the working tree goes through the harness's own permission flow.
964const PLAIN_TOKEN = /^[\w./@%+=:,~-]+$/;
965const SED_RANGE = /^(?:\d+|\$)(?:,(?:\d+|\$))?p$/;
966const RELATIVE_PATH = (p) => !/^[/~]|[:\\]/.test(p) && p.split('/').every((x, i, all) => (x !== '' || i === all.length - 1) && x !== '.' && x !== '..');
967const SECRET_PATH = /(?:^|\/)(?:\.git\/config|[^/]*\.tf(?:state|vars)(?:\.[\w.-]+)?|[^/]*\.env|auth\.json|[^/]*history|\.env(?:rc)?(?:\.[\w.-]+)?|\.netrc|\.npmrc|\.pypirc|\.pgpass|\.git-credentials|id_[\w-]+|[^/]*(?:secret|token|credential|passw|private)[^/]*|[^/]*\.(?:pem|key|p12|pfx|kdbx|keychain(?:-db)?|jks|asc|gpg)|kubeconfig|[^/]*\.kubeconfig)$|(?:^|\/)\.(?:ssh|gnupg|aws|azure|kube|docker|password-store|config\/gh|config\/gcloud)(?:\/|$)|^\/(?:dev|proc|sys)\//i;
968export function pureRead(command) {
969  const m = /^\s*(?:cd\s+(\S+)\s*&&\s*)?(\S+)((?:[ \t]+\S+)+)\s*$/.exec(command);
970  if (!m || (m[1] && (!PLAIN_TOKEN.test(m[1]) || !RELATIVE_PATH(m[1]) || SECRET_PATH.test(m[1])))) return false;
971  const [tool, words] = [m[2], m[3].trim().split(/\s+/)];
972  if (!words.every((w) => PLAIN_TOKEN.test(w))) return false;
973  let paths;
974  if (tool === 'sed') {
975    if (words[0] !== '-n' || !SED_RANGE.test(words[1] ?? '')) return false;
976    paths = words.slice(2);
977  } else if (/^(?:cat|head|tail|less|more)$/.test(tool)) {
978    paths = words.filter((w, i) => !w.startsWith('-') && !(/^\d+$/.test(w) && /^-[nc]$/.test(words[i - 1] ?? '')));
979  } else return false;
980  // Judged joined to the `cd` dir, so `cd .config && cat gh/hosts.yml` is `.config/gh/hosts.yml`.
981  return paths.length > 0 && paths.every((p) => !p.startsWith('-') && RELATIVE_PATH(p) && !SECRET_PATH.test((m[1] ? `${m[1].replace(/\/+$/, '')}/${p}` : p).replace(/\/+$/, '')));
982}
983
984const hint = (rule) => `Skill hint (${rule.why}): load ${rule.skills.map((s) => `\`${s}\``).join(' or ')} if this task is that work.`;
985
986// One plain `gh` command, read without a shell tokenizer: its words, or null for anything else. Single-quoted spans are
987// literal in POSIX shells, and so is a double-quoted one without `\`, `$` or a backtick, or `{name}`; each stays one word. Any
988// shell syntax left after that (a separator, redirection, substitution, glob, escape, comment, leftover quote, newline)
989// gives null, so a misread line is never taken for a plain command.
990function plainGh(command) {
991  if (command.includes('\0')) return null;
992  const spans = [];
993  const rest = command.replace(/'([^']*)'|"([^"\\$`]*)"/g, (_, s, d) => `\0${spans.push(s ?? d) - 1}\0`)
994    // `{owner}`: braces around one name, no `,` or `..`, are literal (gh fills them in), unlike `{a,b}`.
995    .replace(/\{[A-Za-z_]\w*\}/g, (b) => `\0${spans.push(b) - 1}\0`);
996  if (/[;&|<>(){}$`\\#\n\r'"*?[\]]/.test(rest)) return null;
997  const words = rest.trim().split(/[ \t]+/).map((w) => w.replace(/\0(\d+)\0/g, (_, i) => spans[i]));
998  return words[0] === 'gh' ? words.slice(1) : null;
999}
1000
1001const API_LONG = { '--hostname': true, '--cache': true, '--header': true, '--input': true, '--jq': true, '--method': true, '--preview': true, '--raw-field': true, '--field': true, '--template': true, '--include': false, '--paginate': false, '--silent': false, '--slurp': false, '--verbose': false };
1002const API_SHORT = { X: '--method', H: '--header', f: '--raw-field', F: '--field', q: '--jq', t: '--template', p: '--preview' };
1003
1004// What a plain `gh` command does: 'read' (`issue|pr view|list|status` without `--web`; `gh api` with GET and no field,
1005// `--input` or unknown flag), { write: <endpoint> } for another `gh api` call, else null.
1006function ghAction(args) {
1007  const positional = [];
1008  for (let i = 0; i < args.length && positional.length < 2; i++) {
1009    if (['-R', '--repo', '--hostname'].includes(args[i])) i++;
1010    else if (!args[i].startsWith('-')) positional.push(args[i]);
1011  }
1012  if (['issue', 'pr'].includes(positional[0]) && ['view', 'list', 'status'].includes(positional[1])) {
1013    return args.some((a) => /^--web(?:=|$)|^-[A-Za-z]*w/.test(a)) ? null : 'read';
1014  }
1015  if (positional[0] !== 'api') return null;
1016  let method = 'GET', endpoint = null, body = false;
1017  const take = (flag, value) => {
1018    if (flag === '--method') method = value.toUpperCase();
1019    else if (['--raw-field', '--field', '--input'].includes(flag)) body = true;
1020  };
1021  for (let i = args.indexOf('api') + 1; i < args.length; i++) {
1022    const a = args[i];
1023    if (a.startsWith('--')) {
1024      const [flag, glued] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, null];
1025      if (!(flag in API_LONG)) return null;
1026      if (API_LONG[flag]) take(flag, glued ?? args[++i] ?? '');
1027    } else if (a.startsWith('-') && a.length > 1) {
1028      // pflag shorthand: `-i` is the one boolean; the first value flag takes the rest (`-XPOST`, `-f=k=v`) or the next word.
1029      const m = /^-i*([A-Za-z])=?(.*)$/.exec(a);
1030      if (!m || !(m[1] in API_SHORT)) { if (!/^-i+$/.test(a)) return null; continue; }
1031      take(API_SHORT[m[1]], m[2] !== '' || a.includes('=') ? m[2] : args[++i] ?? '');
1032    } else if (endpoint === null) endpoint = a;
1033    else return null;
1034  }
1035  return method === 'GET' && !body ? 'read' : { write: endpoint ?? '' };
1036}
1037
1038// Subcommands that cannot send a body the guard cannot read to a labels or issues endpoint (`gh api` can).
1039const GH_SUBCOMMANDS = new Set(['issue', 'pr', 'repo', 'run', 'workflow', 'search', 'release', 'project', 'auth', 'label', 'browse', 'status']);
1040
1041// How many times a line runs `gh` with `api` or a subcommand the guard cannot read literally (`gh $'a'pi`, `gh "$X"`,
1042// `gh>/dev/null api`, zsh `=gh`, `gh` as a parameter's value or default: `${X:=gh}`, `${X:-gh}`). Read with quotes and
1043// backslashes removed, as the shell joins them; `-R`/`--repo` may come first.
1044function opaqueGhCount(flat) {
1045  let count = 0;
1046  for (const m of flat.matchAll(/(?:^|[\s;&|(){}`!=])(?:[^\s;&|(){}`!=]*\/)?gh(?:\.exe)?(?=[\s;&|(){}`<>]|$)/g)) {
1047    // A bounded window after each match keeps a line of many `gh`s linear; a word cut off there reads as opaque.
1048    const start = m.index + m[0].length;
1049    const words = flat.slice(start, start + 512).split(/[\s;&|()<>`]+/).filter(Boolean);
1050    if (start + 512 < flat.length) words.pop();
1051    let i = 0;
1052    while (words[i] === '-R' || words[i] === '--repo' || /^--repo=/.test(words[i] ?? '')) i += words[i].includes('=') ? 1 : 2;
1053    if (!GH_SUBCOMMANDS.has(words[i])) count++;
1054  }
1055  return count + (flat.match(/\$\{[^{}]*(?<![\w.])gh(?:\.exe)?\}/gi)?.length ?? 0);
1056}
1057const opaqueGh = (flat) => opaqueGhCount(flat) > 0;
1058
1059// Whether every `gh` call opaqueGh counts in a line is a plain `gh` command (plainGh) of its own at the top level, in a
1060// line the shell reader splits cleanly: `echo …; gh api user; git remote -v` is three commands, not an opaque `gh` (G26).
1061// The calls must add up to the line's: one in a heredoc body (which may be run as a script later) or in a substitution
1062// or payload is not a plain command. A line that defines `gh` (a function or an alias) never passes.
1063function plainGhCalls(command) {
1064  const flat = command.replace(/['"\\]/g, '');
1065  if (/(?:^|[\s;&|(){}`])(?:function\s+gh(?:\.exe)?\b|gh(?:\.exe)?\s*\(\s*\)|alias\s+(?:-\S+\s+)*gh=)/.test(flat)) return false;
1066  const a = analyze(command);
1067  if (a.opaque) return false;
1068  let found = 0;
1069  for (const { cmd, depth } of units(a)) {
1070    const n = opaqueGhCount(cmd.raw.replace(/['"\\]/g, ''));
1071    if (!n) continue;
1072    // A read only: a plain `gh <word>` may be an alias or extension that writes.
1073    const args = plainGh(cmd.raw);
1074    if (n !== 1 || depth !== 0 || cmd.payloads.length || args === null || ghAction(args) !== 'read') return false;
1075    found++;
1076  }
1077  return found === opaqueGhCount(flat);
1078}
1079
1080// Whether one plain `gh` command sends a body the guard cannot read to a labels or issues endpoint.
1081function unreadableBody(args) {
1082  const action = ghAction(args);
1083  if (!action || action === 'read') return action !== 'read';
1084  const path = action.write.split('?')[0].replace(/\/+$/, '');
1085  return !(/\/comments(?:\/\d+)?$/.test(path) || (path && !/(?:^|\/)(?:labels|issues)(?:\/|$)/.test(path)));
1086}
1087
1088// Whether one glob alternative matches `target` (lowercase), ignoring case as macOS and Windows do. Linear: the pattern is
1089// read once into tokens, runs of `*` collapse, and a table over the short target replaces regex backtracking (a word of
1090// a hundred stars hung the hook). A POSIX class (`[[:alpha:]]`) is not parsed: it counts as a match.
1091function globMatches(pattern, target) {
1092  const tokens = [];
1093  for (let i = 0; i < pattern.length; i++) {
1094    const c = pattern[i];
1095    if (c === '*') {
1096      if (tokens.at(-1) !== '*') tokens.push('*');
1097    } else if (c === '?') tokens.push((ch) => true);
1098    else if (c === '[' && pattern.indexOf(']', i + 2) > 0) {
1099      const end = pattern.indexOf(']', i + 2);
1100      let body = pattern.slice(i + 1, end);
1101      if (body.includes('[:') || body.includes('[=') || body.includes('[.')) return true;
1102      const negate = /^[!^]/.test(body);
1103      if (negate) body = body.slice(1);
1104      const has = (ch) => {
1105        for (let k = 0; k < body.length; k++) {
1106          if (body[k + 1] === '-' && k + 2 < body.length) {
1107            if (ch >= body[k] && ch <= body[k + 2]) return true;
1108            k += 2;
1109          } else if (body[k] === ch) return true;
1110        }
1111        return false;
1112      };
1113      // Either case may name the file: a class matches when it takes either one, a negated class when it lacks either.
1114      tokens.push((ch) => (negate ? !(has(ch) && has(ch.toUpperCase())) : has(ch) || has(ch.toUpperCase())));
1115      i = end;
1116    } else {
1117      const lower = c.toLowerCase();
1118      tokens.push((ch) => ch === lower);
1119    }
1120  }
1121  let dp = Array.from({ length: target.length + 1 }, (_, j) => j === 0);
1122  for (const t of tokens) {
1123    const next = new Array(target.length + 1).fill(false);
1124    if (t === '*') {
1125      let any = false;
1126      for (let j = 0; j <= target.length; j++) next[j] = any = any || dp[j];
1127    } else for (let j = 0; j < target.length; j++) next[j + 1] = dp[j] && t(target[j]);
1128    dp = next;
1129  }
1130  return dp[target.length];
1131}
1132
1133// Whether a glob or brace word (basename) could name `gh` or `gh.exe`. One level of `{a,b}` is expanded, at most 64
1134// alternatives; `{a..b}`, a second brace group or a word over 256 characters counts as a match.
1135function globMatchesGh(word) {
1136  const name = word.split('/').pop();
1137  if (name.length > 256) return true;
1138  const brace = /\{([^{}]*)\}/.exec(name);
1139  let alts = [name];
1140  if (brace) {
1141    const parts = brace[1].split(',');
1142    if (brace[1].includes('..') || /[{}]/.test(name.slice(brace.index + brace[0].length)) || parts.length > 64) return true;
1143    alts = parts.map((p) => name.slice(0, brace.index) + p + name.slice(brace.index + brace[0].length));
1144  }
1145  return alts.some((alt) => globMatches(alt, 'gh') || globMatches(alt, 'gh.exe'));
1146}
1147
1148// Whether one word may name `gh` without spelling it: a name that decodes to `gh` (or `gh.exe`, trailing dots allowed,
1149// which Windows drops) only with case folded, an ANSI-C string read or a trailing dot, or a glob or brace list that
1150// could match it.
1151function hidesGh(w) {
1152  const spelled = w.raw.replace(/['"\\]/g, '');
1153  if (/^gh(?:\.exe)?\.*$/i.test(w.val.split('/').pop()) && !/(?:^|\/)gh(?:\.exe)?$/.test(spelled)) return true;
1154  return !/[$`]/.test(w.raw) && dynamicText(w.raw) && globMatchesGh(w.val);
1155}
1156
1157// A command, at any depth, that the shell may resolve to `gh` though its text does not spell `gh` (hidesGh). The text
1158// checks above never see such a call. The words at command position are read, past reserved words (`then`, `{`, `!`,
1159// `coproc`): each known wrapper and the command it runs, its options read with that wrapper's own table
1160// (`sudo -E -u x …`, `timeout -s KILL 5 nice …`; a string a wrapper runs, env -S or watch, is a unit of its own). A unit
1161// with a wrapper option the table does not know is read whole. A name the shell builds from a variable (`$GH`) stays
1162// out of sight, and so does a command behind a wrapper the reader does not know (`busybox`, `chronic`).
1163export function ghHidden(command) {
1164  const plain = (w) => !w.val.startsWith('-') && !/^[A-Za-z_]\w*=/.test(w.val);
1165  for (const { cmd } of units(analyze(command))) {
1166    const words = dropReserved(cmd.words);
1167    const run = wrapped(words);
1168    const read = run.unsure ? words : run.heads.map((i) => words[i]);
1169    if (read.some((w) => plain(w) && hidesGh(w))) return true;
1170  }
1171  return false;
1172}
1173
1174// A plain `gh` command whose real command the guard cannot see: an alias or extension (`gh <name>` that is not one of gh's
1175// own commands), or one that makes, swaps or runs one (`gh alias set|import`, `gh extension install|upgrade|remove|exec`). Other lines are left to
1176// touchesApprovalLabel, which denies a non-plain `gh` with an unknown subcommand already.
1177export function ghUnseen(command, rules) {
1178  const args = plainGh(command.replace(/\\\r?\n/g, ''));
1179  if (!args) return false;
1180  const positional = [];
1181  for (let i = 0; i < args.length && positional.length < 2; i++) {
1182    if (['-R', '--repo', '--hostname'].includes(args[i])) i++;
1183    else if (!args[i].startsWith('-')) positional.push(args[i]);
1184  }
1185  const [cmd, sub] = positional;
1186  if (cmd === undefined) return false;
1187  if (!rules.factory.ghCommands.includes(cmd)) return true;
1188  return (cmd === 'alias' && ['set', 'import'].includes(sub)) || (['extension', 'extensions', 'ext'].includes(cmd) && ['install', 'upgrade', 'remove', 'exec'].includes(sub));
1189}
1190
1191// Fail closed while a factory run is active. A command that names the label, approval or takeover (in any quoting or case, as
1192// GitHub compares), is denied unless it is one plain `gh` read; adding and removing alike are the owner's. A `gh` command with a
1193// body the guard cannot read (`--input`, a `key=@file` field) is denied unless it is one plain `gh api` call writing to
1194// a `/comments` endpoint or a path outside `/labels` and `/issues`; so is any other line running `gh api` (opaqueGh) unless
1195// each such call in it is a plain command (plainGhCalls). A label the shell builds (`$L`) stays out of sight
1196// (docs/guard.md).
1197export function touchesApprovalLabel(command, label) {
1198  const want = String(label).trim().toLowerCase();
1199  if (!want) return false;
1200  // A backslash-newline is a line continuation: the shell joins the word (`agent-o\⏎k`).
core/otel.mjs 98 lines
1// Guard decisions as OTLP/HTTP JSON log records, for the OTel collector the harness already exports to.
2// Off unless OTEL_EXPORTER_OTLP_ENDPOINT is set; the headers (Bearer, Cloudflare Access) come from
3// OTEL_EXPORTER_OTLP_HEADERS and are never logged. Every failure is swallowed: a decision never waits on telemetry.
4// Claude Code and Copilot CLI strip OTEL_* from a command hook's environment, so each variable falls back
5// to a GUARD_-prefixed copy (GUARD_OTEL_EXPORTER_OTLP_ENDPOINT, ...) that the launching shell exports.
6// GUARD_OTEL=off (also 0, false) sends nothing, whatever the endpoint: probes and `make test` set it.
7const TIMEOUT_MS = 2000
8
9export const otelOff = (env) => /^(off|0|false)$/i.test(String(env?.GUARD_OTEL ?? '').trim())
10// True when a record would be sent: an endpoint is set (OTEL_* or its GUARD_ copy) and the switch is not off.
11export const otelEnabled = (env) => !otelOff(env) && Boolean(env?.OTEL_EXPORTER_OTLP_ENDPOINT || env?.GUARD_OTEL_EXPORTER_OTLP_ENDPOINT)
12
13// `k=v,k=v` (percent-encoded values), as OTEL_EXPORTER_OTLP_HEADERS and OTEL_RESOURCE_ATTRIBUTES spell them.
14export function parsePairs(str) {
15  const out = {}
16  for (const part of String(str ?? '').split(',')) {
17    const i = part.indexOf('=')
18    if (i < 1) continue
19    try {
20      out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim())
21    } catch {
22      // malformed escape: skip the pair
23    }
24  }
25  return out
26}
27
28// `owner/name` from a git remote URL, read by form: a `scheme://` URL's path (Azure's `<project>/_git/<repo>` gives
29// `project/repo`), or the path after the colon of the scp form `[user@]host:owner/name`. At least two path segments,
30// the last two taken, `.git` dropped; never a host, port, user or token. Null for a local path, `file://`, a relative
31// remote or anything else, since their segments are local directory names.
32const SLUG_SEGMENT = /^(?!\.+$)[\w.-]+$/
33export function repoSlug(url) {
34  const s = String(url ?? '').trim()
35  let path
36  if (/^[a-z][\w+.-]*:\/\//i.test(s)) {
37    if (/^file:/i.test(s)) return null
38    try {
39      path = new URL(s).pathname
40    } catch {
41      return null
42    }
43  } else {
44    const m = /^(?:[^@\s/:]+@)?([^\s/:]+):(?!\/)(\S+)$/.exec(s)
45    if (!m || /^[a-z]$/i.test(m[1])) return null
46    path = m[2]
47  }
48  let segs = path.replace(/\/+$/, '').replace(/\.git$/, '').split('/').filter(Boolean)
49  const azure = segs.indexOf('_git')
50  if (azure > 0) segs = [segs[azure - 1], segs[azure + 1]]
51  segs = segs.slice(-2)
52  return segs.length === 2 && segs.every((x) => SLUG_SEGMENT.test(x ?? '')) ? segs.join('/') : null
53}
54
55const attr = (key, value) => ({ key, value: { stringValue: String(value) } })
56
57// `config` is the raw env values: { endpoint, headers, resource }. `service` other than guard is a harness the
58// guard reports for (opencode, pi: no native telemetry), so it owns service.name and service.namespace.
59export function logRequest({ endpoint, headers, resource }, event, attrs, now = Date.now(), service = 'guard') {
60  if (!endpoint) return null
61  const res = service === 'guard' ? { 'service.name': 'guard', ...parsePairs(resource) } : { ...parsePairs(resource), 'service.name': service, 'service.namespace': service }
62  return {
63    url: `${endpoint.replace(/\/+$/, '')}/v1/logs`,
64    headers: { 'content-type': 'application/json', ...parsePairs(headers) },
65    body: JSON.stringify({
66      resourceLogs: [
67        {
68          resource: { attributes: Object.entries(res).map(([k, v]) => attr(k, v)) },
69          scopeLogs: [
70            {
71              scope: { name: 'guard' },
72              logRecords: [
73                {
74                  timeUnixNano: String(BigInt(now) * 1_000_000n),
75                  severityText: 'INFO',
76                  body: { stringValue: event },
77                  attributes: [attr('event.name', event), ...Object.entries(attrs).filter(([, v]) => v != null).map(([k, v]) => attr(k, v))],
78                },
79              ],
80            },
81          ],
82        },
83      ],
84    }),
85  }
86}
87
88export async function emit(env, event, attrs, fetchFn = fetch, service = 'guard') {
89  try {
90    if (otelOff(env)) return
91    const v = (k) => env[k] || env[`GUARD_${k}`]
92    const req = logRequest({ endpoint: v('OTEL_EXPORTER_OTLP_ENDPOINT'), headers: v('OTEL_EXPORTER_OTLP_HEADERS'), resource: v('OTEL_RESOURCE_ATTRIBUTES') }, event, attrs, Date.now(), service)
93    if (req) await fetchFn(req.url, { method: 'POST', headers: req.headers, body: req.body, signal: AbortSignal.timeout(TIMEOUT_MS) })
94  } catch {
95    // collector unreachable or slow: the decision stands
96  }
97}
98
core/rules.mjs 386 lines
1// Rule data for the shared engine. Plain module (not JSON) so a Claude Code mod can import it.
2export const rules = {
3  "skills": [
4    {
5      "id": "commit",
6      "match": "\\bgit\\s+commit(?![\\w-])",
7      "skills": [
8        "conventional-commits",
9        "ship"
10      ],
11      "why": "commit messages must be Conventional Commits"
12    },
13    {
14      "id": "pr",
15      "match": "\\bgh\\s+(pr\\s+(create|merge|ready)|stack)\\b|\\bglab\\s+mr\\s+(create|merge)\\b|\\baz\\s+repos\\s+pr\\s+(create|update)\\b",
16      "skills": [
17        "github-workflow",
18        "repo-standard",
19        "ship",
20        "land"
21      ],
22      "why": "PRs land through auto-merge or the merge queue"
23    },
24    {
25      "id": "ruleset",
26      // Writes only: an explicit write method, or body fields (gh api then defaults to POST).
27      "match": "\\bgh\\s+api\\b(?=.*\\b(rulesets|protection)\\b)(?=.*(\\s(-X|--method)[\\s=]*(POST|PUT|PATCH|DELETE)\\b|\\s(-f|-F|--field|--raw-field|--input)\\b))",
28      "skills": [
29        "github-workflow"
30      ],
31      "why": "rulesets and branch protection have a house policy"
32    },
33    {
34      "id": "worktree",
35      "match": "\\bgit\\s+worktree\\s+add\\b",
36      "skills": [
37        "wt",
38        "host-hygiene"
39      ],
40      "why": "worktrees are one per task and removed on merge"
41    },
42    {
43      "id": "release",
44      // What cuts a release, not what reads one: release-please-config.json, a release-please workflow or
45      // `git tag -l` pass. Bare `git tag` lists, so a tag name or a creating flag must follow.
46      "match": "\\brelease-please\\s+(release-pr|github-release|bootstrap)\\b|\\bnpm\\s+version\\b|\\bgit\\s+tag\\s+(?!(-l|--list|-n\\d*|--contains|--no-contains|--points-at|--merged|--no-merged|-v|--verify|--sort|--format|--column|-i|--ignore-case)\\b)[^\\s;&|]",
47      "skills": [
48        "release-please",
49        "semver"
50      ],
51      "why": "versions and tags are owned by release automation"
52    },
53    // `remind`: a once-per-session hint instead of a deny, for skills the agent rarely loads on its own.
54    {
55      "id": "pr-fix",
56      "remind": true,
57      "match": "\\bgh\\s+pr\\s+view\\b.*--comments\\b|\\bgh\\s+api\\b.*\\bpulls/\\d+/(comments|reviews)\\b|\\bgh\\s+run\\s+view\\b.*--log-failed\\b|\\bglab\\s+mr\\s+(view|note)\\b.*--comments\\b",
58      "skills": [
59        "pr-fix",
60        "land"
61      ],
62      "why": "review comments and failing CI are fixed in one pass, then pushed"
63    },
64    {
65      "id": "pytest",
66      "remind": true,
67      "match": "\\bpytest\\b",
68      "skills": [
69        "python-testing"
70      ],
71      "why": "tests follow the house pytest conventions"
72    },
73    {
74      "id": "go-test",
75      "remind": true,
76      "match": "\\bgo\\s+test\\b",
77      "skills": [
78        "go-testing"
79      ],
80      "why": "tests follow the house Go test conventions"
81    },
82    {
83      "id": "go-lint",
84      "remind": true,
85      "match": "\\bgo\\s+(vet|build)\\b|\\bgolangci-lint\\b",
86      "skills": [
87        "go-development"
88      ],
89      "why": "build and lint follow the house Go conventions"
90    }
91  ],
92  // Edits whose path matches: a once-per-session hint toward the skill.
93  "files": [
94    {
95      "id": "docs",
96      "match": "(^|/)(docs/.+\\.md|README\\.md|AGENTS\\.md)$",
97      "skills": [
98        "docs-sync"
99      ],
100      "why": "docs, runbooks, README and AGENTS.md follow the change in the same PR"
101    }
102  ],
103  // Subagent spawns that bypass the typed agents: a once-per-session hint per case, never a deny. `tools` are the
104  // spawn tools whose input names the agent type and model: Claude Code `Agent` (`Task` before 2.1.63, `subagent_type`),
105  // Copilot CLI `task` and Codex `spawn_agent` (`agent_type`), VS Code `runSubagent` (`agentName`). `typed` are the agents that carry their own
106  // model and effort, as `plugin:name`. A bare name matches too (Codex and opencode drop the plugin), except a Codex
107  // built-in role (`codexBuiltin`) on `spawn_agent`; `aliases` are names an agent registers under elsewhere (Codex
108  // `dev-tools-worker`). `overrides` are the spawn values the `subagent-orchestration` and `ticket-run` routing tables
109  // allow them. `untyped` are the generic agents (Codex `default`).
110  "routing": {
111    "tools": ["agent", "task", "spawn_agent", "runsubagent"],
112    "typed": ["dev-tools:scout", "dev-tools:worker", "dev-tools:driver", "dev-tools:reviewer", "dev-tools:escalate", "api:api-reviewer", "factory:verifier"],
113    "aliases": { "dev-tools-worker": "dev-tools:worker" },
114    "codexBuiltin": ["default", "worker", "explorer"],
115    "overrides": { "factory:verifier": { "model": "sonnet|opus", "effort": ["medium", "high"] }, "dev-tools:reviewer": { "effort": ["medium", "high"] } },
116    "untyped": ["general-purpose", "claude", "task", "default"],
117    "plan": ["plan"],
118    "judgementModel": "opus|fable|\\bsol\\b|\\bastra\\b",
119    "skill": "subagent-orchestration"
120  },
121  "deny": [
122    {
123      "id": "pip",
124      "when": "uv",
125      // At command position only (D13): `which pip`, `ls .venv/bin/pip` or `git log --grep pip` pass, and so do
126      // read-only `pip list`/`pip freeze`; `uv pip …` is uv's own.
127      "match": "(?:^|[;&|(\\n]\\s*|\\bsudo\\s+|/)(?:pip[\\d.]*\\s+(?:install|uninstall|download|wheel)|pipx\\s+(?:install|run|inject|upgrade|reinstall))\\b|\\bpython[\\d.]*\\s+-m\\s*pip\\s+(?:install|uninstall|download|wheel)\\b",
128      "why": "This repo uses uv: use uv add / uv run / uvx, not pip or pipx."
129    },
130    {
131      "id": "poetry",
132      "when": "uv",
133      "match": "(?<![\\w./-])poetry(\\s|$)",
134      "why": "This repo uses uv: use uv add / uv sync / uv run, not poetry."
135    }
136  ],
137  "heavy": [
138    {
139      "id": "build",
140      "match": "\\b(eas\\s+build|xcodebuild|gradlew?\\s+(assemble|build)|docker\\s+build|npm\\s+run\\s+build|cargo\\s+build|maestro\\s+test)\\b"
141    },
142    {
143      "id": "tests",
144      "match": "\\b(pytest|vitest|jest|npm\\s+(run\\s+)?test|make\\s+(test|check))\\b"
145    }
146  ],
147  // `say`: what the rule's command does, in plain words, at the top of the ask (the rm family, branch-delete and
148  // force build theirs from the targets).
149  // `prefixes`: the plain forms the harness itself asks for where a hook cannot (Codex prompt rules, opencode
150  // `permission.bash` ask). A risky command outside them is denied there. Rules match with git's global options
151  // removed (`git -C dir reset` is `git reset`) and ignore case unless `case` is set.
152  "risky": [
153    {
154      "id": "rm",
155      // Not `git rm`: its files are in git (`git-rm` below asks when it would drop their changes). PowerShell's
156      // `-Recurse` may be abbreviated to `-r`; cmd's `rd /s` and `del /s` recurse too.
157      "match": "(?<!\\bgit\\s+)\\brm\\s+(?:-[\\w-]+\\s+)*(?:-[a-z]*r[a-z]*|--recursive)\\b|\\bremove-item\\b[^;|\\n]*\\s-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?\\b|(?:^|[;&|({\\n])\\s*(?:ri|del|erase|rd|rmdir)\\s(?:[^;|\\n]*\\s)?(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?\\b|/s\\b)",
158      "why": "recursive delete",
159      "prefixes": [["rm", "-r"], ["rm", "-R"], ["rm", "-rf"], ["rm", "-fr"], ["rm", "-Rf"], ["rm", "-fR"], ["rm", "--recursive"]]
160    },
161    {
162      // No prefixes: a prompt rule matches from the first word, and `find PATH` would ask for every find.
163      "id": "find",
164      "match": "\\bfind\\b[^;&|\\n]*\\s(?:-delete\\b|-exec(?:dir)?\\s+(?:\\S*/)?rm\\b)",
165      "why": "find deletes everything it matches, through the whole tree"
166    },
167    {
168      "id": "worktree-rm",
169      "match": "\\bgit\\s+worktree\\s+remove\\b[^;&|\\n]*(--force|\\s-f\\b)",
170      "why": "discards uncommitted work in a worktree",
171      "prefixes": [["git", "worktree", "remove", "--force"], ["git", "worktree", "remove", "-f"]]
172    },
173    {
174      "id": "reset",
175      "say": "Discards uncommitted changes (git reset --hard)",
176      "match": "\\bgit\\s+reset\\b[^;&|\\n]*\\s--hard\\b",
177      "why": "discards local work",
178      "prefixes": [["git", "reset", "--hard"]]
179    },
180    {
181      "id": "clean",
182      "say": "Deletes untracked files, which git cannot restore",
183      "match": "\\bgit\\s+clean\\b(?![^;&|\\n]*\\s(?:-[a-z]*n[a-z]*|--dry-run)\\b)(?=[^;&|\\n]*\\s(?:-[a-z]*f[a-z]*|--force)\\b)",
184      "why": "deletes untracked files, which git cannot restore",
185      "prefixes": [["git", "clean", "-f"], ["git", "clean", "-fd"], ["git", "clean", "-df"], ["git", "clean", "-fdx"], ["git", "clean", "-dfx"], ["git", "clean", "-xdf"], ["git", "clean", "-fx"], ["git", "clean", "-xf"], ["git", "clean", "-ffdx"], ["git", "clean", "--force"]]
186    },
187    {
188      // A whole-tree or directory pathspec (`.`, `:/`, `dir/`, a glob), or a forced checkout or switch. One named
189      // file (`git checkout -- f`, `git restore f`) passes: a narrow, deliberate revert. `restore --staged` only unstages.
190      "id": "discard",
191      "say": "Discards uncommitted changes in the working tree",
192      "match": "\\bgit\\s+checkout\\b[^;&|\\n]*\\s(?:-f|--force|\\.{1,2}/?|:/?|:\\([^)\\s]*\\)\\S*|[^\\s;&|]*[*?][^\\s;&|]*|[^\\s;&|]+/)(?=[\\s;&|)]|$)|\\bgit\\s+switch\\b[^;&|\\n]*\\s(?:-f|--force|--discard-changes)\\b|\\bgit\\s+restore\\b(?=[^;&|\\n]*\\s(?:--worktree|-W)\\b|(?![^;&|\\n]*\\s(?:--staged|-S)\\b))[^;&|\\n]*\\s(?:\\.{1,2}/?|:/?|:\\([^)\\s]*\\)\\S*|[^\\s;&|]*[*?][^\\s;&|]*|[^\\s;&|]+/)(?=[\\s;&|)]|$)",
193      "why": "discards uncommitted changes, which git cannot restore",
194      "prefixes": [["git", "checkout", "."], ["git", "checkout", "--", "."], ["git", "checkout", "-f"], ["git", "checkout", "--force"], ["git", "restore", "."], ["git", "switch", "-f"], ["git", "switch", "--force"], ["git", "switch", "--discard-changes"]]
195    },
196    {
197      "id": "stash",
198      "say": "Drops stashed work",
199      "match": "\\bgit\\s+stash\\s+(?:drop|clear)\\b",
200      "why": "discards stashed work; only git fsck can find it again",
201      "prefixes": [["git", "stash", "drop"], ["git", "stash", "clear"]]
202    },
203    {
204      // Case-sensitive: `-d` refuses an unmerged branch, `-D` (or `-d` with `-f`) deletes it anyway. A branch whose
205      // commits are all on a remote ref or in a merged PR passes (branch facts).
206      "id": "branch-delete",
207      "case": true,
208      "match": "\\bgit\\s+branch\\b(?:[^;&|\\n]*\\s(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-z]*(?:df|fd)[a-z]*)\\b|(?=[^;&|\\n]*\\s(?:--delete|-d)\\b)(?=[^;&|\\n]*\\s(?:--force|-f)\\b))",
209      "why": "deletes a branch git has not seen merged",
210      "prefixes": [["git", "branch", "-D"]]
211    },
212    {
213      "id": "force",
214      // `--force-with-lease` to a feature branch passes: it cannot clobber work it has not seen, and a branch
215      // ruleset rejects it on a protected default branch. ponytail: a bare lease push from main is not caught.
216      // `--force-if-includes` alone is not a force push; a lease push naming main or master anywhere is.
217      "match": "\\bgit\\s+push\\b[^;&|\\n]*(--force(?![\\w-])|\\s-[a-z]*f[a-z]*\\b|\\s\\+[^\\s;&|])|\\bgit\\s+push\\b(?=[^;&|\\n]*--force-with-lease\\b)[^;&|\\n]*[\\s:](?:refs/heads/)?(main|master)(?=[\\s;&|)]|$)",
218      "why": "rewrites remote history",
219      "prefixes": [["git", "push", "--force"], ["git", "push", "-f"]]
220    },
221    {
222      // Deleting a feature branch on the remote passes; main and master do not.
223      "id": "push-delete",
224      "say": "Deletes branches on the remote",
225      "match": "\\bgit\\s+push\\b[^;&|\\n]*\\s(?:--mirror|--prune)\\b|\\bgit\\s+push\\b(?=[^;&|\\n]*\\s(?:--delete|-d)\\b)[^;&|\\n]*\\s(?:refs/heads/)?(?:main|master)(?=[\\s;&|)]|$)|\\bgit\\s+push\\b[^;&|\\n]*\\s:(?:refs/heads/)?(?:main|master)(?=[\\s;&|)]|$)",
226      "why": "deletes remote branches",
227      "prefixes": [["git", "push", "--mirror"], ["git", "push", "--prune"]]
228    },
229    {
230      "id": "git-rm",
231      "say": "Removes files and drops their uncommitted changes",
232      "match": "\\bgit\\s+rm\\b(?![^;&|\\n]*\\s--cached\\b)(?=[^;&|\\n]*\\s(?:-[a-z]*f[a-z]*|--force)\\b)",
233      "why": "drops uncommitted changes to the removed files",
234      "prefixes": [["git", "rm", "-f"], ["git", "rm", "-rf"], ["git", "rm", "-fr"], ["git", "rm", "--force"]]
235    },
236    {
237      "id": "terraform",
238      "say": "Applies or destroys real infrastructure",
239      "match": "\\b(?:terraform|tofu)\\b(?:\\s+-[\\w-]+(?:=\\S+)?)*\\s+(?:apply|destroy)\\b",
240      "why": "changes real infrastructure",
241      "prefixes": [["terraform", "apply"], ["terraform", "destroy"], ["tofu", "apply"], ["tofu", "destroy"]]
242    },
243    {
244      // Refs git cannot restore from the remote: a deleted ref, a rewritten history, main or master moved by force.
245      "id": "rewrite",
246      "say": "Rewrites history or moves a ref git cannot restore from the remote",
247      "match": "\\bgit\\s+update-ref\\b[^;&|\\n]*\\s-d\\b|\\bgit\\s+filter-(?:repo|branch)\\b|\\bgit\\s+branch\\b[^;&|\\n]*\\s(?:-f|--force)\\b[^;&|\\n]*\\s(?:refs/heads/)?(?:main|master)(?=[\\s;&|)]|$)",
248      "why": "rewrites history or a ref on main",
249      "prefixes": [["git", "update-ref", "-d"], ["git", "filter-repo"], ["git", "filter-branch"]]
250    },
251    // Data-loss forms outside git (owner decision D3). Routine `docker rm`/`rmi` of disposable containers and
252    // images, `docker image prune` and `docker stack rm` stay allowed.
253    {
254      "id": "docker",
255      "say": "Deletes container data volumes",
256      "match": "\\b(?:docker|podman)\\s+volume\\s+(?:rm|remove|prune)\\b|\\b(?:docker|podman)\\s+system\\s+prune\\b|\\b(?:docker|podman)\\s+(?:container\\s+)?rm\\b[^;&|\\n]*\\s(?:-[a-z]*v[a-z]*|--volumes)\\b|\\b(?:docker[\\s-]compose|podman-compose)\\b[^;&|\\n]*\\s(?:down|rm)\\b[^;&|\\n]*\\s(?:-[a-z]*v[a-z]*|--volumes)\\b",
257      "why": "deletes container data volumes",
258      "prefixes": [["docker", "volume", "rm"], ["docker", "volume", "prune"], ["docker", "system", "prune"], ["docker", "rm", "-v"], ["docker", "compose", "down", "-v"]]
259    },
260    {
261      "id": "gh-delete",
262      "say": "Deletes a GitHub repository, release, secret or resource",
263      "match": "\\bgh\\s+(?:repo|release|secret)\\s+delete\\b|\\bgh\\s+api\\b(?=[^;&|\\n]*\\s(?:-X|--method)[\\s=]*DELETE\\b)",
264      "why": "deletes on GitHub, which cannot be undone",
265      "prefixes": [["gh", "repo", "delete"], ["gh", "release", "delete"], ["gh", "secret", "delete"]]
266    },
267    {
268      "id": "k8s",
269      "say": "Deletes Kubernetes resources or a Helm release",
270      "match": "\\bkubectl\\b(?:[^;&|\\n]*\\s)?delete\\b|\\bhelm\\s+(?:uninstall|delete|del|un)\\b",
271      "why": "deletes cluster resources and their data",
272      "prefixes": [["kubectl", "delete"], ["helm", "uninstall"], ["helm", "delete"]]
273    },
274    {
275      // No prefixes: a cloud CLI's delete sits after a resource path, so no first-word prefix covers it.
276      "id": "cloud-delete",
277      "say": "Deletes a cloud resource",
278      "match": "\\b(?:gcloud|az)\\b[^;&|\\n]*\\sdelete\\b",
279      "why": "deletes cloud resources, which cannot be undone"
280    },
281    {
282      "id": "disk",
283      "say": "Overwrites or formats a disk",
284      "match": "\\bdd\\b[^;&|\\n]*\\sof=/dev/|\\bmkfs(?:\\.\\w+)?\\b|\\bdiskutil\\s+(?:erase\\w*|zeroDisk|secureErase|partitionDisk|reformat)\\b",
285      "why": "overwrites a disk",
286      "prefixes": [["mkfs"], ["diskutil", "eraseDisk"], ["diskutil", "eraseVolume"]]
287    },
288    {
289      "id": "merge",
290      "say": "Merges past the branch rules",
291      "match": "\\bgh\\s+pr\\s+merge\\b[^;&|\\n]*--admin|\\baz\\s+repos\\s+pr\\s+(create|update)\\b[^;&|\\n]*--bypass-policy\\b(?![\\s=]+false)",
292      "why": "bypasses merge rules"
293    },
294    // Matched on what runs, so a token injected through a quoted `VAR="$(keyring read)" ./script` passes
295    // (`skipInjected`): the value goes to one process's environment, never to output. `echo "$(bws secret get x)"` asks.
296    // `pipeline` + `help`: judged per pipeline, leaving out a command that only prints its usage (`--help`, `-h`, `help`).
297    {
298      "id": "secrets",
299      "say": "Reads a secret",
300      "pipeline": true,
301      "help": true,
302      "match": "\\bbws?\\s+(secret|get|list|run|unlock|login|export|serve)\\b|\\bsecurity\\s+find-(generic|internet)-password\\b[^|;&\\n]*\\s-[a-z]*[wg][a-z]*\\b|\\bsecurity\\s+dump-keychain\\b|\\bchezmoi\\s+secret\\s+keyring\\s+get\\b|\\bop\\s+read\\b|\\bop\\s+item\\s+get\\b[^|;&\\n]*\\s--reveal\\b|\\bpass\\s+show\\b|\\bgcloud\\s+secrets\\s+versions\\s+access\\b|\\baz\\s+keyvault\\s+secret\\s+show\\b|\\bgh\\s+auth\\s+token\\b|\\bgh\\s+auth\\s+status\\b[^;&|\\n]*--show-token\\b|\\bgcloud\\s+auth\\s+(?:application-default\\s+)?print-(?:access|identity)-token\\b|\\baz\\s+account\\s+get-access-token\\b|(?:^|[;&|(\\n]\\s*)printenv\\s+\\w*(token|secret|key|pass|credential)|(?:^|[;&|(\\n]\\s*)(?:env|printenv)\\s*(?=$|[;&\\n)]|\\|(?!\\s*(?:e?grep|rg|findstr|select-string)\\b))|\\b(?:cat|less|more|head|tail|bat|grep|egrep|rg|sed|awk|cut|xxd|strings|base64|jq)\\b[^;&|\\n]*\\s(?:[^\\s;&|]*/)?(?:\\.env(?:rc|\\.(?!example\\b|sample\\b|template\\b|dist\\b)[\\w.-]+)?|\\.netrc|\\.aws/credentials|\\.ssh/id_(?:rsa|ed25519|ecdsa|dsa))(?=[\\s;&|)]|$)|(?:^|[;&|(\\n]\\s*)(?:source|\\.)\\s+(?:[^\\s;&|]*/)?(?:\\.env(?:rc|\\.(?!example\\b|sample\\b|template\\b|dist\\b)[\\w.-]+)?|\\.netrc|\\.aws/credentials|\\.ssh/id_(?:rsa|ed25519|ecdsa|dsa))(?=[\\s;&|)]|$)|<\\s*(?:[^\\s;&|]*/)?(?:\\.env(?:rc|\\.(?!example\\b|sample\\b|template\\b|dist\\b)[\\w.-]+)?|\\.netrc|\\.aws/credentials|\\.ssh/id_(?:rsa|ed25519|ecdsa|dsa))(?=[\\s;&|)]|$)",
303      "why": "reads a secret; load `secrets-handling` and never print or inline the value",
304      "skipInjected": true,
305      "prefixes": [
306        ["bw", "get"], ["bw", "list"], ["bw", "unlock"], ["bw", "login"], ["bw", "export"], ["bw", "serve"],
307        ["bws", "secret"], ["bws", "get"], ["bws", "list"], ["bws", "run"],
308        ["chezmoi", "secret", "keyring", "get"], ["op", "read"], ["pass", "show"],
309        ["gcloud", "secrets", "versions", "access"], ["az", "keyvault", "secret", "show"],
310        ["cat", ".env"], ["less", ".env"],
311        ["gh", "auth", "token"], ["gcloud", "auth", "print-access-token"], ["az", "account", "get-access-token"], ["security", "dump-keychain"]
312      ]
313    },
314    // Echoing a secret variable (D9): `match` finds the echo, `raw` the variable as written (it is usually quoted).
315    // Names ending in TOKEN, KEY, SECRET or PASSWORD; `$KEY_PATH` passes.
316    {
317      "id": "secrets",
318      "say": "Prints a secret variable",
319      "match": "(?:^|[;&|(\\n\\s])(?:echo|printf|write-output|write-host)\\b",
320      "raw": "(?:echo|printf|write-output|write-host)\\b[^;&|\\n]*\\$(?:\\{|env:)?(?:\\w*_)?(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD)\\}?(?!\\w)|(?:echo|printf)\\b[^;&|\\n]*\\$\\{?\\w*PASSWORD\\}?(?!\\w)",
321      "why": "prints a secret variable; load `secrets-handling` and never print the value",
322      "skipInjected": true
323    },
324    // `raw` must also match the command as written: the grep pattern is usually quoted, which `match` cannot see.
325    // `pipeline`: match, raw and unless are tested per pipeline, so a secret word elsewhere in the command does not
326    // count (G09). `unless`: a pipeline that ends by keeping only the names (`cut -d= -f1`) prints no value.
327    {
328      "id": "secrets-env",
329      "pipeline": true,
330      "unless": "\\|\\s*(?:cut\\s+-d\\s*['\"]?=['\"]?\\s+-f\\s*1|cut\\s+-f\\s*1\\s+-d\\s*['\"]?=['\"]?|sed\\s+(?:-E\\s+)?['\"]?s/=\\.\\*//['\"]?|awk\\s+-F\\s*['\"]?=['\"]?\\s+['\"]?\\{\\s*print\\s+\\$1\\s*\\}['\"]?)\\s*$",
331      "say": "Prints environment variables that hold secrets",
332      "match": "(^|[;&|(\\n]\\s*)(printenv|env)\\b(?![-.=])[^;&\\n]*\\|\\s*(e?grep|rg|findstr|select-string)\\b",
333      "raw": "token|secret|key|pass|credential|\\btok|gh_|\\bpat\\b|bearer",
334      "why": "dumps secret environment variables; load `secrets-handling` and filter to names only",
335      "skipInjected": true
336    },
337    {
338      // core/shell.mjs marks what runs but cannot be read: an encoded PowerShell command, base64 decoded into a
339      // shell, payloads nested more than three levels deep.
340      "id": "opaque",
341      "say": "Runs a command the guard cannot read",
342      "match": "<guard:opaque>",
343      "why": "runs a command the guard cannot read (encoded, decoded at run time, or nested too deep)"
344    }
345  ],
346  // The factory approval and takeover labels (config `labels.approval`, `labels.takeover`, defaults below) are never touched
347  // (applied or removed) while FACTORY_RUN_ID is set.
348  "factory": {
349    "approval": "agent-ok",
350    "why": "A factory run must not touch the approval label: with an owner token the runtime would read it as the owner's approval.",
351    "takeover": "factory-takeover",
352    "takeoverWhy": "A runtime run must not touch the takeover label: it hands the ticket from a runtime to an attended run, and only an approver sets it.",
353    "unsureWhy": "The repo's .factory/config.yaml exists but could not be read, or its labels could not be parsed, so the guard cannot tell which labels a factory run must not touch.",
354    // gh's own commands, their aliases and help topics (gh 2.102 `gh help` and each command's ALIASES). Any other `gh <name>`
355    // is an alias or extension (`stack`, an extension; `co`, a default alias stored in the user's gh config, so one a run can replace), and
356    // `gh alias set|import` or `gh extension install|upgrade|remove|exec` makes, swaps or runs one: the command it runs is
357    // unseen. `copilot` is left out: it starts an agent whose commands the guard never sees. On an older gh, a name that is a
358    // built-in only in a later release may be an extension: this list still lets it pass. Newer built-ins deny until listed.
359    "ghCommands": ["a11y", "accessibility", "actions", "agent", "agent-task", "agent-tasks", "agents", "alias", "api", "at", "attestation", "auth", "browse", "cache", "codespace", "completion", "config", "cs", "discussion", "environment", "exit-codes", "ext", "extension", "extensions", "formatting", "gist", "gpg-key", "help", "issue", "label", "licenses", "mintty", "org", "pr", "preview", "project", "reference", "release", "repo", "rs", "ruleset", "run", "search", "secret", "skill", "skills", "ssh-key", "status", "telemetry", "variable", "version", "workflow"],
360    "ghUnseenWhy": "In a factory run every gh command must be one the guard can read: a gh alias or extension can run any command, a label change included, out of its sight.",
361    // Repo-relative globs a run's shell never writes (rule `protected-path`); FACTORY_PROTECTED_PATHS adds to them.
362    "protectedPaths": [".github/**", ".factory/**"],
363    // Waived from the two defaults only (an explicit FACTORY_PROTECTED_PATHS glob still protects): files inside a run directory (the run's own outputs).
364    "exemptPaths": [".factory/runs/*/*/**"],
365    // Past any of these a command that may write is denied (a hook that times out lets the command through): write
366    // targets in one command, characters in a target, milliseconds for the check. Globs past `globs` (defaults counted
367    // first) or over `chars` are ignored.
368    "protectedLimits": { "globs": 256, "targets": 512, "chars": 1024, "ms": 2000 },
369    "protectedWhy": "A factory run must not change a protected path (CI, the factory config, or one the runtime lists): those changes are the owner's."
370  },
371  "titles": {
372    "types": ["feat", "fix", "perf", "refactor", "revert", "docs", "style", "test", "build", "ci", "chore"],
373    "why": "squash merge makes the PR title the commit on main, and release automation reads it"
374  },
375  "host": {
376    "loadCap": 30,
377    "cpuPercentCap": 90,
378    "freeFloorGb": 20,
379    "wrapper": "\\b(heavy|lease)\\.(sh|ps1)\\b"
380  },
381  "usage": {
382    "warnPercent": 70,
383    "throttlePercent": 90
384  }
385}
386
core/shell.mjs 551 lines
1// A small shell reader for the guard: enough of POSIX sh, bash and the Windows shells to see what a command runs.
2// Pure, no I/O. It splits a command into simple commands, blanks quoted text (data, not code), drops heredoc bodies
3// and comments, and finds the payloads that run as code although they are written as data:
4//   - the string after a shell's -c (combined flags like -lc, `--`, $'…' quoting), eval's arguments,
5//     cmd /c, powershell|pwsh -Command, a heredoc or echo/printf/cat piped into a shell,
6//   - ssh's remote command (marked remote, with the host), docker|podman|kubectl exec's command,
7//   - $(…) and `…` anywhere outside single quotes, double-quoted ones included.
8// Each payload is read again the same way, three levels deep at most. What cannot be read statically (an encoded
9// PowerShell command, base64 decoded into a shell, nesting past the cap) marks the command opaque.
10
11const MAX_DEPTH = 3
12const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'ash', 'fish', 'busybox'])
13const POWERSHELLS = new Set(['pwsh', 'powershell', 'pwsh.exe', 'powershell.exe'])
14// Words that run another word as the command, each with its own options (from each tool's getopt string, the GNU,
15// util-linux, BSD and macOS ones merged): `value` short options take a value (glued or the next word), `flag` ones take
16// none, `optional` ones take a value only glued (`-dpermanent`); `long` maps each long option to whether it needs a
17// value (`--user root`; `--name=value` is always one word, and a unique prefix counts, as getopt_long allows). `pos`
18// words follow the options before the command and the tool always takes them (timeout's duration, taskset's mask);
19// `priority`: chrt takes the next word as a priority only when it is all digits, else it is the command. `assign`: name=value
20// words may sit among the options. `split`: env -S, whose value is itself a command line. `shell`: watch, which runs its
21// words joined through `sh -c` unless `-x`. `numeric`: nice's `-5`. `keyword`: time, which a
22// group or `!` may follow. An option not listed here makes the reading unsure.
23const W = (value, flag, long = {}, extra = {}) => ({ value, flag, optional: '', long, pos: 0, ...extra })
24const flags = (names) => Object.fromEntries(names.split(' ').filter(Boolean).map((n) => [n, false]))
25const values = (names) => Object.fromEntries(names.split(' ').filter(Boolean).map((n) => [n, true]))
26const COMMON = flags('help version')
27export const WRAPPER_OPTIONS = {
28  sudo: W('aCcDgpRrTtUu', 'ABbEeHhiKklNnPSsVv', { ...COMMON, ...values('auth-type close-from login-class chdir group host prompt chroot role command-timeout type user other-user'), ...flags('background preserve-env edit set-home login remove-timestamp list preserve-groups shell validate askpass bell reset-timestamp no-update non-interactive stdin') }, { assign: true }),
29  doas: W('aCu', 'Lns'),
30  env: W('aCLPSUu', '0iv', { ...COMMON, ...values('argv0 env0-from unset chdir quoting-style split-string'), ...flags('ignore-environment null default-signal ignore-signal block-signal list-signal-handling debug') }, { assign: true, split: true }),
31  command: W('', 'pVv'),
32  exec: W('a', 'cl'),
33  nohup: W('', '', COMMON),
34  time: W('fo', 'ahlpqVv', { ...COMMON, ...values('format output'), ...flags('append portability verbose quiet') }, { keyword: true }),
35  nice: W('n', '', { ...COMMON, ...values('adjustment') }, { numeric: true }),
36  timeout: W('ks', 'fpv', { ...COMMON, ...values('kill-after signal'), ...flags('foreground preserve-status verbose') }, { pos: 1 }),
37  xargs: W('adEIJLnPRSs', '0oprtx', { ...COMMON, ...values('arg-file delimiter max-args max-chars max-procs process-slot-var'), ...flags('null eof replace max-lines open-tty interactive no-run-if-empty verbose show-limits exit') }, { optional: 'eil' }),
38  caffeinate: W('tw', 'dhimsu'),
39  stdbuf: W('eio', '', { ...COMMON, ...values('input output error') }),
40  ionice: W('cnpPu', 'htV', { ...COMMON, ...values('class classdata pid pgid uid'), ...flags('ignore') }),
41  chrt: W('DPTUX', 'abdefGhimOopRrVv', { ...COMMON, ...values('sched-runtime sched-period sched-deadline clamp-min clamp-max'), ...flags('all-tasks batch deadline deadline-overrun ext fifo idle pid max other rr reset-on-fork reclaim-grub verbose') }, { priority: true }),
42  taskset: W('', 'achpV', { ...COMMON, ...flags('all-tasks pid cpu-list') }, { pos: 1 }),
43  setsid: W('', 'cfhVw', { ...COMMON, ...flags('ctty fork wait') }),
44  unbuffer: W('', 'p'),
45  watch: W('nqs', 'bCcefghprtvwx', { ...COMMON, ...values('interval equexit shotsdir'), ...flags('color no-color differences beep errexit follow chgexit exec precise no-rerun no-title no-wrap') }, { optional: 'd', shell: true }),
46}
47export const WRAPPERS = new Set(Object.keys(WRAPPER_OPTIONS))
48const KEYWORD_NEXT = new Set(['{', '!', 'if', 'while', 'until'])
49// ssh options that take a value (OpenSSH): everything else is a flag.
50const SSH_VALUE = new Set(['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w', '-B'])
51const EXEC_VALUE = new Set(['-e', '--env', '-u', '--user', '-w', '--workdir', '--env-file', '-c', '--container', '-n', '--namespace', '--detach-keys'])
52const DECODERS = /^(?:base64|gbase64|openssl|xxd|uudecode)$/
53const ASSIGNMENT = /^[A-Za-z_]\w*=/
54
55const basename = (w) => w.replace(/^.*[\\/]/, '').toLowerCase()
56
57// ANSI-C quoting ($'…'): the common escapes.
58function ansiC(s) {
59  return s.replace(/\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|.)/g, (m, e) => {
60    if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16))
61    if (/^[0-7]/.test(e)) return String.fromCharCode(parseInt(e, 8))
62    return { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', e: '\x1b', f: '\f', v: '\v' }[e] ?? e
63  })
64}
65
66// The index just past the `)` closing a `$(` or `(` that opens at `i` (src[i] is the first char inside), quotes and
67// nesting respected. src.length when unbalanced.
68function closeParen(src, i) {
69  let depth = 1
70  let heredocs = []
71  while (i < src.length) {
72    const c = src[i]
73    const doc = c === '<' && /^<<(-?)\s*(['"]?)(\w+)\2/.exec(src.slice(i))
74    if (doc && src[i + 2] !== '<') {
75      heredocs.push({ delim: doc[3], strip: doc[1] === '-' })
76      i += doc[0].length
77    } else if (c === '\n' && heredocs.length) {
78      i++
79      for (const h of heredocs) {
80        while (i < src.length) {
81          const nl = src.indexOf('\n', i)
82          const line = src.slice(i, nl < 0 ? src.length : nl)
83          i = nl < 0 ? src.length : nl + 1
84          if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim) break
85        }
86      }
87      heredocs = []
88    } else if (c === '\\') i += 2
89    else if (c === "'") i = (src.indexOf("'", i + 1) + 1 || src.length + 1) - 0
90    else if (c === '"') i = closeDouble(src, i + 1)
91    else if (c === '`') i = (src.indexOf('`', i + 1) + 1 || src.length + 1) - 0
92    else if (c === '(') (depth++, i++)
93    else if (c === ')') {
94      if (--depth === 0) return i + 1
95      i++
96    } else i++
97  }
98  return src.length
99}
100
101// The index just past the `"` closing a double-quoted string whose content starts at `i`.
102function closeDouble(src, i) {
103  while (i < src.length && src[i] !== '"') {
104    if (src[i] === '\\') i += 2
105    else if (src.startsWith('$(', i) && !src.startsWith('$((', i)) i = closeParen(src, i + 2)
106    else if (src[i] === '`') i = (src.indexOf('`', i + 1) + 1) || src.length
107    else i++
108  }
109  return i + 1
110}
111
112const OPS = ['&&', '||', '|&', ';;', ';', '|', '&', '\n', '(', ')']
113
114// Reads `src` into simple commands: { raw, code, words: [{ val, raw, substs: [{ text, quoted }] }], heredocs: [body],
115// sep } where `sep` is the operator before it. `code` is the command with quoted strings as "", heredoc bodies
116// dropped and comments removed: what rules match.
117export function scan(src) {
118  const commands = []
119  let cur = null
120  let sep = ''
121  const pending = [] // heredocs waiting for the end of the line: { delim, strip, cmd }
122  const start = (i) => {
123    if (!cur) cur = { start: i, end: i, code: '', words: [], heredocs: [], redirs: [], sep }
124  }
125  const finish = () => {
126    if (cur && (cur.words.length || cur.code.trim())) commands.push(cur)
127    cur = null
128  }
129  let i = 0
130  const n = src.length
131  while (i < n) {
132    const c = src[i]
133    if (c === ' ' || c === '\t' || c === '\r') {
134      if (cur) cur.code += c
135      i++
136      continue
137    }
138    if (c === '\\' && src[i + 1] === '\n') {
139      i += 2
140      continue
141    }
142    if (c === '#' && (i === 0 || /[ \t\r\n;&|()]/.test(src[i - 1]))) {
143      while (i < n && src[i] !== '\n') i++
144      continue
145    }
146    const op = !src.startsWith('&>', i) && OPS.find((o) => src.startsWith(o, i))
147    // A `(` right after `=`, `@`, `?`, `*`, `+` or `!` belongs to the word before it: a bash array (`x=(a b)`), an
148    // extglob (`@(a|b)`) or zsh's `=(cmd)`, whose body is a command. Read past the group: stopping at it looped forever.
149    if (op === '(' && cur && /[=@?*+!]$/.test(cur.code)) {
150      const end = closeParen(src, i + 1)
151      const text = src.slice(i, end)
152      const w = cur.words.at(-1)
153      if (w) {
154        if (w.val === '=') w.substs.push({ text: src.slice(i + 1, end - (src[end - 1] === ')' ? 1 : 0)), quoted: false })
155        w.val += text
156        w.raw += text
157      }
158      cur.code += text
159      i = end
160      continue
161    }
162    if (op) {
163      if (cur) cur.end = i
164      // A newline right after `&&`, `||`, a pipe or `&` continues the line: the operator stays the separator.
165      const continued = op === '\n' && !cur && ['&&', '||', '|', '|&', '&'].includes(sep)
166      finish()
167      if (!continued) sep = op
168      i += op.length
169      if (op === '\n' && pending.length) i = readHeredocs(src, i, pending)
170      continue
171    }
172    start(i)
173    // A redirection: `<<`/`<<-` opens a heredoc; `<<<`, `>`, `>>`, `<`, `>&`, `&>`, `2>` take one word.
174    const redir = /^[<>]\(/.test(src.slice(i, i + 2)) ? null : /^(\d*)(<<<|<<-|<<|&>>|&>|>>|>&|<&|>\||>|<)/.exec(src.slice(i))
175    if (redir) {
176      i += redir[0].length
177      while (src[i] === ' ' || src[i] === '\t') i++
178      const target = readWord(src, i)
179      i = target.end
180      if (redir[2] === '<<' || redir[2] === '<<-') {
181        pending.push({ delim: target.word.val, strip: redir[2] === '<<-', cmd: cur })
182        cur.code += ` ${redir[0]}${target.word.val} `
183      } else {
184        cur.code += ` ${redir[0]}${target.code} `
185        // An output redirection's file (`>&2`, `>&-` duplicate a descriptor): what the command writes besides its arguments.
186        if (/>/.test(redir[2]) && !(redir[2] === '>&' && /^(?:\d+|-)$/.test(target.word.val))) cur.redirs.push(target.word)
187      }
188      continue
189    }
190    const { word, code, end } = readWord(src, i)
191    cur.words.push(word)
192    cur.code += code
193    i = end
194  }
195  if (cur) cur.end = n
196  finish()
197  for (const cmd of commands) cmd.raw = src.slice(cmd.start, cmd.end).trim()
198  return commands
199}
200
201// Heredoc bodies after the newline at `i`, in order; returns the index after the last delimiter line.
202function readHeredocs(src, i, pending) {
203  for (const h of pending.splice(0)) {
204    const lines = []
205    while (i < src.length) {
206      const nl = src.indexOf('\n', i)
207      const line = src.slice(i, nl < 0 ? src.length : nl)
208      i = nl < 0 ? src.length : nl + 1
209      if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim) break
210      lines.push(h.strip ? line.replace(/^\t+/, '') : line)
211    }
212    h.cmd.heredocs.push(lines.join('\n'))
213  }
214  return i
215}
216
217// One word from `i`: its value (quotes removed, escapes applied), its code (quoted parts as ""), the command
218// substitutions in it, and where it ends.
219function readWord(src, i) {
220  const word = { val: '', raw: '', substs: [], quoted: false }
221  let code = ''
222  const begin = i
223  while (i < src.length) {
224    const c = src[i]
225    // Only a blank (space, tab; CR for CRLF lines), a newline or an operator ends a word, as in the shell: a vertical
226    // tab, form feed or Unicode space is part of the word. Ending a word on one the scan loop does not skip looped forever.
227    if (/[ \t\r\n;&|()<>]/.test(c)) {
228      // `<(…)`/`>(…)` process substitution and `=(` (zsh) are part of the word.
229      if ((c === '<' || c === '>') && src[i + 1] === '(') {
230        const end = closeParen(src, i + 2)
231        word.substs.push({ text: src.slice(i + 2, end - 1), quoted: false })
232        word.val += src.slice(i, end)
233        code += src.slice(i, end)
234        i = end
235        continue
236      }
237      break
238    }
239    if (c === '\\') {
240      if (src[i + 1] === '\n') {
241        i += 2
242        continue
243      }
244      word.val += src[i + 1] ?? ''
245      code += src.slice(i, i + 2)
246      i += 2
247    } else if (c === "'") {
248      const end = src.indexOf("'", i + 1)
249      const stop = end < 0 ? src.length : end
250      word.val += src.slice(i + 1, stop)
251      word.quoted = true
252      code += '""'
253      i = stop + 1
254    } else if (c === '$' && src[i + 1] === "'") {
255      let j = i + 2
256      while (j < src.length && src[j] !== "'") j += src[j] === '\\' ? 2 : 1
257      word.val += ansiC(src.slice(i + 2, j))
258      word.quoted = true
259      code += '""'
260      i = j + 1
261    } else if (c === '"') {
262      const end = closeDouble(src, i + 1)
263      const inner = src.slice(i + 1, end - 1)
264      word.val += inner.replace(/\\([$`"\\\n])/g, '$1')
265      word.quoted = true
266      for (const s of substitutions(inner)) word.substs.push({ text: s, quoted: true })
267      code += '""'
268      i = end
269    } else if (c === '$' && src[i + 1] === '(' && src[i + 2] !== '(') {
270      const end = closeParen(src, i + 2)
271      const inner = src.slice(i + 2, end - 1)
272      word.substs.push({ text: inner, quoted: false })
273      word.val += src.slice(i, end)
274      code += `$(${codeOf(inner)})`
275      i = end
276    } else if (c === '`') {
277      const end = src.indexOf('`', i + 1)
278      const stop = end < 0 ? src.length : end
279      word.substs.push({ text: src.slice(i + 1, stop), quoted: false })
280      word.val += src.slice(i, stop + 1)
281      code += `\`${codeOf(src.slice(i + 1, stop))}\``
282      i = stop + 1
283    } else if (c === '$' && src[i + 1] === '(') {
284      const end = closeParen(src, i + 2)
285      word.val += src.slice(i, end)
286      code += src.slice(i, end)
287      i = end
288    } else if (c === '$' && src[i + 1] === '{') {
289      const end = src.indexOf('}', i)
290      const stop = end < 0 ? src.length : end + 1
291      word.val += src.slice(i, stop)
292      code += src.slice(i, stop)
293      i = stop
294    } else {
295      word.val += c
296      code += c
297      i++
298    }
299  }
300  word.raw = src.slice(begin, i)
301  return { word, code, end: i }
302}
303
304// The $(…) and `…` substitutions inside a double-quoted string's content.
305function substitutions(inner) {
306  const out = []
307  let i = 0
308  while (i < inner.length) {
309    if (inner[i] === '\\') i += 2
310    else if (inner.startsWith('$(', i) && !inner.startsWith('$((', i)) {
311      const end = closeParen(inner, i + 2)
312      out.push(inner.slice(i + 2, end - 1))
313      i = end
314    } else if (inner[i] === '`') {
315      const end = inner.indexOf('`', i + 1)
316      const stop = end < 0 ? inner.length : end
317      out.push(inner.slice(i + 1, stop))
318      i = stop + 1
319    } else i++
320  }
321  return out
322}
323
324// The code of a fragment (quoted text as "", heredoc bodies and comments dropped), operators kept.
325export const codeOf = (src) => scan(src).map((c) => `${c.sep && c.sep !== '\n' ? `${c.sep} ` : ''}${c.code.trim()}`).join(' ').trim()
326
327// One long option of a wrapper (`--name`, `--name=value`, or a unique prefix of a name): { name, words, value }, or
328// null when the wrapper has no such option.
329function longOption(spec, words, k) {
330  const v = words[k].val
331  const eq = v.indexOf('=')
332  const given = v.slice(2, eq < 0 ? v.length : eq)
333  let name = Object.hasOwn(spec.long, given) ? given : null
334  if (name === null) {
335    const found = Object.keys(spec.long).filter((n) => n.startsWith(given))
336    if (!given || found.length !== 1) return null
337    name = found[0]
338  }
339  if (eq >= 0) return { name, words: 1, value: { ...words[k], val: v.slice(eq + 1), raw: words[k].raw } }
340  return spec.long[name] ? { name, words: 2, value: words[k + 1] } : { name, words: 1 }
341}
342
343// One cluster of short options (`-nE`, `-Eu root`, `-oL`): { words, letters, opt, value } where `opt` is the letter that
344// took a value, glued or the next word; `unsure` when a letter is not one of the wrapper's options.
345function shortOptions(spec, words, k) {
346  const v = words[k].val
347  const letters = []
348  for (let i = 1; i < v.length; i++) {
349    const c = v[i]
350    letters.push(c)
351    if (spec.value.includes(c)) {
352      if (i + 1 < v.length) return { words: 1, letters, opt: c, value: { ...words[k], val: v.slice(i + 1) } }
353      return { words: 2, letters, opt: c, value: words[k + 1] }
354    }
355    if (spec.optional.includes(c)) return { words: 1, letters }
356    if (!spec.flag.includes(c)) return { words: 1, letters, unsure: true }
357  }
358  return { words: 1, letters }
359}
360
361// What a simple command runs past its prefix assignments and wrappers, each wrapper read with its own options
362// (WRAPPER_OPTIONS): { words, heads, payload, unsure }. `words` from the command word on; `heads` the index of each
363// wrapper and of the command word; `payload` { text, words } the command line a wrapper runs from a string (env -S,
364// watch), `words` then empty; `unsure` when a wrapper has an option the table does not know, so the command word may be
365// another one. One pass over the words.
366export function wrapped(words) {
367  let k = 0
368  const heads = []
369  let unsure = false
370  while (k < words.length && ASSIGNMENT.test(words[k].val)) k++
371  while (k < words.length && WRAPPERS.has(basename(words[k].val))) {
372    const name = basename(words[k].val)
373    const spec = WRAPPER_OPTIONS[name]
374    heads.push(k++)
375    let ended = false
376    let exec = false
377    while (k < words.length) {
378      const v = words[k].val
379      if (spec.assign && ASSIGNMENT.test(v) && !(ended && name === 'sudo')) k++
380      else if (v === '-' && name === 'env' && (!ended || words[k - 1].val === '--')) {
381        // env's lone `-` is -i, also as the first word after `--` (GNU env); anywhere else it is the command.
382        k++
383      } else if (ended || v[0] !== '-' || v === '-') break
384      else if (v === '--') {
385        ended = true
386        k++
387      } else if (spec.numeric && /^-[-+]?\d/.test(v)) k++
388      else {
389        const o = v.startsWith('--') ? longOption(spec, words, k) : shortOptions(spec, words, k)
390        if (!o || o.unsure) {
391          unsure = true
392          k++
393          continue
394        }
395        // sudo's `-h` alone is a host when a plain word follows (sudo's own rule), else help.
396        if (name === 'sudo' && v === '-h' && words[k + 1] && !words[k + 1].val.startsWith('-') && !ASSIGNMENT.test(words[k + 1].val)) o.words = 2
397        if (o.name === 'exec' || o.letters?.includes('x')) exec = true
398        if (spec.split && (o.name === 'split-string' || o.opt === 'S')) {
399          // env -S: its value is split into words that run ahead of the rest, options and assignments included.
400          const rest = words.slice(k + o.words)
401          const text = `env ${o.value?.val ?? ''}${rest.length ? ` ${requote(rest.map((w) => w.val))}` : ''}`
402          return { words: [], heads, payload: { text, words: [o.value ?? words[k], ...rest] }, unsure }
403        }
404        k += o.words
405      }
406    }
407    for (let p = 0; p < spec.pos && k < words.length; p++) k++
408    if (spec.priority && /^\d+$/.test(words[k]?.val ?? '')) k++
409    // `time` is a shell keyword: a group or a negation may follow it (`time -p { …; }`, `time ! …`), and the command is in it.
410    if (spec.keyword) while (k < words.length && KEYWORD_NEXT.has(words[k].val) && !words[k].quoted) k++
411    if (spec.shell && !exec) {
412      // watch runs its words joined with spaces through `sh -c`.
413      const rest = words.slice(k)
414      return { words: [], heads, payload: rest.length ? { text: rest.map((w) => w.val).join(' '), words: rest } : null, unsure }
415    }
416  }
417  if (k < words.length) heads.push(k)
418  return { words: words.slice(k), heads, payload: null, unsure }
419}
420
421// The words of a simple command after prefix assignments and wrappers (sudo, env, xargs, …): what it runs.
422export const commandWords = (words) => wrapped(words).words
423
424// Re-quotes argv words as one command line: a word with anything a shell would split or expand is single-quoted.
425const requote = (words) => words.map((w) => (/^[\w./@%+=:,~-]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`)).join(' ')
426
427// The payloads of one simple command (see the header), each { kind, text, remote?, host?, injected?, expanded? }, plus
428// `opaque` when it runs code the guard cannot read. `prev` is the command before it in the same pipeline.
429// `expanded`: this shell expands variables in the text before it runs (`bash -c "…$X…"`, an ssh command line, a
430// heredoc or echo piped into a shell), so `$X` there is this level's value, not one the payload sets. Only a
431// single-quoted `-c`, eval or ssh word, or a `$(…)` (which runs in a subshell), is not. `'escaped'` when the text also
432// holds an escaped `\$` or backtick, which the payload's own shell expands: then neither level's values are known.
433const escapes = (text) => (/\\[$`]/.test(text) ? 'escaped' : true)
434const expands = (ws) => {
435  const outside = ws.map((w) => w.raw.replace(/'[^']*'/g, '')).join(' ')
436  return /[$`]/.test(outside) && escapes(outside)
437}
438function payloadsOf(cmd, prev) {
439  const out = []
440  let opaque = false
441  // Substitutions run first, in this shell. One that is the whole value of an assignment, double-quoted
442  // (`TOKEN="$(keyring read)" ./deploy`), injects a value into an environment, never into output.
443  for (const w of cmd.words) {
444    const injected = /^[A-Za-z_]\w*="\$\([\s\S]*\)"$/.test(w.raw) && w.substs.length === 1
445    for (const s of w.substs) out.push({ kind: 'subst', text: s.text, injected })
446  }
447  const run = wrapped(cmd.words)
448  if (run.unsure) opaque = true
449  if (run.payload) out.push({ kind: 'shell', text: run.payload.text, expanded: expands(run.payload.words) })
450  const words = run.words
451  const name = words[0] ? basename(words[0].val) : ''
452  const args = words.slice(1)
453  if (SHELLS.has(name)) {
454    let c = false
455    let k = 0
456    for (; k < args.length; k++) {
457      const a = args[k].val
458      if (a === '--') {
459        k++
460        break
461      }
462      if (!/^[-+]/.test(a)) break
463      if (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(a)) c = true
464      if (/^[-+]o$|^[-+]O$|^--(?:rcfile|init-file)$/.test(a)) k++
465    }
466    if (c && args[k]) out.push({ kind: 'shell', text: args[k].val, expanded: expands([args[k]]) })
467    else if (!c && !args.slice(k).length) {
468      for (const body of cmd.heredocs) out.push({ kind: 'shell', text: body, expanded: escapes(body) })
469      if (prev) {
470        const from = commandWords(prev.words)
471        const fromName = from[0] ? basename(from[0].val) : ''
472        if (prev.decoder) opaque = true
473        else if (fromName === 'echo') out.push({ kind: 'shell', text: from.slice(1).filter((w) => !/^-[neE]+$/.test(w.val)).map((w) => w.val).join(' '), expanded: escapes(from.map((w) => w.raw).join(' ')) })
474        else if (fromName === 'printf') out.push({ kind: 'shell', text: ansiC(from[1]?.val ?? ''), expanded: escapes(from[1]?.raw ?? '') })
475        else if (fromName === 'cat' && prev.heredocs.length) for (const body of prev.heredocs) out.push({ kind: 'shell', text: body, expanded: escapes(body) })
476      }
477    }
478  } else if (name === 'eval') {
479    out.push({ kind: 'shell', text: args.map((w) => w.val).join(' '), expanded: expands(args) })
480  } else if (POWERSHELLS.has(name)) {
481    const k = args.findIndex((w) => /^-(?:c|command)$/i.test(w.val))
482    if (args.some((w) => /^-(?:e|ec|enc|encodedcommand)$/i.test(w.val))) opaque = true
483    else if (k >= 0) out.push({ kind: 'shell', text: args.slice(k + 1).map((w) => w.val).join(' '), expanded: true })
484  } else if (name === 'cmd' || name === 'cmd.exe') {
485    const k = args.findIndex((w) => /^\/[ck]$/i.test(w.val))
486    if (k >= 0) out.push({ kind: 'shell', text: args.slice(k + 1).map((w) => w.val).join(' '), expanded: true })
487  } else if (name === 'ssh') {
488    let k = 0
489    while (k < args.length && args[k].val.startsWith('-')) k += SSH_VALUE.has(args[k].val) ? 2 : 1
490    const host = args[k]?.val
491    const rest = args.slice(k + 1).map((w) => w.val).join(' ')
492    if (host && rest) out.push({ kind: 'ssh', text: rest, remote: true, host: host.replace(/^.*@/, ''), expanded: expands(args.slice(k + 1)) })
493    else if (host) for (const body of cmd.heredocs) out.push({ kind: 'ssh', text: body, remote: true, host: host.replace(/^.*@/, ''), expanded: escapes(body) })
494  } else if (['docker', 'podman', 'nerdctl', 'kubectl', 'oc'].includes(name)) {
495    let k = args.findIndex((w) => w.val === 'exec')
496    if (k >= 0) {
497      if (name === 'kubectl' || name === 'oc') {
498        const dash = args.findIndex((w) => w.val === '--')
499        k = dash
500      } else {
501        k++
502        while (k < args.length && args[k].val.startsWith('-')) k += EXEC_VALUE.has(args[k].val) ? 2 : 1
503      }
504      // The container's files are not this machine's: judged like a remote host.
505      const container = name === 'kubectl' || name === 'oc' ? args.slice(args.findIndex((w) => w.val === 'exec') + 1).find((w) => !w.val.startsWith('-'))?.val : args[k]?.val
506      if (k >= 0 && args[k + 1]) out.push({ kind: 'exec', text: requote(args.slice(k + 1).map((w) => w.val)), remote: true, host: `${name}:${container}`, expanded: expands(args.slice(k + 1)) })
507    }
508  }
509  return { payloads: out, opaque }
510}
511
512// The whole analysis of a command: { commands: [{ raw, code, words, heredocs, redirs, sep, payloads: [{ kind, text, remote,
513// host, injected, analysis }] }], opaque }. Payloads are analysed again, MAX_DEPTH levels at most.
514export function analyze(src, depth = 0) {
515  const commands = scan(src)
516  let opaque = false
517  commands.forEach((cmd, i) => {
518    const prev = cmd.sep === '|' || cmd.sep === '|&' ? commands[i - 1] : null
519    const name = commandWords(cmd.words)[0]?.val ?? ''
520    const args = commandWords(cmd.words).slice(1).map((w) => w.val)
521    // A decoder in a pipeline: what it decodes cannot be read, so a shell it feeds is opaque.
522    cmd.decoder = (prev?.decoder ?? false) || (DECODERS.test(basename(name)) && args.some((a) => /^(?:-d|--decode|-D|-r|-base64)$/.test(a)))
523    const found = payloadsOf(cmd, prev)
524    if (found.opaque) opaque = true
525    cmd.payloads = found.payloads.map((p) => {
526      if (depth + 1 > MAX_DEPTH) {
527        opaque = true
528        return { ...p, analysis: { commands: [], opaque: true } }
529      }
530      const analysis = analyze(p.text, depth + 1)
531      if (analysis.opaque) opaque = true
532      return { ...p, analysis }
533    })
534  })
535  return { commands, opaque }
536}
537
538// Every simple command of an analysis, payloads included, depth-first in order: { cmd, remote, host, injected,
539// depth, siblings } where `siblings` is how many commands share its level (the chain or the payload it is in).
540export function units(analysis, ctx = { remote: false, host: undefined, injected: false, depth: 0 }) {
541  const out = []
542  const siblings = analysis.commands.filter((c) => !/^(?:do|done|then|fi|else|esac|\{|\})?$/.test(c.code.trim())).length
543  for (const cmd of analysis.commands) {
544    out.push({ cmd, ...ctx, siblings })
545    for (const p of cmd.payloads) {
546      out.push(...units(p.analysis, { remote: ctx.remote || Boolean(p.remote), host: p.host ?? ctx.host, injected: ctx.injected || Boolean(p.injected), depth: ctx.depth + 1, kind: p.kind }))
547    }
548  }
549  return out
550}
551