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

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.
tool.call)| Service | Tools | Dialog shows | ||
|---|---|---|---|---|
| Gmail | send_message, reply, forward, trash_* (drafts are not gated) | to/cc/bcc, subject, message/thread ids, attachment count | ||
| n8n | execute_workflow, test_workflow, publish_workflow, unpublish_workflow, archive_workflow | workflow name (looked up) + id, execution mode | ||
| Raindrop | every delete_*, merge_collections | ids / tag names, counts | ||
| Bash | git push (non-force), however wrapped: ( … ), { …; }, if/then/do/else/!, env/sudo/nice/timeout, `bash\ | sh\ | zsh -c '…', eval, $( … )`, backticks, a heredoc fed to a shell | remote, refs, flags (URL credentials redacted) |
| Bash | a 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" | ||
| Bash | rm / 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) | ||
| Bash | xargs rm | "targets come from input (unknown)": always gated | ||
| Bash | find <roots> … -exec rm / -delete with a root outside the vault or unresolvable | the roots | ||
| Bash | mv <path> ~/.Trash for a path outside the vault | as 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).
exempt)/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).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.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.
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.
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.
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.
Modes: auto (default), on, off. Set them in the config menu (mode) or with the OUTWARD_GATE env var, which overrides the config.
| interactive | headless (claude -p, SDK: no surface) | |
|---|---|---|
auto | dialog | passes, logged bypassed-headless |
on | dialog | denied at once (never waits) |
off | passes, logged bypassed-off | passes, 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.● 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.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.
~/.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.
hooks/logic.ts: pure classification (shell lexer, Bash walk, exemptions), payload summaries, durations, log linehooks/register.tsx: hooks (incl. the Write recorder), question, band, /gate, the $.gate.wait nountypes/index.d.ts: state + noun contracttests/outward-gate.test.ts: claude plugin test <this folder>hooks/register.tsx 386 lines1import { 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}
386hooks/logic.ts 595 lines1// 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}
595types/index.d.ts 35 lines1/** 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