SLOPSHOPPER

outward-gate

Confirm dialog before outward-facing sends and out-of-vault deletes; off switch for unattended runs; always-on audit log.

newbandguardcommandtoaststatus
v0.2.0MITupdated 2026-10-02ozlar34/claude-code-mods/outward-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · outward-gate
› fix the failing auth test and add an audit log call ● outward-gate: outward-gate: could not record a created file: Error: ENOENT: /work/app/src/audit.ts ● outward-gate: outward-gate: could not record a created file: Error: ENOENT: /work/app/src/cache.ts ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by outward-gate: outward-gate failed closed (undefined is not an object (evaluating '$.gate.wait')) ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gate ⎿ outward-gate: outward-gate: ON (mode auto; unanswered dialogs auto-deny after 10 min). Log: ~/.claude/state/outward-gate.log ● outward gate on · off 1h · off 8h ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● outward gate on · off 1h · off 8h ⟨Claude Code's own drawing⟩
README

outward-gate

A Claude Code mod that shows a real confirm dialog before outward-facing sends and out-of-vault deletes, writes every gated action to an audit log, and steps aside for unattended runs. It enforces "outward-facing actions and unversioned deletes need my confirm" mechanically, instead of relying on the model to remember to ask.

What it gates (tool.call)

ServiceToolsDialog shows
Gmailsend_message, reply, forward, trash_* (drafts are not gated)to/cc/bcc, subject, message/thread ids, attachment count
n8nexecute_workflow, test_workflow, publish_workflow, unpublish_workflow, archive_workflowworkflow name (looked up) + id, execution mode
Raindropevery delete_*, merge_collectionsids / tag names, counts
Bashgit push (non-force), however wrapped: ( … ), { …; }, if/then/do/else/!, env/sudo/nice/timeout, `bash\sh\zsh -c '…', eval, $( … )`, backticks, a heredoc fed to a shellremote, refs, flags (URL credentials redacted)
Basha command that mentions git and push in a shape the parser can't read (python3 - <<EOF … git push, ssh host git push, git $x, $G push)the command as written, as "a command that may git push"
Bashrm / trash of a path outside the vault root, or of a path it can't resolve (any $ or backtick: rm "$T", rm $D/a)the resolved paths; unresolvable ones as written, marked (unresolved)
Bashxargs rm"targets come from input (unknown)": always gated
Bashfind <roots> … -exec rm / -delete with a root outside the vault or unresolvablethe roots
Bashmv <path> ~/.Trash for a path outside the vaultas trash: <path>

The parser is quote-aware: git stash push, git log --grep=push, git commit -m "fix git push handling", echo "git push", a comment, and a heredoc read only by cat/echo/git commit (the commit-message pattern) all pass. A cd is tracked, scoped to its subshell; a cd it can't resolve makes every later relative target unresolvable. Redirections (2>/dev/null) are not delete targets. git push --dry-run stays gated.

The vault root is the vaultRoot option (~ allowed): a folder whose deletes are safe because something else versions it, e.g. an Obsidian Sync vault. Empty (the default) means there is no such folder, so every delete outside temp files and session-created files asks (fail closed).

Passed with no dialog: harmless deletes (logged exempt)

  • Temp files: a target strictly under /tmp, /private/tmp or $TMPDIR (the session scratchpad lives under /private/tmp). The temp root itself is not exempt, and neither is a $TMPDIR/… spelling (unresolvable wins).
  • Files this session created: a Write (main loop or subagent) to a path that did not exist before the call records it in $.state (created, capped at 500, with its resolved real path too). An rm whose resolved target exactly matches one passes, and the path is forgotten after the call. A Write that overwrote an existing file never qualifies; nor does a directory holding created files.
  • Mixed commands (exempt and non-exempt targets) show the dialog with only the non-exempt targets; the log line names both.
  • find … -delete under a temp root is exempt the same way.

Message bodies, HTML, forward text, workflow inputs and attachment contents never reach the dialog or the log. Payload fields are allow-listed, not deny-listed.

Optional: a hard-block guard script

If ~/.claude/hooks/guard_destructive_bash.sh exists (a classic PreToolUse hook that exits 2 to block force pushes, rm -rf / and similar), the mod runs it first and steps aside for anything it would block, so you get one prompt per action, never two. Without that file, those commands simply get the dialog. Settings hooks run after a mod's tool.call hook, which is why the mod asks the guard up front instead of relying on it later.

Adding your own services

The gated MCP tools are a regex table, MCP_GATED in hooks/logic.ts (Gmail, n8n and Raindrop ship as examples). Add a row for any tool that sends, publishes or deletes, and an entry in MCP_PHRASE for its dialog wording.

The dialog

Claude Code's own question dialog, inline at the prompt (the one AskUserQuestion uses), header chip Outward gate:

Claude wants to git push — remote: origin · refs: main · flags: --dry-run. Allow? (auto-deny 19:00)
❯ 1. Deny
  2. Approve

Deny is listed first, and only an exact Approve approves: Esc, typed text under "Other", a default pick or the engine's idle auto-resolve all deny. A deny goes back to the model with a reason telling it not to retry, to continue other work, and to list the blocked action in its closeout.

Unanswered → auto-deny after timeoutMinutes (default 10), with the reason "unattended — not approved". The run continues and doesn't hang. $.ui.ask can't be cancelled, so the question stays up after a timeout; answering it then does nothing.

Implementation note: two engine limits shape the wait. (1) A hook's own time is capped at 10 s, and an overrun hook is skipped, so the tool would run (fail-open). Time inside a $ call doesn't count, so the dialog wait goes through the mod's own $.gate.wait noun. (2) Each noun call must itself answer within 10 s, so a 5 s $.clock.every pulse makes the wait return pending, and the hook asks again until there is a verdict. A .catch handler denies the call if the gate errors on a gated call; that is what happened live before the pulse existed. A test holds a dialog for 12 s of real time and requires several short wait calls. The test kit enforces limit (1) but not (2), hence the call count.

Off switch

Modes: auto (default), on, off. Set them in the config menu (mode) or with the OUTWARD_GATE env var, which overrides the config.

interactiveheadless (claude -p, SDK: no surface)
autodialogpasses, logged bypassed-headless
ondialogdenied at once (never waits)
offpasses, logged bypassed-offpasses, logged bypassed-off
  • OUTWARD_GATE=off: for launchd jobs and scripts (that process only).
  • /gate off, /gate off 8h, /gate off 30m: session only, kept in $.state (survives a hot reload, never leaks into another session). It turns back on automatically at expiry, with a toast.
  • Toggle band above the prompt, one quiet row that also says what the gate is: ● outward gate on · off 1h · off 8h, or ○ outward gate off until 21:30 · turn on. It writes the same session switch as /gate. The buttons have no hotkeys, so a stray keystroke in the composer can't flip them; click, or ctrl+x tab then Enter. Off by env/config shows the reason and no button, because a session switch can't override it. Collapse it with ctrl+x ctrl+a. The model has no tool that reaches it: only a person's press toggles the gate.
  • /gate on: back on immediately. /gate: status. /gate log: last 20 log lines.
  • While off, the status line shows gate OFF (with time left when it expires). It shows nothing while the gate is on.

"Off" means this mod steps aside. It does not mean "no protection". Your permissions rules, defaultMode, and any shell guards in your settings hooks still run exactly as before.

Audit log (always on, including when off)

~/.claude/state/outward-gate.log, one tab-separated line per gated action:

2026-10-02T23:14:05+02:00	session=<id>	mode=auto	tool=Bash	decision=approved	payload=git push origin main --dry-run

