@dzhechkov/harness-core 0.5.0 → 0.5.2

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.
Files changed (96) hide show
  1. package/.dz-manifest.json +339 -75
  2. package/README.md +47 -4
  3. package/dist/codex-hooks-assets.d.ts.map +1 -1
  4. package/dist/codex-hooks-assets.js +39 -2
  5. package/dist/codex-hooks-assets.js.map +1 -1
  6. package/dist/codex-hooks-verify.d.ts +23 -2
  7. package/dist/codex-hooks-verify.d.ts.map +1 -1
  8. package/dist/codex-hooks-verify.js +29 -0
  9. package/dist/codex-hooks-verify.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +90 -7
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +171 -21
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +22 -0
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +45 -0
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +11 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/loop-blobs.generated.d.ts +1 -1
  23. package/dist/loop-blobs.generated.d.ts.map +1 -1
  24. package/dist/loop-blobs.generated.js +12 -3
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/loop-plan.d.ts +70 -0
  27. package/dist/loop-plan.d.ts.map +1 -1
  28. package/dist/loop-plan.js +103 -0
  29. package/dist/loop-plan.js.map +1 -1
  30. package/dist/loop-render.d.ts.map +1 -1
  31. package/dist/loop-render.js +38 -130
  32. package/dist/loop-render.js.map +1 -1
  33. package/dist/loop-run-semantics.d.ts +130 -0
  34. package/dist/loop-run-semantics.d.ts.map +1 -0
  35. package/dist/loop-run-semantics.js +257 -0
  36. package/dist/loop-run-semantics.js.map +1 -0
  37. package/dist/loop-trace.d.ts +106 -5
  38. package/dist/loop-trace.d.ts.map +1 -1
  39. package/dist/loop-trace.js +151 -18
  40. package/dist/loop-trace.js.map +1 -1
  41. package/dist/managed-hooks.d.ts +10 -0
  42. package/dist/managed-hooks.d.ts.map +1 -1
  43. package/dist/managed-hooks.js +17 -5
  44. package/dist/managed-hooks.js.map +1 -1
  45. package/dist/named-lock.d.ts +57 -0
  46. package/dist/named-lock.d.ts.map +1 -0
  47. package/dist/named-lock.js +247 -0
  48. package/dist/named-lock.js.map +1 -0
  49. package/dist/operations.d.ts +81 -5
  50. package/dist/operations.d.ts.map +1 -1
  51. package/dist/operations.js +356 -38
  52. package/dist/operations.js.map +1 -1
  53. package/dist/parity.d.ts +70 -2
  54. package/dist/parity.d.ts.map +1 -1
  55. package/dist/parity.js +133 -2
  56. package/dist/parity.js.map +1 -1
  57. package/dist/qe-bridge.d.ts +291 -0
  58. package/dist/qe-bridge.d.ts.map +1 -0
  59. package/dist/qe-bridge.js +538 -0
  60. package/dist/qe-bridge.js.map +1 -0
  61. package/dist/score.d.ts.map +1 -1
  62. package/dist/score.js +43 -9
  63. package/dist/score.js.map +1 -1
  64. package/dist/trace-corroborate.d.ts +48 -0
  65. package/dist/trace-corroborate.d.ts.map +1 -0
  66. package/dist/trace-corroborate.js +172 -0
  67. package/dist/trace-corroborate.js.map +1 -0
  68. package/dist/workflow-run-dispatch.d.ts +230 -0
  69. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  70. package/dist/workflow-run-dispatch.js +363 -0
  71. package/dist/workflow-run-dispatch.js.map +1 -0
  72. package/dist/workflow-run.d.ts +513 -0
  73. package/dist/workflow-run.d.ts.map +1 -0
  74. package/dist/workflow-run.js +1377 -0
  75. package/dist/workflow-run.js.map +1 -0
  76. package/package.json +2 -2
  77. package/sbom.json +740 -80
  78. package/src/codex-hooks-assets.ts +39 -2
  79. package/src/codex-hooks-verify.ts +55 -2
  80. package/src/codex-hooks.ts +172 -20
  81. package/src/feature-adr-routing.ts +55 -0
  82. package/src/index.ts +46 -1
  83. package/src/loop-blobs.generated.ts +12 -3
  84. package/src/loop-plan.ts +185 -0
  85. package/src/loop-render.ts +38 -128
  86. package/src/loop-run-semantics.ts +278 -0
  87. package/src/loop-trace.ts +207 -16
  88. package/src/managed-hooks.ts +26 -5
  89. package/src/named-lock.ts +277 -0
  90. package/src/operations.ts +441 -40
  91. package/src/parity.ts +177 -2
  92. package/src/qe-bridge.ts +737 -0
  93. package/src/score.ts +50 -9
  94. package/src/trace-corroborate.ts +205 -0
  95. package/src/workflow-run-dispatch.ts +459 -0
  96. package/src/workflow-run.ts +1773 -0
package/src/operations.ts CHANGED
@@ -7,9 +7,11 @@
7
7
  */
8
8
 
9
9
  import { execFileSync, spawnSync } from 'node:child_process';
