@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,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
+ }
@@ -0,0 +1,54 @@
1
+ // Approval-broker durable log — the daemon's DURABLE trace of every human-approval
2
+ // request it brokers (docs/17). Sibling to lifecycle.log / delivery.log / exits.log
3
+ // under cfg.eventLogDir; tailed by the fleet-API SSE stream (fleetEventFiles), so the
4
+ // `approval-request` / `approval-resolved` events reach every operator client (CLI,
5
+ // tray, telegram) with at-least-once semantics — and a postmortem can reconstruct who
6
+ // asked, what, and how it resolved after the in-memory queue is gone.
7
+ //
8
+ // What is logged: approval METADATA + a one-line `summary` of the action — NOT the full
9
+ // multi-line content (a Write's whole body / a long diff would bloat the log). The full
10
+ // human-readable content lives in the in-memory queue and is served by GET
11
+ // /fleet/v1/approvals/<id>; the log carries enough to correlate + render a badge/line.
12
+ //
13
+ // Same rotation class as its siblings: written by OUR code → self-rotates via the shared
14
+ // storage/rotatelog primitive. The dir is passed IN by the composition point, never
15
+ // re-resolved from env — a falsy dir → no-op (library/test daemons stay hermetic).
16
+
17
+ import { join } from 'path'
18
+ import { DEFAULT_LOG_KEEP, DEFAULT_LOG_MAX_BYTES, appendRotatedEvent } from '../storage/rotatelog.ts'
19
+
20
+ /** The approval-broker log inside `logDir` (sibling to lifecycle.log). */
21
+ export function approvalsLogPath(logDir: string): string {
22
+ return join(logDir, 'approvals.log')
23
+ }
24
+
25
+ function envPosInt(raw: string | undefined, dflt: number): number {
26
+ const n = parseInt(raw ?? '', 10)
27
+ return Number.isFinite(n) && n > 0 ? n : dflt
28
+ }
29
+
30
+ export interface AppendApprovalOptions {
31
+ /** Reads the rotation knobs IAPEER_APPROVALS_LOG_MAX_BYTES / _KEEP. */
32
+ env?: NodeJS.ProcessEnv
33
+ /** Stamp the line with this epoch-ms. Default Date.now(). */
34
+ nowMs?: number
35
+ }
36
+
37
+ /**
38
+ * Append one approval event line into `logDir`/approvals.log. A falsy `logDir` is a
39
+ * no-op (the default for library/test daemons). Fully best-effort — never throws;
40
+ * logging must never fail an approval round-trip.
41
+ */
42
+ export function appendApprovalEvent(
43
+ logDir: string | undefined,
44
+ fields: Record<string, string | number | undefined>,
45
+ opts: AppendApprovalOptions = {},
46
+ ): void {
47
+ if (!logDir) return
48
+ const env = opts.env ?? process.env
49
+ appendRotatedEvent(approvalsLogPath(logDir), fields, {
50
+ nowMs: opts.nowMs,
51
+ maxBytes: envPosInt(env.IAPEER_APPROVALS_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
52
+ keep: envPosInt(env.IAPEER_APPROVALS_LOG_KEEP, DEFAULT_LOG_KEEP),
53
+ })
54
+ }
@@ -42,6 +42,7 @@ import {
42
42
  isEphemeralPeer,
43
43
  isLaunchdManaged,
44
44
  loadLifecycleConfig,
45
+ peerApprovalMode,
45
46
  wakeOrSpawn,
46
47
  type LifecycleConfig,
47
48
  } from '../lifecycle/index.ts'
@@ -59,6 +60,8 @@ import {
59
60
  } from '../storage/rotatelog.ts'
60
61
  import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
61
62
  import { deliveryLogPath } from './deliverylog.ts'
63
+ import { approvalsLogPath } from './approvalslog.ts'
64
+ import { ApprovalBroker } from './approvals.ts'
62
65
  import { callTool, type CallToolDeps } from './index.ts'
63
66
 
64
67
  /** The API generation baked into every snapshot (additive evolution within v1 —
@@ -93,6 +96,9 @@ export interface FleetPeer {
93
96
  wake_policy: 'warm' | 'ephemeral'
94
97
  /** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
95
98
  queue_depth?: number
99
+ /** Human-approval mode (docs/17-approval): `yolo` (default — bypass + auto-confirm
100
+ * circuit-breakers) or `gated` (blocking runtime approvals routed to a human). */
101
+ approval_mode: 'yolo' | 'gated'
96
102
  }
97
103
 