decision ∈ approved | denied | timed-out | bypassed-off | bypassed-headless | exempt. exempt means every target was a temp file or a session-created file, so no dialog showed; its payload tags each target, e.g. rm /private/tmp/…/x.txt (tmp) or rm /Users/…/debug.test.ts (session file). A dialog for a mixed command carries the exempt part after · exempt: in its payload. The log is appended through the shell's >> (O_APPEND), so concurrent sessions don't clobber each other's lines.

Files

  • hooks/logic.ts: pure classification (shell lexer, Bash walk, exemptions), payload summaries, durations, log line
  • hooks/register.tsx: hooks (incl. the Write recorder), question, band, /gate, the $.gate.wait noun
  • types/index.d.ts: state + noun contract
  • tests/outward-gate.test.ts: claude plugin test <this folder>
Source 3 files
hooks/register.tsx 386 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderChildren, Register, ToolCallInput } from 'claude-code'
3
4import type { GateVerdict } from '../types'
5import {
6  classifyBash,
7  clip,
8  denyReason,
9  formatLeft,
10  gatedBash,
11  gatedMcp,
12  headline,
13  logLine,
14  mcpLines,
15  normalize,
16  parseDuration,
17  parseMode,
18} from './logic'
19import type { BashGate, Decision, Gated, Mode, PathContext } from './logic'
20
21// Session state lives in $.state so a hot reload keeps an active `/gate off`.
22const off = atom({ plugin: 'outward-gate', key: 'off' } as const, null)
23// Files a Write created this session (never ones it overwrote): an exact-match rm of one skips the dialog.
24const created = atom({ plugin: 'outward-gate', key: 'created' } as const, [])
25const CREATED_MAX = 500
26
27const LOG = '/.claude/state/outward-gate.log'
28const GUARD = '/.claude/hooks/guard_destructive_bash.sh'
29const SLOT_PREFIX = 'og-'
30
31// The dialog's answer channel. The question's answer, the timeout and an interrupt settle
32// `verdict`. The tool.call hook waits through `$.gate.wait` (added in engine.create):
33// time inside a `$` call doesn't count against the hook's 10 s budget, but the engine
34// also wants each `$.gate.wait` answered within 10 s. So a pulse every PULSE_MS
35// answers 'pending' and the hook simply asks again.
36const PULSE_MS = 5_000
37type Slot = {
38  verdict: Promise<GateVerdict>
39  resolve: (v: GateVerdict) => void
40  tick: Promise<'pending'>
41  fireTick: () => void
42}
43const slots = new Map<string, Slot>()
44
45function newTick(): { tick: Promise<'pending'>; fireTick: () => void } {
46  let fireTick: () => void = () => undefined
47  const tick = new Promise<'pending'>(r => (fireTick = () => r('pending')))
48  return { tick, fireTick }
49}
50
51function arm(id: string): void {
52  let resolve: (v: GateVerdict) => void = () => undefined
53  const verdict = new Promise<GateVerdict>(r => (resolve = r))
54  slots.set(id, { verdict, resolve, ...newTick() })
55}
56
57function settle(id: string, verdict: GateVerdict): void {
58  slots.get(id)?.resolve(verdict) // a second settle is a no-op: a promise resolves once
59}
60
61function pulse(id: string): void {
62  const slot = slots.get(id)
63  if (slot === undefined) return
64  slot.fireTick()
65  slots.set(id, { ...slot, ...newTick() })
66}
67
68function waitOnce(id: string): Promise<GateVerdict | 'pending'> {
69  const slot = slots.get(id)
70  return slot === undefined ? Promise.resolve('denied') : Promise.race([slot.verdict, slot.tick])
71}
72
73type Current = { mode: Mode; offBy?: string; until?: number | null }
74
75/** The manifest's userConfig, read once per load. */
76type Config = { mode: Mode; timeoutMin: number; vaultRoot: string }
77
78/** Cheap synchronous pre-filter so ordinary tool calls pay nothing. */
79function isCandidate(e: ToolCallInput): boolean {
80  if (e.tool === 'Bash') return /\b(git|rm|trash|push)\b|\.Trash|-delete\b/.test(e.command)
81  return gatedMcp(e.tool) !== undefined
82}
83
84function mcpInput(e: ToolCallInput): Readonly<Record<string, unknown>> {
85  const input: Record<string, unknown> = {}
86  for (const [k, v] of Object.entries(e)) if (k !== 'tool' && k !== 'tool_use_id' && k !== 'agentId') input[k] = v
87  return input
88}
89
90async function home($: EngineInterface): Promise<string> {
91  return (await $.env.get('HOME')) ?? ''
92}
93
94async function current($: EngineInterface, now: number, cfg: Config): Promise<Current> {
95  const env = parseMode((await $.env.get('OUTWARD_GATE'))?.trim().toLowerCase())
96  if (env === 'off') return { mode: 'off', offBy: 'OUTWARD_GATE=off' }
97  const base = env ?? cfg.mode
98  if (base === 'off') return { mode: 'off', offBy: 'config' }
99  const s = await read($, off)
100  if (s !== null && (s.until === null || s.until > now)) return { mode: 'off', offBy: '/gate off', until: s.until }
101  return { mode: base }
102}
103
104async function refreshStatus($: EngineInterface, cfg: Config): Promise<void> {
105  if ((await $.session.surfaces()).length === 0) return
106  const now = await $.clock.now()
107  const s = await read($, off)
108  if (s !== null && s.until !== null && s.until <= now) {
109    await update($, off, () => null)
110    $.ui.toast('outward-gate: gate back ON')
111  }
112  const c = await current($, now, cfg)
113  if (c.mode !== 'off') return $.ui.status(undefined)
114  const left = typeof c.until === 'number' ? ` · ${formatLeft(c.until - now)} left` : ''
115  $.ui.status(`gate OFF${left}${c.offBy === '/gate off' ? '' : ` (${c.offBy})`}`)
116}
117
118/** Session switch, shared by `/gate` and the band's buttons: `ms` null = until turned on. */
119async function setOff($: EngineInterface, cfg: Config, ms: number | null): Promise<void> {
120  const now = await $.clock.now()
121  await update($, off, () => ({ until: ms === null ? null : now + ms }))
122  await refreshStatus($, cfg)
123}
124
125async function setOn($: EngineInterface, cfg: Config): Promise<void> {
126  await update($, off, () => null)
127  await refreshStatus($, cfg)
128}
129
130function hhmm(ms: number): string {
131  const at = new Date(ms)
132  return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
133}
134
135async function writeLog($: EngineInterface, decision: Decision, mode: Mode, tool: string, summary: string): Promise<void> {
136  try {
137    const line = logLine({ at: await $.clock.now(), session: await $.session.id(), mode, tool, summary, decision })
138    // O_APPEND via the shell: concurrent sessions can't clobber each other's lines.
139    await $.process.run(['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && printf "%s\\n" "$2" >> "$1"', 'sh', `${await home($)}${LOG}`, line])
140  } catch (err) {
141    $.ui.log(`outward-gate: audit log write failed: ${String(err)}`, { to: 'debug' })
142  }
143}
144
145async function pathContext($: EngineInterface, cfg: Config): Promise<PathContext> {
146  const h = await home($)
147  // Empty = no vault: every rm outside temp/session files counts as outside it (fail closed).
148  const vaultRoot = cfg.vaultRoot === '' ? null : cfg.vaultRoot.replace(/^~(?=\/|$)/, h)
149  // The session scratchpad lives under /private/tmp; $TMPDIR is macOS's per-user /var/folders/…/T.
150  const tmp = (await $.env.get('TMPDIR'))?.trim()
151  const tmpRoots = ['/tmp', '/private/tmp', ...(tmp ? [tmp, ...(tmp.startsWith('/var/') ? [`/private${tmp}`] : [])] : [])]
152  return { cwd: await $.session.cwd(), home: h, vaultRoot, tmpRoots, created: await read($, created) }
153}
154
155/** After a call that removed session files: forget them, so a later file at that path asks again. */
156async function forget($: EngineInterface, paths: readonly string[]): Promise<void> {
157  if (paths.length) await update($, created, list => list.filter(p => !paths.includes(p)))
158}
159
160/** True when guard_destructive_bash.sh would hard-block it anyway: skip the dialog, no double prompt. */
161async function guardBlocks($: EngineInterface, h: string, command: string): Promise<boolean> {
162  try {
163    const r = await $.process.run(['bash', `${h}${GUARD}`], { stdin: JSON.stringify({ tool_input: { command } }) })
164    return r.exitCode === 2
165  } catch {
166    return false
167  }
168}
169
170async function workflowName($: EngineInterface, workflowId: string): Promise<string | undefined> {
171  try {
172    const r = await $.tool.call({ tool: 'mcp__n8n-mcp__get_workflow_details', workflowId, detailLevel: 'execution' })
173    if (r.deny !== undefined || typeof r.text !== 'string') return undefined
174    const parsed: unknown = JSON.parse(r.text)
175    if (typeof parsed !== 'object' || parsed === null || !('workflow' in parsed)) return undefined
176    const wf = parsed.workflow
177    return typeof wf === 'object' && wf !== null && 'name' in wf && typeof wf.name === 'string' ? wf.name : undefined
178  } catch {
179    return undefined
180  }
181}
182
183/** What the call needs (BashGate shape for every tool); undefined = nothing to gate or log. */
184async function classify($: EngineInterface, cfg: Config, e: ToolCallInput): Promise<BashGate | undefined> {
185  if (e.tool === 'Bash') {
186    const ctx = await pathContext($, cfg)
187    const r = classifyBash(e.command, ctx)
188    if (r.gated === undefined && r.exempt.length === 0) return undefined
189    return (await guardBlocks($, ctx.home, e.command)) ? undefined : r
190  }
191  const title = gatedMcp(e.tool)
192  if (title === undefined) return undefined
193  const input = mcpInput(e)
194  const lines = mcpLines(input)
195  return { gated: { title, lines, summary: lines.join('; ') }, exempt: [], createdHits: [] }
196}
197
198/** Adds the workflow's name to an n8n dialog (only when a dialog will actually show). */
199async function enrich($: EngineInterface, e: ToolCallInput, gated: Gated): Promise<Gated> {
200  const workflowId = mcpInput(e).workflowId
201  if (!gated.title.startsWith('n8n') || typeof workflowId !== 'string') return gated
202  const name = await workflowName($, workflowId)
203  return name === undefined ? gated : { ...gated, lines: [`workflow: ${name}`, ...gated.lines], summary: `${name}; ${gated.summary}` }
204}
205
206async function confirm($: EngineInterface, toolUseId: string, gated: Gated, signal: AbortSignal, cfg: Config): Promise<GateVerdict> {
207  const id = `${SLOT_PREFIX}${toolUseId.replace(/[^A-Za-z0-9_-]/g, '').slice(-56)}`
208  const ms = cfg.timeoutMin * 60_000
209  arm(id)
210  const deadline = (await $.clock.now()) + ms
211  const timer = $.clock.after(ms, () => settle(id, 'timed-out'))
212  const pulser = $.clock.every(PULSE_MS, () => pulse(id))
213  const onAbort = () => settle(id, 'denied')
214  signal.addEventListener('abort', onAbort)
215  try {
216    // The engine's own question dialog, inline at the prompt. Deny is listed first and only an
217    // exact "Approve" approves, so a default pick, an idle auto-resolve, typed text or Esc all deny.
218    // $.ui.ask can't be cancelled: after a timeout the question stays up, and a late answer is a no-op.
219    const details = clip(gated.lines.join(' · '), 240)
220    const question = `Claude wants to ${headline(gated.title)}${details ? ` — ${details}` : ''}. Allow? (auto-deny ${hhmm(deadline)})`
221    void $.ui.ask(question, { options: ['Deny', 'Approve'], header: 'Outward gate' }).then(
222      a => settle(id, a === 'Approve' ? 'approved' : 'denied'),
223      () => settle(id, 'denied'),
224    )
225    let verdict = await $.gate.wait({ id })
226    while (verdict === 'pending') verdict = await $.gate.wait({ id })
227    return verdict
228  } finally {
229    timer.cancel()
230    pulser.cancel()
231    signal.removeEventListener('abort', onAbort)
232    slots.delete(id)
233  }
234}
235
236export const register: Register = (on, options) => {
237  const cfg: Config = {
238    mode: parseMode(options.mode) ?? 'auto',
239    timeoutMin: typeof options.timeoutMinutes === 'number' && options.timeoutMinutes > 0 ? options.timeoutMinutes : 10,
240    vaultRoot: typeof options.vaultRoot === 'string' ? options.vaultRoot.trim() : '',
241  }
242
243  on('engine.create', async ($, e, next) => {
244    const built = await next(e)
245    return { ...built, gate: { wait: ({ id }: { id: string }) => waitOnce(id) } }
246  })
247
248  on('session.start', async ($, e, next) => {
249    await $.command.register({
250      name: 'gate',
251      description: 'outward-gate: status, on, off [8h], log',
252      argumentHint: '[on | off [8h|30m] | log]',
253    })
254    $.clock.every(15_000, () => void refreshStatus($, cfg))
255    await refreshStatus($, cfg)
256    return next(e)
257  })
258
259  on('command.run', { command: 'gate' }, async ($, e) => {
260    const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
261    const now = await $.clock.now()
262    if (verb === 'on') {
263      await setOn($, cfg)
264      const c = await current($, now, cfg)
265      return { text: c.mode === 'off' ? `Session switch cleared, but the gate stays OFF (${c.offBy}).` : 'outward-gate: ON.' }
266    }
267    if (verb === 'off') {
268      const ms = arg === '' ? null : parseDuration(arg)
269      if (ms === undefined) return { text: `Can't read "${arg}". Use e.g. /gate off 8h, /gate off 30m, or /gate off.` }
270      await setOff($, cfg, ms)
271      return {
272        text: `outward-gate: OFF${ms === null ? ' until /gate on' : ` for ${formatLeft(ms)} (back ON automatically)`}. Gated actions still go to the audit log; your permission rules and shell guards still apply.`,
273      }
274    }
275    if (verb === 'log') {
276      const path = `${await home($)}${LOG}`
277      const r = await $.process.run(['tail', '-n', '20', path]).catch(() => undefined)
278      return { text: r && r.exitCode === 0 && r.stdout.trim() ? r.stdout.trimEnd() : `No log yet at ${path}.` }
279    }
280    if (verb !== '') return { text: 'Usage: /gate [on | off [8h|30m] | log]' }
281    const c = await current($, now, cfg)
282    const headless = (await $.session.surfaces()).length === 0
283    const state =
284      c.mode === 'off'
285        ? `OFF (${c.offBy}${typeof c.until === 'number' ? `, ${formatLeft(c.until - now)} left` : ''})`
286        : headless
287          ? `ON in mode ${c.mode}, but this session is headless (${c.mode === 'on' ? 'gated calls are denied' : 'gated calls pass, logged'})`
288          : `ON (mode ${c.mode}; unanswered dialogs auto-deny after ${cfg.timeoutMin} min)`
289    return { text: `outward-gate: ${state}. Log: ~${LOG}` }
290  })
291
292  on('tool.call', async ($, e, next) => {
293    if (!isCandidate(e)) return next(e)
294    const r = await classify($, cfg, e)
295    if (r === undefined) return next(e)
296    const run = async () => {
297      const result = await next(e)
298      await forget($, r.createdHits)
299      return result
300    }
301
302    const now = await $.clock.now()
303    const c = await current($, now, cfg)
304    const gated = r.gated
305    if (gated === undefined) {
306      // Every target is a temp file or one this session wrote: no dialog, still logged.
307      await writeLog($, 'exempt', c.mode, e.tool, r.exempt.join('; '))
308      return run()
309    }
310    const headless = (await $.session.surfaces()).length === 0
311    if (c.mode === 'off') {
312      await writeLog($, 'bypassed-off', c.mode, e.tool, gated.summary)
313      return run()
314    }
315    if (headless) {
316      if (c.mode === 'on') {
317        await writeLog($, 'denied', c.mode, e.tool, gated.summary)
318        return { deny: denyReason(gated.title, 'headless-strict', cfg.timeoutMin) }
319      }
320      await writeLog($, 'bypassed-headless', c.mode, e.tool, gated.summary)
321      return run()
322    }
323
324    const shown = await enrich($, e, gated)
325    const verdict = await confirm($, e.tool_use_id, shown, next.signal, cfg)
326    await writeLog($, verdict, c.mode, e.tool, shown.summary)
327    return verdict === 'approved' ? run() : { deny: denyReason(shown.title, verdict, cfg.timeoutMin) }
328  }).catch(($, e, next) => {
329    // Fail closed for gated calls only; a broken gate must never block unrelated tools.
330    const unknown = { cwd: '/', home: '', vaultRoot: null, tmpRoots: [], created: [] }
331    const gated = e.tool === 'Bash' ? gatedBash(e.command, unknown) : gatedMcp(e.tool)
332    return gated === undefined ? next(e) : { deny: `outward-gate failed closed (${next.error.message}). Do not retry; list this action in your closeout.` }
333  })
334
335  // Record files a Write creates (main loop or subagent: both pass through tool.call). A Write
336  // that overwrote an existing file never qualifies; stat before next(e) decides which it is.
337  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
338    const path = e.file_path.startsWith('/') ? normalize(e.file_path) : undefined
339    const existed = path === undefined || (await $.fs.exists(path).catch(() => true))
340    const result = await next(e)
341    if (existed || result.deny !== undefined || result.isError === true) return result
342    try {
343      const st = await $.fs.stat(path, { resolve: true })
344      if (st.kind === 'file') {
345        const add = [path, ...(st.realPath !== undefined && st.realPath !== path ? [st.realPath] : [])]
346        await update($, created, list => [...list.filter(p => !add.includes(p)), ...add].slice(-CREATED_MAX))
347      }
348    } catch (err) {
349      $.ui.log(`outward-gate: could not record a created file: ${String(err)}`, { to: 'debug' })
350    }
351    return result
352  })
353
354  // The toggle band above the prompt: one row, a person's press only (the model has no
355  // tool that reaches it). No hotkeys, so a stray keystroke in the composer can't flip it.
356  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
357    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
358    const { Box, Text, Button } = $.ui.resolve(e)
359    // The band is one site shared by every mod: stack our row on what the mods beneath draw, never replace it.
360    const withBelow = async (mine: RenderChildren) => <Box flexDirection="column">{mine}{await next(e)}</Box>
361    const c = await current($, await $.clock.now(), cfg)
362    const what = 'outward gate'
363    if (c.mode === 'off') {
364      const when = c.offBy !== '/gate off' ? `(${c.offBy})` : typeof c.until === 'number' ? `until ${hhmm(c.until)}` : 'until turned on'
365      return withBelow(
366        <Box flexDirection="row">
367          <Text color="yellow">○ </Text>
368          <Text dimColor>{what} off {when}</Text>
369          {c.offBy === '/gate off' ? <Text dimColor> · </Text> : null}
370          {c.offBy === '/gate off' ? <Button key="gate-on" plain label="turn on" onPress={() => void setOn($, cfg)} /> : null}
371        </Box>
372      )
373    }
374    return withBelow(
375      <Box flexDirection="row">
376        <Text color="green">● </Text>
377        <Text dimColor>{what} on</Text>
378        <Text dimColor>{" · "}</Text>
379        <Button key="gate-off-1h" plain dimColor label="off 1h" onPress={() => void setOff($, cfg, 3_600_000)} />
380        <Text dimColor> · </Text>
381        <Button key="gate-off-8h" plain dimColor label="off 8h" onPress={() => void setOff($, cfg, 8 * 3_600_000)} />
382      </Box>
383    )
384  })
385}
386
hooks/logic.ts 595 lines
1// Pure helpers: what is gated, how its payload is summarised, how the
2// off switch and the log line read. No `$` here, so tests call it directly.
3
4export type Mode = 'auto' | 'on' | 'off'
5export type Decision = 'approved' | 'denied' | 'timed-out' | 'bypassed-off' | 'bypassed-headless' | 'exempt'
6
7/** A gated call: dialog title, payload lines, and the one-line log summary. */
8export type Gated = { title: string; lines: string[]; summary: string }
9
10/**
11 * Where paths resolve: the Bash cwd, $HOME, and the vault root (null = unknown, gate everything);
12 * plus what a delete may skip the dialog for: temp roots, and files this session created.
13 */
14export type PathContext = {
15  cwd: string
16  home: string
17  vaultRoot: string | null
18  /** /tmp, /private/tmp, $TMPDIR: a target strictly under one is exempt. */
19  tmpRoots: readonly string[]
20  /** Absolute paths a Write created this session: an exact match is exempt. */
21  created: readonly string[]
22}
23
24// Add a row per service that sends, publishes or deletes (and its wording in MCP_PHRASE).
25// Force pushes / dangerous rm -rf defer to guard_destructive_bash.sh when present (see README).
26const MCP_GATED: readonly [RegExp, string][] = [
27  [/^mcp__claude_ai_Gmail__(send_message|reply|forward|trash_\w+)$/, 'Gmail'],
28  [/^mcp__n8n-mcp__(execute_workflow|test_workflow|publish_workflow|unpublish_workflow|archive_workflow)$/, 'n8n'],
29  [/^mcp__raindrop-official__(delete_\w+|merge_collections)$/, 'Raindrop'],
30]
31
32// Allow-list of input keys shown in the dialog and log. Bodies (body, htmlBody,
33// forwardText, inputs, attachment content) never match, so they never leak.
34const SHOWN_KEY = /^(to|cc|bcc|subject|replyAll|executionMode|triggerNodeName|tags|\w*Ids?|\w*_ids?)$/
35
36export function gatedMcp(tool: string): string | undefined {
37  for (const [re, service] of MCP_GATED) {
38    const m = re.exec(tool)
39    if (m) return `${service} · ${m[1]}`
40  }
41  return undefined
42}
43
44const MCP_PHRASE: Readonly<Record<string, string>> = {
45  send_message: 'send an email',
46  reply: 'reply to an email',
47  forward: 'forward an email',
48  execute_workflow: 'run an n8n workflow',
49  test_workflow: 'test-run an n8n workflow (every node but the trigger runs for real)',
50  publish_workflow: 'publish an n8n workflow',
51  unpublish_workflow: 'unpublish an n8n workflow',
52  archive_workflow: 'archive an n8n workflow',
53  merge_collections: 'merge Raindrop collections',
54}
55
56/** "Gmail · send_message" → "send an email"; a Bash title is already plain. Reads after "Claude wants to". */
57export function headline(title: string): string {
58  const [service = '', op] = title.split(' · ')
59  if (op === undefined) return title
60  const phrase = MCP_PHRASE[op]
61  if (phrase !== undefined) return phrase
62  if (op.startsWith('trash_')) return `trash a Gmail ${op.slice(6)}`
63  if (op.startsWith('delete_')) return `delete Raindrop ${op.slice(7).replace(/_/g, ' ')}`
64  return `${service} ${op}`
65}
66
67function show(value: unknown): string | undefined {
68  if (typeof value === 'string') return clip(value, 100)
69  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
70  if (Array.isArray(value)) {
71    const items = value.filter(v => typeof v === 'string' || typeof v === 'number').map(String)
72    const head = items.slice(0, 5).join(', ')
73    return `${value.length} item${value.length === 1 ? '' : 's'}${head ? `: ${head}` : ''}${items.length > 5 ? ', …' : ''}`
74  }
75  return undefined
76}
77
78/** Payload lines for an MCP call: allow-listed keys only, plus an attachment count. */
79export function mcpLines(input: Readonly<Record<string, unknown>>): string[] {
80  const lines: string[] = []
81  for (const [key, value] of Object.entries(input)) {
82    if (key === 'attachments' && Array.isArray(value)) {
83      lines.push(`attachments: ${value.length}`)
84      continue
85    }
86    if (!SHOWN_KEY.test(key)) continue
87    const shown = show(value)
88    if (shown !== undefined) lines.push(`${key}: ${shown}`)
89  }
90  return lines
91}
92
93// ---- Bash -------------------------------------------------------------------
94//
95// Fail closed: a delete whose target can't be placed counts as outside the vault,
96// and a command that mentions git and push in a shape the parser doesn't know is
97// gated. Fail quiet: a delete under a temp root, or of a file this session wrote,
98// passes with no dialog (logged `exempt`).
99
100/** A heredoc operator at `i` (`<<`, `<<-`; not `<<<`): its delimiter, and where the operator ends. */
101function heredoc(s: string, i: number): { delim: string; strip: boolean; quoted: boolean; end: number } | undefined {
102  if (!s.startsWith('<<', i) || s.charAt(i + 2) === '<') return undefined
103  let k = i + 2
104  const strip = s.charAt(k) === '-'
105  if (strip) k++
106  while (s.charAt(k) === ' ' || s.charAt(k) === '\t') k++
107  const m = /^(?:'([^']*)'|"([^"]*)"|\\?([^\s;&|<>()]+))/.exec(s.slice(k))
108  if (!m) return undefined
109  const delim = m[1] ?? m[2] ?? m[3] ?? ''
110  return { delim, strip, quoted: m[1] !== undefined || m[2] !== undefined || m[0].startsWith('\\'), end: k + m[0].length }
111}
112
113/** From the newline at `nl`, skip a heredoc body through its delimiter line; returns the index after it. */
114function skipBody(s: string, nl: number, hd: { delim: string; strip: boolean }): { body: string; end: number } {
115  let at = nl + 1
116  while (at <= s.length) {
117    const eol = s.indexOf('\n', at)
118    const line = s.slice(at, eol < 0 ? s.length : eol)
119    if ((hd.strip ? line.replace(/^\t+/, '') : line) === hd.delim) return { body: s.slice(nl + 1, at), end: eol < 0 ? s.length : eol }
120    if (eol < 0) break
121    at = eol + 1
122  }
123  return { body: s.slice(nl + 1), end: s.length }
124}
125
126/** `$(…)`, `<(…)`, `>(…)` or `…` starting at `i`: the index just after it (quote- and heredoc-aware). */
127function skipSub(s: string, i: number): number {
128  if (s.charAt(i) === '`') {
129    for (let j = i + 1; j < s.length; j++) {
130      if (s.charAt(j) === '\\') j++
131      else if (s.charAt(j) === '`') return j + 1
132    }
133    return s.length
134  }
135  let depth = 0
136  let quote: '"' | "'" | null = null
137  const pending: { delim: string; strip: boolean }[] = []
138  for (let j = i + 1; j < s.length; j++) {
139    const c = s.charAt(j)
140    if (quote === "'") {
141      if (c === "'") quote = null
142    } else if (c === '\\') j++
143    else if (quote === '"') {
144      if (c === '"') quote = null
145      else if (c === '$' && s.charAt(j + 1) === '(') j = skipSub(s, j) - 1
146    } else if (c === '"' || c === "'") quote = c
147    else if (c === '`' || (c === '$' && s.charAt(j + 1) === '(' && j > i + 1)) j = skipSub(s, j) - 1
148    else if (c === '<' && heredoc(s, j)) {
149      const hd = heredoc(s, j)
150      if (hd) (pending.push(hd), (j = hd.end - 1))
151    } else if (c === '\n' && pending.length > 0) {
152      for (const hd of pending.splice(0)) j = skipBody(s, j, hd).end
153      j-- // land on the delimiter line's newline next
154    } else if (c === '(') depth++
155    else if (c === ')' && --depth === 0) return j + 1
156  }
157  return s.length
158}
159
160const opensSub = (s: string, i: number, quoted: boolean) =>
161  s.charAt(i) === '`' || (s.charAt(i + 1) === '(' && (s.charAt(i) === '$' || (!quoted && (s.charAt(i) === '<' || s.charAt(i) === '>'))))
162
163/** Shell-ish word split: quotes and backslash escapes, no expansion; a substitution stays one word. */
164export function words(segment: string): string[] {
165  const out: string[] = []
166  let cur = ''
167  let has = false
168  let quote: '"' | "'" | null = null
169  for (let i = 0; i < segment.length; i++) {
170    const c = segment.charAt(i)
171    if (quote !== "'" && opensSub(segment, i, quote === '"')) {
172      const end = skipSub(segment, i)
173      cur += segment.slice(i, end)
174      has = true
175      i = end - 1
176    } else if (quote) {
177      if (c === quote) quote = null
178      else if (c === '\\' && quote === '"' && i + 1 < segment.length) cur += segment.charAt(++i)
179      else cur += c
180    } else if (c === '"' || c === "'") {
181      quote = c
182      has = true
183    } else if (c === '\\' && i + 1 < segment.length) {
184      cur += segment.charAt(++i)
185      has = true
186    } else if (/\s/.test(c)) {
187      if (has) out.push(cur)
188      cur = ''
189      has = false
190    } else {
191      cur += c
192      has = true
193    }
194  }
195  if (has) out.push(cur)
196  return out
197}
198
199/**
200 * A simple command's text and the substitutions inside it; a subshell's `(` / `)`; or a heredoc
201 * body with the texts of the commands on its line (any of them may read it: `cat <<EOF | sh`).
202 */
203type Seg =
204  | { kind: 'cmd'; text: string; subs: string[] }
205  | { kind: 'open' }
206  | { kind: 'close' }
207  | { kind: 'heredoc'; body: string; quoted: boolean; line: string[] }
208
209/** Split on newline ; & | ( ) outside quotes and substitutions; skip comments; heredoc bodies apart. */
210export function lex(command: string): Seg[] {
211  const s = command
212  const out: Seg[] = []
213  let cur = ''
214  let subs: string[] = []
215  let quote: '"' | "'" | null = null
216  const pending: { delim: string; strip: boolean; quoted: boolean }[] = []
217  let lineStart = 0
218  const flush = () => {
219    if (cur.trim() !== '' || subs.length > 0) out.push({ kind: 'cmd', text: cur, subs })
220    cur = ''
221    subs = []
222  }
223  for (let i = 0; i < s.length; i++) {
224    const c = s.charAt(i)
225    if (quote === "'") {
226      cur += c
227      if (c === "'") quote = null
228    } else if (c === '\\' && s.charAt(i + 1) === '\n') {
229      cur += ' '
230      i++
231    } else if (c === '\\') {
232      cur += s.slice(i, i + 2)
233      i++
234    } else if (opensSub(s, i, quote === '"')) {
235      const end = skipSub(s, i)
236      const open = c === '`' ? 1 : 2
237      const closed = end - 1 >= i + open && s.charAt(end - 1) === (c === '`' ? '`' : ')')
238      subs.push(s.slice(i + open, closed ? end - 1 : end))
239      cur += s.slice(i, end)
240      i = end - 1
241    } else if (quote === '"') {
242      cur += c
243      if (c === '"') quote = null
244    } else if (c === '"' || c === "'") {
245      cur += c
246      quote = c
247    } else if (c === '#' && (cur === '' || /\s$/.test(cur))) {
248      const eol = s.indexOf('\n', i)
249      i = (eol < 0 ? s.length : eol) - 1
250    } else if (c === '<' && heredoc(s, i)) {
251      const hd = heredoc(s, i)
252      if (hd) (pending.push(hd), (cur += s.slice(i, hd.end)), (i = hd.end - 1))
253    } else if (c === '&' && (/[<>]$/.test(cur) || s.charAt(i + 1) === '>')) {
254      cur += c // >&2, 2>&1, &> are redirections, not separators
255    } else if (c === '\n' || c === ';' || c === '&' || c === '|') {
256      flush()
257      if (c === '\n') {
258        const line = out.slice(lineStart).flatMap(seg => (seg.kind === 'cmd' ? [seg.text] : []))
259        for (const hd of pending.splice(0)) {
260          const { body, end } = skipBody(s, i, hd)
261          out.push({ kind: 'heredoc', body, quoted: hd.quoted, line })
262          i = end - 1
263        }
264        lineStart = out.length
265      }
266    } else if (c === '(' || c === ')') {
267      flush()
268      out.push({ kind: c === '(' ? 'open' : 'close' })
269    } else cur += c
270  }
271  flush()
272  return out
273}
274
275/** Drop redirections (`> f`, `2>/dev/null`, `2>&1`, `<<EOF`): they aren't operands. */
276function dropRedirects(argv: readonly string[]): string[] {
277  const out: string[] = []
278  for (let i = 0; i < argv.length; i++) {
279    const a = argv[i] ?? ''
280    if (/^(\d*|&)(>>?|<<?<?)(&\d*-?)?$/.test(a) && !/&\d/.test(a)) i++ // bare operator: its target is the next word
281    else if (!/^(\d*|&)(>>?|<<?<?)/.test(a)) out.push(a)
282  }
283  return out
284}
285
286const KEYWORDS = new Set(['if', 'then', 'do', 'else', 'elif', 'while', 'until', '!', '{', 'time', 'command', 'builtin', 'exec', 'nohup'])
287
288/** Strip what runs the real command: keywords, VAR=x, env / sudo / nice / timeout and their options. */
289function unwrap(argv: readonly string[]): string[] {
290  let a = [...argv]
291  for (;;) {
292    const w = a[0]
293    if (w === undefined) return a
294    if (KEYWORDS.has(w) || /^[A-Za-z_]\w*=/.test(w)) a = a.slice(1)
295    else if (w === 'env') {
296      a = a.slice(1)
297      while (a[0]?.startsWith('-')) {
298        const f = a.shift()
299        if (f === '-u' || f === '-C' || f === '-P') a.shift()
300        else if (f === '-S') a = [...words(a.shift() ?? ''), ...a]
301      }
302    } else if (w === 'sudo') {
303      a = a.slice(1)
304      while (a[0]?.startsWith('-')) if (/^-[ugpChrtTDU]$/.test(a.shift() ?? '')) a.shift()
305    } else if (w === 'nice') {
306      a = a.slice(1)
307      while (a[0]?.startsWith('-')) if (a.shift() === '-n') a.shift()
308    } else if (w === 'timeout') {
309      a = a.slice(1)
310      while (a[0]?.startsWith('-')) if (/^-[sk]$/.test(a.shift() ?? '')) a.shift()
311      a = a.slice(1) // the duration
312    } else return a
313  }
314}
315
316/** `git [-C dir] [-c k=v] [--flag] <sub> …`: the subcommand and its index. */
317function gitSub(argv: readonly string[]): { sub: string | undefined; at: number } {
318  let i = 1
319  while (i < argv.length && (argv[i] ?? '').startsWith('-')) i += argv[i] === '-C' || argv[i] === '-c' ? 2 : 1
320  return { sub: argv[i], at: i }
321}
322
323const redactUrl = (s: string) => s.replace(/\/\/[^/@\s]+@/g, '//***@')
324
325/** `git [-C dir] [-c k=v] push …` → remote, refspecs, flags; undefined if not a push. */
326export function gitPush(argv: readonly string[]): { remote: string; refs: string[]; flags: string[] } | undefined {
327  if (argv[0] !== 'git') return undefined
328  const { sub, at } = gitSub(argv)
329  if (sub !== 'push') return undefined
330  const rest = argv.slice(at + 1)
331  const flags = rest.filter(a => a.startsWith('-'))
332  const pos = rest.filter(a => !a.startsWith('-')).map(redactUrl)
333  return { remote: pos[0] ?? '(default remote)', refs: pos.length > 1 ? pos.slice(1) : ['(current branch)'], flags }
334}
335
336// Subcommands that never push: a `push` word under them (`git stash push`) is understood.
337const GIT_NO_PUSH = new Set(
338  'stash log commit show diff status add fetch pull remote branch checkout switch grep tag reflog help rev-parse notes config blame restore reset merge rebase cherry-pick ls-files describe shortlog mv rm init clone apply am format-patch bisect clean gc fsck show-ref for-each-ref rev-list cat-file ls-remote range-diff'.split(' '),
339)
340// Commands that only print or match their arguments: `echo "git push"` runs nothing.
341const NON_EXEC = new Set('echo printf grep egrep fgrep rg head tail wc cat less man which type true false : test [ [[ export local declare readonly unset read'.split(' '))
342const SHELLS = /^(ba|z|da|k)?sh$/
343const DELETERS = /^(rm|trash)$/
344const MENTIONS_PUSH = /\bgit\b[\s\S]*\bpush\b/
345
346export function normalize(path: string): string {
347  const parts: string[] = []
348  for (const part of path.split('/')) {
349    if (part === '' || part === '.') continue
350    if (part === '..') parts.pop()
351    else parts.push(part)
352  }
353  return `/${parts.join('/')}`
354}
355
356/** Absolute path of a target, or null when it can't be placed: a `$`, a backtick, `~user`, or an unknown cwd. */
357export function resolveTarget(arg: string, cwd: string | null, home: string): string | null {
358  const expanded = arg.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home)
359  if (/[$`]/.test(expanded) || expanded.startsWith('~')) return null
360  if (expanded.startsWith('/')) return normalize(expanded)
361  return cwd === null ? null : normalize(`${cwd}/${expanded}`)
362}
363
364export function insideVault(path: string, vaultRoot: string | null): boolean {
365  if (vaultRoot === null) return false
366  const root = normalize(vaultRoot)
367  return path === root || path.startsWith(`${root}/`)
368}
369
370/** Strictly under one of the roots (the root itself never counts). */
371function underAny(path: string, roots: readonly string[]): boolean {
372  return roots.some(r => {
373    const root = normalize(r)
374    return root !== '/' && path.startsWith(`${root}/`)
375  })
376}
377
378/** One thing the dialog asks about: its title piece, dialog lines, and log summary piece. */
379type Ask = { action: string; lines: string[]; part: string }
380type Acc = { asks: Ask[]; exempt: string[]; createdHits: string[] }
381
382function operands(argv: readonly string[]): string[] {
383  const dashes = argv.indexOf('--')
384  return argv.slice(1).filter((a, i) => (dashes >= 0 && i + 1 > dashes) || !a.startsWith('-'))
385}
386
387/** rm / trash targets: vault skipped, temp and session files exempt, everything else asked. */
388function deletes(verb: string, targets: readonly string[], cwd: string | null, ctx: PathContext, acc: Acc): void {
389  const asked: string[] = []
390  for (const raw of targets) {
391    const p = resolveTarget(raw, cwd, ctx.home)
392    if (p === null) asked.push(`${raw} (unresolved)`)
393    else if (insideVault(p, ctx.vaultRoot)) continue
394    else if (underAny(p, ctx.tmpRoots)) acc.exempt.push(`${verb} ${p} (tmp)`)
395    else if (ctx.created.includes(p)) (acc.exempt.push(`${verb} ${p} (session file)`), acc.createdHits.push(p))
396    else asked.push(p)
397  }
398  if (asked.length === 0) return
399  const lines = asked.slice(0, 8).map(p => `${verb}: ${p}`)
400  if (asked.length > 8) lines.push(`${verb}: … ${asked.length - 8} more`)
401  acc.asks.push({
402    action: `${verb} ${asked.length === 1 ? 'a file' : `${asked.length} files`} outside the vault`,
403    lines,
404    part: `${verb} ${asked.slice(0, 3).join(' ')}${asked.length > 3 ? ` (+${asked.length - 3})` : ''}`,
405  })
406}
407
408/** `find <roots> … -exec rm` / `-delete`: gated when a root is outside the vault or can't be placed. */
409function findDelete(argv: readonly string[], cwd: string | null, ctx: PathContext, acc: Acc): void {
410  const exec = argv.findIndex((a, i) => /^-(exec|execdir|ok|okdir)$/.test(a) && DELETERS.test((argv[i + 1] ?? '').split('/').pop() ?? ''))
411  if (exec < 0 && !argv.includes('-delete')) return
412  const verb = exec >= 0 ? `find -exec ${(argv[exec + 1] ?? '').split('/').pop()}` : 'find -delete'
413  let i = 1
414  while (/^-[HLPEX]$/.test(argv[i] ?? '')) i++
415  const roots: string[] = []
416  while (i < argv.length && !/^[-(!]/.test(argv[i] ?? '')) roots.push(argv[i++] ?? '')
417  const asked: string[] = []
418  for (const raw of roots.length ? roots : ['.']) {
419    const p = resolveTarget(raw, cwd, ctx.home)
420    if (p === null) asked.push(`${raw} (unresolved)`)
421    else if (insideVault(p, ctx.vaultRoot)) continue
422    else if (underAny(p, ctx.tmpRoots)) acc.exempt.push(`${verb} under ${p} (tmp)`)
423    else asked.push(p)
424  }
425  if (asked.length === 0) return
426  acc.asks.push({ action: `${verb} under ${asked.length === 1 ? 'a folder' : `${asked.length} folders`} outside the vault`, lines: asked.map(p => `${verb} under: ${p}`), part: `${verb} under ${asked.join(' ')}` })
427}
428
429function unparsedPush(argv: readonly string[], acc: Acc): void {
430  const text = redactUrl(clip(argv.join(' '), 120))
431  acc.asks.push({ action: 'run a command that may git push', lines: [`command: ${text}`], part: `unparsed git push: ${text}` })
432}
433
434/** Walk one command string; returns the cwd it leaves behind (for `eval`). */
435function walk(command: string, start: string | null, ctx: PathContext, acc: Acc, depth: number): string | null {
436  if (depth > 8) {
437    if (MENTIONS_PUSH.test(command)) unparsedPush([command], acc)
438    return null
439  }
440  let cwd = start
441  const stack: (string | null)[] = []
442  for (const seg of lex(command)) {
443    if (seg.kind === 'open') stack.push(cwd)
444    else if (seg.kind === 'close') cwd = stack.length ? (stack.pop() ?? null) : cwd
445    else if (seg.kind === 'heredoc') heredocBody(seg, cwd, ctx, acc, depth)
446    else {
447      for (const sub of seg.subs) walk(sub, cwd, ctx, acc, depth + 1)
448      cwd = step(unwrap(dropRedirects(words(seg.text))), cwd, ctx, acc, depth)
449    }
450  }
451  return cwd
452}
453
454/**
455 * A heredoc body is data to `cat`/`echo`/`git commit`, a script to a shell, and unknown to
456 * anything else (python, ssh…): gated there when it mentions git and push.
457 */
458function heredocBody(seg: { body: string; quoted: boolean; line: string[] }, cwd: string | null, ctx: PathContext, acc: Acc, depth: number): void {
459  // An unquoted delimiter still expands $(…) and `…` in the body.
460  if (!seg.quoted) for (const s of lex(seg.body)) if (s.kind === 'cmd') for (const sub of s.subs) walk(sub, cwd, ctx, acc, depth + 1)
461  const readers = seg.line.map(t => unwrap(dropRedirects(words(t)))).filter(a => a.length > 0)
462  const base = (a: readonly string[]) => (a[0] ?? '').split('/').pop() ?? ''
463  if (readers.some(a => SHELLS.test(base(a)) && !a.slice(1).some(f => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(f)))) {
464    walk(seg.body, cwd, ctx, acc, depth + 1)
465    return
466  }
467  const dataOnly = readers.every(a => NON_EXEC.has(base(a)) || (base(a) === 'git' && GIT_NO_PUSH.has(gitSub(a).sub ?? '')))
468  if (!dataOnly && MENTIONS_PUSH.test(seg.body)) unparsedPush([...(readers[0] ?? []), '<<heredoc'], acc)
469}
470
471/** Classify one simple command; returns the cwd after it. */
472function step(argv: string[], cwd: string | null, ctx: PathContext, acc: Acc, depth: number): string | null {
473  const cmd = argv[0]
474  if (cmd === undefined) return cwd
475  const base = cmd.split('/').pop() ?? cmd
476  if (base === 'cd' || base === 'pushd') {
477    const to = argv[1]
478    return to === undefined ? ctx.home : to === '-' ? null : resolveTarget(to, cwd, ctx.home)
479  }
480  if (base === 'popd') return null
481  if (base === 'eval') return walk(argv.slice(1).join(' '), cwd, ctx, acc, depth + 1)
482  if (SHELLS.test(base)) {
483    const flag = argv.findIndex((a, i) => i > 0 && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
484    const script = flag > 0 ? argv[flag + 1] : undefined
485    if (script !== undefined) walk(script, cwd, ctx, acc, depth + 1)
486    else if (MENTIONS_PUSH.test(argv.join(' '))) unparsedPush(argv, acc)
487    return cwd
488  }
489  if (base === 'git') {
490    argv = ['git', ...argv.slice(1)]
491    const push = gitPush(argv)
492    if (push) {
493      const lines = [`remote: ${push.remote}`, `refs: ${push.refs.join(', ')}`]
494      if (push.flags.length) lines.push(`flags: ${push.flags.join(' ')}`)
495      acc.asks.push({ action: 'git push', lines, part: `git push ${push.remote} ${push.refs.join(' ')}${push.flags.length ? ` ${push.flags.join(' ')}` : ''}` })
496    } else {
497      // A subcommand held in a variable (`git $x`) can't be told apart from push.
498      const sub = gitSub(argv).sub ?? ''
499      if (/[$`]/.test(sub) || (!GIT_NO_PUSH.has(sub) && argv.slice(1).some(a => /\bpush\b/.test(a)))) unparsedPush(argv, acc)
500    }
501    return cwd
502  }
503  // A command word held in a variable (`$G push`): unknown command, so a push word gates it.
504  if (/[$`]/.test(cmd) && argv.some(a => /\bpush\b/.test(a))) {
505    unparsedPush(argv, acc)
506    return cwd
507  }
508  if (DELETERS.test(base)) {
509    deletes(base, operands(argv), cwd, ctx, acc)
510    return cwd
511  }
512  if (base === 'mv') {
513    const ops = operands(argv)
514    const dest = ops.length >= 2 ? resolveTarget(ops[ops.length - 1] ?? '', cwd, ctx.home) : null
515    const trash = normalize(`${ctx.home}/.Trash`)
516    if (dest !== null && (dest === trash || dest.startsWith(`${trash}/`))) deletes('trash', ops.slice(0, -1), cwd, ctx, acc)
517    return cwd
518  }
519  if (base === 'xargs' && argv.slice(1).some(a => /(^|[\s/])(rm|trash)(\s|$)/.test(a))) {
520    acc.asks.push({ action: 'xargs rm (targets unknown)', lines: ['xargs rm: targets come from input (unknown)'], part: 'xargs rm (targets unknown)' })
521    return cwd
522  }
523  if (base === 'find') findDelete(argv, cwd, ctx, acc)
524  if (!NON_EXEC.has(base) && MENTIONS_PUSH.test(argv.join(' '))) unparsedPush(argv, acc)
525  return cwd
526}
527
528/** What a Bash command needs: a dialog (`gated`), and what passed as exempt (logged either way). */
529export type BashGate = { gated: Gated | undefined; exempt: string[]; createdHits: string[] }
530
531export function classifyBash(command: string, ctx: PathContext): BashGate {
532  const acc: Acc = { asks: [], exempt: [], createdHits: [] }
533  walk(command, ctx.cwd, ctx, acc, 0)
534  if (acc.asks.length === 0) return { gated: undefined, exempt: acc.exempt, createdHits: acc.createdHits }
535  const tail = acc.exempt.length ? ` · exempt: ${acc.exempt.join('; ')}` : ''
536  const gated = { title: acc.asks.map(a => a.action).join(' and '), lines: acc.asks.flatMap(a => a.lines), summary: `${acc.asks.map(a => a.part).join('; ')}${tail}` }
537  return { gated, exempt: acc.exempt, createdHits: acc.createdHits }
538}
539
540/** Gate a Bash command: any non-force `git push` (however wrapped), or a delete outside the vault. */
541export function gatedBash(command: string, ctx: PathContext): Gated | undefined {
542  return classifyBash(command, ctx).gated
543}
544
545// ---- off switch, status, log -----------------------------------------------
546
547export function parseMode(value: unknown): Mode | undefined {
548  return value === 'auto' || value === 'on' || value === 'off' ? value : undefined
549}
550
551/** "8h" / "30m" / "2d" / "45" (minutes) → ms; undefined when unreadable. */
552export function parseDuration(text: string): number | undefined {
553  const m = /^(\d+(?:\.\d+)?)\s*([mhd]?)$/i.exec(text.trim())
554  if (!m) return undefined
555  const n = Number(m[1])
556  const unit = (m[2] ?? '').toLowerCase()
557  const ms = n * (unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 60_000)
558  return ms > 0 ? ms : undefined
559}
560
561export function formatLeft(ms: number): string {
562  const min = Math.max(1, Math.ceil(ms / 60_000))
563  if (min < 60) return `${min}m`
564  const h = Math.floor(min / 60)
565  return min % 60 ? `${h}h${min % 60}m` : `${h}h`
566}
567
568export function clip(text: string, max: number): string {
569  const one = text.replace(/\s+/g, ' ').trim()
570  return one.length > max ? `${one.slice(0, max - 1)}…` : one
571}
572
573/** Local time as 2026-10-02T23:14:05+02:00. */
574export function localStamp(ms: number): string {
575  const d = new Date(ms)
576  const off = -d.getTimezoneOffset()
577  const pad = (n: number) => String(Math.floor(Math.abs(n))).padStart(2, '0')
578  const local = new Date(ms + off * 60_000).toISOString().slice(0, 19)
579  return `${local}${off >= 0 ? '+' : '-'}${pad(off / 60)}:${pad(off % 60)}`
580}
581
582export function logLine(f: { at: number; session: string; mode: Mode; tool: string; summary: string; decision: Decision }): string {
583  return [localStamp(f.at), `session=${f.session}`, `mode=${f.mode}`, `tool=${f.tool}`, `decision=${f.decision}`, `payload=${clip(f.summary, 200)}`].join('\t')
584}
585
586export function denyReason(title: string, verdict: 'denied' | 'timed-out' | 'headless-strict', timeoutMin: number): string {
587  const why =
588    verdict === 'timed-out'
589      ? `unattended — not approved (no answer within ${timeoutMin} min)`
590      : verdict === 'headless-strict'
591        ? 'gate mode is "on" and this session is headless, so nobody can approve it'
592        : 'the user denied it in the confirm dialog'
593  return `outward-gate: ${title} was blocked — ${why}. Do not retry it or route around it (no other tool to the same end). Continue with your other work and list this blocked action in your closeout.`
594}
595
types/index.d.ts 35 lines
1/** How a confirm dialog ended. */
2export type GateVerdict = 'approved' | 'denied' | 'timed-out'
3
4/** A session-scoped `/gate off`: `until` epoch ms, or null for "until /gate on". */
5export type GateOff = { until: number | null }
6
7/**
8 * The `$.gate` noun. Its only job is the wait: a `$` call's time never counts
9 * against a hook's 10 s budget, while awaiting a plain promise does (and a hook
10 * that overruns is skipped, i.e. the tool would run: fail-open). The engine also
11 * bounds each noun call to 10 s, so a wait answers 'pending' every few seconds
12 * and the caller asks again.
13 */
14export type Gate = {
15  /** The dialog's verdict once there is one, else 'pending' at the next pulse. */
16  wait: (args: { id: string }) => Promise<GateVerdict | 'pending'>
17}
18
19declare module 'claude-code' {
20  interface EngineInterface {
21    gate: Gate
22  }
23  interface PluginState {
24    'outward-gate': {
25      /** Session off switch; null while the gate is on. */
26      off: GateOff | null
27      /**
28       * Absolute paths a Write created this session (not ones it overwrote), plus where they
29       * resolve; an rm whose targets all match exactly passes with no dialog. Capped at 500.
30       */
31      created: string[]
32    }
33  }
34}
35