@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.
- package/docs/15-fleet-api.md +10 -2
- package/docs/16-tray.md +18 -4
- package/docs/17-approval.md +128 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +7 -1
- package/docs/ru/16-tray.md +14 -2
- package/docs/ru/17-approval.md +128 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/approval/brokerClient.ts +92 -0
- package/src/approval/cli.ts +118 -0
- package/src/approval/hook.ts +193 -0
- package/src/approval/install.ts +200 -0
- package/src/cli/index.ts +66 -2
- package/src/daemon/approvals.ts +202 -0
- package/src/daemon/approvalslog.ts +54 -0
- package/src/daemon/fleet.ts +116 -1
- package/src/identity/index.ts +28 -0
- package/src/identity/profileStandard.ts +3 -0
- package/src/launch/adapters/claude.ts +83 -16
- package/src/launch/adapters/codex.ts +14 -3
- package/src/launch/index.ts +1 -1
- package/src/launch/ptyHost.ts +3 -3
- package/src/launch/types.ts +27 -8
- package/src/lifecycle/index.ts +16 -1
- package/src/supervisor/approvalRoute.ts +76 -0
- package/src/supervisor/boot.ts +13 -3
- package/src/supervisor/daemon.ts +62 -6
- package/src/tray/index.ts +25 -0
- package/src/tray/render.ts +107 -6
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// approval-hook — the blocking bridge from a runtime's PreToolUse hook to the daemon
|
|
2
|
+
// approval broker (docs/17). Installed (gated peers only) as the `command` of a claude /
|
|
3
|
+
// codex PreToolUse hook. On each matched tool call it: reads the runtime's hook JSON on
|
|
4
|
+
// stdin, builds a human-readable approval request, POSTs it to the daemon (long-poll,
|
|
5
|
+
// blocking until a human answers or the broker's default-deny timeout), and prints the
|
|
6
|
+
// runtime hook decision JSON on stdout. claude and codex share the PreToolUse output
|
|
7
|
+
// shape (`hookSpecificOutput.permissionDecision`), so ONE format serves both.
|
|
8
|
+
//
|
|
9
|
+
// FAIL-SAFE by construction: ANY failure (no PEER identity, daemon unreachable, malformed
|
|
10
|
+
// response, client timeout) prints a DENY with a reason — a gated peer is never allowed to
|
|
11
|
+
// act unapproved, and never hangs (the client timeout sits above the broker's).
|
|
12
|
+
//
|
|
13
|
+
// Pure helpers (parseHookInput / actionContent / formatHookDecision) are unit-tested;
|
|
14
|
+
// runApprovalHook is the thin IO orchestration over the shared broker transport (brokerClient).
|
|
15
|
+
|
|
16
|
+
import { type ApprovalDecision, requestApproval, resolveFleetBase } from './brokerClient.ts'
|
|
17
|
+
|
|
18
|
+
function str(v: unknown): string {
|
|
19
|
+
return typeof v === 'string' ? v : v === undefined || v === null ? '' : JSON.stringify(v)
|
|
20
|
+
}
|
|
21
|
+
function firstLine(s: string): string {
|
|
22
|
+
const nl = s.indexOf('\n')
|
|
23
|
+
return (nl >= 0 ? s.slice(0, nl) : s).trim()
|
|
24
|
+
}
|
|
25
|
+
function clip(s: string, max: number): string {
|
|
26
|
+
return s.length > max ? `${s.slice(0, max)}\n…[+${s.length - max} chars]` : s
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActionContent {
|
|
30
|
+
kind: 'tool' | 'plan'
|
|
31
|
+
content: string
|
|
32
|
+
summary: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Turn a PreToolUse (tool_name, tool_input) into the human-readable content + summary the
|
|
36
|
+
* broker shows in every channel. Covers the v1 nomenclature (docs/17 §2); an unknown tool
|
|
37
|
+
* falls back to a pretty-printed tool_input so nothing is opaque. */
|
|
38
|
+
export function actionContent(toolName: string, toolInput: Record<string, unknown> | undefined): ActionContent {
|
|
39
|
+
const ti = toolInput ?? {}
|
|
40
|
+
switch (toolName) {
|
|
41
|
+
case 'Bash': {
|
|
42
|
+
const cmd = str(ti.command)
|
|
43
|
+
const desc = str(ti.description)
|
|
44
|
+
return { kind: 'tool', content: desc ? `${cmd}\n\n# ${desc}` : cmd, summary: cmd || 'Bash' }
|
|
45
|
+
}
|
|
46
|
+
case 'Write': {
|
|
47
|
+
const fp = str(ti.file_path)
|
|
48
|
+
return { kind: 'tool', content: `Write ${fp}\n\n${clip(str(ti.content), 8000)}`, summary: `Write ${fp}` }
|
|
49
|
+
}
|
|
50
|
+
case 'Edit': {
|
|
51
|
+
const fp = str(ti.file_path)
|
|
52
|
+
return {
|
|
53
|
+
kind: 'tool',
|
|
54
|
+
content: `Edit ${fp}\n\n--- old\n${clip(str(ti.old_string), 3000)}\n+++ new\n${clip(str(ti.new_string), 3000)}`,
|
|
55
|
+
summary: `Edit ${fp}`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
case 'MultiEdit': {
|
|
59
|
+
const fp = str(ti.file_path)
|
|
60
|
+
const edits = Array.isArray(ti.edits) ? ti.edits : []
|
|
61
|
+
return { kind: 'tool', content: `MultiEdit ${fp} (${edits.length} edits)\n\n${clip(JSON.stringify(edits, null, 2), 6000)}`, summary: `MultiEdit ${fp} (${edits.length})` }
|
|
62
|
+
}
|
|
63
|
+
case 'NotebookEdit': {
|
|
64
|
+
const fp = str(ti.notebook_path)
|
|
65
|
+
return { kind: 'tool', content: `NotebookEdit ${fp}\n\n${clip(str(ti.new_source), 6000)}`, summary: `NotebookEdit ${fp}` }
|
|
66
|
+
}
|
|
67
|
+
case 'ExitPlanMode': {
|
|
68
|
+
const plan = str(ti.plan)
|
|
69
|
+
return { kind: 'plan', content: plan, summary: firstLine(plan) || 'plan' }
|
|
70
|
+
}
|
|
71
|
+
// codex patch application (tool_input carries the patch text)
|
|
72
|
+
case 'apply_patch':
|
|
73
|
+
case 'ApplyPatch': {
|
|
74
|
+
const patch = str(ti.input ?? ti.patch ?? ti)
|
|
75
|
+
return { kind: 'tool', content: clip(patch, 8000), summary: `apply_patch (${firstLine(patch) || 'diff'})` }
|
|
76
|
+
}
|
|
77
|
+
default: {
|
|
78
|
+
const body = clip(JSON.stringify(ti, null, 2), 6000)
|
|
79
|
+
return { kind: 'tool', content: `${toolName}\n\n${body}`, summary: toolName }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The two hook events we intercept. `PermissionRequest` (claude, and codex) fires ONLY when
|
|
85
|
+
* the runtime's permission config DECIDED to prompt — the primary, matcher-free interceptor
|
|
86
|
+
* (verified live 2.1.201: fires + suppresses the prompt + returns a decision, policy stays
|
|
87
|
+
* 100% at the runtime). `PreToolUse` fires on every matched tool (codex structural path). */
|
|
88
|
+
export type HookEvent = 'PermissionRequest' | 'PreToolUse'
|
|
89
|
+
|
|
90
|
+
export interface HookInput {
|
|
91
|
+
event: HookEvent
|
|
92
|
+
toolName: string
|
|
93
|
+
toolInput: Record<string, unknown> | undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Parse the runtime's hook stdin JSON. Throws on non-JSON / unknown event / missing tool_name
|
|
97
|
+
* (the caller turns a throw into a fail-safe exit-2 deny, which both events honor). */
|
|
98
|
+
export function parseHookInput(stdin: string): HookInput {
|
|
99
|
+
const raw = JSON.parse(stdin) as Record<string, unknown>
|
|
100
|
+
const evName = raw.hook_event_name
|
|
101
|
+
const event: HookEvent =
|
|
102
|
+
evName === 'PermissionRequest' ? 'PermissionRequest' : evName === 'PreToolUse' ? 'PreToolUse' : throwEvent(evName)
|
|
103
|
+
const toolName = raw.tool_name
|
|
104
|
+
if (typeof toolName !== 'string' || !toolName) throw new Error('hook stdin has no tool_name')
|
|
105
|
+
const toolInput =
|
|
106
|
+
raw.tool_input && typeof raw.tool_input === 'object' && !Array.isArray(raw.tool_input)
|
|
107
|
+
? (raw.tool_input as Record<string, unknown>)
|
|
108
|
+
: undefined
|
|
109
|
+
return { event, toolName, toolInput }
|
|
110
|
+
}
|
|
111
|
+
function throwEvent(v: unknown): never {
|
|
112
|
+
throw new Error(`hook stdin has an unexpected hook_event_name "${String(v)}"`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The decision JSON in the shape the given event accepts. A deny carries the reason to the
|
|
116
|
+
* model. PermissionRequest uses `decision.behavior` (+ `message`); PreToolUse uses
|
|
117
|
+
* `permissionDecision` (+ `permissionDecisionReason`) — both verified live 2.1.201 / codex 0.142.5. */
|
|
118
|
+
export function formatHookDecision(d: ApprovalDecision, event: HookEvent): string {
|
|
119
|
+
if (event === 'PermissionRequest') {
|
|
120
|
+
return JSON.stringify({
|
|
121
|
+
hookSpecificOutput: {
|
|
122
|
+
hookEventName: 'PermissionRequest',
|
|
123
|
+
decision: { behavior: d.decision, ...(d.decision === 'deny' && d.reason ? { message: d.reason } : {}) },
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
return JSON.stringify({
|
|
128
|
+
hookSpecificOutput: {
|
|
129
|
+
hookEventName: 'PreToolUse',
|
|
130
|
+
permissionDecision: d.decision,
|
|
131
|
+
...(d.decision === 'deny' && d.reason ? { permissionDecisionReason: d.reason } : {}),
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RunHookDeps {
|
|
137
|
+
env?: NodeJS.ProcessEnv
|
|
138
|
+
/** Injectable fetch (tests). Default global fetch. */
|
|
139
|
+
fetch?: typeof fetch
|
|
140
|
+
/** Injectable client timeout (tests). */
|
|
141
|
+
timeoutMs?: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface HookRunResult {
|
|
145
|
+
/** Decision JSON for the runtime (empty on the exit-2 hard fail-safe). */
|
|
146
|
+
stdout: string
|
|
147
|
+
/** Reason fed to the model on the exit-2 path (empty otherwise). */
|
|
148
|
+
stderr: string
|
|
149
|
+
/** 0 = decision expressed in stdout JSON; 2 = hard fail-safe deny (both events honor it). */
|
|
150
|
+
exitCode: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Full hook run: stdin JSON → broker → decision JSON in the shape the event accepts. Never
|
|
155
|
+
* throws. Two fail-safe layers: once the event is known, any downstream failure (no identity,
|
|
156
|
+
* daemon down, timeout, broker deny) returns an event-appropriate DENY JSON (exit 0, reason to
|
|
157
|
+
* the model); a stdin we cannot even parse (unknown event) returns exit 2 + stderr, which
|
|
158
|
+
* DENIES under BOTH PermissionRequest and PreToolUse — so a gated peer is never allowed
|
|
159
|
+
* unapproved and never hangs, even on a malformed hook payload.
|
|
160
|
+
*/
|
|
161
|
+
export async function runApprovalHook(stdin: string, deps: RunHookDeps = {}): Promise<HookRunResult> {
|
|
162
|
+
const env = deps.env ?? process.env
|
|
163
|
+
const doFetch = deps.fetch ?? fetch
|
|
164
|
+
const timeoutMs = deps.timeoutMs
|
|
165
|
+
let event: HookEvent
|
|
166
|
+
let toolName: string
|
|
167
|
+
let toolInput: Record<string, unknown> | undefined
|
|
168
|
+
try {
|
|
169
|
+
;({ event, toolName, toolInput } = parseHookInput(stdin))
|
|
170
|
+
} catch (e) {
|
|
171
|
+
// Cannot determine the event → exit 2 denies in BOTH events (stderr → model).
|
|
172
|
+
return { stdout: '', stderr: `iapeer approval-hook: ${e instanceof Error ? e.message : String(e)} — denied fail-safe`, exitCode: 2 }
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const personality = env.PEER_PERSONALITY?.trim()
|
|
176
|
+
if (!personality) throw new Error('no PEER_PERSONALITY in the hook environment')
|
|
177
|
+
const runtime = env.PEER_RUNTIME?.trim() || 'claude'
|
|
178
|
+
const { kind, content, summary } = actionContent(toolName, toolInput)
|
|
179
|
+
const decision = await requestApproval(
|
|
180
|
+
resolveFleetBase(env),
|
|
181
|
+
{ personality, runtime, kind, tool: toolName, content, summary },
|
|
182
|
+
{ env, fetch: doFetch, timeoutMs },
|
|
183
|
+
)
|
|
184
|
+
return { stdout: formatHookDecision(decision, event), stderr: '', exitCode: 0 }
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// Event known → event-appropriate DENY JSON (never allow-by-accident, never hang).
|
|
187
|
+
return {
|
|
188
|
+
stdout: formatHookDecision({ decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }, event),
|
|
189
|
+
stderr: '',
|
|
190
|
+
exitCode: 0,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// approval install — the RUNTIME-CONFIG surfaces the gated toggle brings up/down (docs/17
|
|
2
|
+
// §1). The PreToolUse interceptor + the coarse tool-class matcher live on the runtime's OWN
|
|
3
|
+
// hook config (claude: <cwd>/.claude/settings.json hooks.PreToolUse[].matcher; codex:
|
|
4
|
+
// <cwd>/.codex/hooks.json matcher) — so the user tunes WHICH classes are gated as ordinary
|
|
5
|
+
// runtime config; iapeer only SEEDS the default on flip-to-gated (seed-if-absent) and REMOVES
|
|
6
|
+
// its whole block on flip-to-yolo. Idempotent both ways: a gated→yolo→gated round-trip is
|
|
7
|
+
// byte-identical (no accumulation, no half-states) — the toggle invariant.
|
|
8
|
+
//
|
|
9
|
+
// Also installed for gated claude: an allow-rule for the peer's OWN MCP tool
|
|
10
|
+
// (mcp__iapeer__send_to_peer) so a gated peer's IAP channel is never itself gated (a
|
|
11
|
+
// no-bypass session would otherwise prompt on it and hang).
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
14
|
+
import { dirname, join } from 'path'
|
|
15
|
+
import { writeFileAtomic } from '../storage/index.ts'
|
|
16
|
+
import { claudeSettingsPath } from '../launch/nativeMemory.ts'
|
|
17
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
18
|
+
import { readPeerProfile, writePeerProfileAtomic, type ApprovalMode } from '../identity/index.ts'
|
|
19
|
+
import { preSeedCodexHooksTrust, removeCodexHooksTrustUnder, type HooksTrustOutcome } from '../launch/codexHooksTrust.ts'
|
|
20
|
+
|
|
21
|
+
// claude gated uses a matcher-FREE PermissionRequest hook (docs/17, Option D — verified live
|
|
22
|
+
// 2.1.201): PermissionRequest fires ONLY when the runtime's permission config DECIDED to prompt
|
|
23
|
+
// (a tool call not covered by an allow/deny rule), so the policy of "what to ask" stays 100% at
|
|
24
|
+
// the runtime — the user tunes it with ordinary permission rules (a tool in permissions.allow is
|
|
25
|
+
// never asked → the hook never fires → auto-allowed). No class matcher, no drift, no hang: a new
|
|
26
|
+
// tool the runtime prompts for is intercepted the same way. iapeer only seeds the hook + the
|
|
27
|
+
// allow-rule for its own MCP tool (else the runtime would prompt on the peer's own IAP channel).
|
|
28
|
+
// codex tool-name regex (codex PreToolUse path). VERIFIED LIVE (codex-cli 0.142.5, Unit 3): codex
|
|
29
|
+
// fires `PreToolUse` (NOT PermissionRequest) for tool calls, with `tool_name:"Bash"` for shell exec +
|
|
30
|
+
// `tool_input.command` = the full command; a PreToolUse `deny` HARD-BLOCKS the tool AND the
|
|
31
|
+
// `permissionDecisionReason` reaches the model — even under `sandbox_mode=danger-full-access`
|
|
32
|
+
// (permission_mode=bypassPermissions), so under the gated config the hook is the SOLE gate. `Bash`
|
|
33
|
+
// confirmed live; `apply_patch` (the patch tool) per the codex hooks docs. The extra alternatives are a
|
|
34
|
+
// forward-compatible superset (a future codex rename still matches) — harmless, never over-matches.
|
|
35
|
+
export const CODEX_APPROVAL_MATCHER = '^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$'
|
|
36
|
+
/** The peer's own IAP tool — allow-listed so a gated peer's send_to_peer is not itself gated. */
|
|
37
|
+
export const IAPEER_MCP_ALLOW = 'mcp__iapeer__send_to_peer'
|
|
38
|
+
/** Runtime hook timeout (seconds): ABOVE the broker default-deny (300) + the hook client fetch
|
|
39
|
+
* ceiling (600) so the broker/human answers before the runtime kills the hook. */
|
|
40
|
+
const HOOK_TIMEOUT_SECS = 900
|
|
41
|
+
|
|
42
|
+
/** The `command` string the runtime runs per matched tool call (the installed binary). */
|
|
43
|
+
export function approvalHookCommand(env: NodeJS.ProcessEnv = process.env): string {
|
|
44
|
+
return `${iapeerBinPath(env)} approval-hook`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isIapeerGroup(group: unknown): boolean {
|
|
48
|
+
const hooks = (group as { hooks?: unknown })?.hooks
|
|
49
|
+
return (
|
|
50
|
+
Array.isArray(hooks) &&
|
|
51
|
+
hooks.some(h => typeof (h as { command?: unknown })?.command === 'string' && (h as { command: string }).command.includes('approval-hook'))
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function iapeerClaudeGroup(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
56
|
+
// Matcher-free: PermissionRequest already fires only on prompt-worthy calls (Option D).
|
|
57
|
+
return { hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
61
|
+
if (!existsSync(path)) return {}
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
64
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
|
|
65
|
+
return raw as Record<string, unknown>
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── claude (project settings.json) ───────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Seed the gated PreToolUse hook + MCP allow-rule into <cwd>/.claude/settings.json
|
|
74
|
+
* (seed-if-absent, no-clobber merge). Returns the path, or null on a refusal/error. */
|
|
75
|
+
export function installClaudeApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
76
|
+
const path = claudeSettingsPath(cwd)
|
|
77
|
+
const obj = readJsonObject(path)
|
|
78
|
+
if (obj === null) return null
|
|
79
|
+
// hooks.PermissionRequest — push OUR group iff absent (respect a user-added one).
|
|
80
|
+
const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
|
|
81
|
+
const pre = (Array.isArray(hooks.PermissionRequest) ? hooks.PermissionRequest : []) as unknown[]
|
|
82
|
+
if (!pre.some(isIapeerGroup)) pre.push(iapeerClaudeGroup(env))
|
|
83
|
+
hooks.PermissionRequest = pre
|
|
84
|
+
obj.hooks = hooks
|
|
85
|
+
// permissions.allow — allow-list the peer's own MCP tool (idempotent).
|
|
86
|
+
const perms = (obj.permissions && typeof obj.permissions === 'object' && !Array.isArray(obj.permissions) ? obj.permissions : {}) as Record<string, unknown>
|
|
87
|
+
const allow = (Array.isArray(perms.allow) ? perms.allow : []) as unknown[]
|
|
88
|
+
if (!allow.includes(IAPEER_MCP_ALLOW)) allow.push(IAPEER_MCP_ALLOW)
|
|
89
|
+
perms.allow = allow
|
|
90
|
+
obj.permissions = perms
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
92
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
93
|
+
return path
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Remove OUR gated hook + MCP allow-rule, cleaning up any structures we emptied so a
|
|
97
|
+
* gated→yolo flip restores the pre-install bytes (foreign hooks/rules preserved). */
|
|
98
|
+
export function removeClaudeApproval(cwd: string): string | null {
|
|
99
|
+
const path = claudeSettingsPath(cwd)
|
|
100
|
+
if (!existsSync(path)) return null
|
|
101
|
+
const obj = readJsonObject(path)
|
|
102
|
+
if (obj === null) return null
|
|
103
|
+
const hooks = obj.hooks as Record<string, unknown> | undefined
|
|
104
|
+
if (hooks && Array.isArray(hooks.PermissionRequest)) {
|
|
105
|
+
hooks.PermissionRequest = (hooks.PermissionRequest as unknown[]).filter(g => !isIapeerGroup(g))
|
|
106
|
+
if ((hooks.PermissionRequest as unknown[]).length === 0) delete hooks.PermissionRequest
|
|
107
|
+
if (Object.keys(hooks).length === 0) delete obj.hooks
|
|
108
|
+
}
|
|
109
|
+
const perms = obj.permissions as Record<string, unknown> | undefined
|
|
110
|
+
if (perms && Array.isArray(perms.allow)) {
|
|
111
|
+
perms.allow = (perms.allow as unknown[]).filter(r => r !== IAPEER_MCP_ALLOW)
|
|
112
|
+
if ((perms.allow as unknown[]).length === 0) delete perms.allow
|
|
113
|
+
if (Object.keys(perms).length === 0) delete obj.permissions
|
|
114
|
+
}
|
|
115
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
116
|
+
return path
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── codex (project hooks.json + trust pre-seed) ──────────────────────────────
|
|
120
|
+
|
|
121
|
+
export function codexHooksJsonPath(cwd: string): string {
|
|
122
|
+
return join(cwd, '.codex', 'hooks.json')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Write <cwd>/.codex/hooks.json with the gated PreToolUse group (seed-if-absent merge) and
|
|
126
|
+
* pre-seed its trust in ~/.codex/config.toml (else codex silently SKIPS an untrusted hook). */
|
|
127
|
+
export function installCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): { path: string; trust?: HooksTrustOutcome } | null {
|
|
128
|
+
const path = codexHooksJsonPath(cwd)
|
|
129
|
+
const obj = readJsonObject(path)
|
|
130
|
+
if (obj === null) return null
|
|
131
|
+
const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
|
|
132
|
+
const pre = (Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []) as unknown[]
|
|
133
|
+
if (!pre.some(isIapeerGroup)) {
|
|
134
|
+
pre.push({ matcher: CODEX_APPROVAL_MATCHER, hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] })
|
|
135
|
+
}
|
|
136
|
+
hooks.PreToolUse = pre
|
|
137
|
+
obj.hooks = hooks
|
|
138
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
139
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
140
|
+
const trust = preSeedCodexHooksTrust(path, env)
|
|
141
|
+
return { path, trust }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Remove the codex gated hook (drop OUR group; delete the file if it becomes empty) and its
|
|
145
|
+
* trust state under this cwd. */
|
|
146
|
+
export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
147
|
+
const path = codexHooksJsonPath(cwd)
|
|
148
|
+
removeCodexHooksTrustUnder(cwd, env)
|
|
149
|
+
if (!existsSync(path)) return null
|
|
150
|
+
const obj = readJsonObject(path)
|
|
151
|
+
if (obj === null) return null
|
|
152
|
+
const hooks = obj.hooks as Record<string, unknown> | undefined
|
|
153
|
+
if (hooks && Array.isArray(hooks.PreToolUse)) {
|
|
154
|
+
hooks.PreToolUse = (hooks.PreToolUse as unknown[]).filter(g => !isIapeerGroup(g))
|
|
155
|
+
if ((hooks.PreToolUse as unknown[]).length === 0) delete hooks.PreToolUse
|
|
156
|
+
if (Object.keys(hooks).length === 0) delete obj.hooks
|
|
157
|
+
}
|
|
158
|
+
// If nothing but our block was there, drop the file entirely (pristine restore).
|
|
159
|
+
if (Object.keys(obj).length === 0) {
|
|
160
|
+
rmSync(path, { force: true })
|
|
161
|
+
return path
|
|
162
|
+
}
|
|
163
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
164
|
+
return path
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the toggle: profile field + per-runtime surfaces (docs/17 §1) ─────────────
|
|
168
|
+
|
|
169
|
+
export interface ApprovalToggleResult {
|
|
170
|
+
mode: ApprovalMode
|
|
171
|
+
surfaces: string[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Flip a peer's approval mode: persist the profile field AND bring every PERSISTENT runtime
|
|
176
|
+
* surface to the mode (idempotent). The argv/ready-gate surfaces are NOT touched here — they
|
|
177
|
+
* are computed at launch from the profile field, so a LIVE session keeps its launched mode
|
|
178
|
+
* until the next fresh session (the explicit application moment). Throws if the cwd has no
|
|
179
|
+
* peer profile.
|
|
180
|
+
*/
|
|
181
|
+
export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.ProcessEnv = process.env): ApprovalToggleResult {
|
|
182
|
+
const profile = readPeerProfile(cwd)
|
|
183
|
+
if (!profile) throw new Error(`no peer profile at ${cwd}`)
|
|
184
|
+
writePeerProfileAtomic(cwd, { ...profile, approval_mode: mode })
|
|
185
|
+
const surfaces = [`profile: approval_mode=${mode}`]
|
|
186
|
+
for (const rt of profile.runtimes) {
|
|
187
|
+
if (rt === 'claude') {
|
|
188
|
+
if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
|
|
189
|
+
else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
|
|
190
|
+
} else if (rt === 'codex') {
|
|
191
|
+
if (mode === 'gated') {
|
|
192
|
+
const r = installCodexApproval(cwd, env)
|
|
193
|
+
surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
|
|
194
|
+
} else {
|
|
195
|
+
surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { mode, surfaces }
|
|
200
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1278,9 +1278,14 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1278
1278
|
desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
|
|
1279
1279
|
},
|
|
1280
1280
|
{
|
|
1281
|
-
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
|
|
1282
|
-
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1281
|
+
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
|
|
1282
|
+
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1283
1283
|
},
|
|
1284
|
+
{ sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
|
|
1285
|
+
{ sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
|
|
1286
|
+
{ sig: 'approve <id>', desc: 'approve a pending request — the decision clears it in every channel' },
|
|
1287
|
+
{ sig: 'deny <id> [reason]', desc: 'deny a pending request; the reason reaches the model' },
|
|
1288
|
+
{ sig: 'approval-hook', desc: '(runtime-installed) PreToolUse bridge: stdin hook JSON → broker → decision JSON. Not run by hand' },
|
|
1284
1289
|
]
|
|
1285
1290
|
|
|
1286
1291
|
/** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
|
|
@@ -2537,6 +2542,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2537
2542
|
out(`${JSON.stringify(r.body)}\n`)
|
|
2538
2543
|
return r.ok ? 0 : 1
|
|
2539
2544
|
}
|
|
2545
|
+
case 'approve':
|
|
2546
|
+
case 'deny': {
|
|
2547
|
+
// Tray click → resolve a pending approval over the unix-first fleet client (docs/17). The
|
|
2548
|
+
// menu Allow/Deny items run these; the broker resolution is seen by every channel.
|
|
2549
|
+
const id = positionals[1]
|
|
2550
|
+
if (!id) return argErr(errOut, `usage: iapeer tray ${sub} <approval-id> [reason]`)
|
|
2551
|
+
const reason = sub === 'deny' ? positionals.slice(2).join(' ') || undefined : undefined
|
|
2552
|
+
let r: Awaited<ReturnType<typeof tray.trayResolveApproval>>
|
|
2553
|
+
try {
|
|
2554
|
+
r = await tray.trayResolveApproval(env, sub, id, reason)
|
|
2555
|
+
} catch (e) {
|
|
2556
|
+
errOut(`tray ${sub}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
2557
|
+
return 1
|
|
2558
|
+
}
|
|
2559
|
+
out(`${JSON.stringify(r.body)}\n`)
|
|
2560
|
+
return r.ok ? 0 : 1
|
|
2561
|
+
}
|
|
2540
2562
|
case 'install': {
|
|
2541
2563
|
// Explicit activation verb: installs SwiftBar.app when absent
|
|
2542
2564
|
// (owner-sanctioned) + launches, unless --plugin-only (file only).
|
|
@@ -2580,6 +2602,48 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2580
2602
|
)
|
|
2581
2603
|
}
|
|
2582
2604
|
}
|
|
2605
|
+
case 'approval-hook': {
|
|
2606
|
+
// (runtime-installed, docs/17) the PreToolUse bridge: stdin = the runtime's hook JSON,
|
|
2607
|
+
// stdout = the decision JSON (EXACTLY — the runtime parses it), blocking on the broker.
|
|
2608
|
+
// Never run by hand. Fail-safe: any error prints a DENY (expressed in JSON, exit 0).
|
|
2609
|
+
const { runApprovalHook } = await import('../approval/hook.ts')
|
|
2610
|
+
const stdin = await Bun.stdin.text()
|
|
2611
|
+
const r = await runApprovalHook(stdin, { env })
|
|
2612
|
+
if (r.stdout) out(`${r.stdout}\n`)
|
|
2613
|
+
if (r.stderr) errOut(`${r.stderr}\n`)
|
|
2614
|
+
return r.exitCode
|
|
2615
|
+
}
|
|
2616
|
+
case 'approvals': {
|
|
2617
|
+
const { approvalsList } = await import('../approval/cli.ts')
|
|
2618
|
+
const r = await approvalsList(flags.json === true, env)
|
|
2619
|
+
out(r.text)
|
|
2620
|
+
return r.code
|
|
2621
|
+
}
|
|
2622
|
+
case 'approve':
|
|
2623
|
+
case 'deny': {
|
|
2624
|
+
const id = positionals[0]
|
|
2625
|
+
if (!id) return argErr(errOut, `${verb} needs an id — usage: iapeer ${verb} <id>${verb === 'deny' ? ' [reason]' : ''} (list: iapeer approvals)`)
|
|
2626
|
+
const { resolveApproval } = await import('../approval/cli.ts')
|
|
2627
|
+
const reason = verb === 'deny' ? positionals.slice(1).join(' ') || undefined : undefined
|
|
2628
|
+
const approver = typeof flags.approver === 'string' ? flags.approver : undefined
|
|
2629
|
+
const r = await resolveApproval(id, verb, { reason, approver }, env)
|
|
2630
|
+
out(r.text)
|
|
2631
|
+
return r.code
|
|
2632
|
+
}
|
|
2633
|
+
case 'approval-mode': {
|
|
2634
|
+
const peer = positionals[0]
|
|
2635
|
+
if (!peer) return argErr(errOut, 'approval-mode needs a peer — usage: iapeer approval-mode <peer> [gated|yolo] [--now]')
|
|
2636
|
+
const modeArg = positionals[1]
|
|
2637
|
+
if (modeArg && modeArg !== 'gated' && modeArg !== 'yolo') return argErr(errOut, 'approval-mode: mode must be gated | yolo (omit to read the current mode)')
|
|
2638
|
+
const { approvalModeCli } = await import('../approval/cli.ts')
|
|
2639
|
+
const r = approvalModeCli(peer, modeArg as 'gated' | 'yolo' | undefined, env)
|
|
2640
|
+
out(r.text)
|
|
2641
|
+
if (r.code === 0 && modeArg && flags.now === true) {
|
|
2642
|
+
const o = await newPeer(peer, undefined, { env })
|
|
2643
|
+
out(`applied now: fresh session (${o.action})\n`)
|
|
2644
|
+
}
|
|
2645
|
+
return r.code
|
|
2646
|
+
}
|
|
2583
2647
|
default: {
|
|
2584
2648
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
|
|
2585
2649
|
// the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
|