98
104
  export interface FleetHost {
@@ -105,12 +111,32 @@ export interface FleetHost {
105
111
  fda: boolean | null
106
112
  }
107
113
 
114
+ /** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
115
+ * `GET /fleet/v1/approvals` returns (broker.list()) — carried in the snapshot so a client
116
+ * that already fetches `/snapshot` on every event renders the queue with no extra call. The
117
+ * compact `summary` fuels a badge/one-liner; the verbatim `content` (criterion #7 — the full
118
+ * command / diff / plan) rides along for inline display, capped by the client if it wishes. */
119
+ export interface FleetApproval {
120
+ id: string
121
+ personality: string
122
+ runtime: string
123
+ kind: string
124
+ tool: string
125
+ summary: string
126
+ content: string
127
+ createdMs: number
128
+ expiresMs: number
129
+ }
130
+
108
131
  export interface FleetSnapshot {
109
132
  api: typeof FLEET_API_VERSION
110
133
  version: string
111
134
  ts: string
112
135
  host: FleetHost
113
136
  peers: FleetPeer[]
137
+ /** Pending human-approval requests (docs/17). ADDITIVE + omitted when the queue is empty —
138
+ * a client MUST treat its absence as an empty queue (same rule as `approval_mode`). */
139
+ approvals?: FleetApproval[]
114
140
  }
115
141
 
116
142
  // The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
@@ -146,6 +172,7 @@ export function buildFleetSnapshot(
146
172
  ops: Pick<FleetOps, 'listPeers'>,
147
173
  startedAtMs: number,
148
174
  nowMs: number = Date.now(),
175
+ approvals: FleetApproval[] = [],
149
176
  ): FleetSnapshot {
150
177
  const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
151
178
  const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
@@ -163,6 +190,9 @@ export function buildFleetSnapshot(
163
190
  if (ephemeral) {
164
191
  for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
165
192
  }
193
+ // Approval mode from the LOCAL profile (same source as ephemeral); a read hiccup →
194
+ // the safe `yolo` default (peerApprovalMode swallows it).
195
+ const approvalMode = peerApprovalMode(r.cwd)
166
196
  return {
167
197
  personality: r.personality,
168
198
  description: r.description,
@@ -176,6 +206,7 @@ export function buildFleetSnapshot(
176
206
  launchd_managed: isLaunchdManaged(r.personality, env),
177
207
  wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
178
208
  ...(ephemeral ? { queue_depth: queueDepth } : {}),
209
+ approval_mode: approvalMode,
179
210
  }
180
211
  })
181
212
  const mem = readMemoryProvider(env)
@@ -198,6 +229,10 @@ export function buildFleetSnapshot(
198
229
  fda: probeFullDiskAccess(env),
199
230
  },
200
231
  peers,
232
+ // ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
233
+ // superset of FleetApproval — the named fields are the contract; extras ride along and
234
+ // clients ignore them (obligation 2).
235
+ ...(approvals.length ? { approvals } : {}),
201
236
  }
202
237
  }
203
238
 
@@ -217,6 +252,9 @@ export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src
217
252
  { path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
218
253
  { path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
219
254
  { path: exitLogPath(cfg.eventLogDir), src: 'exits' },
255
+ // Human-approval broker events (docs/17): approval-request / approval-resolved.
256
+ // A pre-approval daemon simply never writes this file (readLogTail tolerates absence).
257
+ { path: approvalsLogPath(cfg.eventLogDir), src: 'approvals' },
220
258
  ]
221
259
  }
222
260
 
@@ -432,6 +470,8 @@ export interface FleetHandlerOptions {
432
470
  ops?: FleetOps
433
471
  /** Injectable tailer poll interval (tests). */
434
472
  pollMs?: number
473
+ /** Injectable approval broker (tests — e.g. a short timeout). Default: a fresh one. */
474
+ broker?: ApprovalBroker
435
475
  }
436
476
 
437
477
  export type FleetHandler = (
@@ -451,6 +491,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
451
491
  const startedAtMs = Date.now()
452
492
  const files = fleetEventFiles(cfg)
453
493
  const tailer = new FleetEventTailer(files, opts.pollMs)
494
+ // The approval broker — one per daemon; the ASK side (POST /approvals long-poll) and
495
+ // the ANSWER side (GET/POST /approvals/*) are all interfaces to this one queue.
496
+ const broker = opts.broker ?? new ApprovalBroker({ logDir: cfg.eventLogDir, env })
454
497
  let opsPromise: Promise<FleetOps> | undefined
455
498
  const getOps = (): Promise<FleetOps> => {
456
499
  if (opts.ops) return Promise.resolve(opts.ops)
@@ -466,7 +509,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
466
509
  // ── reads ────────────────────────────────────────────────────────────
467
510
  if (path === '/fleet/v1/snapshot') {
468
511
  if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
469
- return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs))
512
+ // broker.list() is PendingApproval[] — structurally a superset of FleetApproval, so it
513
+ // satisfies the snapshot contract (extra fields serialize + are ignored by clients).
514
+ return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs, undefined, broker.list()))
470
515
  }