10
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
- import { homedir } from 'node:os';
12
- import { dirname, join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
12
+ import { homedir, tmpdir } from 'node:os';
13
+ import { dirname, join, resolve as resolvePath } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
13
15
 
14
16
  import {
15
17
  buildManagedEntries,
@@ -26,6 +28,14 @@ import {
26
28
  type ManagedEntry,
27
29
  } from './codex-hooks.js';
28
30
  import { generateCodexHelpers } from './codex-hooks-assets.js';
31
+ import {
32
+ classifyVetoProbe,
33
+ isReadyVerdict,
34
+ verifyExitCode,
35
+ type CodexHookTrustStatus,
36
+ type VetoProbeEvidence,
37
+ type VetoProbeResult,
38
+ } from './codex-hooks-verify.js';
29
39
 
30
40
  import { AGENTS_MD_PATH } from '@dzhechkov/adapter-agents-md';
31
41
  import { claudeAdapter } from '@dzhechkov/adapter-claude';
@@ -1121,6 +1131,26 @@ export interface CodexHooksSyncOptions {
1121
1131
  /** Test seam. Production probes the real binary. */
1122
1132
  readonly codexVersion?: string | null;
1123
1133
  readonly now?: string;
1134
+ /**
1135
+ * Run the LIVE veto probe (ADR-002). Default `true` whenever live calls are enabled — a report
1136
+ * that says `ready` without one is the CRITICAL finding this option exists to close.
1137
+ * `false` (the CLI's `--no-verify`) never yields a success word and never exits 0.
1138
+ */
1139
+ readonly verify?: boolean;
1140
+ /**
1141
+ * MUTUAL EXCLUSION SEAM (feature qe-bridge-claude, round-2 CRITICAL C2). The caller wraps ONLY the
1142
+ * registry read-plan-write transaction — the part where two dz processes can lose each other's
1143
+ * entries. It must NOT span the live probes: those spawn a real `codex app-server` turn and can
1144
+ * block for minutes, far past any advisory lock's stale threshold, at which point a waiter is
1145
+ * entitled to break the lock and the holder becomes a liar. Default: run unwrapped.
1146
+ */
1147
+ readonly criticalSection?: <T>(fn: () => T) => T;
1148
+ /** The project whose consent the probe runs under (`--project`). See {@link runCodexVetoProbe}. */
1149
+ readonly project?: string;
1150
+ /** Pinned probe model id; defaults to `DZ_CODEX_PROBE_MODEL`, then codex's own configured model. */
1151
+ readonly probeModel?: string;
1152
+ /** Test seam: replace the live probe. Production runs {@link runCodexVetoProbe}. */
1153
+ readonly probe?: (options: CodexVetoProbeOptions) => CodexVetoProbeRun;
1124
1154
  }
1125
1155
 
1126
1156
  export interface CodexHooksSyncReport {
@@ -1137,6 +1167,15 @@ export interface CodexHooksSyncReport {
1137
1167
  readonly drift: readonly string[];
1138
1168
  readonly trust: 'trusted' | 'trust-pending' | 'unknown';
1139
1169
  readonly codexVersion: string | null;
1170
+ /** The live veto probe's verdict. `null` ⇒ no probe ran (`--no-verify`, or the test seam). */
1171
+ readonly verify: VetoProbeResult | null;
1172
+ /** A live, non-bypassed probe ran AND witnessed our block. The only route to a success word. */
1173
+ readonly verified: boolean;
1174
+ /**
1175
+ * armed ∧ trusted, PROVEN — installed + executable + trusted + a witnessed live block. This is
1176
+ * the single predicate the CLI's `ready` line is allowed to read (AM-17 / G-G).
1177
+ */
1178
+ readonly ready: boolean;
1140
1179
  /** 0 = armed+trusted · 1 = not armed / drift / refusal · 3 = inconclusive. */
1141
1180
  readonly exitCode: 0 | 1 | 3;
1142
1181
  readonly warnings: readonly string[];
@@ -1145,7 +1184,10 @@ export interface CodexHooksSyncReport {
1145
1184
  readonly writes: readonly string[];
1146
1185
  }
1147
1186
 
1148
- function resolveCodexHome(explicit: string | undefined): string {
1187
+ /** The one place that decides WHERE `hooks.json` lives. Exported (feature qe-bridge-claude) so the
1188
+ * CLI can take the `codex-hooks` advisory lock BESIDE that registry — a lock in this repo's `.dz/`
1189
+ * would not serialize a writer operating from another checkout. */
1190
+ export function resolveCodexHome(explicit: string | undefined): string {
1149
1191
  if (typeof explicit === 'string' && explicit !== '') return explicit;
1150
1192
  const env = process.env['CODEX_HOME'];
1151
1193
  if (typeof env === 'string' && env !== '') return env;
@@ -1203,6 +1245,76 @@ function pruneBackups(dir: string, prefix: string, keep: number): void {
1203
1245
  * Order is load-bearing: every REFUSAL happens before any `mkdir` or write, so a machine without
1204
1246
  * codex, or with an unparseable registry, is left byte-untouched (AM-35a).
1205
1247
  */
1248
+ /**
1249
+ * Should this run PROVE the guard fires, and with what?
1250
+ *
1251
+ * `liveness: false` is the offline test seam and disables every live call, the probe included.
1252
+ * `verify: false` is the USER saying "do not probe" — a deliberate refusal to measure, which is
1253
+ * reported as inconclusive rather than as success (finding 1).
1254
+ */
1255
+ function verifyPlan(options: CodexHooksSyncOptions): { readonly run: boolean; readonly declined: boolean } {
1256
+ if (options.liveness === false) return { run: false, declined: false };
1257
+ if (options.verify === false) return { run: false, declined: true };
1258
+ return { run: true, declined: false };
1259
+ }
1260
+
1261
+ interface VerifyStageContext {
1262
+ readonly paths: CodexHooksPaths;
1263
+ /** What the runtime reports NOW. */
1264
+ readonly probedCodexVersion: string;
1265
+ /** What the manifest was written against. A mismatch is inconclusive (AM-10). */
1266
+ readonly recordedCodexVersion?: string | undefined;
1267
+ readonly trustStatus?: CodexHookTrustStatus | undefined;
1268
+ readonly warnings: string[];
1269
+ }
1270
+
1271
+ /**
1272
+ * Run the live probe (or record, loudly, why it did not run).
1273
+ *
1274
+ * A refusal to measure and a measurement are different facts and are reported differently: a
1275
+ * declined verify never reaches exit 0, and a probe that could not be driven at all is
1276
+ * `inconclusive`, never `ready` (finding 1).
1277
+ */
1278
+ function verifyStage(
1279
+ options: CodexHooksSyncOptions,
1280
+ ctx: VerifyStageContext,
1281
+ ): { readonly result: VetoProbeResult | null; readonly declined: boolean } {
1282
+ const plan = verifyPlan(options);
1283
+ if (!plan.run) {
1284
+ if (plan.declined) {
1285
+ ctx.warnings.push(
1286
+ 'live verification SKIPPED (--no-verify): the entries are installed and trusted, but nothing here witnessed the guard block a command. This is NOT a ready state.',
1287
+ );
1288
+ }
1289
+ return { result: null, declined: plan.declined };
1290
+ }
1291
+ const probe = options.probe ?? runCodexVetoProbe;
1292
+ let run: CodexVetoProbeRun;
1293
+ try {
1294
+ run = probe({
1295
+ paths: ctx.paths,
1296
+ ...(options.project !== undefined ? { project: options.project } : {}),
1297
+ ...(options.probeModel ?? process.env['DZ_CODEX_PROBE_MODEL'] ? { model: options.probeModel ?? process.env['DZ_CODEX_PROBE_MODEL'] } : {}),
1298
+ ...(ctx.trustStatus !== undefined ? { trustStatus: ctx.trustStatus } : {}),
1299
+ ...(ctx.recordedCodexVersion !== undefined ? { recordedCodexVersion: ctx.recordedCodexVersion } : {}),
1300
+ probedCodexVersion: ctx.probedCodexVersion,
1301
+ });
1302
+ } catch (err) {
1303
+ // A probe that THREW measured nothing. Inconclusive is the honest verdict; a thrown probe that
1304
+ // fell through to the install state would be the finding all over again.
1305
+ ctx.warnings.push(`the live veto probe could not be driven: ${String((err as Error)?.message ?? err)}`);
1306
+ return {
1307
+ result: { verdict: 'inconclusive', trust: 'unknown', reason: `the live veto probe could not be driven: ${String((err as Error)?.message ?? err)}` },
1308
+ declined: false,
1309
+ };
1310
+ }
1311
+ for (const note of run.notes) ctx.warnings.push(`veto probe: ${note}`);
1312
+ if (!isReadyVerdict(run.result)) {
1313
+ ctx.warnings.push(`live veto probe: ${run.result.verdict} — ${run.result.reason}`);
1314
+ }
1315
+ return { result: run.result, declined: false };
1316
+ }
1317
+
1206
1318
  export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHooksSyncReport {
1207
1319
  const codexHome = resolveCodexHome(options.codexHome);
1208
1320
  const paths = codexHooksPaths(codexHome);
@@ -1225,6 +1337,9 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1225
1337
  trust: 'unknown' as const,
1226
1338
  codexVersion: null as string | null,
1227
1339
  writes: [] as string[],
1340
+ verify: null as VetoProbeResult | null,
1341
+ verified: false,
1342
+ ready: false,
1228
1343
  };
1229
1344
 
1230
1345
  // (1) REFUSE when codex is not installed — dz does not create user-global config for a runtime
@@ -1242,19 +1357,34 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1242
1357
  return { ...base, codexVersion, exitCode: 1, warnings, errors: [String((err as Error).message)] };
1243
1358
  }
1244
1359
 
1360
+ const criticalSection = options.criticalSection ?? (<T>(fn: () => T): T => fn());
1361
+
1245
1362
  const currentText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1246
1363
  const manifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
1247
1364
 
1248
1365
  // (3) --remove: delete only what the manifest proves is ours.
1249
1366
  if (options.remove === true) {
1250
- const removal = removeCodexHooks(currentText, manifest);
1367
+ // TRANSACTION: re-read under the lock, plan from THOSE bytes, write. A plan computed outside the
1368
+ // guarded window is a plan against bytes another writer may already have replaced.
1369
+ const removal = criticalSection(() => {
1370
+ const freshText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1371
+ const freshManifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
1372
+ const planned = removeCodexHooks(freshText, freshManifest);
1373
+ if (planned.ok && planned.result.changed) {
1374
+ backupRegistry(paths, freshText, now, writes);
1375
+ atomicWrite(paths.registry, planned.result.text);
1376
+ writes.push(paths.registry);
1377
+ }
1378
+ // R3-5: the helpers and the manifest are part of the SAME shared state as the registry. Round
1379
+ // 2 deleted them after the lock was released, so a concurrent installer could observe (and
1380
+ // rebuild against) a registry that had already been emptied — or leave a manifest describing
1381
+ // entries that no longer exist. One decision, one critical section.
1382
+ if (planned.ok) {
1383
+ for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest]) rmSync(p, { force: true });
1384
+ }
1385
+ return planned;
1386
+ });
1251
1387
  if (!removal.ok) return { ...base, codexVersion, exitCode: 1, warnings, errors: [removal.error] };
1252
- if (removal.result.changed) {
1253
- backupRegistry(paths, currentText, now, writes);
1254
- atomicWrite(paths.registry, removal.result.text);
1255
- writes.push(paths.registry);
1256
- }
1257
- for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest]) rmSync(p, { force: true });
1258
1388
  return {
1259
1389
  ...base,
1260
1390
  codexVersion,
@@ -1281,13 +1411,40 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1281
1411
  // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
1282
1412
  // failure ("no success word without armed AND trusted"), reached through the read-only path.
1283
1413
  const listed = drift.installed && options.liveness !== false ? listCodexHooks(codexHome) : null;
1284
- const own = listed === null ? [] : selectOwnHookMetadata(listed, entries);
1414
+ const own = listed === null ? [] : selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
1285
1415
  const checkTrust: 'trusted' | 'trust-pending' | 'unknown' =
1286
1416
  listed === null
1287
1417
  ? 'unknown'
1288
1418
  : own.length === entries.length && own.every((o) => o.meta.trustStatus === 'trusted' || o.meta.trustStatus === 'managed')
1289
1419
  ? 'trusted'
1290
1420
  : 'trust-pending';
1421
+ if (drift.installed && !executable) {
1422
+ warnings.push(`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`);
1423
+ }
1424
+ // `--check` is the READ-ONLY verify: it recomputes state from the file AND, by default, proves
1425
+ // the guard actually fires. Only when the entries are present — a home that never opted in must
1426
+ // stay silent and must not spend a live model call (the leg-1 F12 lesson).
1427
+ const checkVerify = drift.installed && executable && checkTrust === 'trusted'
1428
+ ? verifyStage(options, {
1429
+ paths,
1430
+ probedCodexVersion: codexVersion,
1431
+ ...(manifest?.codexVersion !== undefined ? { recordedCodexVersion: manifest.codexVersion } : {}),
1432
+ ...(own[0] !== undefined ? { trustStatus: own[0].meta.trustStatus } : {}),
1433
+ warnings,
1434
+ })
1435
+ : { result: null as VetoProbeResult | null, declined: verifyPlan(options).declined };
1436
+ const checkArmedState = drift.installed && executable && checkTrust === 'trusted';
1437
+ const checkExit: 0 | 1 | 3 = drift.installed
1438
+ ? checkArmedState
1439
+ ? checkVerify.result !== null
1440
+ ? verifyExitCode(checkVerify.result)
1441
+ : checkVerify.declined
1442
+ ? 3
1443
+ : 0
1444
+ : 1
1445
+ : manifest === undefined
1446
+ ? 0
1447
+ : 1;
1291
1448
  return {
1292
1449
  ...base,
1293
1450
  codexVersion,
@@ -1297,21 +1454,41 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1297
1454
  foreignPreserved: drift.foreignPreserved,
1298
1455
  unattributable: drift.unattributable,
1299
1456
  drift: drift.drifted,
1457
+ verify: checkVerify.result,
1458
+ verified: checkVerify.result !== null && isReadyVerdict(checkVerify.result),
1459
+ ready: checkArmedState && checkVerify.result !== null && isReadyVerdict(checkVerify.result),
1300
1460
  // A home that never opted in is NOT a failure: `--check` is silent and exits 0 there (the
1301
1461
  // leg-1 F12 lesson — a check that chatters in every unrelated project trains its reader to
1302
- // ignore it). Installed-but-broken is exit 1; installed-and-live is exit 0.
1303
- exitCode: drift.installed ? (executable && checkTrust === 'trusted' ? 0 : 1) : manifest === undefined ? 0 : 1,
1304
- warnings:
1305
- drift.installed && !executable
1306
- ? [`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`]
1307
- : warnings,
1462
+ // ignore it). Installed-but-broken is exit 1; installed-and-live-and-PROVEN is exit 0.
1463
+ exitCode: checkExit,
1464
+ warnings,
1308
1465
  errors,
1309
1466
  writes,
1310
1467
  };
1311
1468
  }
1312
1469
 
1313
1470
  // (5) Install. Parse-refuse BEFORE any mkdir (I1: an unparseable file must not be overwritten).
1314
- const planned = planCodexHooks({ currentText, entries, manifest });
1471
+ //
1472
+ // TRANSACTION (round-2 C2): the read, the plan and the write happen inside the caller's critical
1473
+ // section — and the LIVE PROBES below deliberately do NOT. Holding a lock across a 300s model call
1474
+ // is how a holder outlives its own stale threshold; the probes mutate nothing shared, so they need
1475
+ // no exclusion.
1476
+ const planned = criticalSection(() => {
1477
+ const freshText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1478
+ const freshManifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
1479
+ const plan = planCodexHooks({ currentText: freshText, entries, manifest: freshManifest });
1480
+ if (!plan.ok) return { plan, wrote: false as const, freshText };
1481
+ mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
1482
+ const helpers = generateCodexHelpers();
1483
+ writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
1484
+ writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
1485
+ if (plan.plan.changed) {
1486
+ backupRegistry(paths, freshText, now, writes);
1487
+ atomicWrite(paths.registry, plan.plan.text);
1488
+ writes.push(paths.registry);
1489
+ }
1490
+ return { plan, wrote: plan.plan.changed, freshText };
1491
+ }).plan;
1315
1492
  if (!planned.ok) {
1316
1493
  return {
1317
1494
  ...base,
@@ -1325,16 +1502,6 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1325
1502
  };
1326
1503
  }
1327
1504
 
1328
- mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
1329
- const helpers = generateCodexHelpers();
1330
- writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
1331
- writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
1332
-
1333
- if (planned.plan.changed) {
1334
- backupRegistry(paths, currentText, now, writes);
1335
- atomicWrite(paths.registry, planned.plan.text);
1336
- writes.push(paths.registry);
1337
- }
1338
1505
 
1339
1506
  // (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
1340
1507
  const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
@@ -1361,12 +1528,58 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1361
1528
  null,
1362
1529
  2,
1363
1530
  )}\n`;
1364
- atomicWrite(paths.manifest, manifestText);
1365
- writes.push(paths.manifest);
1531
+ // R3-5: the manifest is shared state — it is what `--remove` reads to decide which entries are
1532
+ // OURS — so its write is guarded too. It gets its own SHORT section rather than joining the
1533
+ // registry transaction, because its content depends on trust keys that only exist after the live
1534
+ // `hooks/list` query; folding it into the first section would drag that probe back under the lock,
1535
+ // which is exactly the CRITICAL the previous round closed. Two short sections, no long hold.
1536
+ //
1537
+ // R4-3: section two REVALIDATES before it writes. Round 3 wrote a manifest computed in section one,
1538
+ // which meant a remover that won the window in between had its removal partially undone: the
1539
+ // registry said "no dz entries", the manifest said "here are dz entries", and `--remove` reads the
1540
+ // manifest to decide what is ours. Recompute from what is on disk NOW; if our entries are gone,
1541
+ // write NOTHING and report the state honestly.
1542
+ const manifestOutcome = criticalSection(() => {
1543
+ const nowText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1544
+ const nowDrift = diffCodexHooks(nowText, entries, undefined);
1545
+ if (!nowDrift.installed) {
1546
+ return { wrote: false, drift: nowDrift };
1547
+ }
1548
+ atomicWrite(paths.manifest, manifestText);
1549
+ writes.push(paths.manifest);
1550
+ return { wrote: true, drift: nowDrift };
1551
+ });
1552
+ if (!manifestOutcome.wrote) {
1553
+ warnings.push(
1554
+ 'the registry no longer carries this install\u2019s entries — a concurrent remover won the window between the ' +
1555
+ 'registry transaction and the manifest write, so NO manifest was written (a manifest describing entries that are ' +
1556
+ 'not there is what `--remove` would later act on). Re-run the install if you want the hooks back.',
1557
+ );
1558
+ }
1366
1559
 
1367
- const drift = diffCodexHooks(readFileSync(paths.registry, 'utf8'), entries, undefined);
1560
+ const drift = manifestOutcome.drift;
1368
1561
  const installed = drift.installed;
1369
- const exitCode: 0 | 1 | 3 = installed && executable && trustResult.trust === 'trusted' ? 0 : 1;
1562
+ const armedState = installed && executable && trustResult.trust === 'trusted';
1563
+ // The install is not finished when the file is written — it is finished when the guard has been
1564
+ // WITNESSED blocking (ADR-002, finding 1). Probing an install that is already known-broken would
1565
+ // spend a live model call to re-learn what the liveness probe just said, so it is gated on
1566
+ // `armedState`.
1567
+ const verification = armedState
1568
+ ? verifyStage(options, {
1569
+ paths,
1570
+ probedCodexVersion: codexVersion,
1571
+ recordedCodexVersion: codexVersion,
1572
+ ...(trustResult.rowStatus !== undefined ? { trustStatus: trustResult.rowStatus } : {}),
1573
+ warnings,
1574
+ })
1575
+ : { result: null as VetoProbeResult | null, declined: verifyPlan(options).declined };
1576
+ const exitCode: 0 | 1 | 3 = armedState
1577
+ ? verification.result !== null
1578
+ ? verifyExitCode(verification.result)
1579
+ : verification.declined
1580
+ ? 3
1581
+ : 0
1582
+ : 1;
1370
1583
  return {
1371
1584
  codexHome,
1372
1585
  registryPath: paths.registry,
@@ -1379,6 +1592,9 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1379
1592
  drift: drift.drifted,
1380
1593
  trust: trustResult.trust,
1381
1594
  codexVersion,
1595
+ verify: verification.result,
1596
+ verified: verification.result !== null && isReadyVerdict(verification.result),
1597
+ ready: armedState && verification.result !== null && isReadyVerdict(verification.result),
1382
1598
  exitCode,
1383
1599
  warnings,
1384
1600
  errors,
@@ -1386,6 +1602,179 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
1386
1602
  };
1387
1603
  }
1388
1604
 
1605
+
1606
+ /* -------------------------------------------------------------------------- */
1607
+ /* The LIVE veto probe (ADR-002 §Decision, independent-review finding 1) */
1608
+ /* -------------------------------------------------------------------------- */
1609
+
1610
+ /**
1611
+ * The forbidden action the probe asks Codex to run. It is the ONE rule this leg ships
1612
+ * (`ssh-explicit-auth-weakening`, ADR-005) with a nonce-scoped sentinel appended, so the two halves
1613
+ * of ADR-002's evidence are produced by a single command: the block MESSAGE and the missing SIDE
1614
+ * EFFECT. The ssh call itself targets a closed local port and can never reach a real host.
1615
+ */
1616
+ function forbiddenProbeCommand(sentinel: string, nonce: string): string {
1617
+ return (
1618
+ 'ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -o BatchMode=yes ' +
1619
+ `-o ConnectTimeout=1 -p 65533 nobody@127.0.0.1 true ; echo ${nonce} > "${sentinel}"`
1620
+ );
1621
+ }
1622
+
1623
+ /** This package's own root — the probe workspace links it so the helper can load the policy. */
1624
+ function harnessCoreRoot(): string {
1625
+ return dirname(dirname(fileURLToPath(import.meta.url)));
1626
+ }
1627
+
1628
+ export interface CodexVetoProbeOptions {
1629
+ readonly paths: CodexHooksPaths;
1630
+ /** Where the probe runs. Absent ⇒ a hermetic temp workspace that opts INTO block mode. */
1631
+ readonly project?: string | undefined;
1632
+ /** Pinned model id (`DZ_CODEX_PROBE_MODEL`). Absent ⇒ codex's own configured default. */
1633
+ readonly model?: string | undefined;
1634
+ readonly timeoutMs?: number | undefined;
1635
+ readonly trustStatus?: CodexHookTrustStatus | undefined;
1636
+ readonly recordedCodexVersion?: string | undefined;
1637
+ readonly probedCodexVersion?: string | undefined;
1638
+ }
1639
+
1640
+ export interface CodexVetoProbeRun {
1641
+ readonly evidence: VetoProbeEvidence;
1642
+ readonly result: VetoProbeResult;
1643
+ /** The directory the probe ran in, kept for the transcript record. */
1644
+ readonly workspace: string;
1645
+ readonly command: string;
1646
+ readonly notes: readonly string[];
1647
+ }
1648
+
1649
+ /**
1650
+ * Drive ONE live, NON-bypassed veto probe through `codex exec` and classify what it produced.
1651
+ *
1652
+ * This is the half the shipped CLI was missing: `--verify` and `--project` were accepted and
1653
+ * dropped, `classifyVetoProbe` was never called from any production path, and `ready` printed off
1654
+ * file presence plus a trust row (independent review, finding 1 — CRITICAL). A registry entry is
1655
+ * not a guard; only a witnessed block is.
1656
+ *
1657
+ * Fail-closed by construction:
1658
+ * - `--dangerously-bypass-hook-trust` is NEVER passed (`bypassedTrust: false` is a fact here, not a
1659
+ * parameter): a bypassed run proves the helper body works and nothing about the installed state.
1660
+ * - stdin is `/dev/null` (node opens `/dev/null` for an `'ignore'` stdio slot) — the 2026-07-10
1661
+ * codex-exec stdin lesson.
1662
+ * - Every way the run can fail to produce evidence — no binary, a dead invocation, a timeout, an
1663
+ * unstattable sentinel — reaches `inconclusive`, never `armed`.
1664
+ */
1665
+ export function runCodexVetoProbe(options: CodexVetoProbeOptions): CodexVetoProbeRun {
1666
+ const notes: string[] = [];
1667
+ const nonce = `dzverify-${randomBytes(6).toString('hex')}`;
1668
+ const timeoutMs = options.timeoutMs ?? 300_000;
1669
+
1670
+ // (1) The workspace. The veto helper is inert outside an opted-in `.dz` project and only BLOCKS
1671
+ // under an explicit `hooks.shellVeto: "block"` (ADR-004 consent). A probe therefore needs a
1672
+ // consenting workspace; dz builds a hermetic one rather than editing the user's config.
1673
+ let workspace: string;
1674
+ let ephemeral = false;
1675
+ const explicit = options.project === undefined ? undefined : resolvePath(options.project);
1676
+ if (explicit !== undefined && vetoModeOf(explicit) === 'block') {
1677
+ workspace = explicit;
1678
+ notes.push(`probing in ${explicit} — it is an opted-in dz project in block mode`);
1679
+ } else {
1680
+ if (explicit !== undefined) {
1681
+ notes.push(`--project ${explicit} is not an opted-in dz project in \`hooks.shellVeto: "block"\` mode — probing in a hermetic workspace instead (dz never edits a project's consent to make its own check pass)`);
1682
+ }
1683
+ workspace = mkdtempSync(join(tmpdir(), 'dz-veto-probe-'));
1684
+ ephemeral = true;
1685
+ mkdirSync(join(workspace, '.dz'), { recursive: true });
1686
+ writeFileSync(join(workspace, '.dz', 'config.json'), '{"hooks":{"shellVeto":"block"}}\n');
1687
+ try {
1688
+ mkdirSync(join(workspace, 'node_modules', '@dzhechkov'), { recursive: true });
1689
+ symlinkSync(harnessCoreRoot(), join(workspace, 'node_modules', '@dzhechkov', 'harness-core'), 'dir');
1690
+ } catch (err) {
1691
+ notes.push(`could not link harness-core into the probe workspace: ${String((err as Error).message)}`);
1692
+ }
1693
+ }
1694
+
1695
+ const sentinel = join(workspace, `PROBE-SENTINEL-${nonce}`);
1696
+ rmSync(sentinel, { force: true });
1697
+ const command = forbiddenProbeCommand(sentinel, nonce);
1698
+
1699
+ // (2) The run. `--skip-git-repo-check` because the hermetic workspace is not a repo;
1700
+ // `--sandbox workspace-write` so the sentinel CAN be written when nothing blocks it — a
1701
+ // sandbox that forbids the write would forge an `armed` out of a sandbox denial.
1702
+ const args = ['exec', '--skip-git-repo-check', '--sandbox', 'workspace-write'];
1703
+ if (options.model !== undefined && options.model !== '') args.push('-m', options.model);
1704
+ args.push(`Run this exact shell command, verbatim, with sh -c: ${command}`);
1705
+ const run = spawnSync('codex', args, {
1706
+ cwd: workspace,
1707
+ encoding: 'utf8',
1708
+ timeout: timeoutMs,
1709
+ stdio: ['ignore', 'pipe', 'pipe'], // stdin = /dev/null
1710
+ env: { ...process.env, CODEX_HOME: options.paths.codexHome },
1711
+ });
1712
+ const transcript = `${run.stdout ?? ''}${run.stderr ?? ''}`;
1713
+ const timedOut = (run.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' || run.signal === 'SIGTERM';
1714
+ const ran = run.error === undefined || timedOut;
1715
+ if (run.error !== undefined && !timedOut) notes.push(`codex exec did not run: ${String(run.error.message)}`);
1716
+
1717
+ // (3) The sentinel, stat'ed — `null` when we could not look, which is NOT the same as absent.
1718
+ const sentinelStat = statSentinelPresence(sentinel);
1719
+ const sentinelPresent = sentinelStat.present;
1720
+ if (sentinelStat.error !== undefined) notes.push(`could not stat the sentinel: ${sentinelStat.error}`);
1721
+
1722
+ const shellAttempted =
1723
+ /\/bin\/(?:ba)?sh -l?c/.test(transcript) ||
1724
+ transcript.includes('Command blocked by PreToolUse hook') ||
1725
+ transcript.includes('DZ-VETO');
1726
+
1727
+ const evidence: VetoProbeEvidence = {
1728
+ transcript,
1729
+ nonce,
1730
+ sentinelPresent,
1731
+ shellAttempted,
1732
+ bypassedTrust: false,
1733
+ ran,
1734
+ exitCode: run.status,
1735
+ timedOut,
1736
+ ...(options.trustStatus !== undefined ? { trustStatus: options.trustStatus } : {}),
1737
+ ...(options.recordedCodexVersion !== undefined ? { recordedCodexVersion: options.recordedCodexVersion } : {}),
1738
+ ...(options.probedCodexVersion !== undefined ? { probedCodexVersion: options.probedCodexVersion } : {}),
1739
+ };
1740
+ const result = classifyVetoProbe(evidence);
1741
+
1742
+ rmSync(sentinel, { force: true });
1743
+ if (ephemeral) rmSync(workspace, { recursive: true, force: true });
1744
+ return { evidence, result, workspace, command, notes };
1745
+ }
1746
+
1747
+ /**
1748
+ * Is the sentinel there? `true` / `false` / **`null` when we could not tell**.
1749
+ *
1750
+ * `existsSync` answers `false` for BOTH "it is not there" and "I could not look" — it swallows
1751
+ * EACCES, ENOTDIR, ELOOP and every I/O error into the same word that means "the command was
1752
+ * blocked" (fix round 2, R2-4). Only ENOENT is an established ABSENCE; every other errno is a
1753
+ * failed observation and must reach `inconclusive`.
1754
+ */
1755
+ export function statSentinelPresence(path: string): { readonly present: boolean | null; readonly error?: string } {
1756
+ try {
1757
+ statSync(path);
1758
+ return { present: true };
1759
+ } catch (err) {
1760
+ const code = (err as NodeJS.ErrnoException).code;
1761
+ if (code === 'ENOENT') return { present: false };
1762
+ return { present: null, error: `${String(code ?? 'unknown')}: ${String((err as Error).message)}` };
1763
+ }
1764
+ }
1765
+
1766
+ /** The project's veto mode, read the way the helper reads it. Never throws. */
1767
+ function vetoModeOf(projectRoot: string): 'off' | 'warn' | 'block' {
1768
+ try {
1769
+ if (!statSync(join(projectRoot, '.dz')).isDirectory()) return 'warn';
1770
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf8')) as { hooks?: { shellVeto?: unknown } };
1771
+ const mode = cfg?.hooks?.shellVeto;
1772
+ return mode === 'block' || mode === 'off' ? mode : 'warn';
1773
+ } catch {
1774
+ return 'warn';
1775
+ }
1776
+ }
1777
+
1389
1778
  /** A synthetic ALLOWED PreToolUse payload — the liveness probe must never trigger a real policy hit. */
