@agfpd/iapeer 0.4.67 → 0.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,7 +55,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
55
55
  "last_active_ms": 1751731100000,
56
56
  "attached": true,
57
57
  "launchd_managed": false,
58
- "wake_policy": "warm"
58
+ "wake_policy": "warm",
59
+ "approval_mode": "yolo"
59
60
  }
60
61
  ]
61
62
  }
@@ -68,6 +69,7 @@ Field notes:
68
69
  - `attached` — any runtime attached (peer-level convenience).
69
70
  - `launchd_managed` — launchd owns this peer's lifecycle (H4): the daemon never wakes/reaps it, and wake/stop/start commands are guarded accordingly.
70
71
  - `wake_policy` — `warm` (default: wake-on-message, idle-reap, resume) or `ephemeral` (stateless worker: serial task queue, die-after-reply).
72
+ - `approval_mode` — the peer's human-approval mode (docs/17-approval): `yolo` (default — launched with the runtime bypass flag, the supervisor auto-confirms circuit-breakers) or `gated` (launched without bypass; the runtime's blocking approval requests are routed to a human through the daemon approval broker). Additive field; a pre-approval daemon omits it — clients MUST treat its absence as `yolo`.
71
73
  - `queue_depth` — ephemeral peers only: pending tasks across the peer's serial queues.
72
74
  - `host.memory` / `host.voice` — the provider slots (`null` = empty slot); `heartbeatAgeSecs: null` with a non-null slot = provider declared but its daemon isn't heartbeating.
73
75
  - `host.fda` — Full Disk Access of the binary: `true` / `false` / `null` (undeterminable).
