@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.
@@ -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,
@@ -0,0 +1,76 @@
1
+ // Supervisor circuit-breaker → human-approval routing (Unit 4, docs/17). The GATED half of the
2
+ // nag-watcher's 'approve' branch: instead of auto-pressing YES (the yolo status-quo), POST the breaker
3
+ // to the daemon approval broker, block on a human's allow/deny, and inject the decided keys into the
4
+ // pty — the AFFIRMATIVE keys on allow, the taxonomy-specific DECLINE keys on deny.
5
+ //
6
+ // FAIL-SAFE by construction: ANY broker failure (daemon unreachable, abort/timeout, non-200) resolves
7
+ // to DENY (inject the decline keys), NEVER auto-Yes — this is the engineering basis of gated=fail-safe
8
+ // (a gated peer under a supervision fault falls back to the safe answer, not the permissive one). The
9
+ // breaker still sits ABOVE the permission layer, so this is the ONLY interceptor for the prompts the
10
+ // runtime hook cannot see (dangerous-rm; an rm from a subprocess/script).
11
+ //
12
+ // IO is injected (fetch, the pty-write, the log, the clock), so the routing + fail-safe + key selection
13
+ // are unit-testable without a live daemon or a real pty.
14
+
15
+ import { requestApproval, resolveFleetBase, type ApprovalDecision } from '../approval/brokerClient.ts'
16
+
17
+ export interface BreakerApprovalInput {
18
+ /** Class tag — 'dangerous-rm' | 'command-approval'. Sent as the broker `tool` + `summary`. */
19
+ taxonomy: string
20
+ /** Best-effort one-line command/target trace parsed off the pane. Sent as the broker `content`. */
21
+ detail: string
22
+ /** Keys to inject on ALLOW — the position-robust affirmative ('1'+Enter). */
23
+ approveBytes: Buffer
24
+ /** Keys to inject on DENY — the taxonomy-specific decline (2.No | 3.No). */
25
+ denyBytes: Buffer
26
+ }
27
+
28
+ export interface BreakerRouteDeps {
29
+ personality: string
30
+ runtime: string
31
+ env: NodeJS.ProcessEnv
32
+ /** Inject keys into the pty (child.terminal.write). */
33
+ write: (bytes: Buffer) => void
34
+ /** Injectable fetch (tests). */
35
+ fetch?: typeof fetch
36
+ /** Injectable client timeout (tests). */
37
+ timeoutMs?: number
38
+ /** Post-hoc audit line (console.warn in prod; a spy in tests). */
39
+ log?: (line: string) => void
40
+ /** Injectable clock (tests). Default Date.now. */
41
+ now?: () => number
42
+ }
43
+
44
+ /**
45
+ * POST the circuit-breaker to the human-approval broker, await the decision, and inject the decided
46
+ * keys into the pty. Returns the decision for the caller's cooldown/in-flight bookkeeping. NEVER
47
+ * throws — a broker failure fails safe to deny (decline keys injected). Every path leaves one audit
48
+ * line: BREAKER-ALLOW / BREAKER-DENY with the taxonomy, the parsed detail, and (on deny) the reason.
49
+ */
50
+ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<ApprovalDecision> {
51
+ const now = deps.now ?? Date.now
52
+ let decision: ApprovalDecision
53
+ try {
54
+ decision = await requestApproval(
55
+ resolveFleetBase(deps.env),
56
+ {
57
+ personality: deps.personality,
58
+ runtime: deps.runtime,
59
+ kind: 'circuit-breaker',
60
+ tool: input.taxonomy,
61
+ content: input.detail,
62
+ summary: input.taxonomy,
63
+ },
64
+ { env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
65
+ )
66
+ } catch (e) {
67
+ // Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe.
68
+ decision = { decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }
69
+ }
70
+ deps.write(decision.decision === 'allow' ? input.approveBytes : input.denyBytes)
71
+ deps.log?.(
72
+ `[supervisor] BREAKER-${decision.decision.toUpperCase()} ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
73
+ (decision.reason ? ` reason=${JSON.stringify(decision.reason)}` : ''),
74
+ )
75
+ return decision
76
+ }
@@ -38,12 +38,14 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
38
38
  * claudeAdapter satisfies both; codex/router omit them → no action. */
39
39
  export interface NagPredicate {
40
40
  nagDismissKeys?(pane: string): string[] | null
41
- blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
41
+ blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
42
42
  }
43
43
 
44
44
  export type NagAction =
45
45
  | { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
46
- | { kind: 'approve'; keys: string[]; bytes: Buffer; taxonomy: string; detail: string } // circuit-breaker — press keys + LOG
46
+ // circuit-breaker — press `bytes` to AFFIRM (yolo auto-Yes / gated-approved) or `denyBytes` to DECLINE
47
+ // (gated-denied / broker fail-safe); LOG the taxonomy + detail either way.
48
+ | { kind: 'approve'; keys: string[]; bytes: Buffer; denyKeys: string[]; denyBytes: Buffer; taxonomy: string; detail: string }
47
49
  | { kind: 'none' }
48
50
 
49
51
  /**
@@ -59,7 +61,15 @@ export type NagAction =
59
61
  export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
60
62
  const confirm = adapter.blockingConfirm?.(viewport)
61
63
  if (confirm && confirm.keys.length)
62
- return { kind: 'approve', keys: confirm.keys, bytes: keysToBytes(confirm.keys, enc), taxonomy: confirm.taxonomy, detail: confirm.detail }
64
+ return {
65
+ kind: 'approve',
66
+ keys: confirm.keys,
67
+ bytes: keysToBytes(confirm.keys, enc),
68
+ denyKeys: confirm.denyKeys,
69
+ denyBytes: keysToBytes(confirm.denyKeys, enc),
70
+ taxonomy: confirm.taxonomy,
71
+ detail: confirm.detail,
72
+ }
63
73
  const keys = adapter.nagDismissKeys?.(viewport)
64
74
  if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
65
75
  return { kind: 'none' }
@@ -31,6 +31,8 @@ import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPred
31
31
  import { modelToPlainText } from './render.ts'
32
32
  import { codexAdapter } from '../launch/adapters/codex.ts'
33
33
  import { claudeAdapter } from '../launch/adapters/claude.ts'
34
+ import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
35
+ import { routeCircuitBreaker } from './approvalRoute.ts'
34
36
 
35
37
  /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
36
38
  * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
@@ -688,6 +690,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
688
690
  // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
689
691
  const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
690
692
  if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
693
+ // ── Unit 4 — approval-mode routing for the circuit-breaker ──────────────────────
694
+ // Resolve the peer's approval mode ONCE at watcher setup: the profile is stable for the session (a
695
+ // mid-session flip needs a fresh launch), and re-reading it every tick would hammer the fs. yolo
696
+ // (default) → the breaker AUTO-presses YES as before (restores the pre-2.1.x bypass status quo,
697
+ // owner decision). gated → the breaker ROUTES the confirm to the human-approval broker (blocking
698
+ // POST, inject the human's Yes/No). approvalModeOf is the ONE "absent⇒yolo" default — never
699
+ // re-derived here; an unreadable/malformed profile → yolo (approvalModeOf(null)), the safe backstop
700
+ // (a gated peer only launched WITHOUT bypass because the SAME profile read gated moments earlier).
701
+ let approvalMode: ApprovalMode = 'yolo'
702
+ try {
703
+ approvalMode = approvalModeOf(readPeerProfile(opts.cwd ?? runDir))
704
+ } catch {
705
+ /* unreadable / malformed profile → yolo default */
706
+ }
707
+ // Broker identity: prefer the composed child-env ABI (PEER_PERSONALITY/RUNTIME, invocation.ts), else
708
+ // derive from the session name (`<runtime>-<personality>`) by STRIPPING the runtime prefix — the
709
+ // personality itself may carry a '-', so never split on the first dash.
710
+ const brokerEnv = opts.env ?? process.env
711
+ const brokerPersonality =
712
+ brokerEnv.PEER_PERSONALITY?.trim() || (session.startsWith(`${runtime}-`) ? session.slice(runtime.length + 1) : session)
713
+ const brokerRuntime = brokerEnv.PEER_RUNTIME?.trim() || runtime
714
+ // In-flight guard: the setInterval keeps firing while a gated POST is outstanding (the modal stays up,
715
+ // the pane stays stuck), so gate to EXACTLY ONE broker request per wedged modal.
716
+ let brokerInFlight = false
691
717
  let nagFiredMs = 0
692
718
  // В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
693
719
  // RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
@@ -712,12 +738,42 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
712
738
  child.terminal.write(action.bytes)
713
739
  nagFiredMs = Date.now()
714
740
  } else if (action.kind === 'approve') {
715
- // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — press the affirmative so a headless peer never
716
- // hangs on it (owner decision: auto-YES restores the pre-2.1.x bypass status quo). Leave a
717
- // post-hoc AUDIT line in the supervisor's own log (console → logPath): WHICH peer (session),
718
- // WHAT (taxonomy + the parsed command/target), WHEN (ts). A rare bad rm is then at least visible
719
- // after the fact — the interim before human-approval (Telegram) lands. Best-effort log, never
720
- // gates the keypress.
741
+ // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — a headless peer has no human to press it, so it
742
+ // hangs forever. What we press depends on the peer's approval MODE (resolved once above):
743
+ if (approvalMode === 'gated') {
744
+ // GATED — route the confirm to the human-approval broker (docs/17, Unit 4). A blocking POST
745
+ // awaits a human's allow/deny (≤ broker 300s); routeCircuitBreaker injects the affirmative on
746
+ // allow, the taxonomy-specific decline on deny (dangerous-rm=2.No / command-approval=3.No), and
747
+ // fails safe to DENY on any broker fault. The breaker sits ABOVE the permission layer, so this
748
+ // is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm; rm from a
749
+ // subprocess). Guarded by brokerInFlight so a re-firing tick never opens a second request.
750
+ if (brokerInFlight) return
751
+ brokerInFlight = true
752
+ void routeCircuitBreaker(
753
+ { taxonomy: action.taxonomy, detail: action.detail, approveBytes: action.bytes, denyBytes: action.denyBytes },
754
+ {
755
+ personality: brokerPersonality,
756
+ runtime: brokerRuntime,
757
+ env: brokerEnv,
758
+ write: b => child.terminal.write(b),
759
+ log: line => {
760
+ try {
761
+ console.warn(line)
762
+ } catch {
763
+ /* logging must never break the conduit */
764
+ }
765
+ },
766
+ },
767
+ ).finally(() => {
768
+ brokerInFlight = false
769
+ nagFiredMs = Date.now() // just answered → let the (now-clearing) modal repaint away before re-check
770
+ })
771
+ return
772
+ }
773
+ // YOLO (default) — auto-press YES so a headless peer never hangs (owner decision: restores the
774
+ // pre-2.1.x bypass status quo). Leave a post-hoc AUDIT line (WHICH peer, WHAT taxonomy + parsed
775
+ // command/target, WHEN) so a rare bad rm is at least visible after the fact. Best-effort log,
776
+ // never gates the keypress.
721
777
  child.terminal.write(action.bytes)
722
778
  nagFiredMs = Date.now()
723
779
  try {
package/src/tray/index.ts CHANGED
@@ -150,3 +150,28 @@ export async function trayCmd(
150
150
  const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
151
151
  return { ok: status < 400, status, body }
152
152
  }
153
+
154
+ export type ApprovalVerb = 'approve' | 'deny'
155
+
156
+ /**
157
+ * Resolve a pending human-approval from the tray: POST /fleet/v1/approvals/<id>/(approve|deny) over
158
+ * the SAME unix-first fleet client trayCmd uses — the proxy-safe transport (the absolute-form-misroute
159
+ * grabli: a peer's Happ proxy would otherwise mangle a loopback POST). Tags `via:'tray'` for the audit
160
+ * trail; the single-queue invariant means this resolution is seen by every channel (CLI/telegram). A
161
+ * 404 (already resolved / expired) surfaces as ok=false with the daemon's message — the caller reports
162
+ * it (the request the user saw was answered elsewhere first).
163
+ */
164
+ export async function trayResolveApproval(
165
+ env: NodeJS.ProcessEnv,
166
+ decision: ApprovalVerb,
167
+ id: string,
168
+ reason?: string,
169
+ ): Promise<TrayCmdResult> {
170
+ const addr = resolveFleetAddress({ env })
171
+ if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
172
+ return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
173
+ }
174
+ const path = `/fleet/v1/approvals/${encodeURIComponent(id)}/${decision}`
175
+ const { status, body } = await fleetPostJson(addr, path, { via: 'tray', ...(reason ? { reason } : {}) }, env)
176
+ return { ok: status < 400, status, body }
177
+ }