@agfpd/iapeer 0.4.90 → 0.4.92
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.
package/docs/17-approval.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 17 — Human approval
|
|
2
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,
|
|
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, `iapeer approve` on the CLI, or the web console's approvals panel. The human's `allow`/`deny` (with a reason) flows back into the runtime and the tool proceeds or is refused.
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -103,7 +103,7 @@ An unknown tool falls back to a pretty-printed `tool_input`, so nothing is opaqu
|
|
|
103
103
|
|
|
104
104
|
## The broker (single source of truth)
|
|
105
105
|
|
|
106
|
-
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.
|
|
106
|
+
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, the web console) 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.
|
|
107
107
|
|
|
108
108
|
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.
|
|
109
109
|
|
|
@@ -118,7 +118,7 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
|
|
|
118
118
|
**Events** (compact logfmt line → SSE JSON; the full content is *not* in the event — read the queue for it):
|
|
119
119
|
|
|
120
120
|
- `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
|
|
121
|
-
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
|
|
121
|
+
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|web|timeout|disconnect), latencyMs`. `via` is the surface the decision came from, **self-declared by the answering face** in its `POST` body (free-form-tolerant, like `kind`; `timeout`/`disconnect` are broker-internal). A face that did not self-identify gets the field **omitted** — the audit line never guesses a surface.
|
|
122
122
|
|
|
123
123
|
**Endpoints:**
|
|
124
124
|
|
|
@@ -126,12 +126,14 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
|
|
|
126
126
|
|---|---|
|
|
127
127
|
| `GET /fleet/v1/approvals` | list pending requests (full items) |
|
|
128
128
|
| `GET /fleet/v1/approvals/<id>` | one pending request, full `content` (the multi-line diff / plan / command) |
|
|
129
|
-
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
|
|
130
|
-
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (reason to the model) |
|
|
129
|
+
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?, via?}` → allow |
|
|
130
|
+
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?, via?}` → deny (reason to the model) |
|
|
131
131
|
| `POST /fleet/v1/approvals` | the **blocking long-poll** the asking hook/breaker holds: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
|
|
132
132
|
|
|
133
133
|
`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.
|
|
134
134
|
|
|
135
|
+
`via` on approve/deny is the answering face's **self-identification of its surface** for the audit trail: the CLI sends `cli`, the tray `tray`, the Telegram runtime `telegram`, the web console `web`. A face MUST send it (the log omits `via` otherwise); the broker passes it through verbatim into `approvals.log`.
|
|
136
|
+
|
|
135
137
|
## CLI
|
|
136
138
|
|
|
137
139
|
- `iapeer approvals [--json]` — the pending queue: personality · runtime · kind · **the verbatim command/content** · age · id.
|
package/package.json
CHANGED
|
@@ -57,6 +57,10 @@ export interface RequestApprovalDeps {
|
|
|
57
57
|
fetch?: typeof fetch
|
|
58
58
|
/** Injectable client timeout (tests). */
|
|
59
59
|
timeoutMs?: number
|
|
60
|
+
/** External cancel: aborting this signal aborts the long-poll fetch (the daemon sees the requester
|
|
61
|
+
* transport close and settles the pending request cancel/default-deny, clearing it from every
|
|
62
|
+
* face). The supervisor uses it to close a request whose modal disappeared before a decision. */
|
|
63
|
+
signal?: AbortSignal
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -73,6 +77,11 @@ export async function requestApproval(base: string, body: ApprovalRequestBody, d
|
|
|
73
77
|
const controller = new AbortController()
|
|
74
78
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
75
79
|
;(timer as { unref?: () => void }).unref?.()
|
|
80
|
+
const onExternalAbort = (): void => controller.abort()
|
|
81
|
+
if (deps.signal) {
|
|
82
|
+
if (deps.signal.aborted) controller.abort()
|
|
83
|
+
else deps.signal.addEventListener('abort', onExternalAbort, { once: true })
|
|
84
|
+
}
|
|
76
85
|
try {
|
|
77
86
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
78
87
|
const bearer = env.IAPEER_BEARER_TOKEN?.trim()
|
|
@@ -88,5 +97,6 @@ export async function requestApproval(base: string, body: ApprovalRequestBody, d
|
|
|
88
97
|
return data.decision === 'allow' ? { decision: 'allow' } : { decision: 'deny', reason: data.reason || 'denied' }
|
|
89
98
|
} finally {
|
|
90
99
|
clearTimeout(timer)
|
|
100
|
+
deps.signal?.removeEventListener('abort', onExternalAbort)
|
|
91
101
|
}
|
|
92
102
|
}
|
package/src/daemon/approvals.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Approval broker — the in-daemon queue that is the SINGLE SOURCE OF TRUTH for
|
|
2
2
|
// human-approval requests (docs/17). Every channel (the runtime hook that ASKS, and
|
|
3
|
-
// the CLI / tray / telegram faces that ANSWER) is an interface to THIS one queue: a
|
|
3
|
+
// the CLI / tray / telegram / web-console faces that ANSWER) is an interface to THIS one queue: a
|
|
4
4
|
// decision from any face resolves the request everywhere (the owner's invariant).
|
|
5
5
|
//
|
|
6
6
|
// Flow: a gated peer's PreToolUse hook blocks and POSTs /fleet/v1/approvals → the fleet
|
|
@@ -56,7 +56,9 @@ export interface ApprovalDecision {
|
|
|
56
56
|
export interface ResolveMeta {
|
|
57
57
|
/** The approver who answered (a nature-peer personality). */
|
|
58
58
|
by?: string
|
|
59
|
-
/** The
|
|
59
|
+
/** The surface that answered: cli | tray | telegram | web | timeout | disconnect.
|
|
60
|
+
* Self-declared by the answering face (free-form-tolerant); absent when the face
|
|
61
|
+
* did not self-identify — the audit line then omits `via` rather than guessing. */
|
|
60
62
|
via?: string
|
|
61
63
|
}
|
|
62
64
|
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -636,7 +636,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
636
636
|
/* empty/invalid body is fine — approve/deny carry only optional fields */
|
|
637
637
|
}
|
|
638
638
|
const by = typeof body.approver === 'string' ? body.approver : undefined
|
|
639
|
-
|
|
639
|
+
// The answering face self-identifies its surface in `via` (cli | tray | telegram | web | …;
|
|
640
|
+
// free-form-tolerant like `kind`). NO default — an audit line must never claim a surface the
|
|
641
|
+
// caller did not state; a face that sent nothing is logged with the field omitted.
|
|
642
|
+
const via = typeof body.via === 'string' ? body.via : undefined
|
|
640
643
|
const ok =
|
|
641
644
|
action === 'approve'
|
|
642
645
|
? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
|
|
@@ -74,6 +74,19 @@ export interface BreakerRouteDeps {
|
|
|
74
74
|
env: NodeJS.ProcessEnv
|
|
75
75
|
/** Inject keys into the pty (child.terminal.write). */
|
|
76
76
|
write: (bytes: Buffer) => void
|
|
77
|
+
/**
|
|
78
|
+
* STALE-PROOF (15.07 incident, codex-zapret2-oneclick): return true iff the SAME modal this request
|
|
79
|
+
* was raised for is on screen RIGHT NOW (the caller compares the live pane's nagSignature against
|
|
80
|
+
* the fingerprint captured at request time). Consulted (a) periodically while the long-poll is
|
|
81
|
+
* outstanding — a vanished modal ABORTS the request at the broker (the card clears from every face)
|
|
82
|
+
* — and (b) synchronously immediately before ANY key injection. Without this, a late allow/deny
|
|
83
|
+
* lands its keys in whatever the pty shows NOW (a NEWER live turn — a bare Escape aborted it).
|
|
84
|
+
* REQUIRED by design: keys must never be injected without proof the target modal is still up.
|
|
85
|
+
* A throw is treated as `false` (fail-closed: cannot prove the modal → do not touch the pty).
|
|
86
|
+
*/
|
|
87
|
+
stillActive: () => boolean
|
|
88
|
+
/** Cadence of the stale re-check while the long-poll is outstanding. Default 2000 ms. */
|
|
89
|
+
staleCheckMs?: number
|
|
77
90
|
/** Injectable fetch (tests). */
|
|
78
91
|
fetch?: typeof fetch
|
|
79
92
|
/** Injectable client timeout (tests). */
|
|
@@ -84,16 +97,57 @@ export interface BreakerRouteDeps {
|
|
|
84
97
|
now?: () => number
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
/** routeCircuitBreaker outcome: the decision (human / fail-safe / stale-deny) + whether keys were
|
|
101
|
+
* actually injected into the pty. `stale: true` = the target modal was gone, so the request was
|
|
102
|
+
* closed WITHOUT touching the pty — the caller must not treat it as an answered modal. */
|
|
103
|
+
export interface BreakerRouteResult extends ApprovalDecision {
|
|
104
|
+
/** True iff approve/deny keys were written to the pty. */
|
|
105
|
+
injected: boolean
|
|
106
|
+
/** True iff the request was closed because the target modal disappeared (no keys injected). */
|
|
107
|
+
stale?: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
/**
|
|
88
111
|
* POST the circuit-breaker to the human-approval broker, await the decision, and inject the decided
|
|
89
|
-
* keys into the pty
|
|
90
|
-
* throws — a broker failure fails safe to deny
|
|
91
|
-
*
|
|
112
|
+
* keys into the pty — IF AND ONLY IF the modal the request was raised for is still on screen. Returns
|
|
113
|
+
* the decision + whether keys were injected. NEVER throws — a broker failure fails safe to deny
|
|
114
|
+
* (decline keys injected, modal permitting).
|
|
115
|
+
*
|
|
116
|
+
* STALE-PROOF invariant (15.07 incident): pty bytes are written ONLY under a live re-proof of the
|
|
117
|
+
* target modal (`deps.stillActive`). Two checkpoints:
|
|
118
|
+
* 1. While the long-poll is outstanding, a poller re-proves the modal every `staleCheckMs`; the
|
|
119
|
+
* moment it is gone the request is ABORTED (the daemon's requester-disconnect cancel settles it
|
|
120
|
+
* and clears the card from every face) — the peer moved on, nobody should keep being asked.
|
|
121
|
+
* 2. Immediately before ANY injection (human decision, fail-safe deny alike) the modal is re-proved
|
|
122
|
+
* one final time — synchronously, with no yield between proof and write, so a pane update cannot
|
|
123
|
+
* interleave. A vanished modal → BREAKER-STALE audit line, NO pty bytes, `stale: true`.
|
|
124
|
+
* Every path leaves one audit line: BREAKER-ALLOW / BREAKER-DENY (keys injected) or BREAKER-STALE
|
|
125
|
+
* (request closed, pty untouched), each with the taxonomy, the parsed detail, and the reason/decision.
|
|
92
126
|
*/
|
|
93
|
-
export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<
|
|
127
|
+
export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<BreakerRouteResult> {
|
|
94
128
|
const now = deps.now ?? Date.now
|
|
95
129
|
const { content, summary } = composeApprovalContent(input)
|
|
130
|
+
// Fail-closed re-proof: an unreadable pane model is NOT proof the modal is up → never inject on it.
|
|
131
|
+
const stillActive = (): boolean => {
|
|
132
|
+
try {
|
|
133
|
+
return deps.stillActive()
|
|
134
|
+
} catch {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Checkpoint 1 — poll the modal while the long-poll is outstanding; a vanished modal aborts the
|
|
139
|
+
// request at the broker (requester-disconnect → cancel → the card clears from every face).
|
|
140
|
+
let stale = false
|
|
141
|
+
const controller = new AbortController()
|
|
142
|
+
const stalePoll = setInterval(() => {
|
|
143
|
+
if (!stillActive()) {
|
|
144
|
+
stale = true
|
|
145
|
+
controller.abort()
|
|
146
|
+
}
|
|
147
|
+
}, deps.staleCheckMs ?? 2000)
|
|
148
|
+
;(stalePoll as { unref?: () => void }).unref?.()
|
|
96
149
|
let decision: ApprovalDecision
|
|
150
|
+
let lateDecision: string | undefined // a human decision that arrived for an already-gone modal
|
|
97
151
|
try {
|
|
98
152
|
decision = await requestApproval(
|
|
99
153
|
resolveFleetBase(deps.env),
|
|
@@ -105,16 +159,30 @@ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: Bre
|
|
|
105
159
|
content,
|
|
106
160
|
summary,
|
|
107
161
|
},
|
|
108
|
-
{ env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
|
|
162
|
+
{ env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs, signal: controller.signal },
|
|
109
163
|
)
|
|
164
|
+
lateDecision = decision.decision
|
|
110
165
|
} catch (e) {
|
|
111
|
-
// Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe.
|
|
166
|
+
// Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe. Whether the
|
|
167
|
+
// deny KEYS may be pressed is still gated on the modal being up (checkpoint 2 below).
|
|
112
168
|
decision = { decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }
|
|
169
|
+
} finally {
|
|
170
|
+
clearInterval(stalePoll)
|
|
171
|
+
}
|
|
172
|
+
// Checkpoint 2 — final synchronous re-proof immediately before the injection. No yield between this
|
|
173
|
+
// check and the write below, so the pane model cannot change in between (single-threaded).
|
|
174
|
+
if (!stale && !stillActive()) stale = true
|
|
175
|
+
if (stale) {
|
|
176
|
+
deps.log?.(
|
|
177
|
+
`[supervisor] BREAKER-STALE ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
|
|
178
|
+
` — modal gone before resolution${lateDecision ? ` (late human ${lateDecision} NOT injected)` : ''}; request closed, NO pty bytes`,
|
|
179
|
+
)
|
|
180
|
+
return { decision: 'deny', reason: 'stale — modal cleared before the decision; no keys injected', injected: false, stale: true }
|
|
113
181
|
}
|
|
114
182
|
deps.write(decision.decision === 'allow' ? input.approveBytes : input.denyBytes)
|
|
115
183
|
deps.log?.(
|
|
116
184
|
`[supervisor] BREAKER-${decision.decision.toUpperCase()} ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
|
|
117
185
|
(decision.reason ? ` reason=${JSON.stringify(decision.reason)}` : ''),
|
|
118
186
|
)
|
|
119
|
-
return decision
|
|
187
|
+
return { ...decision, injected: true }
|
|
120
188
|
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -795,6 +795,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
795
795
|
// safe to DENY on any broker fault. For unknown-modal the broker content carries EXPLICIT button
|
|
796
796
|
// semantics (Allow presses option 1 "<label>"; Deny cancels via Esc) so the human decides informed.
|
|
797
797
|
brokerInFlight = true
|
|
798
|
+
// STALE-PROOF (15.07 incident): pin THIS modal's content signature at request time; the route
|
|
799
|
+
// re-proves it against the LIVE pane model before any key injection. Modal gone / replaced by a
|
|
800
|
+
// different one → signature mismatch → the request closes WITHOUT touching the pty (a late
|
|
801
|
+
// allow/deny must never land in a newer live turn). A transient model-read throw propagates —
|
|
802
|
+
// routeCircuitBreaker treats it as fail-closed false (cannot prove the modal → no bytes).
|
|
803
|
+
const requestSig = sig
|
|
804
|
+
const stillActive = (): boolean => {
|
|
805
|
+
const encNow = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
|
|
806
|
+
return nagSignature(nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), encNow)) === requestSig
|
|
807
|
+
}
|
|
798
808
|
void routeCircuitBreaker(
|
|
799
809
|
{
|
|
800
810
|
taxonomy: action.taxonomy,
|
|
@@ -809,6 +819,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
809
819
|
runtime: brokerRuntime,
|
|
810
820
|
env: brokerEnv,
|
|
811
821
|
write: b => child.terminal.write(b),
|
|
822
|
+
stillActive,
|
|
812
823
|
log: line => {
|
|
813
824
|
try {
|
|
814
825
|
console.warn(line)
|