@agfpd/iapeer 0.4.68 → 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,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 {
@@ -163,6 +169,9 @@ export function buildFleetSnapshot(
163
169
  if (ephemeral) {
164
170
  for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
165
171
  }
172
+ // Approval mode from the LOCAL profile (same source as ephemeral); a read hiccup →
173
+ // the safe `yolo` default (peerApprovalMode swallows it).
174
+ const approvalMode = peerApprovalMode(r.cwd)
166
175
  return {
167
176
  personality: r.personality,
168
177
  description: r.description,
@@ -176,6 +185,7 @@ export function buildFleetSnapshot(
176
185
  launchd_managed: isLaunchdManaged(r.personality, env),
177
186
  wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
178
187
  ...(ephemeral ? { queue_depth: queueDepth } : {}),
188
+ approval_mode: approvalMode,
179
189
  }
180
190
  })
181
191
  const mem = readMemoryProvider(env)
@@ -217,6 +227,9 @@ export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src
217
227
  { path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
218
228
  { path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
219
229
  { path: exitLogPath(cfg.eventLogDir), src: 'exits' },
230
+ // Human-approval broker events (docs/17): approval-request / approval-resolved.
231
+ // A pre-approval daemon simply never writes this file (readLogTail tolerates absence).
232
+ { path: approvalsLogPath(cfg.eventLogDir), src: 'approvals' },
220
233
  ]
221
234
  }
222
235
 
@@ -432,6 +445,8 @@ export interface FleetHandlerOptions {
432
445
  ops?: FleetOps
433
446
  /** Injectable tailer poll interval (tests). */
434
447
  pollMs?: number
448
+ /** Injectable approval broker (tests — e.g. a short timeout). Default: a fresh one. */
449
+ broker?: ApprovalBroker
435
450
  }
436
451
 
437
452
  export type FleetHandler = (
@@ -451,6 +466,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
451
466
  const startedAtMs = Date.now()
452
467
  const files = fleetEventFiles(cfg)
453
468
  const tailer = new FleetEventTailer(files, opts.pollMs)
469
+ // The approval broker — one per daemon; the ASK side (POST /approvals long-poll) and
470
+ // the ANSWER side (GET/POST /approvals/*) are all interfaces to this one queue.
471
+ const broker = opts.broker ?? new ApprovalBroker({ logDir: cfg.eventLogDir, env })
454
472
  let opsPromise: Promise<FleetOps> | undefined
455
473
  const getOps = (): Promise<FleetOps> => {
456
474
  if (opts.ops) return Promise.resolve(opts.ops)
@@ -533,6 +551,72 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
533
551
  if (result.isError) return sendJson(res, 502, { error: text })
534
552
  return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
535
553
  }
554
+ // ── approvals — the human-approval broker surface (docs/17) ────────────
555
+ if (path === '/fleet/v1/approvals') {
556
+ if (method === 'GET') {
557
+ return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approvals: broker.list() })
558
+ }
559
+ if (method === 'POST') {
560
+ // The gated peer's hook BLOCKS here (long-poll): enqueue + await a decision.
561
+ let body: Record<string, unknown>
562
+ try {
563
+ body = await readBody(req)
564
+ } catch (e) {
565
+ return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
566
+ }
567
+ if (typeof body.personality !== 'string' || !body.personality || typeof body.tool !== 'string' || typeof body.content !== 'string') {
568
+ return sendJson(res, 400, { error: 'approval request needs {personality, runtime, tool, content} (+ optional kind/summary/title/approvers)' })
569
+ }
570
+ const { id, decision } = broker.request({
571
+ personality: body.personality,
572
+ runtime: typeof body.runtime === 'string' ? body.runtime : 'claude',
573
+ kind: typeof body.kind === 'string' ? body.kind : 'tool',
574
+ tool: body.tool,
575
+ content: body.content,
576
+ summary: typeof body.summary === 'string' ? body.summary : undefined,
577
+ title: typeof body.title === 'string' ? body.title : undefined,
578
+ approvers: Array.isArray(body.approvers) ? body.approvers.filter((a): a is string => typeof a === 'string') : undefined,
579
+ })
580
+ // No idle-socket kill during the (≤ broker-timeout) hold; cancel default-deny
581
+ // if the asking hook's transport dies before an answer (drop it from the queue).
582
+ res.setTimeout?.(0)
583
+ let settled = false
584
+ const onClose = (): void => {
585
+ if (!settled) broker.cancel(id)
586
+ }
587
+ req.on('close', onClose)
588
+ const d = await decision
589
+ settled = true
590
+ return sendJson(res, 200, { id, ...d })
591
+ }
592
+ return sendJson(res, 405, { error: 'method not allowed — GET|POST /fleet/v1/approvals' })
593
+ }
594
+ const apMatch = /^\/fleet\/v1\/approvals\/([^/]+)(?:\/(approve|deny))?$/.exec(path)
595
+ if (apMatch) {
596
+ const id = decodeURIComponent(apMatch[1]!)
597
+ const action = apMatch[2]
598
+ if (!action) {
599
+ if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/approvals/<id>' })
600
+ const item = broker.get(id)
601
+ if (!item) return sendJson(res, 404, { error: `no pending approval "${id}"` })
602
+ return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approval: item })
603
+ }
604
+ if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/approvals/<id>/(approve|deny)' })
605
+ let body: Record<string, unknown> = {}
606
+ try {
607
+ body = await readBody(req)
608
+ } catch {
609
+ /* empty/invalid body is fine — approve/deny carry only optional fields */
610
+ }
611
+ const by = typeof body.approver === 'string' ? body.approver : undefined
612
+ const via = typeof body.via === 'string' ? body.via : 'cli'
613
+ const ok =
614
+ action === 'approve'
615
+ ? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
616
+ : broker.resolve(id, { decision: 'deny', reason: typeof body.reason === 'string' ? body.reason : 'denied by human' }, { by, via })
617
+ if (!ok) return sendJson(res, 404, { error: `no pending approval "${id}" (already resolved, expired, or unknown)` })
618
+ return sendJson(res, 200, { id, action, ok: true })
619
+ }
536
620
  return sendJson(res, 404, {
537
621
  error: `unknown fleet endpoint ${method} ${path}`,
538
622
  endpoints: [
@@ -541,6 +625,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
541
625
  'GET /fleet/v1/events?replay=N',
542
626
  'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
543
627
  'POST /fleet/v1/send',
628
+ 'GET /fleet/v1/approvals',
629
+ 'POST /fleet/v1/approvals (hook long-poll)',
630
+ 'GET /fleet/v1/approvals/<id>',
631
+ 'POST /fleet/v1/approvals/<id>/(approve|deny)',
544
632
  ],
545
633
  })