@@ -0,0 +1,128 @@
1
+ # 17 — Human approval
2
+
3
+ A peer can run in one of two **approval modes**. In `yolo` (the default — every peer today) the agent acts autonomously: the runtime is launched with its bypass flag and nothing asks a human before a tool runs. In `gated` the peer is launched **without** bypass, and every blocking approval the runtime would raise is routed to a **human** through the daemon's approval broker — a Telegram button, a tray click, or `iapeer approve` on the CLI. The human's `allow`/`deny` (with a reason) flows back into the runtime and the tool proceeds or is refused.
4
+
5
+ This is a **normative contract**. The approval broker is the daemon's, the fleet endpoints and `ev` kinds below are the surface that operator faces (CLI, tray, the Telegram runtime) are written against — not the daemon source. The interception facts (which runtime event fires, in which shape) are pinned to **Claude Code 2.1.201** and **codex-cli 0.142.5**, taken from the live binaries + current runtime docs.
6
+
7
+ `gated` means **«ask before acting»**, not «sandboxed». It removes the bypass so blocking questions surface; it does **not** add an OS sandbox (a separate axis, not in v1). On codex, gated is explicitly `approvals ON + sandbox OFF` (see the toggle table).
8
+
9
+ ## The approval-mode toggle
10
+
11
+ ### Profile field
12
+
13
+ A foundation-owned lifecycle field in `peer-profile.json`, next to `wake_policy` / `initial_prompt`:
14
+
15
+ ```json
16
+ { "approval_mode": "gated" }
17
+ ```
18
+
19
+ `"yolo" | "gated"`, **default `yolo`**. Only `gated` is persisted — writing `yolo` **removes** the field (so a `gated→yolo→gated` round-trip is byte-identical, and the whole fleet keeps behaving exactly as before this feature existed). The one place the «absent ⇒ yolo» default lives is `approvalModeOf(profile)`; every reader (launch, supervisor, fleet snapshot, CLI) resolves through it and never re-derives it. The mode appears in the fleet snapshot as the additive `approval_mode` field (docs/15) by reading the local profile — a pre-approval daemon omits it and clients MUST treat its absence as `yolo`.
20
+
21
+ ### Launch surfaces the flip touches
22
+
23
+ The bypass is a **launch argument**, different per runtime, and the mode is a coordinated operation over **all** the surfaces that argument implies. iapeer owns the launch, so completeness is on the foundation.
24
+
25
+ **claude** (Option D — a matcher-free `PermissionRequest` hook; verified live 2.1.201):
26
+
27
+ | # | Surface | yolo | gated |
28
+ |---|---|---|---|
29
+ | C1 | argv `--dangerously-skip-permissions` | present | **removed** |
30
+ | C2 | argv permission-mode | (bypass) | `--permission-mode default` (explicit — never inherits an acceptEdits/bypass defaultMode) |
31
+ | C3 | ready-gate marker `isInputReady` | needs the `bypass permissions on` banner | banner is **absent** → the marker is mode-aware (composer `❯` + boot dialogs cleared), else the peer never becomes ready and the wake fails |
32
+ | C4 | boot-dialog «Bypass Permissions mode» accept | appears (first bypass) | not shown (no bypass) |
33
+ | C5 | `PermissionRequest` hook in `<cwd>/.claude/settings.json` | **absent** (0 overhead, byte-identical to today) | **installed** — matcher-free (see below), `command = iapeer approval-hook` |
34
+ | C6 | allow-rule for the peer's own MCP tool (`permissions.allow: ["mcp__iapeer__send_to_peer"]`) | not needed (bypass) | **needed** — else default-mode gates the peer's own IAP channel and it hangs on `send_to_peer` |
35
+ | C7 | supervisor circuit-breaker (dangerous-rm, above the permission layer) | auto-Yes + audit log | **routed to a human** through the broker |
36
+ | C8 | argv `--disallowedTools AskUserQuestion` | stays | stays in v1 (see Limitations) |
37
+
38
+ **Why matcher-free `PermissionRequest`, not `PreToolUse`.** `PermissionRequest` fires **only when the runtime's permission config decided to prompt** — a call not covered by an allow/deny rule. So the policy of *what to ask* stays 100% at the runtime: a user tunes it with ordinary permission rules (a tool in `permissions.allow` is never asked → the hook never fires → auto-allowed). No class matcher to drift, no new tool slipping through, no hang. iapeer only seeds the hook + the one allow-rule for the peer's own MCP tool.
39
+
40
+ **codex** (`PreToolUse` hook — structural; live-verify pending):
41
+
42
+ | # | Surface | yolo | gated |
43
+ |---|---|---|---|
44
+ | X1 | argv `--dangerously-bypass-approvals-and-sandbox` | present | **removed** |
45
+ | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — approvals **ON**, sandbox **OFF** (so loopback-MCP `send_to_peer` + writes to the memory vault outside cwd still work; `workspace-write` would cut the network and external paths). Session-scoped via `-c`, not host config. |
46
+ | X3 | `PreToolUse` hook `<cwd>/.codex/hooks.json` + trust pre-seed in `~/.codex/config.toml [hooks.state]` | **absent** | **installed + trusted** (`preSeedCodexHooksTrust`; an untrusted codex hook is silently skipped) |
47
+ | X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (both modes) | pre-trusted (matters more with approvals active) |
48
+ | X5 | ready-gate `isInputReady` (`›` composer) | mode-independent | unchanged |
49
+
50
+ The codex hook is matched by tool-name regex `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — the coarse class matcher lives on the runtime's own `hooks.json` (the user tunes it as ordinary runtime config).
51
+
52
+ ### Idempotency and the application moment
53
+
54
+ - **argv surfaces** (C1–C2, C8, X1–X2) are computed from the mode on every launch — structurally idempotent, no accumulation.
55
+ - **settings.json / hooks.json** (C5–C6, X3) are add-on-gated / remove-on-yolo with a no-clobber merge, keyed on the stable `approval-hook` marker: our block is written deterministically and removed cleanly (an emptied `hooks.PermissionRequest` / `PreToolUse` array is dropped, an emptied `hooks` / `permissions` object is dropped, foreign hooks/rules untouched). A `gated→yolo→gated` round-trip restores the pre-install bytes.
56
+ - **codex trust** (X3–X4) reuses the already-idempotent `preSeedCodexHooksTrust` / `removeCodexHooksTrustUnder`.
57
+
58
+ All surfaces are read/applied **at session start**. A **live session keeps its launched mode** until the next fresh session — exactly like an agentic peer picking up new doctrine lazily on its next wake. The toggle (1) persists `approval_mode`, (2) idempotently brings the settings/hooks surfaces to the mode, (3) does **not** touch the live session. To apply immediately, start a fresh session (`iapeer approval-mode <peer> <mode> --now`, or `iapeer new`/`refresh`).
59
+
60
+ ## Interception mechanism
61
+
62
+ Both runtimes carry a Claude-compatible **hook** system that intercepts an approval programmatically and returns `allow`/`deny` + a reason + the structured action content. That is the primary mechanism. **pty screen-scrape is a backstop for one class only** — claude's `dangerous-rm` circuit-breaker, which sits *above* the permission layer and no hook can see.
63
+
64
+ - **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
65
+ - **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies.
66
+ - **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and, under gated, POSTs it to the broker (below); under yolo it auto-presses YES with an audit line.
67
+
68
+ The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
69
+
70
+ ## Nomenclature v1 and content per action (criterion «what is being approved»)
71
+
72
+ In every channel a human sees the **concrete content** of the action, not just the tool name.
73
+
74
+ **claude** — `Bash` → full command (+ description); `Edit` → file + old/new; `Write` → file + content; `ExitPlanMode` → the plan text; `dangerous-rm` circuit-breaker → the full prompt + the rm command + target (pane-scraped, not structured).
75
+
76
+ **codex** — `Bash`/`exec` → the command; `apply_patch` → the patch/diff; escalations (shell/net/MCP) via `PermissionRequest` → tool + input + human-readable description.
77
+
78
+ An unknown tool falls back to a pretty-printed `tool_input`, so nothing is opaque.
79
+
80
+ **Limitations (not hidden):**
81
+
82
+ 1. **claude `AskUserQuestion` is not hookable** — no permission-check, not a `PreToolUse` event. It stays suppressed by `--disallowedTools AskUserQuestion` in v1 (a deliberate owner decision, not a gap to close; re-enabling is only on the owner's explicit request).
83
+ 2. **codex MCP-elicitation** delivers content only on screen, not via a structured hook — rare, not covered in v1.
84
+ 3. **codex has no plan-mode approval** — the «plan» class exists only on claude (an asymmetry, not a missing feature).
85
+
86
+ ## The broker (single source of truth)
87
+
88
+ The approval queue lives **in the daemon** (the always-on process). Every channel that asks (the runtime hook, the supervisor breaker) and every channel that answers (CLI, tray, the Telegram runtime) is an interface to this one queue: a decision from any face resolves the request everywhere. In-memory + ephemeral — pending requests do **not** survive a daemon restart (the blocking hook connection breaks → the hook fails safe to deny); the durable `approvals.log` is the audit trace, not a recovery store.
89
+
90
+ Flow: the gated peer's hook (or the supervisor breaker) blocks on `POST /fleet/v1/approvals` → the broker enqueues, emits `approval-request` (to `approvals.log` → SSE), and holds the connection → a face answers via `POST /fleet/v1/approvals/<id>/(approve|deny)` → the promise resolves, the daemon writes the runtime hook JSON back, the tool proceeds or is blocked with the reason.
91
+
92
+ **Fail-safety — every failure direction is `deny`:** broker unreachable, daemon restarted mid-wait (connection breaks), requester disconnect, unknown id, and the per-request timeout all resolve to deny. This is the engineering basis for removing the bypass under gated: with no bypass, «no decision» degrades to the runtime's own permission prompt (a blocked TUI), **not** an auto-run — so a supervision fault is safe, never permissive. (Under a *retained* bypass a hook timeout would auto-run — exactly the harm gated exists to prevent.)
93
+
94
+ **Timeouts (ordered so the broker's default-deny always wins):** the broker default-deny is **300 s** (`IAPEER_APPROVAL_TIMEOUT_MS`); the hook client's fetch ceiling is **600 s**; the installed runtime hook `timeout` is **900 s**. So the broker answers (or default-denies) first, the client aborts second, and the runtime only kills the hook last. A timeout is delivered to the model as `deny` with `reason="approval timed out (default-deny)"`.
95
+
96
+ ## Fleet API
97
+
98
+ The approval surface is served on both daemon listeners under `/fleet/v1`, same auth and client obligations as the rest of the Fleet API (docs/15). It adds a fourth durable log, `approvals.log`, to the SSE tail — clients MUST ignore unknown `ev` kinds (obligation 1), which is exactly how `approval-request` / `approval-resolved` were reserved.
99
+
100
+ **Events** (compact logfmt line → SSE JSON; the full content is *not* in the event — read the queue for it):
101
+
102
+ - `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
103
+ - `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
104
+
105
+ **Endpoints:**
106
+
107
+ | Method + path | Purpose |
108
+ |---|---|
109
+ | `GET /fleet/v1/approvals` | list pending requests (full items) |
110
+ | `GET /fleet/v1/approvals/<id>` | one pending request, full `content` (the multi-line diff / plan / command) |
111
+ | `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
112
+ | `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (reason to the model) |
113
+ | `POST /fleet/v1/approvals` | the **blocking long-poll** the asking hook/breaker holds: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
114
+
115
+ `kind` is the taxonomy tag (`tool` | `plan` | `question` | `circuit-breaker`; free-form-tolerant). So the SSE/badge stays light while the full content is available in every channel through the two `GET`s.
116
+
117
+ ## CLI
118
+
119
+ - `iapeer approvals [--json]` — the pending queue: personality · runtime · kind · **the verbatim command/content** · age · id.
120
+ - `iapeer approve <id> [--approver <peer>]` — allow.
121
+ - `iapeer deny <id> [reason] [--approver <peer>]` — deny with a reason (delivered to the model).
122
+ - `iapeer approval-mode <peer> [gated|yolo] [--now]` — read the current mode (omit the mode), or flip it: persists the field, idempotently brings the runtime surfaces to the mode, and reports the application moment. `--now` also starts a fresh session so the mode takes effect immediately.
123
+
124
+ `approve` / `deny` / `approvals` reach the in-daemon broker over the Fleet API (like the tray), not in-process — the queue lives in the live daemon.
125
+
126
+ ## Compatibility
127
+
128
+ Additive within `/fleet/v1` (docs/15): the `approval-*` endpoints and `ev` kinds grow the surface without a version bump. A pre-approval daemon simply omits `approval_mode` from the snapshot and serves no `/approvals` endpoints; a client feature-detects by the presence of the field / a `200` from `GET /fleet/v1/approvals`.
package/docs/README.md CHANGED
@@ -78,6 +78,7 @@ The sections are layered: from "install and use" to "understand the architecture
78
78
  - [14 — Configuration and logs](14-configuration-and-logs.md) — environment variables, logs, diagnostics
79
79
  - [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
80
80
  - [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
81
+ - [17 — Human approval](17-approval.md) — per-peer gated/yolo mode: routing a runtime's blocking approvals to a human through the daemon broker (hook interception, the broker, Fleet API + CLI)
81
82
 
82
83
  ## Status
83
84
 
@@ -0,0 +1,128 @@
1
+ # 17 — Подтверждение человеком
2
+
3
+ Пир работает в одном из двух **режимов подтверждения**. В `yolo` (режим по умолчанию — сегодня так у всего флота) агент действует автономно: рантайм запускается со своим bypass-флагом, и ничто не спрашивает человека перед выполнением инструмента. В `gated` пир запускается **без** bypass, и каждый блокирующий вопрос-подтверждение, который поднял бы рантайм, уходит **человеку** через брокер подтверждений в демоне — кнопкой в Telegram, кликом в трее или командой `iapeer approve`. Решение человека `allow`/`deny` (с причиной) возвращается в рантайм, и инструмент либо исполняется, либо отклоняется.
4
+
5
+ Это **нормативный контракт**. Брокер подтверждений живёт в демоне; fleet-эндпоинты и типы событий `ev` ниже — это поверхность, под которую пишутся операторские клиенты (CLI, трей, telegram-рантайм), а не исходники демона. Факты перехвата (какое событие рантайма фаярит, в какой форме) закреплены за **Claude Code 2.1.201** и **codex-cli 0.142.5** — сняты с живых бинарей и актуальных доков рантаймов.
6
+
7
+ `gated` значит **«спрашивать перед действием»**, а не «в песочнице». Он убирает bypass, чтобы блокирующие вопросы всплывали; он **не** добавляет OS-песочницу (отдельная ось, не в v1). На codex gated явно есть `аппрувы ВКЛ + песочница ВЫКЛ` (см. таблицу тумблера).
8
+
9
+ ## Тумблер режима
10
+
11
+ ### Поле профиля
12
+
13
+ Foundation-owned lifecycle-поле в `peer-profile.json`, рядом с `wake_policy` / `initial_prompt`:
14
+
15
+ ```json
16
+ { "approval_mode": "gated" }
17
+ ```
18
+
19
+ `"yolo" | "gated"`, **дефолт `yolo`**. Персистится только `gated` — запись `yolo` **удаляет** поле (поэтому круг `gated→yolo→gated` байт-в-байт, а весь флот остаётся ровно таким, каким был до появления этой фичи). Единственное место, где живёт дефолт «отсутствует ⇒ yolo», — `approvalModeOf(profile)`; каждый читатель (запуск, супервизор, fleet-снапшот, CLI) резолвит через него и никогда не переопределяет. В fleet-снапшот режим попадает аддитивным полем `approval_mode` (docs/15) чтением локального профиля — pre-approval-демон его опускает, и клиенты ОБЯЗАНЫ трактовать отсутствие как `yolo`.
20
+
21
+ ### Поверхности запуска, которых касается флип
22
+
23
+ Bypass — это **аргумент запуска**, у каждого рантайма свой, и режим есть согласованная операция над **всеми** поверхностями, которые этот аргумент подразумевает. iapeer владеет запуском, поэтому полнота списка — за фундаментом.
24
+
25
+ **claude** (вариант D — matcher-free хук `PermissionRequest`; проверено живьём 2.1.201):
26
+
27
+ | # | Поверхность | yolo | gated |
28
+ |---|---|---|---|
29
+ | C1 | argv `--dangerously-skip-permissions` | присутствует | **убран** |
30
+ | C2 | argv permission-mode | (bypass) | `--permission-mode default` (явно — не наследует acceptEdits/bypass defaultMode) |
31
+ | C3 | ready-gate маркер `isInputReady` | нужен баннер `bypass permissions on` | баннера **НЕТ** → маркер mode-aware (composer `❯` + сняты boot-диалоги), иначе пир никогда не станет ready и wake проваливается |
32
+ | C4 | boot-диалог «Bypass Permissions mode» accept | появляется (первый bypass) | не появляется (нет bypass) |
33
+ | C5 | хук `PermissionRequest` в `<cwd>/.claude/settings.json` | **отсутствует** (0 оверхеда, байт-в-байт как сегодня) | **установлен** — matcher-free (см. ниже), `command = iapeer approval-hook` |
34
+ | C6 | allow-rule собственного MCP-инструмента пира (`permissions.allow: ["mcp__iapeer__send_to_peer"]`) | не нужен (bypass) | **нужен** — иначе default-mode гейтит сам IAP-канал пира, и он виснет на `send_to_peer` |
35
+ | C7 | супервизор: circuit-breaker (dangerous-rm, выше permission-слоя) | авто-Yes + аудит-лог | **маршрут человеку** через брокер |
36
+ | C8 | argv `--disallowedTools AskUserQuestion` | остаётся | остаётся в v1 (см. Лимитации) |
37
+
38
+ **Почему matcher-free `PermissionRequest`, а не `PreToolUse`.** `PermissionRequest` фаярит **только когда permission-конфиг рантайма решил спросить** — вызов, не покрытый allow/deny-правилом. Значит политика *что спрашивать* остаётся на 100% у рантайма: юзер настраивает её обычными permission-правилами (инструмент в `permissions.allow` не спрашивается никогда → хук не фаярит → авто-allow). Нет матчера классов, который дрейфует, нет проскакивающего нового инструмента, нет зависания. iapeer лишь сеет хук + одно allow-правило для собственного MCP-инструмента пира.
39
+
40
+ **codex** (хук `PreToolUse` — структурно; живая верификация ожидается):
41
+
42
+ | # | Поверхность | yolo | gated |
43
+ |---|---|---|---|
44
+ | X1 | argv `--dangerously-bypass-approvals-and-sandbox` | присутствует | **убран** |
45
+ | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — аппрувы **ВКЛ**, песочница **ВЫКЛ** (чтобы loopback-MCP `send_to_peer` и запись в vault-память вне cwd работали; `workspace-write` отрезал бы сеть и внешние пути). Session-scoped через `-c`, не хост-конфиг. |
46
+ | X3 | хук `PreToolUse` `<cwd>/.codex/hooks.json` + trust-preseed в `~/.codex/config.toml [hooks.state]` | **отсутствует** | **установлен + доверен** (`preSeedCodexHooksTrust`; недоверенный codex-хук молча пропускается) |
47
+ | X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (обе моды) | pre-trusted (при активных аппрувах важнее) |
48
+ | X5 | ready-gate `isInputReady` (composer `›`) | mode-независим | без изменений |
49
+
50
+ codex-хук матчится по регекспу имён инструментов `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — грубый матчер классов живёт на собственном `hooks.json` рантайма (юзер правит как обычный рантайм-конфиг).
51
+
52
+ ### Идемпотентность и момент применения
53
+
54
+ - **argv-поверхности** (C1–C2, C8, X1–X2) вычисляются из моды на каждом запуске — идемпотентны структурно, накопления нет.
55
+ - **settings.json / hooks.json** (C5–C6, X3) — add при gated / remove при yolo, no-clobber merge, ключ по стабильному маркеру `approval-hook`: наш блок дописывается детерминированно и удаляется дочиста (опустевший массив `hooks.PermissionRequest` / `PreToolUse` снимается, опустевший объект `hooks` / `permissions` снимается, чужие хуки/правила не трогаются). Круг `gated→yolo→gated` восстанавливает pre-install байты.
56
+ - **codex trust** (X3–X4) переиспользует уже идемпотентные `preSeedCodexHooksTrust` / `removeCodexHooksTrustUnder`.
57
+
58
+ Все поверхности читаются/применяются **на старте сессии**. **Живая сессия держит свою запущенную моду** до следующей фреш-сессии — ровно как агентик-пир лениво подхватывает новую доктрину на следующем wake. Тумблер: (1) персистит `approval_mode`, (2) идемпотентно приводит settings/hooks-поверхности к моде, (3) НЕ трогает живую сессию. Чтобы применить сразу — подними фреш-сессию (`iapeer approval-mode <peer> <mode> --now`, или `iapeer new`/`refresh`).
59
+
60
+ ## Механизм перехвата
61
+
62
+ У обоих рантаймов есть Claude-совместимая **hook-система**, которая перехватывает подтверждение программно и возвращает `allow`/`deny` + причину + структурное содержимое действия. Это первичный механизм. **Экранный pty-скрейп — бэкстоп только для одного класса** — claude-circuit-breaker `dangerous-rm`, который живёт *выше* permission-слоя и не виден ни одному хуку.
63
+
64
+ - **claude `PermissionRequest`** — на stdin `{tool_name, tool_input, …}`; хук печатает `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. `deny` доносит `message` до модели. Фаярит только на вопрос-достойных вызовах.
65
+ - **codex `PreToolUse`** — тот же shape stdin; печатает `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) тоже отклоняет.
66
+ - **супервизор circuit-breaker** — гард `dangerous-rm`/`rmdir` claude (и стандартный command-approval-промпт, который показывает no-bypass-сессия) — это TUI-селект, невидимый хуку. pty-супервизор читает его со своей авторитетной модели и под gated POSTит брокеру (ниже); под yolo авто-жмёт YES с аудит-строкой.
67
+
68
+ Хук-бинарь — сабкоманда `iapeer approval-hook`: читает hook-JSON рантайма со stdin, резолвит `PEER_PERSONALITY`/`PEER_RUNTIME` из session-env и URL демона из `router.json`, POSTит блокирующий запрос брокеру и печатает рантайм-специфичный JSON решения.
69
+
70
+ ## Номенклатура v1 и содержимое на точке перехвата (критерий «что подтверждается»)
71
+
72
+ В любом канале человек видит **конкретное содержимое** действия, а не только имя инструмента.
73
+
74
+ **claude** — `Bash` → полная команда (+ описание); `Edit` → файл + old/new; `Write` → файл + содержимое; `ExitPlanMode` → текст плана; circuit-breaker `dangerous-rm` → полный текст промпта + rm-команда + target (pane-скрейп, не структурно).
75
+
76
+ **codex** — `Bash`/`exec` → команда; `apply_patch` → патч/диф; эскалации (shell/сеть/MCP) через `PermissionRequest` → инструмент + input + человекочитаемое описание.
77
+
78
+ Неизвестный инструмент падает на pretty-print `tool_input`, так что непрозрачного нет ничего.
79
+
80
+ **Лимитации (не замалчиваю):**
81
+
82
+ 1. **claude `AskUserQuestion` не перехватывается хуком** — у него нет permission-check и он не событие `PreToolUse`. В v1 остаётся заглушённым через `--disallowedTools AskUserQuestion` (осознанное решение владельца, не дыра к закрытию; возврат — только по его явному запросу).
83
+ 2. **codex MCP-elicitation** отдаёт содержимое только на экране, не структурным хуком — редко, в v1 не покрывается.
84
+ 3. **у codex нет plan-mode-аппрува** — класс «план» существует только у claude (асимметрия, не отсутствие фичи).
85
+
86
+ ## Брокер (единый источник истины)
87
+
88
+ Очередь подтверждений живёт **в демоне** (always-on процесс). Каждый канал, который спрашивает (хук рантайма, breaker супервизора), и каждый, который отвечает (CLI, трей, telegram-рантайм), — интерфейс к этой одной очереди: решение из любого канала гасит запрос везде. In-memory + эфемерна — ожидающие запросы **не** переживают рестарт демона (блокирующее соединение хука рвётся → хук fail-safe в deny); durable `approvals.log` — аудит-след, не хранилище восстановления.
89
+
90
+ Путь: хук gated-пира (или breaker супервизора) блокируется на `POST /fleet/v1/approvals` → брокер кладёт в очередь, эмитит `approval-request` (в `approvals.log` → SSE) и держит соединение → канал отвечает `POST /fleet/v1/approvals/<id>/(approve|deny)` → промис резолвится, демон пишет обратно hook-JSON, инструмент исполняется или блокируется с причиной.
91
+
92
+ **Fail-safety — все направления отказа = `deny`:** брокер недоступен, демон рестартнул при ожидании (соединение рвётся), requester-дисконнект, неизвестный id и таймаут per-request — всё резолвится в deny. Это инженерное основание уборки bypass под gated: без bypass «нет решения» деградирует к собственному permission-промпту рантайма (блокирующий TUI), а **не** к авто-run — сбой перехвата безопасен, никогда не пермиссивен. (Под *сохранённым* bypass хук-таймаут = авто-run — ровно тот вред, ради которого gated существует.)
93
+
94
+ **Таймауты (упорядочены так, чтобы default-deny брокера всегда выигрывал):** брокер default-deny — **300 с** (`IAPEER_APPROVAL_TIMEOUT_MS`); потолок fetch хук-клиента — **600 с**; `timeout` установленного хука рантайма — **900 с**. Так брокер отвечает (или default-денит) первым, клиент абортится вторым, а рантайм убивает хук последним. Таймаут доносится модели как `deny` с `reason="approval timed out (default-deny)"`.
95
+
96
+ ## Fleet API
97
+
98
+ Поверхность подтверждений отдаётся на обоих листенерах демона под `/fleet/v1`, с той же авторизацией и обязательствами клиента, что и остальной Fleet API (docs/15). Добавляет четвёртый durable-лог `approvals.log` в SSE-тейл — клиенты ОБЯЗАНЫ игнорить неизвестные `ev` (обязательство 1), ровно так зарезервированы `approval-request` / `approval-resolved`.
99
+
100
+ **События** (компактная logfmt-строка → SSE JSON; полное содержимое НЕ в событии — читай очередь):
101
+
102
+ - `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
103
+ - `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
104
+
105
+ **Эндпоинты:**
106
+
107
+ | Метод + путь | Назначение |
108
+ |---|---|
109
+ | `GET /fleet/v1/approvals` | список ожидающих (полные item'ы) |
110
+ | `GET /fleet/v1/approvals/<id>` | один ожидающий, полный `content` (многострочный диф / план / команда) |
111
+ | `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
112
+ | `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (причина к модели) |
113
+ | `POST /fleet/v1/approvals` | **блокирующий long-poll**, который держит спрашивающий хук/breaker: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
114
+
115
+ `kind` — таксономический тег (`tool` | `plan` | `question` | `circuit-breaker`; терпим к свободной форме). Так бейдж/SSE остаётся лёгким, а полное содержимое доступно в каждом канале через два `GET`.
116
+
117
+ ## CLI
118
+
119
+ - `iapeer approvals [--json]` — очередь ожидающих: personality · runtime · kind · **дословная команда/содержимое** · возраст · id.
120
+ - `iapeer approve <id> [--approver <peer>]` — одобрить.
121
+ - `iapeer deny <id> [reason] [--approver <peer>]` — отклонить с причиной (доносится модели).
122
+ - `iapeer approval-mode <peer> [gated|yolo] [--now]` — прочитать текущую моду (опустить моду) или флипнуть: персистит поле, идемпотентно приводит рантайм-поверхности к моде и сообщает момент применения. `--now` дополнительно поднимает фреш-сессию, чтобы мода вступила сразу.
123
+
124
+ `approve` / `deny` / `approvals` достают до in-daemon брокера по Fleet API (как трей), не in-process — очередь живёт в живом демоне.
125
+
126
+ ## Совместимость
127
+
128
+ Аддитивно в рамках `/fleet/v1` (docs/15): эндпоинты и `ev` `approval-*` растят поверхность без бампа версии. Pre-approval-демон просто опускает `approval_mode` в снапшоте и не отдаёт эндпоинтов `/approvals`; клиент feature-детектит по наличию поля / `200` от `GET /fleet/v1/approvals`.
package/docs/ru/README.md CHANGED
@@ -78,6 +78,7 @@ iapeer send assistant --message "привет, ты на связи?"
78
78
  - [14 — Конфигурация и журналы](14-конфигурация-и-журналы.md) — переменные окружения, журналы, диагностика
79
79
  - [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
80
80
  - [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
81
+ - [17 — Подтверждение человеком](17-approval.md) — режим gated/yolo на пира: маршрутизация блокирующих подтверждений рантайма человеку через брокер демона (перехват хуком, брокер, Fleet API + CLI)
81
82
 
82
83
  ## Статус
83
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.67",
3
+ "version": "0.4.69",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -0,0 +1,92 @@
1
+ // brokerClient — the shared client transport for the human-approval broker (docs/17). Both the
2
+ // runtime hook (approval/hook.ts, the ASK side of a gated peer's PreToolUse/PermissionRequest) and the
3
+ // supervisor circuit-breaker (supervisor/approvalRoute.ts, Unit 4 — the pty-scrape ASK side above the
4
+ // permission layer) POST a blocking approval request to the daemon's `/fleet/v1/approvals` and await a
5
+ // human decision. Factoring the request/response contract into ONE place (headers, optional H8 bearer,
6
+ // abort-timeout, throw-on-non-200) makes the two callers behave IDENTICALLY by construction — the same
7
+ // parity-by-one-source discipline as buildLaunchInvocation.
8
+ //
9
+ // Dependency-light on purpose: fetch + a router.json path resolver (readFileSync + pluginStateDir). The
10
+ // supervisor daemon dynamic-imports this into its detached @xterm process, so it must never pull the
11
+ // central router / transport graph.
12
+
13
+ import { readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { pluginStateDir } from '../storage/index.ts'
16
+
17
+ export interface ApprovalDecision {
18
+ decision: 'allow' | 'deny'
19
+ reason?: string
20
+ }
21
+
22
+ /** The request body POST /fleet/v1/approvals accepts (broker.request input; docs/17 §2).
23
+ * `kind` is the taxonomy tag (tool | plan | question | circuit-breaker); the hook sends tool/plan,
24
+ * the supervisor breaker sends circuit-breaker. */
25
+ export interface ApprovalRequestBody {
26
+ personality: string
27
+ runtime: string
28
+ kind: string
29
+ tool: string
30
+ content: string
31
+ summary: string
32
+ }
33
+
34
+ /** Resolve the daemon fleet base URL (origin-form, no path) from router.json's `tcp` field, else the
35
+ * well-known loopback default. TCP loopback is always served (the daemon dual-listens), so a client
36
+ * never needs the unix socket. */
37
+ export function resolveFleetBase(env: NodeJS.ProcessEnv = process.env): string {
38
+ try {
39
+ const routerJson = join(pluginStateDir('iapeer', { env }), 'router.json')
40
+ const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
41
+ if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp.replace(/\/mcp\/?$/, '')
42
+ } catch {
43
+ /* no router.json → default below */
44
+ }
45
+ const port = env.IAPEER_PORT?.trim() || '8765'
46
+ return `http://127.0.0.1:${port}`
47
+ }
48
+
49
+ /** Client-side hold ceiling — comfortably ABOVE the broker's default-deny timeout (300 s) so the
50
+ * broker's answer always arrives first; if the daemon is dead the fetch aborts and the caller fails
51
+ * safe to deny. */
52
+ export const APPROVAL_FETCH_TIMEOUT_MS = 600_000
53
+
54
+ export interface RequestApprovalDeps {
55
+ env?: NodeJS.ProcessEnv
56
+ /** Injectable fetch (tests). Default global fetch. */
57
+ fetch?: typeof fetch
58
+ /** Injectable client timeout (tests). */
59
+ timeoutMs?: number
60
+ }
61
+
62
+ /**
63
+ * POST a blocking approval request and await the human's decision. Long-poll: the daemon holds the
64
+ * connection until a face answers or the broker's default-deny timeout fires, then returns
65
+ * `{ id, decision, reason? }`. THROWS on any transport failure (daemon unreachable, abort/timeout,
66
+ * non-200) — the CALLER owns the fail-safe (both the hook and the supervisor deny on a throw). A
67
+ * normalized decision comes back: allow carries no reason; deny always carries one.
68
+ */
69
+ export async function requestApproval(base: string, body: ApprovalRequestBody, deps: RequestApprovalDeps = {}): Promise<ApprovalDecision> {
70
+ const env = deps.env ?? process.env
71
+ const doFetch = deps.fetch ?? fetch
72
+ const timeoutMs = deps.timeoutMs ?? APPROVAL_FETCH_TIMEOUT_MS
73
+ const controller = new AbortController()
74
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
75
+ ;(timer as { unref?: () => void }).unref?.()
76
+ try {
77
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
78
+ const bearer = env.IAPEER_BEARER_TOKEN?.trim()
79
+ if (bearer) headers.authorization = `Bearer ${bearer}`
80
+ const resp = await doFetch(`${base}/fleet/v1/approvals`, {
81
+ method: 'POST',
82
+ headers,
83
+ body: JSON.stringify(body),
84
+ signal: controller.signal,
85
+ })
86
+ if (!resp.ok) throw new Error(`daemon returned ${resp.status}`)
87
+ const data = (await resp.json()) as ApprovalDecision
88
+ return data.decision === 'allow' ? { decision: 'allow' } : { decision: 'deny', reason: data.reason || 'denied' }
89
+ } finally {
90
+ clearTimeout(timer)
91
+ }
92
+ }
@@ -0,0 +1,118 @@
1
+ // approval CLI — the operator faces of the approval broker (docs/17 §5), all thin clients
2
+ // of the daemon fleet-API (the queue lives in the daemon; these reach it over HTTP, like the
3
+ // tray). `approval-hook` is the runtime-installed bridge; `approvals`/`approve`/`deny` are the
4
+ // host answer channel; `approval-mode` is the per-peer gated/yolo toggle.
5
+
6
+ import { resolveFleetBase } from './brokerClient.ts'
7
+ import { setApprovalMode } from './install.ts'
8
+ import { readPeersIndex } from '../registry/index.ts'
9
+ import { readPeerProfile, approvalModeOf, type ApprovalMode } from '../identity/index.ts'
10
+
11
+ function authHeaders(env: NodeJS.ProcessEnv): Record<string, string> {
12
+ const bearer = env.IAPEER_BEARER_TOKEN?.trim()
13
+ return { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }
14
+ }
15
+
16
+ interface PendingApprovalRow {
17
+ id: string
18
+ personality: string
19
+ runtime: string
20
+ kind: string
21
+ tool: string
22
+ summary: string
23
+ content: string
24
+ createdMs: number
25
+ expiresMs: number
26
+ }
27
+
28
+ function ageStr(ms: number, nowMs: number): string {
29
+ const s = Math.max(0, Math.floor((nowMs - ms) / 1000))
30
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
31
+ }
32
+
33
+ /** `iapeer approvals [--json]` — the pending queue. Each row carries the VERBATIM action
34
+ * content (a Bash command shown in full — boris's CLI acceptance criterion). */
35
+ export async function approvalsList(json: boolean, env: NodeJS.ProcessEnv): Promise<{ text: string; code: number }> {
36
+ const base = resolveFleetBase(env)
37
+ let rows: PendingApprovalRow[]
38
+ try {
39
+ const resp = await fetch(`${base}/fleet/v1/approvals`, { headers: authHeaders(env) })
40
+ if (!resp.ok) return { text: `approvals: daemon returned ${resp.status}\n`, code: 1 }
41
+ rows = ((await resp.json()) as { approvals: PendingApprovalRow[] }).approvals
42
+ } catch (e) {
43
+ return { text: `approvals: cannot reach daemon (${e instanceof Error ? e.message : String(e)})\n`, code: 1 }
44
+ }
45
+ if (json) return { text: `${JSON.stringify(rows, null, 2)}\n`, code: 0 }
46
+ if (rows.length === 0) return { text: 'no pending approvals\n', code: 0 }
47
+ const now = Date.now()
48
+ const lines = [`${rows.length} pending approval(s):`]
49
+ for (const r of rows) {
50
+ lines.push(` ${r.id} ${r.personality}/${r.runtime} [${r.kind}:${r.tool}] ${ageStr(r.createdMs, now)} ago`)
51
+ lines.push(` ${r.summary}`) // verbatim command / file / plan-line
52
+ }
53
+ lines.push('', 'approve: iapeer approve <id> · deny: iapeer deny <id> [reason]')
54
+ return { text: `${lines.join('\n')}\n`, code: 0 }
55
+ }
56
+
57
+ /** `iapeer approve <id>` / `iapeer deny <id> [reason]` — resolve a pending request. The
58
+ * decision propagates to whichever channel is waiting (the blocking hook) and clears it
59
+ * everywhere. `approver` (default: the single natural peer) records who answered. */
60
+ export async function resolveApproval(
61
+ id: string,
62
+ action: 'approve' | 'deny',
63
+ opts: { reason?: string; approver?: string },
64
+ env: NodeJS.ProcessEnv,
65
+ ): Promise<{ text: string; code: number }> {
66
+ const base = resolveFleetBase(env)
67
+ const approver = opts.approver ?? defaultApprover(env)
68
+ const body: Record<string, unknown> = { via: 'cli', ...(approver ? { approver } : {}), ...(opts.reason ? { reason: opts.reason } : {}) }
69
+ try {
70
+ const resp = await fetch(`${base}/fleet/v1/approvals/${encodeURIComponent(id)}/${action}`, {
71
+ method: 'POST',
72
+ headers: authHeaders(env),
73
+ body: JSON.stringify(body),
74
+ })
75
+ if (resp.status === 404) return { text: `no pending approval "${id}" (already resolved, expired, or unknown)\n`, code: 1 }
76
+ if (!resp.ok) return { text: `${action}: daemon returned ${resp.status}\n`, code: 1 }
77
+ return { text: `${action === 'approve' ? 'approved' : 'denied'} ${id}${opts.reason ? ` (${opts.reason})` : ''}\n`, code: 0 }
78
+ } catch (e) {
79
+ return { text: `${action}: cannot reach daemon (${e instanceof Error ? e.message : String(e)})\n`, code: 1 }
80
+ }
81
+ }
82
+
83
+ /** The default approver: the registry's single natural-intelligence peer (the owner). null
84
+ * when zero/several — then the answer is recorded without a `by`. */
85
+ function defaultApprover(env: NodeJS.ProcessEnv): string | undefined {
86
+ try {
87
+ const natural = readPeersIndex({ env }).peers.filter(p => p.intelligence === 'natural')
88
+ return natural.length === 1 ? natural[0]!.personality : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /** `iapeer approval-mode <peer> [gated|yolo]` — read (no mode) or flip the toggle. Flipping
95
+ * persists the field + brings the runtime surfaces to the mode; a live session keeps its
96
+ * launched mode until a fresh session (the application moment). */
97
+ export function approvalModeCli(peer: string, mode: ApprovalMode | undefined, env: NodeJS.ProcessEnv): { text: string; code: number } {
98
+ const rec = readPeersIndex({ env }).peers.find(p => p.personality === peer)
99
+ if (!rec) return { text: `unknown peer "${peer}"\n`, code: 1 }
100
+ if (!mode) {
101
+ let current: ApprovalMode = 'yolo'
102
+ try {
103
+ current = approvalModeOf(readPeerProfile(rec.cwd))
104
+ } catch {
105
+ /* unreadable → yolo */
106
+ }
107
+ return { text: `${peer}: approval_mode=${current}\n`, code: 0 }
108
+ }
109
+ let result
110
+ try {
111
+ result = setApprovalMode(rec.cwd, mode, env)
112
+ } catch (e) {
113
+ return { text: `approval-mode: ${e instanceof Error ? e.message : String(e)}\n`, code: 1 }
114
+ }
115
+ const lines = [`${peer} → approval_mode=${mode}`, ...result.surfaces.map(s => ` · ${s}`)]
116
+ lines.push('', 'applies on the peer\'s NEXT fresh session — run `iapeer new ' + peer + '` to apply now.')
117
+ return { text: `${lines.join('\n')}\n`, code: 0 }
118
+ }