@agfpd/iapeer 0.2.31 → 0.2.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -284,6 +284,10 @@ export interface RemoveOutcome {
284
284
  * was dropped from the host codex config; a failure detail otherwise. Absent
285
285
  * for non-codex peers and when no entry existed. */
286
286
  codexTrust?: string
287
+ /** Codex hooks-trust cleanup outcome: `removed <n>` when pre-seeded
288
+ * `[hooks.state."<cwd>/…"]` entries were dropped (trust-hooks verb is the
289
+ * writer); a failure detail otherwise. Absent when nothing matched. */
290
+ codexHooksTrust?: string
287
291
  /** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
288
292
  * `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
289
293
  * personality inherits the dead namesake's parking (live defect, boris 10.06:
@@ -365,6 +369,7 @@ export async function removePeerCli(
365
369
  // unprovision (the provider sees the peer's last consistent state first),
366
370
  // best-effort like everything else on this path.
367
371
  let trustCleaned: string | undefined
372
+ let hooksTrustCleaned: string | undefined
368
373
  if (peer.runtimes.includes('codex')) {
369
374
  try {
370
375
  const { removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
@@ -373,6 +378,16 @@ export async function removePeerCli(
373
378
  } catch (e) {
374
379
  trustCleaned = `failed (${e instanceof Error ? e.message : String(e)})`
375
380
  }
381
+ // Same class, hooks edition: pre-seeded `[hooks.state."<cwd>/…"]` entries
382
+ // (trust-hooks verb) must not outlive the peer either.
383
+ try {
384
+ const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
385
+ const h = removeCodexHooksTrustUnder(peer.cwd, env)
386
+ hooksTrustCleaned =
387
+ h.state === 'written' ? `removed ${h.removed.length}` : h.state === 'already' ? undefined : `${h.state}${h.detail ? ` (${h.detail})` : ''}`
388
+ } catch (e) {
389
+ hooksTrustCleaned = `failed (${e instanceof Error ? e.message : String(e)})`
390
+ }
376
391
  }
377
392
  // Purge identity-keyed lifecycle state WITH the record (per runtime): stale
378
393
  // .stopped/.idle-reaped/... must never outlive the peer and ambush a future
@@ -387,6 +402,7 @@ export async function removePeerCli(
387
402
  purgedState,
388
403
  ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
389
404
  ...(trustCleaned ? { codexTrust: trustCleaned } : {}),
405
+ ...(hooksTrustCleaned ? { codexHooksTrust: hooksTrustCleaned } : {}),
390
406
  }
391
407
  }
392
408
 
@@ -542,6 +558,7 @@ const USAGE = `usage: iapeer <verb> [args]
542
558
  self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
543
559
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
544
560
  memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
561
+ trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
545
562
  `
546
563
 
547
564
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
@@ -700,6 +717,45 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
700
717
  }
701
718
  return r.ok ? 0 : 1
702
719
  }
720
+ case 'trust-hooks': {
721
+ // Deterministic pre-seed of the codex hooks trust state for a FILE-form
722
+ // hooks.json (user- or project-local layer) — the headless replacement for
723
+ // the "Hooks need review" modal: codex trusts per hook-command HASH, and in
724
+ // `codex exec` an untrusted hook is SILENTLY skipped (no modal, no error).
725
+ // Verified on codex-cli 0.138.0 (golden hashes in the module tests).
726
+ // Consumers: the memory provider's provision command (writes its per-peer
727
+ // <cwd>/.codex/hooks.json, then shells this verb), operators. `--check` is
728
+ // the read-only drift detector for verify pipelines — per-hook
729
+ // trusted/missing/drift, exit 1 on anything but full trust.
730
+ const target = positionals[0]
731
+ if (!target) return usage(errOut)
732
+ const { preSeedCodexHooksTrust, checkCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
733
+ if (flags.check === true) {
734
+ let checks
735
+ try {
736
+ checks = checkCodexHooksTrust(target, env)
737
+ } catch (e) {
738
+ errOut(`trust-hooks --check: ${e instanceof Error ? e.message : String(e)}\n`)
739
+ return 1
740
+ }
741
+ for (const c of checks.checks) {
742
+ out(`${c.status}\t${c.event}\t${c.key}${c.status === 'drift' ? ` (state: ${c.found ?? '?'} ≠ expected: ${c.hash})` : ''}\n`)
743
+ }
744
+ if (checks.checks.length === 0) out('no command hooks found — nothing to check\n')
745
+ return checks.checks.every(c => c.status === 'trusted') ? 0 : 1
746
+ }
747
+ const r = preSeedCodexHooksTrust(target, env)
748
+ if (r.state === 'failed') {
749
+ errOut(`trust-hooks: ${r.detail}\n`)
750
+ return 1
751
+ }
752
+ out(
753
+ r.state === 'already'
754
+ ? `already trusted: ${r.entries.length} hook(s) from ${r.source}${r.detail ? ` — ${r.detail}` : ''}\n`
755
+ : `trusted ${r.entries.length} hook(s) from ${r.source} → ${r.path}\n`,
756
+ )
757
+ return 0
758
+ }
703
759
  case 'status': {
704
760
  // Host snapshot (контракт «Слот памяти» §status): version + daemon health +
705
761
  // the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
@@ -850,6 +906,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
850
906
  if (o.codexTrust) {
851
907
  out(`codex trust entry: ${o.codexTrust}\n`)
852
908
  }
909
+ // Same for pre-seeded hooks trust state (trust-hooks verb's writes).
910
+ if (o.codexHooksTrust) {
911
+ out(`codex hooks trust: ${o.codexHooksTrust}\n`)
912
+ }
853
913
  // Stale identity-keyed markers must die with the record (boris 10.06: a
854
914
  // namesake newborn inherited a dead peer's .stopped → refused to wake).
855
915
  if (o.purgedState?.length) {
@@ -0,0 +1,248 @@
1
+ // codex hooks trust pre-seed — the deterministic replacement for the "Hooks
2
+ // need review" modal. The HARD anchor here: the GOLDEN hashes — three values
3
+ // codex-cli 0.138.0 itself granted live ("Trust all and continue", smoke
4
+ // 11.06, isolated CODEX_HOME). If the re-implemented algorithm drifts from
5
+ // these, we would seed state codex ignores → hooks silently skipped fleet-wide.
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test'
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { join } from 'path'
11
+ import { realpathSync } from 'fs'
12
+ import {
13
+ checkCodexHooksTrust,
14
+ commandHookHash,
15
+ computeHooksTrustEntries,
16
+ preSeedCodexHooksTrust,
17
+ removeCodexHooksTrustUnder,
18
+ } from './codexHooksTrust.ts'
19
+ import { codexGlobalConfigPath } from './nativeMemory.ts'
20
+
21
+ const dirs: string[] = []
22
+ function mkTmp(): string {
23
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-hookstrust-'))
24
+ dirs.push(d)
25
+ return d
26
+ }
27
+ afterEach(() => {
28
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
29
+ })
30
+
31
+ /** Env pointing the codex global config into an isolated CODEX_HOME. */
32
+ function isolatedEnv(): { env: NodeJS.ProcessEnv; configPath: string } {
33
+ const home = mkTmp()
34
+ const env = { CODEX_HOME: home } as NodeJS.ProcessEnv
35
+ return { env, configPath: codexGlobalConfigPath(env) }
36
+ }
37
+
38
+ function writeHooksJson(dir: string, content: unknown): string {
39
+ const path = join(dir, 'hooks.json')
40
+ writeFileSync(path, JSON.stringify(content, null, 2))
41
+ return path
42
+ }
43
+
44
+ // ─── golden hashes (granted LIVE by codex-cli 0.138.0) ──────────────────────
45
+
46
+ describe('commandHookHash — golden values from live-granted trust', () => {
47
+ test('session_start, no matcher, default timeout (V1 smoke hook)', () => {
48
+ expect(
49
+ commandHookHash('session_start', undefined, {
50
+ command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh user-hooks-json-sessionstart',
51
+ }),
52
+ ).toBe('sha256:c21b34240dd15d7184bf391f13e820399cb4b8e97c22347689316f8d02207391')
53
+ })
54
+
55
+ test('session_start, no matcher, default timeout (V2 smoke hook)', () => {
56
+ expect(
57
+ commandHookHash('session_start', undefined, {
58
+ command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh user-hooks-json-sessionstart-V2',
59
+ }),
60
+ ).toBe('sha256:5727103dc6b7409ae2a811561e28de70b625a2bdd28f8715a73e6542836f34fe')
61
+ })
62
+
63
+ test('post_tool_use, no matcher, default timeout (project-local smoke hook)', () => {
64
+ expect(
65
+ commandHookHash('post_tool_use', undefined, {
66
+ command: '/tmp/iapeer-codex-hooks-smoke/bin/stamp.sh proj-hooks-json-posttooluse',
67
+ }),
68
+ ).toBe('sha256:86f96ab754e05851c6f2f9b162bd99c70359099cdb6c93c114d0e6df42971030')
69
+ })
70
+
71
+ test('normalization knobs shift the hash: timeout, matcher, statusMessage', () => {
72
+ const base = commandHookHash('post_tool_use', undefined, { command: 'x' })
73
+ expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 30 })).not.toBe(base)
74
+ expect(commandHookHash('post_tool_use', 'Write|Edit', { command: 'x' })).not.toBe(base)
75
+ expect(commandHookHash('post_tool_use', undefined, { command: 'x', statusMessage: 's' })).not.toBe(base)
76
+ // explicit default timeout hashes the SAME as omitted (both normalize to 600)
77
+ expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 600 })).toBe(base)
78
+ // timeout 0 clamps to 1 (upstream .max(1)), distinct from the default
79
+ expect(commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 0 })).toBe(
80
+ commandHookHash('post_tool_use', undefined, { command: 'x', timeout: 1 }),
81
+ )
82
+ })
83
+ })
84
+
85
+ // ─── hooks.json → entries ────────────────────────────────────────────────────
86
+
87
+ describe('computeHooksTrustEntries', () => {
88
+ test('keys carry source:event_snake:group:handler; matcher passes through', () => {
89
+ const entries = computeHooksTrustEntries(
90
+ JSON.stringify({
91
+ hooks: {
92
+ PostToolUse: [
93
+ { matcher: 'Write', hooks: [{ type: 'command', command: 'a' }] },
94
+ { hooks: [{ type: 'command', command: 'b' }, { type: 'command', command: 'c' }] },
95
+ ],
96
+ },
97
+ }),
98
+ '/some/hooks.json',
99
+ )
100
+ expect(entries.map(e => e.key)).toEqual([
101
+ '/some/hooks.json:post_tool_use:0:0',
102
+ '/some/hooks.json:post_tool_use:1:0',
103
+ '/some/hooks.json:post_tool_use:1:1',
104
+ ])
105
+ expect(entries[0]!.hash).toBe(commandHookHash('post_tool_use', 'Write', { command: 'a' }))
106
+ expect(entries[1]!.hash).toBe(commandHookHash('post_tool_use', undefined, { command: 'b' }))
107
+ })
108
+
109
+ test('skipped handlers (async / prompt / empty) consume their index, get no entry', () => {
110
+ const entries = computeHooksTrustEntries(
111
+ JSON.stringify({
112
+ hooks: {
113
+ SessionStart: [
114
+ {
115
+ hooks: [
116
+ { type: 'command', command: 'first', async: true }, // h=0 skipped
117
+ { type: 'prompt' }, // h=1 skipped
118
+ { type: 'command', command: ' ' }, // h=2 skipped (empty)
119
+ { type: 'command', command: 'real' }, // h=3 KEPT
120
+ ],
121
+ },
122
+ ],
123
+ },
124
+ }),
125
+ '/p/hooks.json',
126
+ )
127
+ expect(entries).toHaveLength(1)
128
+ expect(entries[0]!.key).toBe('/p/hooks.json:session_start:0:3')
129
+ })
130
+
131
+ test('UserPromptSubmit/Stop force the matcher OFF (upstream matcher_pattern_for_event)', () => {
132
+ const entries = computeHooksTrustEntries(
133
+ JSON.stringify({
134
+ hooks: {
135
+ Stop: [{ matcher: 'ignored', hooks: [{ type: 'command', command: 'x' }] }],
136
+ },
137
+ }),
138
+ '/p/hooks.json',
139
+ )
140
+ expect(entries[0]!.hash).toBe(commandHookHash('stop', undefined, { command: 'x' }))
141
+ })
142
+
143
+ test('unknown event / malformed shapes are loud errors', () => {
144
+ expect(() => computeHooksTrustEntries(JSON.stringify({ hooks: { NotAnEvent: [] } }), '/p')).toThrow(
145
+ /unknown hook event/,
146
+ )
147
+ expect(() => computeHooksTrustEntries('not json', '/p')).toThrow(/not valid JSON/)
148
+ expect(() => computeHooksTrustEntries(JSON.stringify({}), '/p')).toThrow(/"hooks" object/)
149
+ })
150
+ })
151
+
152
+ // ─── pre-seed (state surgery) ────────────────────────────────────────────────
153
+
154
+ describe('preSeedCodexHooksTrust', () => {
155
+ test('absent config → sections appended; second run → already', () => {
156
+ const { env, configPath } = isolatedEnv()
157
+ const proj = mkTmp()
158
+ const hooksPath = writeHooksJson(proj, {
159
+ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: '/x/stamp.sh' }] }] },
160
+ })
161
+ const first = preSeedCodexHooksTrust(hooksPath, env)
162
+ expect(first.state).toBe('written')
163
+ expect(first.entries).toHaveLength(1)
164
+ const real = realpathSync(hooksPath)
165
+ const text = readFileSync(configPath, 'utf8')
166
+ expect(text).toContain(`[hooks.state."${real}:session_start:0:0"]`)
167
+ expect(text).toContain(`trusted_hash = "${first.entries[0]!.hash}"`)
168
+ expect(preSeedCodexHooksTrust(hooksPath, env).state).toBe('already')
169
+ })
170
+
171
+ test('NO-CLOBBER: foreign sections survive; changed hook updates IN PLACE', () => {
172
+ const { env, configPath } = isolatedEnv()
173
+ const proj = mkTmp()
174
+ const hooksPath = writeHooksJson(proj, {
175
+ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'one' }] }] },
176
+ })
177
+ mkdirSync(join(env.CODEX_HOME!), { recursive: true })
178
+ const foreign = '[projects."/keep/me"]\ntrust_level = "trusted"\n\n[features]\nmemories = false\n'
179
+ writeFileSync(configPath, foreign)
180
+ preSeedCodexHooksTrust(hooksPath, env)
181
+ // change the command → hash changes → same section updated, not duplicated
182
+ writeHooksJson(proj, { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'two' }] }] } })
183
+ const second = preSeedCodexHooksTrust(hooksPath, env)
184
+ expect(second.state).toBe('written')
185
+ const text = readFileSync(configPath, 'utf8')
186
+ expect(text).toContain('[projects."/keep/me"]')
187
+ expect(text).toContain('memories = false')
188
+ const real = realpathSync(hooksPath)
189
+ const headers = text.split('\n').filter(l => l.includes(`${real}:session_start:0:0`))
190
+ expect(headers).toHaveLength(1) // updated in place — no duplicate section
191
+ expect(text).toContain(`trusted_hash = "${second.entries[0]!.hash}"`)
192
+ })
193
+
194
+ test('missing hooks.json → failed (loud), config untouched', () => {
195
+ const { env, configPath } = isolatedEnv()
196
+ const o = preSeedCodexHooksTrust('/nonexistent/hooks.json', env)
197
+ expect(o.state).toBe('failed')
198
+ expect(existsSync(configPath)).toBe(false)
199
+ })
200
+ })
201
+
202
+ // ─── check (read-only drift detector) ────────────────────────────────────────
203
+
204
+ describe('checkCodexHooksTrust', () => {
205
+ test('missing → trusted after seed → drift after edit without re-seed', () => {
206
+ const { env } = isolatedEnv()
207
+ const proj = mkTmp()
208
+ const hooksPath = writeHooksJson(proj, {
209
+ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'stamp' }] }] },
210
+ })
211
+ expect(checkCodexHooksTrust(hooksPath, env).checks[0]!.status).toBe('missing')
212
+ preSeedCodexHooksTrust(hooksPath, env)
213
+ expect(checkCodexHooksTrust(hooksPath, env).checks[0]!.status).toBe('trusted')
214
+ writeHooksJson(proj, { hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'edited' }] }] } })
215
+ const drifted = checkCodexHooksTrust(hooksPath, env).checks[0]!
216
+ expect(drifted.status).toBe('drift')
217
+ expect(drifted.found).toBeDefined()
218
+ })
219
+ })
220
+
221
+ // ─── remove (reap-side cleanup) ──────────────────────────────────────────────
222
+
223
+ describe('removeCodexHooksTrustUnder', () => {
224
+ test('drops sections under the cwd, keeps everything else', () => {
225
+ const { env, configPath } = isolatedEnv()
226
+ const peerCwd = mkTmp()
227
+ const hooksPath = writeHooksJson(peerCwd, {
228
+ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'x' }] }] },
229
+ })
230
+ preSeedCodexHooksTrust(hooksPath, env)
231
+ // a foreign hooks.state entry from ANOTHER peer must survive
232
+ const foreign = '[hooks.state."/other/peer/hooks.json:session_start:0:0"]\ntrusted_hash = "sha256:keep"\n'
233
+ writeFileSync(configPath, readFileSync(configPath, 'utf8') + foreign)
234
+ const o = removeCodexHooksTrustUnder(peerCwd, env)
235
+ expect(o.state).toBe('written')
236
+ expect(o.removed).toHaveLength(1)
237
+ const text = readFileSync(configPath, 'utf8')
238
+ expect(text).not.toContain(realpathSync(hooksPath))
239
+ expect(text).toContain('/other/peer/hooks.json')
240
+ // idempotent
241
+ expect(removeCodexHooksTrustUnder(peerCwd, env).state).toBe('already')
242
+ })
243
+
244
+ test('no config / no matches → already', () => {
245
+ const { env } = isolatedEnv()
246
+ expect(removeCodexHooksTrustUnder('/nope', env).state).toBe('already')
247
+ })
248
+ })
@@ -0,0 +1,372 @@
1
+ // codexHooksTrust — deterministic pre-seed of the codex hooks trust state.
2
+ //
3
+ // codex gates EVERY hook (plugin-form AND file-form alike) behind a one-time
4
+ // interactive "Hooks need review" modal; trust is recorded per hook COMMAND in
5
+ // the codex GLOBAL config as
6
+ //
7
+ // [hooks.state."<source>:<event_snake>:<group>:<handler>"]
8
+ // trusted_hash = "sha256:<hex>"
9
+ //
10
+ // where <source> is the absolute (resolved) path of the hooks.json the hook
11
+ // came from. In headless `codex exec` the modal never shows — an untrusted
12
+ // hook is SILENTLY skipped. Writing the state entry BEFORE the first session
13
+ // makes the hook trusted deterministically: no modal, no TUI auto-responder.
14
+ //
15
+ // The hash algorithm is reverse-engineered from openai/codex
16
+ // (`hooks/src/engine/discovery.rs::command_hook_hash` +
17
+ // `config/src/fingerprint.rs::version_for_toml`) and verified against two
18
+ // live-granted hashes (smoke 11.06, isolated CODEX_HOME — the golden values
19
+ // live in the test file):
20
+ //
21
+ // trusted_hash = "sha256:" + sha256(compactJson(sortKeysDeep(identity)))
22
+ // identity = { event_name: "<snake>", matcher?: "<verbatim>",
23
+ // hooks: [{ type: "command", command, timeout, async: false,
24
+ // statusMessage? }] }
25
+ //
26
+ // Normalization (mirrors upstream discovery before hashing):
27
+ // - timeout = given.max(1), default 600 (commandWindows is dropped: the
28
+ // None fields vanish in the TOML round-trip upstream hashes through)
29
+ // - matcher = group's matcher verbatim, EXCEPT UserPromptSubmit/Stop where
30
+ // codex forces it to None (matcher_pattern_for_event)
31
+ // - async hooks, non-"command" handlers and empty commands are SKIPPED by
32
+ // codex before hashing — they get no state entry, but they DO consume
33
+ // their positional index.
34
+ //
35
+ // FRAGILITY PIN: verified on codex-cli 0.138.0. Upstream carries a TODO to
36
+ // replace the positional key suffix with a durable hook id — when that lands,
37
+ // `--check` (checkCodexHooksTrust) is the detector: it compares the state
38
+ // against a re-computation, so a format drift reads as missing/drift instead
39
+ // of silently lying. A future backend may instead ask codex itself
40
+ // (app-server `hooks/list` returns `current_hash`) without changing callers.
41
+ //
42
+ // Single ecosystem writer: ALL iapeer writes to the host ~/.codex/config.toml
43
+ // live in this repo (preTrustCodexCwd / removeCodexCwdTrust / here) — codex
44
+ // itself also writes the file, so every mutation is a section-scoped
45
+ // read-modify-write + atomic rename, never a rewrite.
46
+
47
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
48
+ import { createHash } from 'crypto'
49
+ import { dirname } from 'path'
50
+ import { writeFileAtomic } from '../storage/index.ts'
51
+ import { codexGlobalConfigPath } from './nativeMemory.ts'
52
+
53
+ /** The codex-cli version the hash algorithm + key format were verified on. */
54
+ export const TRUST_HOOKS_TESTED_CODEX_VERSION = '0.138.0'
55
+
56
+ /** hooks.json event names → the snake_case labels codex keys state with
57
+ * (upstream `hook_event_key_label`). */
58
+ const EVENT_LABELS: Record<string, string> = {
59
+ PreToolUse: 'pre_tool_use',
60
+ PermissionRequest: 'permission_request',
61
+ PostToolUse: 'post_tool_use',
62
+ PreCompact: 'pre_compact',
63
+ PostCompact: 'post_compact',
64
+ SessionStart: 'session_start',
65
+ UserPromptSubmit: 'user_prompt_submit',
66
+ SubagentStart: 'subagent_start',
67
+ SubagentStop: 'subagent_stop',
68
+ Stop: 'stop',
69
+ }
70
+
71
+ /** Events where codex force-drops the matcher (upstream matcher_pattern_for_event). */
72
+ const MATCHERLESS_EVENTS = new Set(['UserPromptSubmit', 'Stop'])
73
+
74
+ export interface HookTrustEntry {
75
+ /** Full state key: `<source path>:<event_snake>:<group>:<handler>`. */
76
+ key: string
77
+ /** `sha256:<hex>` of the normalized hook identity. */
78
+ hash: string
79
+ event: string
80
+ command: string
81
+ }
82
+
83
+ export type HookTrustCheckStatus = 'trusted' | 'missing' | 'drift'
84
+
85
+ export interface HookTrustCheck extends HookTrustEntry {
86
+ status: HookTrustCheckStatus
87
+ /** The hash currently in the state (when present and different). */
88
+ found?: string
89
+ }
90
+
91
+ export interface HooksTrustOutcome {
92
+ path: string // the codex global config written/read
93
+ source: string // the resolved hooks.json path used as the key source
94
+ state: 'written' | 'already' | 'failed'
95
+ entries: HookTrustEntry[]
96
+ detail?: string
97
+ }
98
+
99
+ // ─── hashing ─────────────────────────────────────────────────────────────────
100
+
101
+ function sortKeysDeep(value: unknown): unknown {
102
+ if (Array.isArray(value)) return value.map(sortKeysDeep)
103
+ if (value && typeof value === 'object') {
104
+ const src = value as Record<string, unknown>
105
+ const sorted: Record<string, unknown> = {}
106
+ for (const k of Object.keys(src).sort()) sorted[k] = sortKeysDeep(src[k])
107
+ return sorted
108
+ }
109
+ return value
110
+ }
111
+
112
+ /** Hash ONE normalized command hook the way codex does (see module header). */
113
+ export function commandHookHash(
114
+ eventLabel: string,
115
+ matcher: string | undefined,
116
+ handler: { command: string; timeout?: number; statusMessage?: string },
117
+ ): string {
118
+ const normalized: Record<string, unknown> = {
119
+ type: 'command',
120
+ command: handler.command,
121
+ timeout: Math.max(1, Math.trunc(handler.timeout ?? 600)),
122
+ async: false,
123
+ ...(handler.statusMessage !== undefined ? { statusMessage: handler.statusMessage } : {}),
124
+ }
125
+ const identity: Record<string, unknown> = {
126
+ event_name: eventLabel,
127
+ ...(matcher !== undefined ? { matcher } : {}),
128
+ hooks: [normalized],
129
+ }
130
+ const json = JSON.stringify(sortKeysDeep(identity))
131
+ return `sha256:${createHash('sha256').update(json, 'utf8').digest('hex')}`
132
+ }
133
+
134
+ // ─── hooks.json → trust entries ──────────────────────────────────────────────
135
+
136
+ interface RawHandler {
137
+ type?: unknown
138
+ command?: unknown
139
+ timeout?: unknown
140
+ statusMessage?: unknown
141
+ async?: unknown
142
+ }
143
+ interface RawGroup {
144
+ matcher?: unknown
145
+ hooks?: unknown
146
+ }
147
+
148
+ /**
149
+ * Parse a hooks.json (Claude-compatible shape codex reads) and compute the
150
+ * trust entries codex would record for it. `keySource` is the path codex will
151
+ * see the file at — pass the RESOLVED path (codex keys by realpath).
152
+ */
153
+ export function computeHooksTrustEntries(hooksJsonText: string, keySource: string): HookTrustEntry[] {
154
+ let raw: unknown
155
+ try {
156
+ raw = JSON.parse(hooksJsonText)
157
+ } catch (e) {
158
+ throw new Error(`hooks.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`)
159
+ }
160
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('hooks.json root must be an object')
161
+ const events = (raw as Record<string, unknown>).hooks
162
+ if (!events || typeof events !== 'object' || Array.isArray(events)) {
163
+ throw new Error('hooks.json must carry a "hooks" object ({"hooks": {"<Event>": [...]}})')
164
+ }
165
+ const entries: HookTrustEntry[] = []
166
+ for (const [eventName, groups] of Object.entries(events as Record<string, unknown>)) {
167
+ const label = EVENT_LABELS[eventName]
168
+ if (!label) throw new Error(`unknown hook event "${eventName}" (known: ${Object.keys(EVENT_LABELS).join(', ')})`)
169
+ if (!Array.isArray(groups)) throw new Error(`event "${eventName}" must hold an array of matcher groups`)
170
+ for (let g = 0; g < groups.length; g++) {
171
+ const group = groups[g] as RawGroup
172
+ if (!group || typeof group !== 'object') continue
173
+ const matcher =
174
+ !MATCHERLESS_EVENTS.has(eventName) && typeof group.matcher === 'string' ? group.matcher : undefined
175
+ const handlers = Array.isArray(group.hooks) ? group.hooks : []
176
+ for (let h = 0; h < handlers.length; h++) {
177
+ const handler = handlers[h] as RawHandler
178
+ // Mirror upstream skips — each skip still CONSUMES its index (h):
179
+ if (!handler || typeof handler !== 'object') continue
180
+ if (handler.type !== 'command') continue // prompt/agent: unsupported upstream
181
+ if (handler.async === true) continue // async: skipped before hashing
182
+ if (typeof handler.command !== 'string' || handler.command.trim() === '') continue
183
+ entries.push({
184
+ key: `${keySource}:${label}:${g}:${h}`,
185
+ hash: commandHookHash(label, matcher, {
186
+ command: handler.command,
187
+ timeout: typeof handler.timeout === 'number' ? handler.timeout : undefined,
188
+ statusMessage: typeof handler.statusMessage === 'string' ? handler.statusMessage : undefined,
189
+ }),
190
+ event: eventName,
191
+ command: handler.command,
192
+ })
193
+ }
194
+ }
195
+ }
196
+ return entries
197
+ }
198
+
199
+ // ─── global-config section surgery ───────────────────────────────────────────
200
+
201
+ /** Locate the body bounds of `[hooks.state."<key>"]`: header line index and
202
+ * [start, end) of the body (up to the next `[` header / EOF). null = absent. */
203
+ function stateSectionBounds(lines: string[], key: string): { header: number; start: number; end: number } | null {
204
+ const wanted = `[hooks.state."${key}"]`
205
+ const header = lines.findIndex(l => l.trim() === wanted)
206
+ if (header < 0) return null
207
+ let end = lines.length
208
+ for (let i = header + 1; i < lines.length; i++) {
209
+ if (/^\s*\[/.test(lines[i]!)) {
210
+ end = i
211
+ break
212
+ }
213
+ }
214
+ return { header, start: header + 1, end }
215
+ }
216
+
217
+ function readTrustedHash(lines: string[], bounds: { start: number; end: number }): string | undefined {
218
+ for (let i = bounds.start; i < bounds.end; i++) {
219
+ const m = lines[i]!.match(/^\s*trusted_hash\s*=\s*"([^"]+)"\s*$/)
220
+ if (m) return m[1]
221
+ }
222
+ return undefined
223
+ }
224
+
225
+ function resolveHooksSource(hooksJsonPath: string): string {
226
+ // codex keys trust state on the RESOLVED path (same lesson as cwd trust:
227
+ // /tmp → /private/tmp made a literal entry miss). The file must exist —
228
+ // we parse it anyway.
229
+ return realpathSync(hooksJsonPath)
230
+ }
231
+
232
+ /** TOML basic strings treat `\` and `"` specially — paths carrying them would
233
+ * need escaping codex-side too; refuse instead of guessing. */
234
+ function assertTomlSafeKey(key: string): void {
235
+ if (key.includes('"') || key.includes('\\')) {
236
+ throw new Error(`hooks.json path is not TOML-key-safe (contains " or \\): ${key}`)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Pre-seed the codex hooks trust state for every command hook in the given
242
+ * hooks.json — the deterministic, headless replacement for the "Hooks need
243
+ * review → Trust all" modal. Idempotent: unchanged entries are left alone;
244
+ * changed hashes are updated in place; missing sections are appended.
245
+ */
246
+ export function preSeedCodexHooksTrust(
247
+ hooksJsonPath: string,
248
+ env: NodeJS.ProcessEnv = process.env,
249
+ ): HooksTrustOutcome {
250
+ const path = codexGlobalConfigPath(env)
251
+ try {
252
+ const source = resolveHooksSource(hooksJsonPath)
253
+ assertTomlSafeKey(source)
254
+ const entries = computeHooksTrustEntries(readFileSync(source, 'utf8'), source)
255
+ if (entries.length === 0) {
256
+ return { path, source, state: 'already', entries, detail: 'no command hooks to trust' }
257
+ }
258
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
259
+ const lines = text.length ? text.split('\n') : []
260
+ let changed = false
261
+ for (const entry of entries) {
262
+ const bounds = stateSectionBounds(lines, entry.key)
263
+ if (bounds) {
264
+ if (readTrustedHash(lines, bounds) === entry.hash) continue
265
+ // replace (or insert) the trusted_hash line inside the existing section
266
+ let replaced = false
267
+ for (let i = bounds.start; i < bounds.end; i++) {
268
+ if (/^\s*trusted_hash\s*=/.test(lines[i]!)) {
269
+ lines[i] = `trusted_hash = "${entry.hash}"`
270
+ replaced = true
271
+ break
272
+ }
273
+ }
274
+ if (!replaced) lines.splice(bounds.start, 0, `trusted_hash = "${entry.hash}"`)
275
+ changed = true
276
+ } else {
277
+ if (lines.length && lines[lines.length - 1]!.trim() !== '') lines.push('')
278
+ lines.push(`[hooks.state."${entry.key}"]`, `trusted_hash = "${entry.hash}"`)
279
+ changed = true
280
+ }
281
+ }
282
+ if (!changed) return { path, source, state: 'already', entries }
283
+ mkdirSync(dirname(path), { recursive: true })
284
+ const outText = lines.join('\n')
285
+ writeFileAtomic(path, outText.endsWith('\n') ? outText : `${outText}\n`)
286
+ return { path, source, state: 'written', entries }
287
+ } catch (e) {
288
+ return {
289
+ path,
290
+ source: hooksJsonPath,
291
+ state: 'failed',
292
+ entries: [],
293
+ detail: e instanceof Error ? e.message : String(e),
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Read-only drift check: for every command hook in hooks.json, compare the
300
+ * expected hash against the recorded state. `trusted` = match, `missing` = no
301
+ * state entry, `drift` = entry exists with a DIFFERENT hash (hooks.json edited
302
+ * after seeding, or the upstream algorithm/key format moved). Consumers (the
303
+ * memory provider's verify line) shell this instead of re-implementing the
304
+ * hash — the algorithm lives in ONE place.
305
+ */
306
+ export function checkCodexHooksTrust(
307
+ hooksJsonPath: string,
308
+ env: NodeJS.ProcessEnv = process.env,
309
+ ): { path: string; source: string; checks: HookTrustCheck[] } {
310
+ const path = codexGlobalConfigPath(env)
311
+ const source = resolveHooksSource(hooksJsonPath)
312
+ const entries = computeHooksTrustEntries(readFileSync(source, 'utf8'), source)
313
+ const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : []
314
+ const checks: HookTrustCheck[] = entries.map(entry => {
315
+ const bounds = stateSectionBounds(lines, entry.key)
316
+ if (!bounds) return { ...entry, status: 'missing' }
317
+ const found = readTrustedHash(lines, bounds)
318
+ if (found === entry.hash) return { ...entry, status: 'trusted' }
319
+ return { ...entry, status: 'drift', found }
320
+ })
321
+ return { path, source, checks }
322
+ }
323
+
324
+ /**
325
+ * Reap-side counterpart for `iapeer remove`: drop every `[hooks.state."…"]`
326
+ * section whose key SOURCE path lies under the given cwd (resolved or literal
327
+ * form — a deleted cwd can no longer be resolved). Same class as
328
+ * removeCodexCwdTrust: a removed peer must not leave trust state behind.
329
+ */
330
+ export function removeCodexHooksTrustUnder(
331
+ cwd: string,
332
+ env: NodeJS.ProcessEnv = process.env,
333
+ ): { path: string; state: 'written' | 'already' | 'failed'; removed: string[]; detail?: string } {
334
+ const path = codexGlobalConfigPath(env)
335
+ try {
336
+ if (!existsSync(path)) return { path, state: 'already', removed: [] }
337
+ let real = cwd
338
+ try {
339
+ real = realpathSync(cwd)
340
+ } catch {
341
+ /* cwd already deleted → only the literal form can match */
342
+ }
343
+ const prefixes = [...new Set([real, cwd])].map(p => (p.endsWith('/') ? p : `${p}/`))
344
+ const lines = readFileSync(path, 'utf8').split('\n')
345
+ const kept: string[] = []
346
+ const removed: string[] = []
347
+ let inDoomed = false
348
+ for (const line of lines) {
349
+ const header = line.match(/^\s*\[hooks\.state\."(.+)"\]\s*$/)
350
+ if (header) {
351
+ const key = header[1]!
352
+ // key = `<source path>:<event>:<group>:<handler>` — strip the 3-part
353
+ // positional suffix to recover the source path.
354
+ const m = key.match(/^(.*):[a-z_]+:\d+:\d+$/)
355
+ const sourcePath = m ? m[1]! : key
356
+ inDoomed = prefixes.some(p => sourcePath.startsWith(p))
357
+ if (inDoomed) {
358
+ removed.push(key)
359
+ continue
360
+ }
361
+ } else if (inDoomed && /^\s*\[/.test(line)) {
362
+ inDoomed = false // next section starts — stop dropping
363
+ }
364
+ if (!inDoomed) kept.push(line)
365
+ }
366
+ if (removed.length === 0) return { path, state: 'already', removed }
367
+ writeFileAtomic(path, kept.join('\n').replace(/\n{3,}/g, '\n\n'))
368
+ return { path, state: 'written', removed }
369
+ } catch (e) {
370
+ return { path, state: 'failed', removed: [], detail: e instanceof Error ? e.message : String(e) }
371
+ }
372
+ }