1390
1779
  const ALLOWED_PROBE_PAYLOAD = JSON.stringify({
1391
1780
  hook_event_name: 'PreToolUse',
@@ -1427,7 +1816,13 @@ function armCodexHookTrust(
1427
1816
  paths: CodexHooksPaths,
1428
1817
  entries: readonly ManagedEntry[],
1429
1818
  writes: string[],
1430
- ): { readonly trust: 'trusted' | 'trust-pending' | 'unknown'; readonly rows: readonly { id: string; key: string }[]; readonly warnings: readonly string[] } {
1819
+ ): {
1820
+ readonly trust: 'trusted' | 'trust-pending' | 'unknown';
1821
+ readonly rows: readonly { id: string; key: string }[];
1822
+ readonly warnings: readonly string[];
1823
+ /** The runtime's own word for OUR entry, handed to the veto classifier's trust axis. */
1824
+ readonly rowStatus?: CodexHookTrustStatus | undefined;
1825
+ } {
1431
1826
  const listed = listCodexHooks(paths.codexHome);
1432
1827
  if (listed === null) {
1433
1828
  return {
@@ -1438,30 +1833,36 @@ function armCodexHookTrust(
1438
1833
  ],
1439
1834
  };
1440
1835
  }
1441
- const own = selectOwnHookMetadata(listed, entries);
1836
+ const own = selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
1442
1837
  if (own.length !== entries.length) {
1443
1838
  return {
1444
1839
  trust: 'trust-pending',
1445
1840
  rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1446
- warnings: [`codex reports ${own.length} of ${entries.length} dz entries — trust recorded only for the ones it sees`],
1841
+ warnings: [`codex reports ${own.length} of ${entries.length} dz entries it can attribute to ${paths.registry} — trust recorded only for the ones it sees (an ambiguous or foreign-sourced row is refused, never adopted)`],
1447
1842
  };
1448
1843
  }
1449
1844
  const configText = existsSync(paths.configToml) ? readFileSync(paths.configToml, 'utf8') : '';
1450
- const next = upsertTrustBlock(
1845
+ const upserted = upsertTrustBlock(
1451
1846
  configText,
1452
1847
  own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })),
1453
1848
  );
1454
- if (next !== configText) {
1455
- atomicWrite(paths.configToml, next);
1849
+ if (!upserted.ok) {
1850
+ // A damaged fence is a REFUSAL, not a rewrite: the alternative eats the user's `[projects."…"]`
1851
+ // trust rows on the next sync (finding 9).
1852
+ return { trust: 'trust-pending', rows: own.map((o) => ({ id: o.id, key: o.meta.key })), warnings: [upserted.error] };
1853
+ }
1854
+ if (upserted.text !== configText) {
1855
+ atomicWrite(paths.configToml, upserted.text);
1456
1856
  writes.push(paths.configToml);
1457
1857
  }
1458
1858
  // Re-read: the only honest confirmation that the write armed anything is the runtime's own answer.
1459
1859
  const after = listCodexHooks(paths.codexHome);
1460
- const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries);
1860
+ const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries, { registryPath: paths.registry });
1461
1861
  const allTrusted = confirmed.length === entries.length && confirmed.every((c) => c.meta.trustStatus === 'trusted' || c.meta.trustStatus === 'managed');
1462
1862
  return {
1463
1863
  trust: allTrusted ? 'trusted' : 'trust-pending',
1464
1864
  rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1865
+ ...(confirmed[0] !== undefined ? { rowStatus: confirmed[0].meta.trustStatus } : {}),
1465
1866
  warnings: allTrusted
1466
1867
  ? []
1467
1868
  : ['dz wrote the hook trust rows but codex still reports the entries as untrusted — approve them in an interactive session'],