471
516
  if (path === '/fleet/v1/events') {
472
517
  if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
@@ -533,6 +578,72 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
533
578
  if (result.isError) return sendJson(res, 502, { error: text })
534
579
  return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
535
580
  }
581
+ // ── approvals — the human-approval broker surface (docs/17) ────────────
582
+ if (path === '/fleet/v1/approvals') {
583
+ if (method === 'GET') {
584
+ return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approvals: broker.list() })
585
+ }
586
+ if (method === 'POST') {
587
+ // The gated peer's hook BLOCKS here (long-poll): enqueue + await a decision.
588
+ let body: Record<string, unknown>
589
+ try {
590
+ body = await readBody(req)
591
+ } catch (e) {
592
+ return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
593
+ }
594
+ if (typeof body.personality !== 'string' || !body.personality || typeof body.tool !== 'string' || typeof body.content !== 'string') {
595
+ return sendJson(res, 400, { error: 'approval request needs {personality, runtime, tool, content} (+ optional kind/summary/title/approvers)' })
596
+ }
597
+ const { id, decision } = broker.request({
598
+ personality: body.personality,
599
+ runtime: typeof body.runtime === 'string' ? body.runtime : 'claude',
600
+ kind: typeof body.kind === 'string' ? body.kind : 'tool',
601
+ tool: body.tool,
602
+ content: body.content,
603
+ summary: typeof body.summary === 'string' ? body.summary : undefined,
604
+ title: typeof body.title === 'string' ? body.title : undefined,
605
+ approvers: Array.isArray(body.approvers) ? body.approvers.filter((a): a is string => typeof a === 'string') : undefined,
606
+ })
607
+ // No idle-socket kill during the (≤ broker-timeout) hold; cancel default-deny
608
+ // if the asking hook's transport dies before an answer (drop it from the queue).
609
+ res.setTimeout?.(0)
610
+ let settled = false
611
+ const onClose = (): void => {
612
+ if (!settled) broker.cancel(id)
613
+ }
614
+ req.on('close', onClose)
615
+ const d = await decision
616
+ settled = true
617
+ return sendJson(res, 200, { id, ...d })
618
+ }
619
+ return sendJson(res, 405, { error: 'method not allowed — GET|POST /fleet/v1/approvals' })
620
+ }
621
+ const apMatch = /^\/fleet\/v1\/approvals\/([^/]+)(?:\/(approve|deny))?$/.exec(path)
622
+ if (apMatch) {
623
+ const id = decodeURIComponent(apMatch[1]!)
624
+ const action = apMatch[2]
625
+ if (!action) {
626
+ if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/approvals/<id>' })
627
+ const item = broker.get(id)
628
+ if (!item) return sendJson(res, 404, { error: `no pending approval "${id}"` })
629
+ return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approval: item })
630
+ }
631
+ if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/approvals/<id>/(approve|deny)' })
632
+ let body: Record<string, unknown> = {}
633
+ try {
634
+ body = await readBody(req)
635
+ } catch {
636
+ /* empty/invalid body is fine — approve/deny carry only optional fields */
637
+ }
638
+ const by = typeof body.approver === 'string' ? body.approver : undefined
639
+ const via = typeof body.via === 'string' ? body.via : 'cli'
640
+ const ok =
641
+ action === 'approve'
642
+ ? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
643
+ : broker.resolve(id, { decision: 'deny', reason: typeof body.reason === 'string' ? body.reason : 'denied by human' }, { by, via })
644
+ if (!ok) return sendJson(res, 404, { error: `no pending approval "${id}" (already resolved, expired, or unknown)` })
645
+ return sendJson(res, 200, { id, action, ok: true })
646
+ }
536
647
  return sendJson(res, 404, {
537
648
  error: `unknown fleet endpoint ${method} ${path}`,
538
649
  endpoints: [
@@ -541,6 +652,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
541
652
  'GET /fleet/v1/events?replay=N',
542
653
  'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
543
654
  'POST /fleet/v1/send',
655
+ 'GET /fleet/v1/approvals',
656
+ 'POST /fleet/v1/approvals (hook long-poll)',
657
+ 'GET /fleet/v1/approvals/<id>',
658
+ 'POST /fleet/v1/approvals/<id>/(approve|deny)',
544
659
  ],
545
660
  })
