@agfpd/iapeer 0.4.67 → 0.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ // approval-hook — the blocking bridge from a runtime's PreToolUse hook to the daemon
2
+ // approval broker (docs/17). Installed (gated peers only) as the `command` of a claude /
3
+ // codex PreToolUse hook. On each matched tool call it: reads the runtime's hook JSON on
4
+ // stdin, builds a human-readable approval request, POSTs it to the daemon (long-poll,
5
+ // blocking until a human answers or the broker's default-deny timeout), and prints the
6
+ // runtime hook decision JSON on stdout. claude and codex share the PreToolUse output
7
+ // shape (`hookSpecificOutput.permissionDecision`), so ONE format serves both.
8
+ //
9
+ // FAIL-SAFE by construction: ANY failure (no PEER identity, daemon unreachable, malformed
10
+ // response, client timeout) prints a DENY with a reason — a gated peer is never allowed to
11
+ // act unapproved, and never hangs (the client timeout sits above the broker's).
12
+ //
13
+ // Pure helpers (parseHookInput / actionContent / formatHookDecision) are unit-tested;
14
+ // runApprovalHook is the thin IO orchestration over the shared broker transport (brokerClient).
15
+
16
+ import { type ApprovalDecision, requestApproval, resolveFleetBase } from './brokerClient.ts'
17
+
18
+ function str(v: unknown): string {
19
+ return typeof v === 'string' ? v : v === undefined || v === null ? '' : JSON.stringify(v)
20
+ }
21
+ function firstLine(s: string): string {
22
+ const nl = s.indexOf('\n')
23
+ return (nl >= 0 ? s.slice(0, nl) : s).trim()
24
+ }
25
+ function clip(s: string, max: number): string {
26
+ return s.length > max ? `${s.slice(0, max)}\n…[+${s.length - max} chars]` : s
27
+ }
28
+
29
+ export interface ActionContent {
30
+ kind: 'tool' | 'plan'
31
+ content: string
32
+ summary: string
33
+ }
34
+
35
+ /** Turn a PreToolUse (tool_name, tool_input) into the human-readable content + summary the
36
+ * broker shows in every channel. Covers the v1 nomenclature (docs/17 §2); an unknown tool
37
+ * falls back to a pretty-printed tool_input so nothing is opaque. */
38
+ export function actionContent(toolName: string, toolInput: Record<string, unknown> | undefined): ActionContent {
39
+ const ti = toolInput ?? {}
40
+ switch (toolName) {
41
+ case 'Bash': {
42
+ const cmd = str(ti.command)
43
+ const desc = str(ti.description)
44
+ return { kind: 'tool', content: desc ? `${cmd}\n\n# ${desc}` : cmd, summary: cmd || 'Bash' }
45
+ }
46
+ case 'Write': {
47
+ const fp = str(ti.file_path)
48
+ return { kind: 'tool', content: `Write ${fp}\n\n${clip(str(ti.content), 8000)}`, summary: `Write ${fp}` }
49
+ }
50
+ case 'Edit': {
51
+ const fp = str(ti.file_path)
52
+ return {
53
+ kind: 'tool',
54
+ content: `Edit ${fp}\n\n--- old\n${clip(str(ti.old_string), 3000)}\n+++ new\n${clip(str(ti.new_string), 3000)}`,
55
+ summary: `Edit ${fp}`,
56
+ }
57
+ }
58
+ case 'MultiEdit': {
59
+ const fp = str(ti.file_path)
60
+ const edits = Array.isArray(ti.edits) ? ti.edits : []
61
+ return { kind: 'tool', content: `MultiEdit ${fp} (${edits.length} edits)\n\n${clip(JSON.stringify(edits, null, 2), 6000)}`, summary: `MultiEdit ${fp} (${edits.length})` }
62
+ }
63
+ case 'NotebookEdit': {
64
+ const fp = str(ti.notebook_path)
65
+ return { kind: 'tool', content: `NotebookEdit ${fp}\n\n${clip(str(ti.new_source), 6000)}`, summary: `NotebookEdit ${fp}` }
66
+ }
67
+ case 'ExitPlanMode': {
68
+ const plan = str(ti.plan)
69
+ return { kind: 'plan', content: plan, summary: firstLine(plan) || 'plan' }
70
+ }
71
+ // codex patch application (tool_input carries the patch text)
72
+ case 'apply_patch':
73
+ case 'ApplyPatch': {
74
+ const patch = str(ti.input ?? ti.patch ?? ti)
75
+ return { kind: 'tool', content: clip(patch, 8000), summary: `apply_patch (${firstLine(patch) || 'diff'})` }
76
+ }
77
+ default: {
78
+ const body = clip(JSON.stringify(ti, null, 2), 6000)
79
+ return { kind: 'tool', content: `${toolName}\n\n${body}`, summary: toolName }
80
+ }
81
+ }
82
+ }
83
+
84
+ /** The two hook events we intercept. `PermissionRequest` (claude, and codex) fires ONLY when
85
+ * the runtime's permission config DECIDED to prompt — the primary, matcher-free interceptor
86
+ * (verified live 2.1.201: fires + suppresses the prompt + returns a decision, policy stays
87
+ * 100% at the runtime). `PreToolUse` fires on every matched tool (codex structural path). */
88
+ export type HookEvent = 'PermissionRequest' | 'PreToolUse'
89
+
90
+ export interface HookInput {
91
+ event: HookEvent
92
+ toolName: string
93
+ toolInput: Record<string, unknown> | undefined
94
+ }
95
+
96
+ /** Parse the runtime's hook stdin JSON. Throws on non-JSON / unknown event / missing tool_name
97
+ * (the caller turns a throw into a fail-safe exit-2 deny, which both events honor). */
98
+ export function parseHookInput(stdin: string): HookInput {
99
+ const raw = JSON.parse(stdin) as Record<string, unknown>
100
+ const evName = raw.hook_event_name
101
+ const event: HookEvent =
102
+ evName === 'PermissionRequest' ? 'PermissionRequest' : evName === 'PreToolUse' ? 'PreToolUse' : throwEvent(evName)
103
+ const toolName = raw.tool_name
104
+ if (typeof toolName !== 'string' || !toolName) throw new Error('hook stdin has no tool_name')
105
+ const toolInput =
106
+ raw.tool_input && typeof raw.tool_input === 'object' && !Array.isArray(raw.tool_input)
107
+ ? (raw.tool_input as Record<string, unknown>)
108
+ : undefined
109
+ return { event, toolName, toolInput }
110
+ }
111
+ function throwEvent(v: unknown): never {
112
+ throw new Error(`hook stdin has an unexpected hook_event_name "${String(v)}"`)
113
+ }
114
+
115
+ /** The decision JSON in the shape the given event accepts. A deny carries the reason to the
116
+ * model. PermissionRequest uses `decision.behavior` (+ `message`); PreToolUse uses
117
+ * `permissionDecision` (+ `permissionDecisionReason`) — both verified live 2.1.201 / codex 0.142.5. */
118
+ export function formatHookDecision(d: ApprovalDecision, event: HookEvent): string {
119
+ if (event === 'PermissionRequest') {
120
+ return JSON.stringify({
121
+ hookSpecificOutput: {
122
+ hookEventName: 'PermissionRequest',
123
+ decision: { behavior: d.decision, ...(d.decision === 'deny' && d.reason ? { message: d.reason } : {}) },
124
+ },
125
+ })
126
+ }
127
+ return JSON.stringify({
128
+ hookSpecificOutput: {
129
+ hookEventName: 'PreToolUse',
130
+ permissionDecision: d.decision,
131
+ ...(d.decision === 'deny' && d.reason ? { permissionDecisionReason: d.reason } : {}),
132
+ },
133
+ })
134
+ }
135
+
136
+ export interface RunHookDeps {
137
+ env?: NodeJS.ProcessEnv
138
+ /** Injectable fetch (tests). Default global fetch. */
139
+ fetch?: typeof fetch
140
+ /** Injectable client timeout (tests). */
141
+ timeoutMs?: number
142
+ }
143
+
144
+ export interface HookRunResult {
145
+ /** Decision JSON for the runtime (empty on the exit-2 hard fail-safe). */
146
+ stdout: string
147
+ /** Reason fed to the model on the exit-2 path (empty otherwise). */
148
+ stderr: string
149
+ /** 0 = decision expressed in stdout JSON; 2 = hard fail-safe deny (both events honor it). */
150
+ exitCode: number
151
+ }
152
+
153
+ /**
154
+ * Full hook run: stdin JSON → broker → decision JSON in the shape the event accepts. Never
155
+ * throws. Two fail-safe layers: once the event is known, any downstream failure (no identity,
156
+ * daemon down, timeout, broker deny) returns an event-appropriate DENY JSON (exit 0, reason to
157
+ * the model); a stdin we cannot even parse (unknown event) returns exit 2 + stderr, which
158
+ * DENIES under BOTH PermissionRequest and PreToolUse — so a gated peer is never allowed
159
+ * unapproved and never hangs, even on a malformed hook payload.
160
+ */
161
+ export async function runApprovalHook(stdin: string, deps: RunHookDeps = {}): Promise<HookRunResult> {
162
+ const env = deps.env ?? process.env
163
+ const doFetch = deps.fetch ?? fetch
164
+ const timeoutMs = deps.timeoutMs
165
+ let event: HookEvent
166
+ let toolName: string
167
+ let toolInput: Record<string, unknown> | undefined
168
+ try {
169
+ ;({ event, toolName, toolInput } = parseHookInput(stdin))
170
+ } catch (e) {
171
+ // Cannot determine the event → exit 2 denies in BOTH events (stderr → model).
172
+ return { stdout: '', stderr: `iapeer approval-hook: ${e instanceof Error ? e.message : String(e)} — denied fail-safe`, exitCode: 2 }
173
+ }
174
+ try {
175
+ const personality = env.PEER_PERSONALITY?.trim()
176
+ if (!personality) throw new Error('no PEER_PERSONALITY in the hook environment')
177
+ const runtime = env.PEER_RUNTIME?.trim() || 'claude'
178
+ const { kind, content, summary } = actionContent(toolName, toolInput)
179
+ const decision = await requestApproval(
180
+ resolveFleetBase(env),
181
+ { personality, runtime, kind, tool: toolName, content, summary },
182
+ { env, fetch: doFetch, timeoutMs },
183
+ )
184
+ return { stdout: formatHookDecision(decision, event), stderr: '', exitCode: 0 }
185
+ } catch (e) {
186
+ // Event known → event-appropriate DENY JSON (never allow-by-accident, never hang).
187
+ return {
188
+ stdout: formatHookDecision({ decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }, event),
189
+ stderr: '',
190
+ exitCode: 0,
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,194 @@
1
+ // approval install — the RUNTIME-CONFIG surfaces the gated toggle brings up/down (docs/17
2
+ // §1). The PreToolUse interceptor + the coarse tool-class matcher live on the runtime's OWN
3
+ // hook config (claude: <cwd>/.claude/settings.json hooks.PreToolUse[].matcher; codex:
4
+ // <cwd>/.codex/hooks.json matcher) — so the user tunes WHICH classes are gated as ordinary
5
+ // runtime config; iapeer only SEEDS the default on flip-to-gated (seed-if-absent) and REMOVES
6
+ // its whole block on flip-to-yolo. Idempotent both ways: a gated→yolo→gated round-trip is
7
+ // byte-identical (no accumulation, no half-states) — the toggle invariant.
8
+ //
9
+ // Also installed for gated claude: an allow-rule for the peer's OWN MCP tool
10
+ // (mcp__iapeer__send_to_peer) so a gated peer's IAP channel is never itself gated (a
11
+ // no-bypass session would otherwise prompt on it and hang).
12
+
13
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
14
+ import { dirname, join } from 'path'
15
+ import { writeFileAtomic } from '../storage/index.ts'
16
+ import { claudeSettingsPath } from '../launch/nativeMemory.ts'
17
+ import { iapeerBinPath } from '../install/index.ts'
18
+ import { readPeerProfile, writePeerProfileAtomic, type ApprovalMode } from '../identity/index.ts'
19
+ import { preSeedCodexHooksTrust, removeCodexHooksTrustUnder, type HooksTrustOutcome } from '../launch/codexHooksTrust.ts'
20
+
21
+ // claude gated uses a matcher-FREE PermissionRequest hook (docs/17, Option D — verified live
22
+ // 2.1.201): PermissionRequest fires ONLY when the runtime's permission config DECIDED to prompt
23
+ // (a tool call not covered by an allow/deny rule), so the policy of "what to ask" stays 100% at
24
+ // the runtime — the user tunes it with ordinary permission rules (a tool in permissions.allow is
25
+ // never asked → the hook never fires → auto-allowed). No class matcher, no drift, no hang: a new
26
+ // tool the runtime prompts for is intercepted the same way. iapeer only seeds the hook + the
27
+ // allow-rule for its own MCP tool (else the runtime would prompt on the peer's own IAP channel).
28
+ /** codex tool-name regex (codex PreToolUse path — structural, live-verify pending). */
29
+ export const CODEX_APPROVAL_MATCHER = '^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$'
30
+ /** The peer's own IAP tool — allow-listed so a gated peer's send_to_peer is not itself gated. */
31
+ export const IAPEER_MCP_ALLOW = 'mcp__iapeer__send_to_peer'
32
+ /** Runtime hook timeout (seconds): ABOVE the broker default-deny (300) + the hook client fetch
33
+ * ceiling (600) so the broker/human answers before the runtime kills the hook. */
34
+ const HOOK_TIMEOUT_SECS = 900
35
+
36
+ /** The `command` string the runtime runs per matched tool call (the installed binary). */
37
+ export function approvalHookCommand(env: NodeJS.ProcessEnv = process.env): string {
38
+ return `${iapeerBinPath(env)} approval-hook`
39
+ }
40
+
41
+ function isIapeerGroup(group: unknown): boolean {
42
+ const hooks = (group as { hooks?: unknown })?.hooks
43
+ return (
44
+ Array.isArray(hooks) &&
45
+ hooks.some(h => typeof (h as { command?: unknown })?.command === 'string' && (h as { command: string }).command.includes('approval-hook'))
46
+ )
47
+ }
48
+
49
+ function iapeerClaudeGroup(env: NodeJS.ProcessEnv): Record<string, unknown> {
50
+ // Matcher-free: PermissionRequest already fires only on prompt-worthy calls (Option D).
51
+ return { hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] }
52
+ }
53
+
54
+ function readJsonObject(path: string): Record<string, unknown> | null {
55
+ if (!existsSync(path)) return {}
56
+ try {
57
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
58
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
59
+ return raw as Record<string, unknown>
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ // ── claude (project settings.json) ───────────────────────────────────────────
66
+
67
+ /** Seed the gated PreToolUse hook + MCP allow-rule into <cwd>/.claude/settings.json
68
+ * (seed-if-absent, no-clobber merge). Returns the path, or null on a refusal/error. */
69
+ export function installClaudeApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
70
+ const path = claudeSettingsPath(cwd)
71
+ const obj = readJsonObject(path)
72
+ if (obj === null) return null
73
+ // hooks.PermissionRequest — push OUR group iff absent (respect a user-added one).
74
+ const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
75
+ const pre = (Array.isArray(hooks.PermissionRequest) ? hooks.PermissionRequest : []) as unknown[]
76
+ if (!pre.some(isIapeerGroup)) pre.push(iapeerClaudeGroup(env))
77
+ hooks.PermissionRequest = pre
78
+ obj.hooks = hooks
79
+ // permissions.allow — allow-list the peer's own MCP tool (idempotent).
80
+ const perms = (obj.permissions && typeof obj.permissions === 'object' && !Array.isArray(obj.permissions) ? obj.permissions : {}) as Record<string, unknown>
81
+ const allow = (Array.isArray(perms.allow) ? perms.allow : []) as unknown[]
82
+ if (!allow.includes(IAPEER_MCP_ALLOW)) allow.push(IAPEER_MCP_ALLOW)
83
+ perms.allow = allow
84
+ obj.permissions = perms
85
+ mkdirSync(dirname(path), { recursive: true })
86
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
87
+ return path
88
+ }
89
+
90
+ /** Remove OUR gated hook + MCP allow-rule, cleaning up any structures we emptied so a
91
+ * gated→yolo flip restores the pre-install bytes (foreign hooks/rules preserved). */
92
+ export function removeClaudeApproval(cwd: string): string | null {
93
+ const path = claudeSettingsPath(cwd)
94
+ if (!existsSync(path)) return null
95
+ const obj = readJsonObject(path)
96
+ if (obj === null) return null
97
+ const hooks = obj.hooks as Record<string, unknown> | undefined
98
+ if (hooks && Array.isArray(hooks.PermissionRequest)) {
99
+ hooks.PermissionRequest = (hooks.PermissionRequest as unknown[]).filter(g => !isIapeerGroup(g))
100
+ if ((hooks.PermissionRequest as unknown[]).length === 0) delete hooks.PermissionRequest
101
+ if (Object.keys(hooks).length === 0) delete obj.hooks
102
+ }
103
+ const perms = obj.permissions as Record<string, unknown> | undefined
104
+ if (perms && Array.isArray(perms.allow)) {
105
+ perms.allow = (perms.allow as unknown[]).filter(r => r !== IAPEER_MCP_ALLOW)
106
+ if ((perms.allow as unknown[]).length === 0) delete perms.allow
107
+ if (Object.keys(perms).length === 0) delete obj.permissions
108
+ }
109
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
110
+ return path
111
+ }
112
+
113
+ // ── codex (project hooks.json + trust pre-seed) ──────────────────────────────
114
+
115
+ export function codexHooksJsonPath(cwd: string): string {
116
+ return join(cwd, '.codex', 'hooks.json')
117
+ }
118
+
119
+ /** Write <cwd>/.codex/hooks.json with the gated PreToolUse group (seed-if-absent merge) and
120
+ * pre-seed its trust in ~/.codex/config.toml (else codex silently SKIPS an untrusted hook). */
121
+ export function installCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): { path: string; trust?: HooksTrustOutcome } | null {
122
+ const path = codexHooksJsonPath(cwd)
123
+ const obj = readJsonObject(path)
124
+ if (obj === null) return null
125
+ const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
126
+ const pre = (Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []) as unknown[]
127
+ if (!pre.some(isIapeerGroup)) {
128
+ pre.push({ matcher: CODEX_APPROVAL_MATCHER, hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] })
129
+ }
130
+ hooks.PreToolUse = pre
131
+ obj.hooks = hooks
132
+ mkdirSync(dirname(path), { recursive: true })
133
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
134
+ const trust = preSeedCodexHooksTrust(path, env)
135
+ return { path, trust }
136
+ }
137
+
138
+ /** Remove the codex gated hook (drop OUR group; delete the file if it becomes empty) and its
139
+ * trust state under this cwd. */
140
+ export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
141
+ const path = codexHooksJsonPath(cwd)
142
+ removeCodexHooksTrustUnder(cwd, env)
143
+ if (!existsSync(path)) return null
144
+ const obj = readJsonObject(path)
145
+ if (obj === null) return null
146
+ const hooks = obj.hooks as Record<string, unknown> | undefined
147
+ if (hooks && Array.isArray(hooks.PreToolUse)) {
148
+ hooks.PreToolUse = (hooks.PreToolUse as unknown[]).filter(g => !isIapeerGroup(g))
149
+ if ((hooks.PreToolUse as unknown[]).length === 0) delete hooks.PreToolUse
150
+ if (Object.keys(hooks).length === 0) delete obj.hooks
151
+ }
152
+ // If nothing but our block was there, drop the file entirely (pristine restore).
153
+ if (Object.keys(obj).length === 0) {
154
+ rmSync(path, { force: true })
155
+ return path
156
+ }
157
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
158
+ return path
159
+ }
160
+
161
+ // ── the toggle: profile field + per-runtime surfaces (docs/17 §1) ─────────────
162
+
163
+ export interface ApprovalToggleResult {
164
+ mode: ApprovalMode
165
+ surfaces: string[]
166
+ }
167
+
168
+ /**
169
+ * Flip a peer's approval mode: persist the profile field AND bring every PERSISTENT runtime
170
+ * surface to the mode (idempotent). The argv/ready-gate surfaces are NOT touched here — they
171
+ * are computed at launch from the profile field, so a LIVE session keeps its launched mode
172
+ * until the next fresh session (the explicit application moment). Throws if the cwd has no
173
+ * peer profile.
174
+ */
175
+ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.ProcessEnv = process.env): ApprovalToggleResult {
176
+ const profile = readPeerProfile(cwd)
177
+ if (!profile) throw new Error(`no peer profile at ${cwd}`)
178
+ writePeerProfileAtomic(cwd, { ...profile, approval_mode: mode })
179
+ const surfaces = [`profile: approval_mode=${mode}`]
180
+ for (const rt of profile.runtimes) {
181
+ if (rt === 'claude') {
182
+ if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
183
+ else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
184
+ } else if (rt === 'codex') {
185
+ if (mode === 'gated') {
186
+ const r = installCodexApproval(cwd, env)
187
+ surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
188
+ } else {
189
+ surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
190
+ }
191
+ }
192
+ }
193
+ return { mode, surfaces }
194
+ }
package/src/cli/index.ts CHANGED
@@ -1281,6 +1281,11 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1281
1281
  sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
1282
1282
  desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; install activates it (installs SwiftBar when absent). See docs/16.',
1283
1283
  },
1284
+ { sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
1285
+ { sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
1286
+ { sig: 'approve <id>', desc: 'approve a pending request — the decision clears it in every channel' },
1287
+ { sig: 'deny <id> [reason]', desc: 'deny a pending request; the reason reaches the model' },
1288
+ { sig: 'approval-hook', desc: '(runtime-installed) PreToolUse bridge: stdin hook JSON → broker → decision JSON. Not run by hand' },
1284
1289
  ]
1285
1290
 
1286
1291
  /** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
@@ -2580,6 +2585,48 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2580
2585
  )
2581
2586
  }
2582
2587
  }
2588
+ case 'approval-hook': {
2589
+ // (runtime-installed, docs/17) the PreToolUse bridge: stdin = the runtime's hook JSON,
2590
+ // stdout = the decision JSON (EXACTLY — the runtime parses it), blocking on the broker.
2591
+ // Never run by hand. Fail-safe: any error prints a DENY (expressed in JSON, exit 0).
2592
+ const { runApprovalHook } = await import('../approval/hook.ts')
2593
+ const stdin = await Bun.stdin.text()
2594
+ const r = await runApprovalHook(stdin, { env })
2595
+ if (r.stdout) out(`${r.stdout}\n`)
2596
+ if (r.stderr) errOut(`${r.stderr}\n`)
2597
+ return r.exitCode
2598
+ }
2599
+ case 'approvals': {
2600
+ const { approvalsList } = await import('../approval/cli.ts')
2601
+ const r = await approvalsList(flags.json === true, env)
2602
+ out(r.text)
2603
+ return r.code
2604
+ }
2605
+ case 'approve':
2606
+ case 'deny': {
2607
+ const id = positionals[0]
2608
+ if (!id) return argErr(errOut, `${verb} needs an id — usage: iapeer ${verb} <id>${verb === 'deny' ? ' [reason]' : ''} (list: iapeer approvals)`)
2609
+ const { resolveApproval } = await import('../approval/cli.ts')
2610
+ const reason = verb === 'deny' ? positionals.slice(1).join(' ') || undefined : undefined
2611
+ const approver = typeof flags.approver === 'string' ? flags.approver : undefined
2612
+ const r = await resolveApproval(id, verb, { reason, approver }, env)
2613
+ out(r.text)
2614
+ return r.code
2615
+ }
2616
+ case 'approval-mode': {
2617
+ const peer = positionals[0]
2618
+ if (!peer) return argErr(errOut, 'approval-mode needs a peer — usage: iapeer approval-mode <peer> [gated|yolo] [--now]')
2619
+ const modeArg = positionals[1]
2620
+ if (modeArg && modeArg !== 'gated' && modeArg !== 'yolo') return argErr(errOut, 'approval-mode: mode must be gated | yolo (omit to read the current mode)')
2621
+ const { approvalModeCli } = await import('../approval/cli.ts')
2622
+ const r = approvalModeCli(peer, modeArg as 'gated' | 'yolo' | undefined, env)
2623
+ out(r.text)
2624
+ if (r.code === 0 && modeArg && flags.now === true) {
2625
+ const o = await newPeer(peer, undefined, { env })
2626
+ out(`applied now: fresh session (${o.action})\n`)
2627
+ }
2628
+ return r.code
2629
+ }
2583
2630
  default: {
2584
2631
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
2585
2632
  // the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
@@ -0,0 +1,202 @@
1
+ // Approval broker — the in-daemon queue that is the SINGLE SOURCE OF TRUTH for
2
+ // human-approval requests (docs/17). Every channel (the runtime hook that ASKS, and
3
+ // the CLI / tray / telegram faces that ANSWER) is an interface to THIS one queue: a
4
+ // decision from any face resolves the request everywhere (the owner's invariant).
5
+ //
6
+ // Flow: a gated peer's PreToolUse hook blocks and POSTs /fleet/v1/approvals → the fleet
7
+ // handler calls broker.request(...) and awaits the returned promise (long-poll) → the
8
+ // broker enqueues, emits `approval-request` (approvals.log → SSE), and holds the promise
9
+ // → a face calls broker.resolve(id, …) (via POST /fleet/v1/approvals/<id>/approve|deny)
10
+ // → the promise resolves, the fleet handler writes the runtime hook JSON back, the tool
11
+ // proceeds or is blocked with the reason. A per-request timeout resolves default-DENY so
12
+ // a peer never hangs. Every failure direction is deny (fail-safe): timeout, requester
13
+ // disconnect, unknown id.
14
+ //
15
+ // In-memory + ephemeral: pending requests do NOT survive a daemon restart (the blocking
16
+ // hook connection breaks on restart → the hook fails safe to deny). The durable
17
+ // approvals.log is the audit/observability trace, not a recovery store.
18
+
19
+ import { appendApprovalEvent } from './approvalslog.ts'
20
+
21
+ /** v1 taxonomy of intercepted blocking requests (docs/17 §2). Free-form-tolerant. */
22
+ export type ApprovalKind = 'tool' | 'plan' | 'question' | 'circuit-breaker'
23
+
24
+ export interface ApprovalRequestInput {
25
+ personality: string
26
+ runtime: string
27
+ /** Taxonomy tag (tool | plan | question | circuit-breaker | …). */
28
+ kind: ApprovalKind | string
29
+ /** The specific tool / breaker name, e.g. `Bash`, `Edit`, `dangerous-rm`. */
30
+ tool: string
31
+ /** FULL human-readable content of the action — the command, the diff, the plan text,
32
+ * the circuit-breaker prompt. Shown to the human in every channel (criterion #7). */
33
+ content: string
34
+ /** One-line summary for the SSE event / badge (default: the first line of content). */
35
+ summary?: string
36
+ /** Short title, e.g. "boris · Bash" (default: "<personality> · <tool>"). */
37
+ title?: string
38
+ /** nature-peers who may answer (informational in v1 — same-uid faces are trusted). */
39
+ approvers?: string[]
40
+ }
41
+
42
+ export interface PendingApproval extends ApprovalRequestInput {
43
+ id: string
44
+ summary: string
45
+ title: string
46
+ approvers: string[]
47
+ createdMs: number
48
+ expiresMs: number
49
+ }
50
+
51
+ export interface ApprovalDecision {
52
+ decision: 'allow' | 'deny'
53
+ reason?: string
54
+ }
55
+
56
+ export interface ResolveMeta {
57
+ /** The approver who answered (a nature-peer personality). */
58
+ by?: string
59
+ /** The channel that answered: cli | tray | telegram | timeout | disconnect. */
60
+ via?: string
61
+ }
62
+
63
+ interface Entry {
64
+ item: PendingApproval
65
+ resolve: (d: ApprovalDecision) => void
66
+ timer: ReturnType<typeof setTimeout>
67
+ }
68
+
69
+ export interface ApprovalBrokerOptions {
70
+ /** cfg.eventLogDir — approvals.log lives here (falsy → no durable log, hermetic). */
71
+ logDir?: string
72
+ env?: NodeJS.ProcessEnv
73
+ /** Per-request default-deny timeout. Default IAPEER_APPROVAL_TIMEOUT_MS or 300 s.
74
+ * MUST stay below the runtime hook's own timeout (claude/codex default 600 s) so the
75
+ * broker's default-deny wins before the runtime kills the hook (fail-safe ordering). */
76
+ timeoutMs?: number
77
+ /** Injectable clock (tests). Default Date.now. */
78
+ now?: () => number
79
+ }
80
+
81
+ const DEFAULT_TIMEOUT_MS = 300_000
82
+
83
+ function envPosInt(raw: string | undefined, dflt: number): number {
84
+ const n = parseInt(raw ?? '', 10)
85
+ return Number.isFinite(n) && n > 0 ? n : dflt
86
+ }
87
+
88
+ function firstLine(s: string): string {
89
+ const nl = s.indexOf('\n')
90
+ return (nl >= 0 ? s.slice(0, nl) : s).trim()
91
+ }
92
+
93
+ export class ApprovalBroker {
94
+ private readonly pending = new Map<string, Entry>()
95
+ private counter = 0
96
+ private readonly logDir?: string
97
+ private readonly env: NodeJS.ProcessEnv
98
+ private readonly timeoutMs: number
99
+ private readonly now: () => number
100
+
101
+ constructor(opts: ApprovalBrokerOptions = {}) {
102
+ this.logDir = opts.logDir
103
+ this.env = opts.env ?? process.env
104
+ this.timeoutMs = opts.timeoutMs ?? envPosInt(this.env.IAPEER_APPROVAL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
105
+ this.now = opts.now ?? Date.now
106
+ }
107
+
108
+ /** Enqueue a request and return its id + a promise that resolves when a face answers
109
+ * or the timeout fires (default-deny). The caller (fleet handler) awaits `decision`
110
+ * and MUST call `cancel(id)` if its transport dies before the promise settles. */
111
+ request(input: ApprovalRequestInput): { id: string; decision: Promise<ApprovalDecision> } {
112
+ const id = `a${++this.counter}`
113
+ const createdMs = this.now()
114
+ const expiresMs = createdMs + this.timeoutMs
115
+ const summary = (input.summary?.trim() || firstLine(input.content) || input.tool).slice(0, 240)
116
+ const title = input.title?.trim() || `${input.personality} · ${input.tool}`
117
+ const approvers = input.approvers ?? []
118
+ const item: PendingApproval = { ...input, id, summary, title, approvers, createdMs, expiresMs }
119
+ let resolve!: (d: ApprovalDecision) => void
120
+ const decision = new Promise<ApprovalDecision>(res => {
121
+ resolve = res
122
+ })
123
+ const timer = setTimeout(
124
+ () => this.settle(id, { decision: 'deny', reason: 'approval timed out (default-deny)' }, { via: 'timeout' }),
125
+ this.timeoutMs,
126
+ )
127
+ ;(timer as { unref?: () => void }).unref?.()
128
+ this.pending.set(id, { item, resolve, timer })
129
+ appendApprovalEvent(
130
+ this.logDir,
131
+ {
132
+ ev: 'approval-request',
133
+ id,
134
+ personality: input.personality,
135
+ runtime: input.runtime,
136
+ kind: input.kind,
137
+ tool: input.tool,
138
+ summary,
139
+ created: createdMs,
140
+ expires: expiresMs,
141
+ approvers: approvers.join(',') || undefined,
142
+ },
143
+ { env: this.env, nowMs: createdMs },
144
+ )
145
+ return { id, decision }
146
+ }
147
+
148
+ /** All pending requests, oldest first (GET /fleet/v1/approvals + `iapeer approvals`). */
149
+ list(): PendingApproval[] {
150
+ return [...this.pending.values()].map(e => e.item).sort((a, b) => a.createdMs - b.createdMs)
151
+ }
152
+
153
+ /** One pending request by id (GET /fleet/v1/approvals/<id> — full content). */
154
+ get(id: string): PendingApproval | undefined {
155
+ return this.pending.get(id)?.item
156
+ }
157
+
158
+ /** Answer a request from a face. Returns false iff the id is unknown (already resolved,
159
+ * expired, or never existed) — the caller reports 404. */
160
+ resolve(id: string, decision: ApprovalDecision, meta: ResolveMeta = {}): boolean {
161
+ if (!this.pending.has(id)) return false
162
+ this.settle(id, decision, meta)
163
+ return true
164
+ }
165
+
166
+ /** Requester transport died before an answer — settle default-deny + drop from the
167
+ * queue so it does not linger (the asking hook is gone; the tool call is aborted). */
168
+ cancel(id: string, reason = 'requester disconnected'): boolean {
169
+ if (!this.pending.has(id)) return false
170
+ this.settle(id, { decision: 'deny', reason }, { via: 'disconnect' })
171
+ return true
172
+ }
173
+
174
+ /** Pending count (badge/metrics). */
175
+ size(): number {
176
+ return this.pending.size
177
+ }
178
+
179
+ private settle(id: string, decision: ApprovalDecision, meta: ResolveMeta): void {
180
+ const entry = this.pending.get(id)
181
+ if (!entry) return
182
+ clearTimeout(entry.timer)
183
+ this.pending.delete(id)
184
+ const nowMs = this.now()
185
+ appendApprovalEvent(
186
+ this.logDir,
187
+ {
188
+ ev: 'approval-resolved',
189
+ id,
190
+ personality: entry.item.personality,
191
+ runtime: entry.item.runtime,
192
+ decision: decision.decision,
193
+ reason: decision.reason,
194
+ by: meta.by,
195
+ via: meta.via,
196
+ latencyMs: nowMs - entry.item.createdMs,
197
+ },
198
+ { env: this.env, nowMs },
199
+ )
200
+ entry.resolve(decision)
201
+ }
202
+ }