546
634
  } 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))) {
@@ -23,7 +23,7 @@
23
23
  import { homedir } from 'os'
24
24
  import { join } from 'path'
25
25
  import { readdirSync, realpathSync, statSync } from 'fs'
26
- import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
26
+ import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
27
27
  import { lastTimestampedEntryMs } from './transcriptTail.ts'
28
28
 
29
29
  // ─────────────────────────────────────────────────────────────────────────────
@@ -129,6 +129,46 @@ function dangerousRmDetail(pane: string): string {
129
129
  return [cmd && `cmd=${JSON.stringify(cmd)}`, target && `target=${JSON.stringify(target)}`].filter(Boolean).join(' ') || '(unparsed)'
130
130
  }
131
131
 
132
+ // Standard command-approval prompt (claude 2.1.201 — the NO-RULE-MATCH passthrough / file-access "ask")
133
+ // needles. This is the prompt a peer sees when it is NOT in bypass — i.e. GATED mode, OR a yolo peer
134
+ // whose bypass the runtime disabled mid-session (the `tengu_disable_bypass_permissions_mode` feature
135
+ // gate / `disableBypassPermissionsMode` setting). Under GENUINE bypass this prompt never surfaces (only
136
+ // the dangerous-rm breaker does — verified from the 2.1.201 permission-gate: bypass auto-allows every
137
+ // `ask` except "Dangerous rm/rmdir operation"). The live render (captured via a nested pty, default
138
+ // mode) is a THREE-option select — "❯ 1. Yes / 2. Yes, and <don't ask again | always allow …> / 3. No"
139
+ // under "Do you want to proceed?" — which is what distinguishes it from the dangerous-rm breaker's
140
+ // TWO-option "1. Yes / 2. No". Split-literal (В40): a peer reviewing this adapter must not self-trigger.
141
+ const ORG_POLICY_APPROVAL = 'organization requires ' + 'approval' // MCP org-restriction — NEVER auto-pressed
142
+ const BASH_CMD_HEADER = 'Bash' + ' command'
143
+ /** FULL-signature + position match for the LIVE standard command-approval prompt. Returns true ONLY for
144
+ * the 3-option layout with the bottom-most `❯` cursor on "1. Yes" — so a mere QUOTE of the prompt (ready
145
+ * composer `❯` below) never matches. Two hard exclusions satisfy the owner's NARROW-matcher rule:
146
+ * · org-policy ("Your organization requires approval for this tool", an MCP restriction) → excluded,
147
+ * the owner's org rule is HUMAN-only and must never be auto-pressed;
148
+ * · the dangerous-rm breaker → handled by its own taxonomy (checked first in blockingConfirm), and
149
+ * structurally excluded here anyway (it is a 2-option "2. No", never "2. Yes," + "3. No"). */
150
+ function isCommandApprovalPrompt(pane: string): boolean {
151
+ if (!pane.includes(RM_PROCEED)) return false // shares "Do you want to proceed?" with the rm breaker
152
+ if (pane.includes(ORG_POLICY_APPROVAL)) return false // org-policy → never auto-Yes
153
+ if (isDangerousRmPrompt(pane)) return false // its own taxonomy owns this pane
154
+ // 3-option standard layout: option 2 is a "Yes," variant + option 3 is "No" (the rm breaker is
155
+ // 2-option "1. Yes / 2. No", so it fails BOTH tests — no overlap).
156
+ if (!/(^|\n)\s*2\.\s*Yes/.test(pane)) return false
157
+ if (!/(^|\n)\s*3\.\s*No\b/.test(pane)) return false
158
+ // live-modal position gate: the bottom-most `❯` row must sit on "1. Yes".
159
+ let lastCursorRow = ''
160
+ for (const line of pane.split(/\r?\n/)) if (line.includes('❯')) lastCursorRow = line
161
+ return /❯\s*1\.\s*Yes\b/.test(lastCursorRow)
162
+ }
163
+ /** Best-effort trace of the command the approval prompt guarded — the first non-blank line under the
164
+ * "Bash command" block header. Mirrors dangerousRmDetail's post-hoc audit purpose. */
165
+ function commandApprovalDetail(pane: string): string {
166
+ const lines = pane.split(/\r?\n/)
167
+ const hi = lines.findIndex(l => l.trim() === BASH_CMD_HEADER)
168
+ if (hi >= 0) for (let j = hi + 1; j < lines.length; j++) { const t = lines[j].trim(); if (t) return `cmd=${JSON.stringify(t)}` }
169
+ return '(unparsed)'
170
+ }
171
+
132
172
  // ─────────────────────────────────────────────────────────────────────────────
133
173
  // Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
134
174
  // ─────────────────────────────────────────────────────────────────────────────
@@ -228,9 +268,16 @@ export const claudeAdapter: RuntimeAdapter = {
228
268
  * NO currency — no marketplace/install/update on this path.
229
269
  */
230
270
  buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
271
+ // Human-approval mode (docs/17). yolo (default) = '--dangerously-skip-permissions'
272
+ // (headless bypass, current fleet behavior). gated = NO bypass + an EXPLICIT
273
+ // '--permission-mode default' so the base is deterministic (never inherits an
274
+ // acceptEdits/bypass defaultMode from settings): the runtime's permission layer is
275
+ // active as a fail-SAFE backstop, and the separately-installed PreToolUse hook is
276
+ // the primary interceptor (it fires regardless of mode — verified live 2.1.201).
277
+ const gated = spec.approvalMode === 'gated'
231
278
  return [
232
279
  cfg.claudeBin,
233
- '--dangerously-skip-permissions',
280
+ ...(gated ? ['--permission-mode', 'default'] : ['--dangerously-skip-permissions']),
234
281
  '--disallowedTools',
235
282
  'AskUserQuestion',
236
283
  '--add-dir',
@@ -329,18 +376,30 @@ export const claudeAdapter: RuntimeAdapter = {
329
376
  },
330
377
 
331
378
  /**
332
- * Dangerous-rm/rmdir circuit-breaker (RuntimeAdapter.blockingConfirm). Unlike the fullscreen nag
333
- * (benign upsell → DECLINE), this guard sits ABOVE `--dangerously-skip-permissions` and hangs a
334
- * headless peer forever with no human to answer. Owner decision (Артур, 04.07): auto-press YES —
335
- * peers run on bypass, so this only restores the pre-2.1.x status quo, and a hang is real recurring
336
- * harm. The default cursor is already on "1. Yes" (verified live), so ['1','Enter'] is the explicit,
337
- * position-robust affirmative. Returns the class tag + a one-line command/target trace so the
338
- * supervisor logs a post-hoc audit line. NB deliberately NOT a "smart" safe-decline/escalation — that
339
- * lands later on the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
379
+ * Blocking-confirm circuit-breaker (RuntimeAdapter.blockingConfirm). Unlike the fullscreen nag
380
+ * (benign upsell → DECLINE), these guards hang a headless peer forever with no human to answer.
381
+ * Owner decision (Артур, 04.07): auto-press YES — peers run on bypass, so this only restores the
382
+ * pre-2.1.x status quo, and a hang is real recurring harm. Two signatures are matched, most-specific
383
+ * first, each with its own taxonomy so the supervisor's audit log and (later) the human-approval
384
+ * broker can tell them apart:
385
+ * 1. 'dangerous-rm' — the rm/rmdir breaker that sits ABOVE `--dangerously-skip-permissions` (the
386
+ * ONLY prompt that surfaces under genuine bypass). Default cursor on "1. Yes" (verified live).
387
+ * 2. 'command-approval' — the STANDARD 3-option approval prompt, surfaced when the peer is NOT in
388
+ * bypass: gated mode, or a yolo peer whose bypass the runtime disabled mid-session (feature gate
389
+ * / setting). Auto-Yes keeps a yolo peer alive; a gated peer is routed to the human broker by the
390
+ * supervisor (Unit 4, mode-aware). Org-policy ("Your organization requires approval for this
391
+ * tool") is EXCLUDED by the matcher — the owner's org rule is never auto-pressed.
392
+ * Both AFFIRM with ['1','Enter'] (the position-robust affirmative — cursor already on "1. Yes"); the
393
+ * DECLINE keys differ by layout: dangerous-rm is a 2-option "1.Yes/2.No" → ['2','Enter']; command-
394
+ * approval is a 3-option "1.Yes/2.Yes,…/3.No" → ['3','Enter']. The supervisor presses `keys` (yolo
395
+ * auto-Yes / gated-approved) or `denyKeys` (gated-denied / broker fail-safe). Each carries a one-line
396
+ * command/target trace for the audit + broker content. NB deliberately NOT a "smart" safe-decline —
397
+ * the gated human-approval path lands via the daemon broker (docs/17, Unit 4).
340
398
  */
341
- blockingConfirm(pane: string): { keys: string[]; taxonomy: string; detail: string } | null {
342
- if (!isDangerousRmPrompt(pane)) return null
343
- return { keys: ['1', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
399
+ blockingConfirm(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null {
400
+ if (isDangerousRmPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['2', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
401
+ if (isCommandApprovalPrompt(pane)) return { keys: ['1', 'Enter'], denyKeys: ['3', 'Enter'], taxonomy: 'command-approval', detail: commandApprovalDetail(pane) }
402
+ return null
344
403
  },
345
404
 
346
405
  /**
@@ -350,12 +409,20 @@ export const claudeAdapter: RuntimeAdapter = {
350
409
  * - '❯' (U+276F) — the TUI input-prompt glyph, present only at the ready
351
410
  * input row, never in the splash art (claude-start.sh:357-360).
352
411
  * - 'bypass permissions on' — the banner --dangerously-skip-permissions
353
- * emits once booted past the splash.
412
+ * emits once booted past the splash (YOLO mode only).
354
413
  * - none of the boot dialogs present (a dialog row can also carry '❯').
414
+ *
415
+ * GATED (docs/17): the peer launches WITHOUT --dangerously-skip-permissions, so the
416
+ * 'bypass permissions on' banner is ABSENT (verified live 2.1.201 — the no-bypass
417
+ * ready pane is '❯ Try "edit <filepath> to…"', no banner). The ready signal is then
418
+ * the composer '❯' with every boot dialog cleared. This branch is evaluated ONLY at
419
+ * BOOT (waitHostReady / the boot-driver), before any tool runs — so a mid-session
420
+ * '❯'-bearing modal (dangerous-rm / nag) cannot false-positive it.
355
421
  */
356
- isInputReady(pane: string): boolean {
422
+ isInputReady(pane: string, mode?: ApprovalMode): boolean {
357
423
  if (anyBootDialog(pane)) return false
358
- return pane.includes('❯') && pane.includes('bypass permissions on')
424
+ if (!pane.includes('❯')) return false
425
+ return mode === 'gated' ? true : pane.includes('bypass permissions on')
359
426
  },
360
427
 
361
428
  /**
@@ -43,7 +43,7 @@ import { homedir } from 'os'
43
43
  import { join } from 'path'
44
44
  import { readdirSync, realpathSync, statSync } from 'fs'
45
45
  import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
46
- import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
46
+ import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
47
47
 
48
48
  // ─────────────────────────────────────────────────────────────────────────────
49
49
  // Boot dialog markers (watcher.codexUpdate/DirTrust/HooksReview, 253-261)
@@ -199,6 +199,13 @@ export const codexAdapter: RuntimeAdapter = {
199
199
  * NO currency — no marketplace/install/update on this path.
200
200
  */
201
201
  buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
202
+ // Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current
203
+ // fleet behavior). gated = NO bypass + `-c approval_policy=on-request` (the model
204
+ // asks before acting → the PreToolUse/PermissionRequest hook intercepts) with
205
+ // `-c sandbox_mode=danger-full-access` — gated gates APPROVALS, it does NOT add a
206
+ // sandbox (workspace-write would sever the loopback-MCP send_to_peer + vault writes;
207
+ // sandboxing is a separate axis, not v1). Session-scoped `-c`, never a host-config edit.
208
+ const gated = spec.approvalMode === 'gated'
202
209
  return [
203
210
  cfg.codexBin,
204
211
  ...(spec.resume ? ['resume', '--last'] : []),
@@ -208,7 +215,9 @@ export const codexAdapter: RuntimeAdapter = {
208
215
  ...(spec.systemPromptFile
209
216
  ? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
210
217
  : []),
211
- '--dangerously-bypass-approvals-and-sandbox',
218
+ ...(gated
219
+ ? ['-c', 'approval_policy=on-request', '-c', 'sandbox_mode=danger-full-access']
220
+ : ['--dangerously-bypass-approvals-and-sandbox']),
212
221
  ...(spec.extraArgs ?? []),
213
222
  ]
214
223
  },
@@ -261,7 +270,9 @@ export const codexAdapter: RuntimeAdapter = {
261
270
  * composer, and the ready-gate still verifies a real model turn (activity
262
271
  * mtime advance) before declaring READY.
263
272
  */
264
- isInputReady(pane: string): boolean {
273
+ // mode is accepted for interface parity but ignored: codex's ready marker (the '› '
274
+ // composer arrow) is the same whether or not it launched with the YOLO bypass flag.
275
+ isInputReady(pane: string, _mode?: ApprovalMode): boolean {
265
276
  if (pane.includes('Press enter to continue')) return false
266
277
  return pane.split(/\r?\n/).some(line => line.trimStart().startsWith('› '))
267
278
  },
@@ -202,7 +202,7 @@ export const launch: LaunchFn = async (
202
202
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
203
203
  const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
204
204
  const isReady = await waitHostReady(
205
- { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, env },
205
+ { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
206
206
  sleep,
207
207
  )
208
208
  if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
@@ -19,7 +19,7 @@ import { parseSessionName } from '../core/socket.ts'
19
19
  import type { TmuxRuntime } from '../core/constants.ts'
20
20
  import { paneLogViewport } from './readyGateModel.ts'
21
21
  import type { LaunchInvocation } from './invocation.ts'
22
- import type { RuntimeAdapter } from './types.ts'
22
+ import type { ApprovalMode, RuntimeAdapter } from './types.ts'
23
23
 
24
24
  /** Match the tmux launch geometry (index.ts new-session -x 220 -y 50) so the served child + the
25
25
  * pane-log model render at the same size launch reads readiness at. */
@@ -154,7 +154,7 @@ export async function startPtyHost(b: {
154
154
  * 2 s cadence + bootDeadline.
155
155
  */
156
156
  export async function waitHostReady(
157
- b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; env?: NodeJS.ProcessEnv },
157
+ b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; approvalMode?: ApprovalMode; env?: NodeJS.ProcessEnv },
158
158
  sleep: (ms: number) => Promise<void>,
159
159
  ): Promise<boolean> {
160
160
  const log = `${b.logDir}/${b.identity}.log`
@@ -168,7 +168,7 @@ export async function waitHostReady(
168
168
  await sleep(2000)
169
169
  if (!hostSessionAlive(b.identity, b.env)) return false
170
170
  const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
171
- if (view && b.adapter.isInputReady(view)) return true
171
+ if (view && b.adapter.isInputReady(view, b.approvalMode)) return true
172
172
  }
173
173
  return false
174
174
  }
@@ -14,8 +14,13 @@
14
14
  // be implemented independently against a known contract.
15
15
 
16
16
  import type { Intelligence, Runtime } from '../core/constants.ts'
17
+ import type { ApprovalMode } from '../identity/index.ts'
17
18
  import type { PublicPeerSummary } from '../registry/index.ts'
18
19
 
20
+ // Re-export so the adapters (which already import their contract types from here) get
21
+ // ApprovalMode from ONE place rather than reaching into identity directly.
22
+ export type { ApprovalMode }
23
+
19
24
  export type { PublicPeerSummary } from '../registry/index.ts'
20
25
 
21
26
  // ─────────────────────────────────────────────────────────────────────────────
@@ -159,6 +164,12 @@ export interface LaunchSpec {
159
164
  * less throwaway may omit it, but an adapter that declares requiresIntelligence
160
165
  * then refuses (cannot confirm the required nature). */
161
166
  intelligence?: Intelligence
167
+ /** Human-approval mode (docs/17): `yolo` (default/omitted) launches with the
168
+ * runtime bypass flag (current fleet behavior); `gated` launches WITHOUT bypass
169
+ * and swaps in the gated permission/approval config, so the runtime's blocking
170
+ * requests route to a human via the daemon broker (a PreToolUse hook, installed
171
+ * separately at toggle time, is the interceptor). buildArgv reads it. */
172
+ approvalMode?: ApprovalMode
162
173
  }
163
174
 
164
175
  // ─────────────────────────────────────────────────────────────────────────────
@@ -307,17 +318,25 @@ export interface RuntimeAdapter {
307
318
  * settings, or flag disables it). A headless peer has no human to press 1/2, so it HANGS forever
308
319
  * (live-observed on a verification session). Owner decision (Артур, 04.07): auto-press YES — peers
309
320
  * already run on bypass, so this only restores the pre-2.1.x status quo; a hang is real recurring
310
- * harm. Return `{keys, taxonomy, detail}` (the affirmative keys + a class tag + a one-line trace of
311
- * WHAT was approved) so the supervisor can press it AND leave a post-hoc log; else null. Match the
312
- * FULL signature + cursor position so ordinary content mentioning the phrase never fires. Optional
313
- * (codex/router omit it). tui only. FUTURE: replace blind-YES with human approval via Telegram —
314
- * see the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
321
+ * harm. Return `{keys, denyKeys, taxonomy, detail}` (the AFFIRMATIVE keys + the taxonomy-specific
322
+ * DECLINE keys + a class tag + a one-line trace of WHAT was approved) so the supervisor can press
323
+ * YES on a yolo peer AND leave a post-hoc log, OR route the confirm to the human broker on a gated
324
+ * peer and press the decision (Unit 4 — allow→keys, deny→denyKeys). `denyKeys` differs by taxonomy
325
+ * because the modal layouts differ (dangerous-rm is a 2-option "1.Yes/2.No" → `['2','Enter']`;
326
+ * command-approval is a 3-option "1.Yes/2.Yes,…/3.No" → `['3','Enter']`) — the adapter owns that
327
+ * layout knowledge, not the supervisor. Match the FULL signature + cursor position so ordinary
328
+ * content mentioning the phrase never fires; else null. Optional (codex/router omit it). tui only.
329
+ * Human-approval lands via the daemon broker (docs/17); the vault idea is "Human-approval
330
+ * подтверждения действий пира через Telegram-кнопки".
315
331
  */
316
- blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
332
+ blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
317
333
 
318
334
  /** Is the input surface ready for the first message? (tui: ready marker present
319
- * AND startup dialogs gone). Router runtimes return true (no input surface). */
320
- isInputReady(pane: string): boolean
335
+ * AND startup dialogs gone). Router runtimes return true (no input surface).
336
+ * `mode` (default yolo) lets a runtime whose READY marker differs by approval mode
337
+ * pick the right one — claude's yolo ready banner ('bypass permissions on') is
338
+ * absent under gated (launched WITHOUT --dangerously-skip-permissions). */
339
+ isInputReady(pane: string, mode?: ApprovalMode): boolean
321
340
 
322
341
  /** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
323
342
  * baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
@@ -28,7 +28,7 @@ import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
29
  import { capPaneLogs } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
- import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
31
+ import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
32
32
  import {
33
33
  ephemeralQueueDepth,
34
34
  listQueuedIdentities,
@@ -600,6 +600,18 @@ export function isEphemeralPeer(cwd: string): boolean {
600
600
  }
601
601
  }
602
602
 
603
+ /** The effective human-approval mode of the peer at `cwd` — `gated` iff its profile
604
+ * persists it, else the default `yolo`. A profile read hiccup → `yolo` (safe default:
605
+ * the current-fleet behavior; never gates a peer we cannot read). The single cwd-level
606
+ * reader the launch/supervisor/fleet paths call (default lives in approvalModeOf). */
607
+ export function peerApprovalMode(cwd: string): ApprovalMode {
608
+ try {
609
+ return approvalModeOf(readPeerProfile(cwd))
610
+ } catch {
611
+ return 'yolo'
612
+ }
613
+ }
614
+
603
615
  /**
604
616
  * Decide resume vs fresh on a wake (TARGET redesign). Branch order:
605
617
  * 1. argsResume === false (folder-launch `iapeer <runtime>`) → FRESH.
@@ -1112,6 +1124,9 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1112
1124
  // Carry the peer's nature so the launch primitive can enforce an adapter's
1113
1125
  // intelligence gate (telegram requires natural). From the registry record.
1114
1126
  intelligence: peer.intelligence,
1127
+ // Human-approval mode from the LOCAL profile (docs/17): drives the runtime
1128
+ // bypass flag + gated permission/approval config in buildArgv. Default yolo.
1129
+ approvalMode: peerApprovalMode(cwd),
1115
1130
  }
1116
1131
  const launchCfg: LaunchConfig = {
1117
1132
  claudeBin: cfg.claudeBin,