546
661
  } catch (e) {
@@ -56,6 +56,14 @@ import {
56
56
  * Enum (not bool) to leave room for future policies. */
57
57
  export type WakePolicy = 'ephemeral'
58
58
 
59
+ /** Per-peer human-approval mode (foundation lifecycle-owned; docs/17-approval).
60
+ * `yolo` (default, absent) = current fleet behavior — runtime bypass flag + the
61
+ * supervisor auto-confirms circuit-breakers. `gated` = launch WITHOUT bypass, the
62
+ * runtime's blocking approval requests are routed to a human through the daemon
63
+ * approval broker. Only `gated` is persisted (yolo = field absent, byte-identical to
64
+ * a never-toggled profile — mirrors wake_policy). */
65
+ export type ApprovalMode = 'yolo' | 'gated'
66
+
59
67
  export interface PeerProfile {
60
68
  personality: string
61
69
  runtime: Runtime
@@ -69,6 +77,9 @@ export interface PeerProfile {
69
77
  interfaces?: PeerInterfaces
70
78
  /** Per-peer wake policy (lifecycle-owned). Absent = normal warm-on-demand. */
71
79
  wake_policy?: WakePolicy
80
+ /** Per-peer human-approval mode (lifecycle-owned). Absent = `yolo` (default). Only
81
+ * `gated` is persisted; see ApprovalMode. */
82
+ approval_mode?: ApprovalMode
72
83
  }
73
84
 
74
85
  // Write shape: intelligence/description optional so a caller can write a profile
@@ -241,9 +252,19 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
241
252
  // Wake policy — only the known enum value is honored; anything else → omitted
242
253
  // (treated as normal warm-on-demand, never throws on an unknown future value).
243
254
  ...(obj.wake_policy === 'ephemeral' ? { wake_policy: 'ephemeral' as const } : {}),
255
+ // Approval mode — only `gated` is persisted/honored; absent / `yolo` / unknown →
256
+ // omitted (treated as the default `yolo`, never throws on an unknown future value).
257
+ ...(obj.approval_mode === 'gated' ? { approval_mode: 'gated' as const } : {}),
244
258
  }
245
259
  }
246
260
 
261
+ /** The effective approval mode of a profile: `gated` iff persisted, else the default
262
+ * `yolo`. The ONE place the "absent ⇒ yolo" default lives — every reader (launch,
263
+ * supervisor, fleet snapshot, CLI) resolves the mode through this, never re-derives it. */
264
+ export function approvalModeOf(profile: PeerProfile | null | undefined): ApprovalMode {
265
+ return profile?.approval_mode === 'gated' ? 'gated' : 'yolo'
266
+ }
267
+
247
268
  /**
248
269
  * Atomically write peer-profile.json, preserving fields the foundation does not
249
270
  * own (e.g. persistent-peer's initial_prompt/aliases section) via a raw read-
@@ -375,6 +396,13 @@ function writePeerProfileMerged(path: string, profile: PeerProfileWrite): void {
375
396
  if (typeof profile.initial_prompt === 'string' && profile.initial_prompt) {
376
397
  merged.initial_prompt = profile.initial_prompt
377
398
  }
399
+ // Approval mode (lifecycle-owned toggle). `gated` persists the field; `yolo` REMOVES
400
+ // it (default = absence, so a gated→yolo flip restores a byte-identical pristine
401
+ // profile — the toggle idempotency invariant). An ABSENT approval_mode in the write
402
+ // (any non-toggle writer: verify --fix, rename, re-provision) preserves the existing
403
+ // value verbatim via `...existing` — a caller that does not own the toggle never flips it.
404
+ if (profile.approval_mode === 'gated') merged.approval_mode = 'gated'
405
+ else if (profile.approval_mode === 'yolo') delete merged.approval_mode
378
406
  writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`)
379
407
  }
380
408
 
@@ -115,6 +115,9 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
115
115
  if (obj.wake_policy !== undefined && obj.wake_policy !== 'ephemeral') {
116
116
  issues.push({ severity: 'warn', field: 'wake_policy', message: `unknown value "${String(obj.wake_policy)}" — only 'ephemeral' is honored` })
117
117
  }
118
+ if (obj.approval_mode !== undefined && obj.approval_mode !== 'gated' && obj.approval_mode !== 'yolo') {
119
+ issues.push({ severity: 'warn', field: 'approval_mode', message: `unknown value "${String(obj.approval_mode)}" — only 'gated'|'yolo' honored (absent = yolo default)` })
120
+ }
118
121
 
119
122
  // Owner sections — well-typed only; foundation never restructures them.
120
123
  if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {