@agfpd/iapeer 0.4.68 → 0.4.70

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,118 @@
1
+ // approval CLI — the operator faces of the approval broker (docs/17 §5), all thin clients
2
+ // of the daemon fleet-API (the queue lives in the daemon; these reach it over HTTP, like the
3
+ // tray). `approval-hook` is the runtime-installed bridge; `approvals`/`approve`/`deny` are the
4
+ // host answer channel; `approval-mode` is the per-peer gated/yolo toggle.
5
+
6
+ import { resolveFleetBase } from './brokerClient.ts'
7
+ import { setApprovalMode } from './install.ts'
8
+ import { readPeersIndex } from '../registry/index.ts'
9
+ import { readPeerProfile, approvalModeOf, type ApprovalMode } from '../identity/index.ts'
10
+
11
+ function authHeaders(env: NodeJS.ProcessEnv): Record<string, string> {
12
+ const bearer = env.IAPEER_BEARER_TOKEN?.trim()
13
+ return { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }
14
+ }
15
+
16
+ interface PendingApprovalRow {
17
+ id: string
18
+ personality: string
19
+ runtime: string
20
+ kind: string
21
+ tool: string
22
+ summary: string
23
+ content: string
24
+ createdMs: number
25
+ expiresMs: number
26
+ }
27
+
28
+ function ageStr(ms: number, nowMs: number): string {
29
+ const s = Math.max(0, Math.floor((nowMs - ms) / 1000))
30
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
31
+ }
32
+
33
+ /** `iapeer approvals [--json]` — the pending queue. Each row carries the VERBATIM action
34
+ * content (a Bash command shown in full — boris's CLI acceptance criterion). */
35
+ export async function approvalsList(json: boolean, env: NodeJS.ProcessEnv): Promise<{ text: string; code: number }> {
36
+ const base = resolveFleetBase(env)
37
+ let rows: PendingApprovalRow[]
38
+ try {
39
+ const resp = await fetch(`${base}/fleet/v1/approvals`, { headers: authHeaders(env) })
40
+ if (!resp.ok) return { text: `approvals: daemon returned ${resp.status}\n`, code: 1 }
41
+ rows = ((await resp.json()) as { approvals: PendingApprovalRow[] }).approvals
42
+ } catch (e) {
43
+ return { text: `approvals: cannot reach daemon (${e instanceof Error ? e.message : String(e)})\n`, code: 1 }
44
+ }
45
+ if (json) return { text: `${JSON.stringify(rows, null, 2)}\n`, code: 0 }
46
+ if (rows.length === 0) return { text: 'no pending approvals\n', code: 0 }
47
+ const now = Date.now()
48
+ const lines = [`${rows.length} pending approval(s):`]
49
+ for (const r of rows) {
50
+ lines.push(` ${r.id} ${r.personality}/${r.runtime} [${r.kind}:${r.tool}] ${ageStr(r.createdMs, now)} ago`)
51
+ lines.push(` ${r.summary}`) // verbatim command / file / plan-line
52
+ }
53
+ lines.push('', 'approve: iapeer approve <id> · deny: iapeer deny <id> [reason]')
54
+ return { text: `${lines.join('\n')}\n`, code: 0 }
55
+ }
56
+
57
+ /** `iapeer approve <id>` / `iapeer deny <id> [reason]` — resolve a pending request. The
58
+ * decision propagates to whichever channel is waiting (the blocking hook) and clears it
59
+ * everywhere. `approver` (default: the single natural peer) records who answered. */
60
+ export async function resolveApproval(
61
+ id: string,
62
+ action: 'approve' | 'deny',
63
+ opts: { reason?: string; approver?: string },
64
+ env: NodeJS.ProcessEnv,
65
+ ): Promise<{ text: string; code: number }> {
66
+ const base = resolveFleetBase(env)
67
+ const approver = opts.approver ?? defaultApprover(env)
68
+ const body: Record<string, unknown> = { via: 'cli', ...(approver ? { approver } : {}), ...(opts.reason ? { reason: opts.reason } : {}) }
69
+ try {
70
+ const resp = await fetch(`${base}/fleet/v1/approvals/${encodeURIComponent(id)}/${action}`, {
71
+ method: 'POST',
72
+ headers: authHeaders(env),
73
+ body: JSON.stringify(body),
74
+ })
75
+ if (resp.status === 404) return { text: `no pending approval "${id}" (already resolved, expired, or unknown)\n`, code: 1 }
76
+ if (!resp.ok) return { text: `${action}: daemon returned ${resp.status}\n`, code: 1 }
77
+ return { text: `${action === 'approve' ? 'approved' : 'denied'} ${id}${opts.reason ? ` (${opts.reason})` : ''}\n`, code: 0 }
78
+ } catch (e) {
79
+ return { text: `${action}: cannot reach daemon (${e instanceof Error ? e.message : String(e)})\n`, code: 1 }
80
+ }
81
+ }
82
+
83
+ /** The default approver: the registry's single natural-intelligence peer (the owner). null
84
+ * when zero/several — then the answer is recorded without a `by`. */
85
+ function defaultApprover(env: NodeJS.ProcessEnv): string | undefined {
86
+ try {
87
+ const natural = readPeersIndex({ env }).peers.filter(p => p.intelligence === 'natural')
88
+ return natural.length === 1 ? natural[0]!.personality : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /** `iapeer approval-mode <peer> [gated|yolo]` — read (no mode) or flip the toggle. Flipping
95
+ * persists the field + brings the runtime surfaces to the mode; a live session keeps its
96
+ * launched mode until a fresh session (the application moment). */
97
+ export function approvalModeCli(peer: string, mode: ApprovalMode | undefined, env: NodeJS.ProcessEnv): { text: string; code: number } {
98
+ const rec = readPeersIndex({ env }).peers.find(p => p.personality === peer)
99
+ if (!rec) return { text: `unknown peer "${peer}"\n`, code: 1 }
100
+ if (!mode) {
101
+ let current: ApprovalMode = 'yolo'
102
+ try {
103
+ current = approvalModeOf(readPeerProfile(rec.cwd))
104
+ } catch {
105
+ /* unreadable → yolo */
106
+ }
107
+ return { text: `${peer}: approval_mode=${current}\n`, code: 0 }
108
+ }
109
+ let result
110
+ try {
111
+ result = setApprovalMode(rec.cwd, mode, env)
112
+ } catch (e) {
113
+ return { text: `approval-mode: ${e instanceof Error ? e.message : String(e)}\n`, code: 1 }
114
+ }
115
+ const lines = [`${peer} → approval_mode=${mode}`, ...result.surfaces.map(s => ` · ${s}`)]
116
+ lines.push('', 'applies on the peer\'s NEXT fresh session — run `iapeer new ' + peer + '` to apply now.')
117
+ return { text: `${lines.join('\n')}\n`, code: 0 }
118
+ }
@@ -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,200 @@
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). VERIFIED LIVE (codex-cli 0.142.5, Unit 3): codex
29
+ // fires `PreToolUse` (NOT PermissionRequest) for tool calls, with `tool_name:"Bash"` for shell exec +
30
+ // `tool_input.command` = the full command; a PreToolUse `deny` HARD-BLOCKS the tool AND the
31
+ // `permissionDecisionReason` reaches the model — even under `sandbox_mode=danger-full-access`
32
+ // (permission_mode=bypassPermissions), so under the gated config the hook is the SOLE gate. `Bash`
33
+ // confirmed live; `apply_patch` (the patch tool) per the codex hooks docs. The extra alternatives are a
34
+ // forward-compatible superset (a future codex rename still matches) — harmless, never over-matches.
35
+ export const CODEX_APPROVAL_MATCHER = '^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$'
36
+ /** The peer's own IAP tool — allow-listed so a gated peer's send_to_peer is not itself gated. */
37
+ export const IAPEER_MCP_ALLOW = 'mcp__iapeer__send_to_peer'
38
+ /** Runtime hook timeout (seconds): ABOVE the broker default-deny (300) + the hook client fetch
39
+ * ceiling (600) so the broker/human answers before the runtime kills the hook. */
40
+ const HOOK_TIMEOUT_SECS = 900
41
+
42
+ /** The `command` string the runtime runs per matched tool call (the installed binary). */
43
+ export function approvalHookCommand(env: NodeJS.ProcessEnv = process.env): string {
44
+ return `${iapeerBinPath(env)} approval-hook`
45
+ }
46
+
47
+ function isIapeerGroup(group: unknown): boolean {
48
+ const hooks = (group as { hooks?: unknown })?.hooks
49
+ return (
50
+ Array.isArray(hooks) &&
51
+ hooks.some(h => typeof (h as { command?: unknown })?.command === 'string' && (h as { command: string }).command.includes('approval-hook'))
52
+ )
53
+ }
54
+
55
+ function iapeerClaudeGroup(env: NodeJS.ProcessEnv): Record<string, unknown> {
56
+ // Matcher-free: PermissionRequest already fires only on prompt-worthy calls (Option D).
57
+ return { hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] }
58
+ }
59
+
60
+ function readJsonObject(path: string): Record<string, unknown> | null {
61
+ if (!existsSync(path)) return {}
62
+ try {
63
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
64
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
65
+ return raw as Record<string, unknown>
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ // ── claude (project settings.json) ───────────────────────────────────────────
72
+
73
+ /** Seed the gated PreToolUse hook + MCP allow-rule into <cwd>/.claude/settings.json
74
+ * (seed-if-absent, no-clobber merge). Returns the path, or null on a refusal/error. */
75
+ export function installClaudeApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
76
+ const path = claudeSettingsPath(cwd)
77
+ const obj = readJsonObject(path)
78
+ if (obj === null) return null
79
+ // hooks.PermissionRequest — push OUR group iff absent (respect a user-added one).
80
+ const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
81
+ const pre = (Array.isArray(hooks.PermissionRequest) ? hooks.PermissionRequest : []) as unknown[]
82
+ if (!pre.some(isIapeerGroup)) pre.push(iapeerClaudeGroup(env))
83
+ hooks.PermissionRequest = pre
84
+ obj.hooks = hooks
85
+ // permissions.allow — allow-list the peer's own MCP tool (idempotent).
86
+ const perms = (obj.permissions && typeof obj.permissions === 'object' && !Array.isArray(obj.permissions) ? obj.permissions : {}) as Record<string, unknown>
87
+ const allow = (Array.isArray(perms.allow) ? perms.allow : []) as unknown[]
88
+ if (!allow.includes(IAPEER_MCP_ALLOW)) allow.push(IAPEER_MCP_ALLOW)
89
+ perms.allow = allow
90
+ obj.permissions = perms
91
+ mkdirSync(dirname(path), { recursive: true })
92
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
93
+ return path
94
+ }
95
+
96
+ /** Remove OUR gated hook + MCP allow-rule, cleaning up any structures we emptied so a
97
+ * gated→yolo flip restores the pre-install bytes (foreign hooks/rules preserved). */
98
+ export function removeClaudeApproval(cwd: string): string | null {
99
+ const path = claudeSettingsPath(cwd)
100
+ if (!existsSync(path)) return null
101
+ const obj = readJsonObject(path)
102
+ if (obj === null) return null
103
+ const hooks = obj.hooks as Record<string, unknown> | undefined
104
+ if (hooks && Array.isArray(hooks.PermissionRequest)) {
105
+ hooks.PermissionRequest = (hooks.PermissionRequest as unknown[]).filter(g => !isIapeerGroup(g))
106
+ if ((hooks.PermissionRequest as unknown[]).length === 0) delete hooks.PermissionRequest
107
+ if (Object.keys(hooks).length === 0) delete obj.hooks
108
+ }
109
+ const perms = obj.permissions as Record<string, unknown> | undefined
110
+ if (perms && Array.isArray(perms.allow)) {
111
+ perms.allow = (perms.allow as unknown[]).filter(r => r !== IAPEER_MCP_ALLOW)
112
+ if ((perms.allow as unknown[]).length === 0) delete perms.allow
113
+ if (Object.keys(perms).length === 0) delete obj.permissions
114
+ }
115
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
116
+ return path
117
+ }
118
+
119
+ // ── codex (project hooks.json + trust pre-seed) ──────────────────────────────
120
+
121
+ export function codexHooksJsonPath(cwd: string): string {
122
+ return join(cwd, '.codex', 'hooks.json')
123
+ }
124
+
125
+ /** Write <cwd>/.codex/hooks.json with the gated PreToolUse group (seed-if-absent merge) and
126
+ * pre-seed its trust in ~/.codex/config.toml (else codex silently SKIPS an untrusted hook). */
127
+ export function installCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): { path: string; trust?: HooksTrustOutcome } | null {
128
+ const path = codexHooksJsonPath(cwd)
129
+ const obj = readJsonObject(path)
130
+ if (obj === null) return null
131
+ const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
132
+ const pre = (Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []) as unknown[]
133
+ if (!pre.some(isIapeerGroup)) {
134
+ pre.push({ matcher: CODEX_APPROVAL_MATCHER, hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] })
135
+ }
136
+ hooks.PreToolUse = pre
137
+ obj.hooks = hooks
138
+ mkdirSync(dirname(path), { recursive: true })
139
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
140
+ const trust = preSeedCodexHooksTrust(path, env)
141
+ return { path, trust }
142
+ }
143
+
144
+ /** Remove the codex gated hook (drop OUR group; delete the file if it becomes empty) and its
145
+ * trust state under this cwd. */
146
+ export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
147
+ const path = codexHooksJsonPath(cwd)
148
+ removeCodexHooksTrustUnder(cwd, env)
149
+ if (!existsSync(path)) return null
150
+ const obj = readJsonObject(path)
151
+ if (obj === null) return null
152
+ const hooks = obj.hooks as Record<string, unknown> | undefined
153
+ if (hooks && Array.isArray(hooks.PreToolUse)) {
154
+ hooks.PreToolUse = (hooks.PreToolUse as unknown[]).filter(g => !isIapeerGroup(g))
155
+ if ((hooks.PreToolUse as unknown[]).length === 0) delete hooks.PreToolUse
156
+ if (Object.keys(hooks).length === 0) delete obj.hooks
157
+ }
158
+ // If nothing but our block was there, drop the file entirely (pristine restore).
159
+ if (Object.keys(obj).length === 0) {
160
+ rmSync(path, { force: true })
161
+ return path
162
+ }
163
+ writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
164
+ return path
165
+ }
166
+
167
+ // ── the toggle: profile field + per-runtime surfaces (docs/17 §1) ─────────────
168
+
169
+ export interface ApprovalToggleResult {
170
+ mode: ApprovalMode
171
+ surfaces: string[]
172
+ }
173
+
174
+ /**
175
+ * Flip a peer's approval mode: persist the profile field AND bring every PERSISTENT runtime
176
+ * surface to the mode (idempotent). The argv/ready-gate surfaces are NOT touched here — they
177
+ * are computed at launch from the profile field, so a LIVE session keeps its launched mode
178
+ * until the next fresh session (the explicit application moment). Throws if the cwd has no
179
+ * peer profile.
180
+ */
181
+ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.ProcessEnv = process.env): ApprovalToggleResult {
182
+ const profile = readPeerProfile(cwd)
183
+ if (!profile) throw new Error(`no peer profile at ${cwd}`)
184
+ writePeerProfileAtomic(cwd, { ...profile, approval_mode: mode })
185
+ const surfaces = [`profile: approval_mode=${mode}`]
186
+ for (const rt of profile.runtimes) {
187
+ if (rt === 'claude') {
188
+ if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
189
+ else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
190
+ } else if (rt === 'codex') {
191
+ if (mode === 'gated') {
192
+ const r = installCodexApproval(cwd, env)
193
+ surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
194
+ } else {
195
+ surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
196
+ }
197
+ }
198
+ }
199
+ return { mode, surfaces }
200
+ }
package/src/cli/index.ts CHANGED
@@ -1278,9 +1278,14 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1278
1278
  desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
1279
1279
  },
1280
1280
  {
1281
- sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
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.',
1281
+ sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
1282
+ desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), 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
@@ -2537,6 +2542,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2537
2542
  out(`${JSON.stringify(r.body)}\n`)
2538
2543
  return r.ok ? 0 : 1
2539
2544
  }
2545
+ case 'approve':
2546
+ case 'deny': {
2547
+ // Tray click → resolve a pending approval over the unix-first fleet client (docs/17). The
2548
+ // menu Allow/Deny items run these; the broker resolution is seen by every channel.
2549
+ const id = positionals[1]
2550
+ if (!id) return argErr(errOut, `usage: iapeer tray ${sub} <approval-id> [reason]`)
2551
+ const reason = sub === 'deny' ? positionals.slice(2).join(' ') || undefined : undefined
2552
+ let r: Awaited<ReturnType<typeof tray.trayResolveApproval>>
2553
+ try {
2554
+ r = await tray.trayResolveApproval(env, sub, id, reason)
2555
+ } catch (e) {
2556
+ errOut(`tray ${sub}: ${e instanceof Error ? e.message : String(e)}\n`)
2557
+ return 1
2558
+ }
2559
+ out(`${JSON.stringify(r.body)}\n`)
2560
+ return r.ok ? 0 : 1
2561
+ }
2540
2562
  case 'install': {
2541
2563
  // Explicit activation verb: installs SwiftBar.app when absent
2542
2564
  // (owner-sanctioned) + launches, unless --plugin-only (file only).
@@ -2580,6 +2602,48 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2580
2602
  )
2581
2603
  }
2582
2604
  }
2605
+ case 'approval-hook': {
2606
+ // (runtime-installed, docs/17) the PreToolUse bridge: stdin = the runtime's hook JSON,
2607
+ // stdout = the decision JSON (EXACTLY — the runtime parses it), blocking on the broker.
2608
+ // Never run by hand. Fail-safe: any error prints a DENY (expressed in JSON, exit 0).
2609
+ const { runApprovalHook } = await import('../approval/hook.ts')
2610
+ const stdin = await Bun.stdin.text()
2611
+ const r = await runApprovalHook(stdin, { env })
2612
+ if (r.stdout) out(`${r.stdout}\n`)
2613
+ if (r.stderr) errOut(`${r.stderr}\n`)
2614
+ return r.exitCode
2615
+ }
2616
+ case 'approvals': {
2617
+ const { approvalsList } = await import('../approval/cli.ts')
2618
+ const r = await approvalsList(flags.json === true, env)
2619
+ out(r.text)
2620
+ return r.code
2621
+ }
2622
+ case 'approve':
2623
+ case 'deny': {
2624
+ const id = positionals[0]
2625
+ if (!id) return argErr(errOut, `${verb} needs an id — usage: iapeer ${verb} <id>${verb === 'deny' ? ' [reason]' : ''} (list: iapeer approvals)`)
2626
+ const { resolveApproval } = await import('../approval/cli.ts')
2627
+ const reason = verb === 'deny' ? positionals.slice(1).join(' ') || undefined : undefined
2628
+ const approver = typeof flags.approver === 'string' ? flags.approver : undefined
2629
+ const r = await resolveApproval(id, verb, { reason, approver }, env)
2630
+ out(r.text)
2631
+ return r.code
2632
+ }
2633
+ case 'approval-mode': {
2634
+ const peer = positionals[0]
2635
+ if (!peer) return argErr(errOut, 'approval-mode needs a peer — usage: iapeer approval-mode <peer> [gated|yolo] [--now]')
2636
+ const modeArg = positionals[1]
2637
+ if (modeArg && modeArg !== 'gated' && modeArg !== 'yolo') return argErr(errOut, 'approval-mode: mode must be gated | yolo (omit to read the current mode)')
2638
+ const { approvalModeCli } = await import('../approval/cli.ts')
2639
+ const r = approvalModeCli(peer, modeArg as 'gated' | 'yolo' | undefined, env)
2640
+ out(r.text)
2641
+ if (r.code === 0 && modeArg && flags.now === true) {
2642
+ const o = await newPeer(peer, undefined, { env })
2643
+ out(`applied now: fresh session (${o.action})\n`)
2644
+ }
2645
+ return r.code
2646
+ }
2583
2647
  default: {
2584
2648
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
2585
2649
  // the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):