@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.
- package/docs/15-fleet-api.md +3 -1
- package/docs/17-approval.md +128 -0
- package/docs/README.md +1 -0
- 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 +194 -0
- package/src/cli/index.ts +47 -0
- package/src/daemon/approvals.ts +202 -0
- package/src/daemon/approvalslog.ts +54 -0
- package/src/daemon/fleet.ts +88 -0
- 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/install.ts +4 -3
- package/src/tray/render.ts +6 -2
|
@@ -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,194 @@
|
|
|
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 — structural, live-verify pending). */
|
|
29
|
+
export const CODEX_APPROVAL_MATCHER = '^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$'
|
|
30
|
+
/** The peer's own IAP tool — allow-listed so a gated peer's send_to_peer is not itself gated. */
|
|
31
|
+
export const IAPEER_MCP_ALLOW = 'mcp__iapeer__send_to_peer'
|
|
32
|
+
/** Runtime hook timeout (seconds): ABOVE the broker default-deny (300) + the hook client fetch
|
|
33
|
+
* ceiling (600) so the broker/human answers before the runtime kills the hook. */
|
|
34
|
+
const HOOK_TIMEOUT_SECS = 900
|
|
35
|
+
|
|
36
|
+
/** The `command` string the runtime runs per matched tool call (the installed binary). */
|
|
37
|
+
export function approvalHookCommand(env: NodeJS.ProcessEnv = process.env): string {
|
|
38
|
+
return `${iapeerBinPath(env)} approval-hook`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isIapeerGroup(group: unknown): boolean {
|
|
42
|
+
const hooks = (group as { hooks?: unknown })?.hooks
|
|
43
|
+
return (
|
|
44
|
+
Array.isArray(hooks) &&
|
|
45
|
+
hooks.some(h => typeof (h as { command?: unknown })?.command === 'string' && (h as { command: string }).command.includes('approval-hook'))
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function iapeerClaudeGroup(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
50
|
+
// Matcher-free: PermissionRequest already fires only on prompt-worthy calls (Option D).
|
|
51
|
+
return { hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
55
|
+
if (!existsSync(path)) return {}
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
58
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
|
|
59
|
+
return raw as Record<string, unknown>
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── claude (project settings.json) ───────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** Seed the gated PreToolUse hook + MCP allow-rule into <cwd>/.claude/settings.json
|
|
68
|
+
* (seed-if-absent, no-clobber merge). Returns the path, or null on a refusal/error. */
|
|
69
|
+
export function installClaudeApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
70
|
+
const path = claudeSettingsPath(cwd)
|
|
71
|
+
const obj = readJsonObject(path)
|
|
72
|
+
if (obj === null) return null
|
|
73
|
+
// hooks.PermissionRequest — push OUR group iff absent (respect a user-added one).
|
|
74
|
+
const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
|
|
75
|
+
const pre = (Array.isArray(hooks.PermissionRequest) ? hooks.PermissionRequest : []) as unknown[]
|
|
76
|
+
if (!pre.some(isIapeerGroup)) pre.push(iapeerClaudeGroup(env))
|
|
77
|
+
hooks.PermissionRequest = pre
|
|
78
|
+
obj.hooks = hooks
|
|
79
|
+
// permissions.allow — allow-list the peer's own MCP tool (idempotent).
|
|
80
|
+
const perms = (obj.permissions && typeof obj.permissions === 'object' && !Array.isArray(obj.permissions) ? obj.permissions : {}) as Record<string, unknown>
|
|
81
|
+
const allow = (Array.isArray(perms.allow) ? perms.allow : []) as unknown[]
|
|
82
|
+
if (!allow.includes(IAPEER_MCP_ALLOW)) allow.push(IAPEER_MCP_ALLOW)
|
|
83
|
+
perms.allow = allow
|
|
84
|
+
obj.permissions = perms
|
|
85
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
86
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
87
|
+
return path
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Remove OUR gated hook + MCP allow-rule, cleaning up any structures we emptied so a
|
|
91
|
+
* gated→yolo flip restores the pre-install bytes (foreign hooks/rules preserved). */
|
|
92
|
+
export function removeClaudeApproval(cwd: string): string | null {
|
|
93
|
+
const path = claudeSettingsPath(cwd)
|
|
94
|
+
if (!existsSync(path)) return null
|
|
95
|
+
const obj = readJsonObject(path)
|
|
96
|
+
if (obj === null) return null
|
|
97
|
+
const hooks = obj.hooks as Record<string, unknown> | undefined
|
|
98
|
+
if (hooks && Array.isArray(hooks.PermissionRequest)) {
|
|
99
|
+
hooks.PermissionRequest = (hooks.PermissionRequest as unknown[]).filter(g => !isIapeerGroup(g))
|
|
100
|
+
if ((hooks.PermissionRequest as unknown[]).length === 0) delete hooks.PermissionRequest
|
|
101
|
+
if (Object.keys(hooks).length === 0) delete obj.hooks
|
|
102
|
+
}
|
|
103
|
+
const perms = obj.permissions as Record<string, unknown> | undefined
|
|
104
|
+
if (perms && Array.isArray(perms.allow)) {
|
|
105
|
+
perms.allow = (perms.allow as unknown[]).filter(r => r !== IAPEER_MCP_ALLOW)
|
|
106
|
+
if ((perms.allow as unknown[]).length === 0) delete perms.allow
|
|
107
|
+
if (Object.keys(perms).length === 0) delete obj.permissions
|
|
108
|
+
}
|
|
109
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
110
|
+
return path
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── codex (project hooks.json + trust pre-seed) ──────────────────────────────
|
|
114
|
+
|
|
115
|
+
export function codexHooksJsonPath(cwd: string): string {
|
|
116
|
+
return join(cwd, '.codex', 'hooks.json')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Write <cwd>/.codex/hooks.json with the gated PreToolUse group (seed-if-absent merge) and
|
|
120
|
+
* pre-seed its trust in ~/.codex/config.toml (else codex silently SKIPS an untrusted hook). */
|
|
121
|
+
export function installCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): { path: string; trust?: HooksTrustOutcome } | null {
|
|
122
|
+
const path = codexHooksJsonPath(cwd)
|
|
123
|
+
const obj = readJsonObject(path)
|
|
124
|
+
if (obj === null) return null
|
|
125
|
+
const hooks = (obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks) ? obj.hooks : {}) as Record<string, unknown>
|
|
126
|
+
const pre = (Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []) as unknown[]
|
|
127
|
+
if (!pre.some(isIapeerGroup)) {
|
|
128
|
+
pre.push({ matcher: CODEX_APPROVAL_MATCHER, hooks: [{ type: 'command', command: approvalHookCommand(env), timeout: HOOK_TIMEOUT_SECS }] })
|
|
129
|
+
}
|
|
130
|
+
hooks.PreToolUse = pre
|
|
131
|
+
obj.hooks = hooks
|
|
132
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
133
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
134
|
+
const trust = preSeedCodexHooksTrust(path, env)
|
|
135
|
+
return { path, trust }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Remove the codex gated hook (drop OUR group; delete the file if it becomes empty) and its
|
|
139
|
+
* trust state under this cwd. */
|
|
140
|
+
export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
141
|
+
const path = codexHooksJsonPath(cwd)
|
|
142
|
+
removeCodexHooksTrustUnder(cwd, env)
|
|
143
|
+
if (!existsSync(path)) return null
|
|
144
|
+
const obj = readJsonObject(path)
|
|
145
|
+
if (obj === null) return null
|
|
146
|
+
const hooks = obj.hooks as Record<string, unknown> | undefined
|
|
147
|
+
if (hooks && Array.isArray(hooks.PreToolUse)) {
|
|
148
|
+
hooks.PreToolUse = (hooks.PreToolUse as unknown[]).filter(g => !isIapeerGroup(g))
|
|
149
|
+
if ((hooks.PreToolUse as unknown[]).length === 0) delete hooks.PreToolUse
|
|
150
|
+
if (Object.keys(hooks).length === 0) delete obj.hooks
|
|
151
|
+
}
|
|
152
|
+
// If nothing but our block was there, drop the file entirely (pristine restore).
|
|
153
|
+
if (Object.keys(obj).length === 0) {
|
|
154
|
+
rmSync(path, { force: true })
|
|
155
|
+
return path
|
|
156
|
+
}
|
|
157
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
158
|
+
return path
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── the toggle: profile field + per-runtime surfaces (docs/17 §1) ─────────────
|
|
162
|
+
|
|
163
|
+
export interface ApprovalToggleResult {
|
|
164
|
+
mode: ApprovalMode
|
|
165
|
+
surfaces: string[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Flip a peer's approval mode: persist the profile field AND bring every PERSISTENT runtime
|
|
170
|
+
* surface to the mode (idempotent). The argv/ready-gate surfaces are NOT touched here — they
|
|
171
|
+
* are computed at launch from the profile field, so a LIVE session keeps its launched mode
|
|
172
|
+
* until the next fresh session (the explicit application moment). Throws if the cwd has no
|
|
173
|
+
* peer profile.
|
|
174
|
+
*/
|
|
175
|
+
export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.ProcessEnv = process.env): ApprovalToggleResult {
|
|
176
|
+
const profile = readPeerProfile(cwd)
|
|
177
|
+
if (!profile) throw new Error(`no peer profile at ${cwd}`)
|
|
178
|
+
writePeerProfileAtomic(cwd, { ...profile, approval_mode: mode })
|
|
179
|
+
const surfaces = [`profile: approval_mode=${mode}`]
|
|
180
|
+
for (const rt of profile.runtimes) {
|
|
181
|
+
if (rt === 'claude') {
|
|
182
|
+
if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
|
|
183
|
+
else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
|
|
184
|
+
} else if (rt === 'codex') {
|
|
185
|
+
if (mode === 'gated') {
|
|
186
|
+
const r = installCodexApproval(cwd, env)
|
|
187
|
+
surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
|
|
188
|
+
} else {
|
|
189
|
+
surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { mode, surfaces }
|
|
194
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1281,6 +1281,11 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1281
1281
|
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
|
|
1282
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.',
|
|
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
|
|
@@ -2580,6 +2585,48 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2580
2585
|
)
|
|
2581
2586
|
}
|
|
2582
2587
|
}
|
|
2588
|
+
case 'approval-hook': {
|
|
2589
|
+
// (runtime-installed, docs/17) the PreToolUse bridge: stdin = the runtime's hook JSON,
|
|
2590
|
+
// stdout = the decision JSON (EXACTLY — the runtime parses it), blocking on the broker.
|
|
2591
|
+
// Never run by hand. Fail-safe: any error prints a DENY (expressed in JSON, exit 0).
|
|
2592
|
+
const { runApprovalHook } = await import('../approval/hook.ts')
|
|
2593
|
+
const stdin = await Bun.stdin.text()
|
|
2594
|
+
const r = await runApprovalHook(stdin, { env })
|
|
2595
|
+
if (r.stdout) out(`${r.stdout}\n`)
|
|
2596
|
+
if (r.stderr) errOut(`${r.stderr}\n`)
|
|
2597
|
+
return r.exitCode
|
|
2598
|
+
}
|
|
2599
|
+
case 'approvals': {
|
|
2600
|
+
const { approvalsList } = await import('../approval/cli.ts')
|
|
2601
|
+
const r = await approvalsList(flags.json === true, env)
|
|
2602
|
+
out(r.text)
|
|
2603
|
+
return r.code
|
|
2604
|
+
}
|
|
2605
|
+
case 'approve':
|
|
2606
|
+
case 'deny': {
|
|
2607
|
+
const id = positionals[0]
|
|
2608
|
+
if (!id) return argErr(errOut, `${verb} needs an id — usage: iapeer ${verb} <id>${verb === 'deny' ? ' [reason]' : ''} (list: iapeer approvals)`)
|
|
2609
|
+
const { resolveApproval } = await import('../approval/cli.ts')
|
|
2610
|
+
const reason = verb === 'deny' ? positionals.slice(1).join(' ') || undefined : undefined
|
|
2611
|
+
const approver = typeof flags.approver === 'string' ? flags.approver : undefined
|
|
2612
|
+
const r = await resolveApproval(id, verb, { reason, approver }, env)
|
|
2613
|
+
out(r.text)
|
|
2614
|
+
return r.code
|
|
2615
|
+
}
|
|
2616
|
+
case 'approval-mode': {
|
|
2617
|
+
const peer = positionals[0]
|
|
2618
|
+
if (!peer) return argErr(errOut, 'approval-mode needs a peer — usage: iapeer approval-mode <peer> [gated|yolo] [--now]')
|
|
2619
|
+
const modeArg = positionals[1]
|
|
2620
|
+
if (modeArg && modeArg !== 'gated' && modeArg !== 'yolo') return argErr(errOut, 'approval-mode: mode must be gated | yolo (omit to read the current mode)')
|
|
2621
|
+
const { approvalModeCli } = await import('../approval/cli.ts')
|
|
2622
|
+
const r = approvalModeCli(peer, modeArg as 'gated' | 'yolo' | undefined, env)
|
|
2623
|
+
out(r.text)
|
|
2624
|
+
if (r.code === 0 && modeArg && flags.now === true) {
|
|
2625
|
+
const o = await newPeer(peer, undefined, { env })
|
|
2626
|
+
out(`applied now: fresh session (${o.action})\n`)
|
|
2627
|
+
}
|
|
2628
|
+
return r.code
|
|
2629
|
+
}
|
|
2583
2630
|
default: {
|
|
2584
2631
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
|
|
2585
2632
|
// the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Approval broker — the in-daemon queue that is the SINGLE SOURCE OF TRUTH for
|
|
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
|
|
4
|
+
// decision from any face resolves the request everywhere (the owner's invariant).
|
|
5
|
+
//
|
|
6
|
+
// Flow: a gated peer's PreToolUse hook blocks and POSTs /fleet/v1/approvals → the fleet
|
|
7
|
+
// handler calls broker.request(...) and awaits the returned promise (long-poll) → the
|
|
8
|
+
// broker enqueues, emits `approval-request` (approvals.log → SSE), and holds the promise
|
|
9
|
+
// → a face calls broker.resolve(id, …) (via POST /fleet/v1/approvals/<id>/approve|deny)
|
|
10
|
+
// → the promise resolves, the fleet handler writes the runtime hook JSON back, the tool
|
|
11
|
+
// proceeds or is blocked with the reason. A per-request timeout resolves default-DENY so
|
|
12
|
+
// a peer never hangs. Every failure direction is deny (fail-safe): timeout, requester
|
|
13
|
+
// disconnect, unknown id.
|
|
14
|
+
//
|
|
15
|
+
// In-memory + ephemeral: pending requests do NOT survive a daemon restart (the blocking
|
|
16
|
+
// hook connection breaks on restart → the hook fails safe to deny). The durable
|
|
17
|
+
// approvals.log is the audit/observability trace, not a recovery store.
|
|
18
|
+
|
|
19
|
+
import { appendApprovalEvent } from './approvalslog.ts'
|
|
20
|
+
|
|
21
|
+
/** v1 taxonomy of intercepted blocking requests (docs/17 §2). Free-form-tolerant. */
|
|
22
|
+
export type ApprovalKind = 'tool' | 'plan' | 'question' | 'circuit-breaker'
|
|
23
|
+
|
|
24
|
+
export interface ApprovalRequestInput {
|
|
25
|
+
personality: string
|
|
26
|
+
runtime: string
|
|
27
|
+
/** Taxonomy tag (tool | plan | question | circuit-breaker | …). */
|
|
28
|
+
kind: ApprovalKind | string
|
|
29
|
+
/** The specific tool / breaker name, e.g. `Bash`, `Edit`, `dangerous-rm`. */
|
|
30
|
+
tool: string
|
|
31
|
+
/** FULL human-readable content of the action — the command, the diff, the plan text,
|
|
32
|
+
* the circuit-breaker prompt. Shown to the human in every channel (criterion #7). */
|
|
33
|
+
content: string
|
|
34
|
+
/** One-line summary for the SSE event / badge (default: the first line of content). */
|
|
35
|
+
summary?: string
|
|
36
|
+
/** Short title, e.g. "boris · Bash" (default: "<personality> · <tool>"). */
|
|
37
|
+
title?: string
|
|
38
|
+
/** nature-peers who may answer (informational in v1 — same-uid faces are trusted). */
|
|
39
|
+
approvers?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PendingApproval extends ApprovalRequestInput {
|
|
43
|
+
id: string
|
|
44
|
+
summary: string
|
|
45
|
+
title: string
|
|
46
|
+
approvers: string[]
|
|
47
|
+
createdMs: number
|
|
48
|
+
expiresMs: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ApprovalDecision {
|
|
52
|
+
decision: 'allow' | 'deny'
|
|
53
|
+
reason?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolveMeta {
|
|
57
|
+
/** The approver who answered (a nature-peer personality). */
|
|
58
|
+
by?: string
|
|
59
|
+
/** The channel that answered: cli | tray | telegram | timeout | disconnect. */
|
|
60
|
+
via?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Entry {
|
|
64
|
+
item: PendingApproval
|
|
65
|
+
resolve: (d: ApprovalDecision) => void
|
|
66
|
+
timer: ReturnType<typeof setTimeout>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ApprovalBrokerOptions {
|
|
70
|
+
/** cfg.eventLogDir — approvals.log lives here (falsy → no durable log, hermetic). */
|
|
71
|
+
logDir?: string
|
|
72
|
+
env?: NodeJS.ProcessEnv
|
|
73
|
+
/** Per-request default-deny timeout. Default IAPEER_APPROVAL_TIMEOUT_MS or 300 s.
|
|
74
|
+
* MUST stay below the runtime hook's own timeout (claude/codex default 600 s) so the
|
|
75
|
+
* broker's default-deny wins before the runtime kills the hook (fail-safe ordering). */
|
|
76
|
+
timeoutMs?: number
|
|
77
|
+
/** Injectable clock (tests). Default Date.now. */
|
|
78
|
+
now?: () => number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_TIMEOUT_MS = 300_000
|
|
82
|
+
|
|
83
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
84
|
+
const n = parseInt(raw ?? '', 10)
|
|
85
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function firstLine(s: string): string {
|
|
89
|
+
const nl = s.indexOf('\n')
|
|
90
|
+
return (nl >= 0 ? s.slice(0, nl) : s).trim()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ApprovalBroker {
|
|
94
|
+
private readonly pending = new Map<string, Entry>()
|
|
95
|
+
private counter = 0
|
|
96
|
+
private readonly logDir?: string
|
|
97
|
+
private readonly env: NodeJS.ProcessEnv
|
|
98
|
+
private readonly timeoutMs: number
|
|
99
|
+
private readonly now: () => number
|
|
100
|
+
|
|
101
|
+
constructor(opts: ApprovalBrokerOptions = {}) {
|
|
102
|
+
this.logDir = opts.logDir
|
|
103
|
+
this.env = opts.env ?? process.env
|
|
104
|
+
this.timeoutMs = opts.timeoutMs ?? envPosInt(this.env.IAPEER_APPROVAL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
|
105
|
+
this.now = opts.now ?? Date.now
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Enqueue a request and return its id + a promise that resolves when a face answers
|
|
109
|
+
* or the timeout fires (default-deny). The caller (fleet handler) awaits `decision`
|
|
110
|
+
* and MUST call `cancel(id)` if its transport dies before the promise settles. */
|
|
111
|
+
request(input: ApprovalRequestInput): { id: string; decision: Promise<ApprovalDecision> } {
|
|
112
|
+
const id = `a${++this.counter}`
|
|
113
|
+
const createdMs = this.now()
|
|
114
|
+
const expiresMs = createdMs + this.timeoutMs
|
|
115
|
+
const summary = (input.summary?.trim() || firstLine(input.content) || input.tool).slice(0, 240)
|
|
116
|
+
const title = input.title?.trim() || `${input.personality} · ${input.tool}`
|
|
117
|
+
const approvers = input.approvers ?? []
|
|
118
|
+
const item: PendingApproval = { ...input, id, summary, title, approvers, createdMs, expiresMs }
|
|
119
|
+
let resolve!: (d: ApprovalDecision) => void
|
|
120
|
+
const decision = new Promise<ApprovalDecision>(res => {
|
|
121
|
+
resolve = res
|
|
122
|
+
})
|
|
123
|
+
const timer = setTimeout(
|
|
124
|
+
() => this.settle(id, { decision: 'deny', reason: 'approval timed out (default-deny)' }, { via: 'timeout' }),
|
|
125
|
+
this.timeoutMs,
|
|
126
|
+
)
|
|
127
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
128
|
+
this.pending.set(id, { item, resolve, timer })
|
|
129
|
+
appendApprovalEvent(
|
|
130
|
+
this.logDir,
|
|
131
|
+
{
|
|
132
|
+
ev: 'approval-request',
|
|
133
|
+
id,
|
|
134
|
+
personality: input.personality,
|
|
135
|
+
runtime: input.runtime,
|
|
136
|
+
kind: input.kind,
|
|
137
|
+
tool: input.tool,
|
|
138
|
+
summary,
|
|
139
|
+
created: createdMs,
|
|
140
|
+
expires: expiresMs,
|
|
141
|
+
approvers: approvers.join(',') || undefined,
|
|
142
|
+
},
|
|
143
|
+
{ env: this.env, nowMs: createdMs },
|
|
144
|
+
)
|
|
145
|
+
return { id, decision }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** All pending requests, oldest first (GET /fleet/v1/approvals + `iapeer approvals`). */
|
|
149
|
+
list(): PendingApproval[] {
|
|
150
|
+
return [...this.pending.values()].map(e => e.item).sort((a, b) => a.createdMs - b.createdMs)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One pending request by id (GET /fleet/v1/approvals/<id> — full content). */
|
|
154
|
+
get(id: string): PendingApproval | undefined {
|
|
155
|
+
return this.pending.get(id)?.item
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Answer a request from a face. Returns false iff the id is unknown (already resolved,
|
|
159
|
+
* expired, or never existed) — the caller reports 404. */
|
|
160
|
+
resolve(id: string, decision: ApprovalDecision, meta: ResolveMeta = {}): boolean {
|
|
161
|
+
if (!this.pending.has(id)) return false
|
|
162
|
+
this.settle(id, decision, meta)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Requester transport died before an answer — settle default-deny + drop from the
|
|
167
|
+
* queue so it does not linger (the asking hook is gone; the tool call is aborted). */
|
|
168
|
+
cancel(id: string, reason = 'requester disconnected'): boolean {
|
|
169
|
+
if (!this.pending.has(id)) return false
|
|
170
|
+
this.settle(id, { decision: 'deny', reason }, { via: 'disconnect' })
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Pending count (badge/metrics). */
|
|
175
|
+
size(): number {
|
|
176
|
+
return this.pending.size
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private settle(id: string, decision: ApprovalDecision, meta: ResolveMeta): void {
|
|
180
|
+
const entry = this.pending.get(id)
|
|
181
|
+
if (!entry) return
|
|
182
|
+
clearTimeout(entry.timer)
|
|
183
|
+
this.pending.delete(id)
|
|
184
|
+
const nowMs = this.now()
|
|
185
|
+
appendApprovalEvent(
|
|
186
|
+
this.logDir,
|
|
187
|
+
{
|
|
188
|
+
ev: 'approval-resolved',
|
|
189
|
+
id,
|
|
190
|
+
personality: entry.item.personality,
|
|
191
|
+
runtime: entry.item.runtime,
|
|
192
|
+
decision: decision.decision,
|
|
193
|
+
reason: decision.reason,
|
|
194
|
+
by: meta.by,
|
|
195
|
+
via: meta.via,
|
|
196
|
+
latencyMs: nowMs - entry.item.createdMs,
|
|
197
|
+
},
|
|
198
|
+
{ env: this.env, nowMs },
|
|
199
|
+
)
|
|
200
|
+
entry.resolve(decision)
|
|
201
|
+
}
|
|
202
|
+
}
|