@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
@@ -6,11 +6,14 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
  import { execFileSync, spawnSync } from 'node:child_process';
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
10
- import { homedir } from 'node:os';
11
- import { dirname, join } from 'node:path';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
11
+ import { homedir, tmpdir } from 'node:os';
12
+ import { dirname, join, resolve as resolvePath } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
12
14
  import { buildManagedEntries, buildCodexHookManifest, codexHooksPaths, diffCodexHooks, parseCodexHookManifest, planCodexHooks, removeCodexHooks, selectOwnHookMetadata, upsertTrustBlock, } from './codex-hooks.js';
13
15
  import { generateCodexHelpers } from './codex-hooks-assets.js';
16
+ import { classifyVetoProbe, isReadyVerdict, verifyExitCode, } from './codex-hooks-verify.js';
14
17
  import { AGENTS_MD_PATH } from '@dzhechkov/adapter-agents-md';
15
18
  import { claudeAdapter } from '@dzhechkov/adapter-claude';
16
19
  import { CODEX_SKILLS_ROOT } from '@dzhechkov/adapter-codex';
@@ -801,7 +804,10 @@ export async function runDoctor(options) {
801
804
  catch { /* advisory only — the vector tier must never fail doctor */ }
802
805
  return { node: process.version, checks, ok: checks.every((check) => check.ok) };
803
806
  }
804
- function resolveCodexHome(explicit) {
807
+ /** The one place that decides WHERE `hooks.json` lives. Exported (feature qe-bridge-claude) so the
808
+ * CLI can take the `codex-hooks` advisory lock BESIDE that registry — a lock in this repo's `.dz/`
809
+ * would not serialize a writer operating from another checkout. */
810
+ export function resolveCodexHome(explicit) {
805
811
  if (typeof explicit === 'string' && explicit !== '')
806
812
  return explicit;
807
813
  const env = process.env['CODEX_HOME'];
@@ -860,6 +866,63 @@ function pruneBackups(dir, prefix, keep) {
860
866
  * Order is load-bearing: every REFUSAL happens before any `mkdir` or write, so a machine without
861
867
  * codex, or with an unparseable registry, is left byte-untouched (AM-35a).
862
868
  */
869
+ /**
870
+ * Should this run PROVE the guard fires, and with what?
871
+ *
872
+ * `liveness: false` is the offline test seam and disables every live call, the probe included.
873
+ * `verify: false` is the USER saying "do not probe" — a deliberate refusal to measure, which is
874
+ * reported as inconclusive rather than as success (finding 1).
875
+ */
876
+ function verifyPlan(options) {
877
+ if (options.liveness === false)
878
+ return { run: false, declined: false };
879
+ if (options.verify === false)
880
+ return { run: false, declined: true };
881
+ return { run: true, declined: false };
882
+ }
883
+ /**
884
+ * Run the live probe (or record, loudly, why it did not run).
885
+ *
886
+ * A refusal to measure and a measurement are different facts and are reported differently: a
887
+ * declined verify never reaches exit 0, and a probe that could not be driven at all is
888
+ * `inconclusive`, never `ready` (finding 1).
889
+ */
890
+ function verifyStage(options, ctx) {
891
+ const plan = verifyPlan(options);
892
+ if (!plan.run) {
893
+ if (plan.declined) {
894
+ ctx.warnings.push('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.');
895
+ }
896
+ return { result: null, declined: plan.declined };
897
+ }
898
+ const probe = options.probe ?? runCodexVetoProbe;
899
+ let run;
900
+ try {
901
+ run = probe({
902
+ paths: ctx.paths,
903
+ ...(options.project !== undefined ? { project: options.project } : {}),
904
+ ...(options.probeModel ?? process.env['DZ_CODEX_PROBE_MODEL'] ? { model: options.probeModel ?? process.env['DZ_CODEX_PROBE_MODEL'] } : {}),
905
+ ...(ctx.trustStatus !== undefined ? { trustStatus: ctx.trustStatus } : {}),
906
+ ...(ctx.recordedCodexVersion !== undefined ? { recordedCodexVersion: ctx.recordedCodexVersion } : {}),
907
+ probedCodexVersion: ctx.probedCodexVersion,
908
+ });
909
+ }
910
+ catch (err) {
911
+ // A probe that THREW measured nothing. Inconclusive is the honest verdict; a thrown probe that
912
+ // fell through to the install state would be the finding all over again.
913
+ ctx.warnings.push(`the live veto probe could not be driven: ${String(err?.message ?? err)}`);
914
+ return {
915
+ result: { verdict: 'inconclusive', trust: 'unknown', reason: `the live veto probe could not be driven: ${String(err?.message ?? err)}` },
916
+ declined: false,
917
+ };
918
+ }
919
+ for (const note of run.notes)
920
+ ctx.warnings.push(`veto probe: ${note}`);
921
+ if (!isReadyVerdict(run.result)) {
922
+ ctx.warnings.push(`live veto probe: ${run.result.verdict} — ${run.result.reason}`);
923
+ }
924
+ return { result: run.result, declined: false };
925
+ }
863
926
  export function runSyncCodexHooks(options = {}) {
864
927
  const codexHome = resolveCodexHome(options.codexHome);
865
928
  const paths = codexHooksPaths(codexHome);
@@ -881,6 +944,9 @@ export function runSyncCodexHooks(options = {}) {
881
944
  trust: 'unknown',
882
945
  codexVersion: null,
883
946
  writes: [],
947
+ verify: null,
948
+ verified: false,
949
+ ready: false,
884
950
  };
885
951
  // (1) REFUSE when codex is not installed — dz does not create user-global config for a runtime
886
952
  // that is not there (AM-35a). `--check` is allowed to answer, but it writes nothing anyway.
@@ -896,20 +962,34 @@ export function runSyncCodexHooks(options = {}) {
896
962
  catch (err) {
897
963
  return { ...base, codexVersion, exitCode: 1, warnings, errors: [String(err.message)] };
898
964
  }
965
+ const criticalSection = options.criticalSection ?? ((fn) => fn());
899
966
  const currentText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
900
967
  const manifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
901
968
  // (3) --remove: delete only what the manifest proves is ours.
902
969
  if (options.remove === true) {
903
- const removal = removeCodexHooks(currentText, manifest);
970
+ // TRANSACTION: re-read under the lock, plan from THOSE bytes, write. A plan computed outside the
971
+ // guarded window is a plan against bytes another writer may already have replaced.
972
+ const removal = criticalSection(() => {
973
+ const freshText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
974
+ const freshManifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
975
+ const planned = removeCodexHooks(freshText, freshManifest);
976
+ if (planned.ok && planned.result.changed) {
977
+ backupRegistry(paths, freshText, now, writes);
978
+ atomicWrite(paths.registry, planned.result.text);
979
+ writes.push(paths.registry);
980
+ }
981
+ // R3-5: the helpers and the manifest are part of the SAME shared state as the registry. Round
982
+ // 2 deleted them after the lock was released, so a concurrent installer could observe (and
983
+ // rebuild against) a registry that had already been emptied — or leave a manifest describing
984
+ // entries that no longer exist. One decision, one critical section.
985
+ if (planned.ok) {
986
+ for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest])
987
+ rmSync(p, { force: true });
988
+ }
989
+ return planned;
990
+ });
904
991
  if (!removal.ok)
905
992
  return { ...base, codexVersion, exitCode: 1, warnings, errors: [removal.error] };
906
- if (removal.result.changed) {
907
- backupRegistry(paths, currentText, now, writes);
908
- atomicWrite(paths.registry, removal.result.text);
909
- writes.push(paths.registry);
910
- }
911
- for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest])
912
- rmSync(p, { force: true });
913
993
  return {
914
994
  ...base,
915
995
  codexVersion,
@@ -934,12 +1014,39 @@ export function runSyncCodexHooks(options = {}) {
934
1014
  // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
935
1015
  // failure ("no success word without armed AND trusted"), reached through the read-only path.
936
1016
  const listed = drift.installed && options.liveness !== false ? listCodexHooks(codexHome) : null;
937
- const own = listed === null ? [] : selectOwnHookMetadata(listed, entries);
1017
+ const own = listed === null ? [] : selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
938
1018
  const checkTrust = listed === null
939
1019
  ? 'unknown'
940
1020
  : own.length === entries.length && own.every((o) => o.meta.trustStatus === 'trusted' || o.meta.trustStatus === 'managed')
941
1021
  ? 'trusted'
942
1022
  : 'trust-pending';
1023
+ if (drift.installed && !executable) {
1024
+ warnings.push(`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`);
1025
+ }
1026
+ // `--check` is the READ-ONLY verify: it recomputes state from the file AND, by default, proves
1027
+ // the guard actually fires. Only when the entries are present — a home that never opted in must
1028
+ // stay silent and must not spend a live model call (the leg-1 F12 lesson).
1029
+ const checkVerify = drift.installed && executable && checkTrust === 'trusted'
1030
+ ? verifyStage(options, {
1031
+ paths,
1032
+ probedCodexVersion: codexVersion,
1033
+ ...(manifest?.codexVersion !== undefined ? { recordedCodexVersion: manifest.codexVersion } : {}),
1034
+ ...(own[0] !== undefined ? { trustStatus: own[0].meta.trustStatus } : {}),
1035
+ warnings,
1036
+ })
1037
+ : { result: null, declined: verifyPlan(options).declined };
1038
+ const checkArmedState = drift.installed && executable && checkTrust === 'trusted';
1039
+ const checkExit = drift.installed
1040
+ ? checkArmedState
1041
+ ? checkVerify.result !== null
1042
+ ? verifyExitCode(checkVerify.result)
1043
+ : checkVerify.declined
1044
+ ? 3
1045
+ : 0
1046
+ : 1
1047
+ : manifest === undefined
1048
+ ? 0
1049
+ : 1;
943
1050
  return {
944
1051
  ...base,
945
1052
  codexVersion,
@@ -949,19 +1056,41 @@ export function runSyncCodexHooks(options = {}) {
949
1056
  foreignPreserved: drift.foreignPreserved,
950
1057
  unattributable: drift.unattributable,
951
1058
  drift: drift.drifted,
1059
+ verify: checkVerify.result,
1060
+ verified: checkVerify.result !== null && isReadyVerdict(checkVerify.result),
1061
+ ready: checkArmedState && checkVerify.result !== null && isReadyVerdict(checkVerify.result),
952
1062
  // A home that never opted in is NOT a failure: `--check` is silent and exits 0 there (the
953
1063
  // leg-1 F12 lesson — a check that chatters in every unrelated project trains its reader to
954
- // ignore it). Installed-but-broken is exit 1; installed-and-live is exit 0.
955
- exitCode: drift.installed ? (executable && checkTrust === 'trusted' ? 0 : 1) : manifest === undefined ? 0 : 1,
956
- warnings: drift.installed && !executable
957
- ? [`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`]
958
- : warnings,
1064
+ // ignore it). Installed-but-broken is exit 1; installed-and-live-and-PROVEN is exit 0.
1065
+ exitCode: checkExit,
1066
+ warnings,
959
1067
  errors,
960
1068
  writes,
961
1069
  };
962
1070
  }
963
1071
  // (5) Install. Parse-refuse BEFORE any mkdir (I1: an unparseable file must not be overwritten).
964
- const planned = planCodexHooks({ currentText, entries, manifest });
1072
+ //
1073
+ // TRANSACTION (round-2 C2): the read, the plan and the write happen inside the caller's critical
1074
+ // section — and the LIVE PROBES below deliberately do NOT. Holding a lock across a 300s model call
1075
+ // is how a holder outlives its own stale threshold; the probes mutate nothing shared, so they need
1076
+ // no exclusion.
1077
+ const planned = criticalSection(() => {
1078
+ const freshText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1079
+ const freshManifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
1080
+ const plan = planCodexHooks({ currentText: freshText, entries, manifest: freshManifest });
1081
+ if (!plan.ok)
1082
+ return { plan, wrote: false, freshText };
1083
+ mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
1084
+ const helpers = generateCodexHelpers();
1085
+ writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
1086
+ writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
1087
+ if (plan.plan.changed) {
1088
+ backupRegistry(paths, freshText, now, writes);
1089
+ atomicWrite(paths.registry, plan.plan.text);
1090
+ writes.push(paths.registry);
1091
+ }
1092
+ return { plan, wrote: plan.plan.changed, freshText };
1093
+ }).plan;
965
1094
  if (!planned.ok) {
966
1095
  return {
967
1096
  ...base,
@@ -974,15 +1103,6 @@ export function runSyncCodexHooks(options = {}) {
974
1103
  ],
975
1104
  };
976
1105
  }
977
- mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
978
- const helpers = generateCodexHelpers();
979
- writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
980
- writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
981
- if (planned.plan.changed) {
982
- backupRegistry(paths, currentText, now, writes);
983
- atomicWrite(paths.registry, planned.plan.text);
984
- writes.push(paths.registry);
985
- }
986
1106
  // (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
987
1107
  const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0].command, ALLOWED_PROBE_PAYLOAD);
988
1108
  const executable = live.status === 0 || live.status === 2;
@@ -1001,11 +1121,55 @@ export function runSyncCodexHooks(options = {}) {
1001
1121
  for (const row of trustResult.rows)
1002
1122
  trustKeys[row.id] = row.key;
1003
1123
  const manifestText = `${JSON.stringify(buildCodexHookManifest({ entries, paths, codexVersion, writtenAt: now, nodePath, trustKeys }), null, 2)}\n`;
1004
- atomicWrite(paths.manifest, manifestText);
1005
- writes.push(paths.manifest);
1006
- const drift = diffCodexHooks(readFileSync(paths.registry, 'utf8'), entries, undefined);
1124
+ // R3-5: the manifest is shared state — it is what `--remove` reads to decide which entries are
1125
+ // OURS — so its write is guarded too. It gets its own SHORT section rather than joining the
1126
+ // registry transaction, because its content depends on trust keys that only exist after the live
1127
+ // `hooks/list` query; folding it into the first section would drag that probe back under the lock,
1128
+ // which is exactly the CRITICAL the previous round closed. Two short sections, no long hold.
1129
+ //
1130
+ // R4-3: section two REVALIDATES before it writes. Round 3 wrote a manifest computed in section one,
1131
+ // which meant a remover that won the window in between had its removal partially undone: the
1132
+ // registry said "no dz entries", the manifest said "here are dz entries", and `--remove` reads the
1133
+ // manifest to decide what is ours. Recompute from what is on disk NOW; if our entries are gone,
1134
+ // write NOTHING and report the state honestly.
1135
+ const manifestOutcome = criticalSection(() => {
1136
+ const nowText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1137
+ const nowDrift = diffCodexHooks(nowText, entries, undefined);
1138
+ if (!nowDrift.installed) {
1139
+ return { wrote: false, drift: nowDrift };
1140
+ }
1141
+ atomicWrite(paths.manifest, manifestText);
1142
+ writes.push(paths.manifest);
1143
+ return { wrote: true, drift: nowDrift };
1144
+ });
1145
+ if (!manifestOutcome.wrote) {
1146
+ warnings.push('the registry no longer carries this install\u2019s entries — a concurrent remover won the window between the ' +
1147
+ 'registry transaction and the manifest write, so NO manifest was written (a manifest describing entries that are ' +
1148
+ 'not there is what `--remove` would later act on). Re-run the install if you want the hooks back.');
1149
+ }
1150
+ const drift = manifestOutcome.drift;
1007
1151
  const installed = drift.installed;
1008
- const exitCode = installed && executable && trustResult.trust === 'trusted' ? 0 : 1;
1152
+ const armedState = installed && executable && trustResult.trust === 'trusted';
1153
+ // The install is not finished when the file is written — it is finished when the guard has been
1154
+ // WITNESSED blocking (ADR-002, finding 1). Probing an install that is already known-broken would
1155
+ // spend a live model call to re-learn what the liveness probe just said, so it is gated on
1156
+ // `armedState`.
1157
+ const verification = armedState
1158
+ ? verifyStage(options, {
1159
+ paths,
1160
+ probedCodexVersion: codexVersion,
1161
+ recordedCodexVersion: codexVersion,
1162
+ ...(trustResult.rowStatus !== undefined ? { trustStatus: trustResult.rowStatus } : {}),
1163
+ warnings,
1164
+ })
1165
+ : { result: null, declined: verifyPlan(options).declined };
1166
+ const exitCode = armedState
1167
+ ? verification.result !== null
1168
+ ? verifyExitCode(verification.result)
1169
+ : verification.declined
1170
+ ? 3
1171
+ : 0
1172
+ : 1;
1009
1173
  return {
1010
1174
  codexHome,
1011
1175
  registryPath: paths.registry,
@@ -1018,12 +1182,160 @@ export function runSyncCodexHooks(options = {}) {
1018
1182
  drift: drift.drifted,
1019
1183
  trust: trustResult.trust,
1020
1184
  codexVersion,
1185
+ verify: verification.result,
1186
+ verified: verification.result !== null && isReadyVerdict(verification.result),
1187
+ ready: armedState && verification.result !== null && isReadyVerdict(verification.result),
1021
1188
  exitCode,
1022
1189
  warnings,
1023
1190
  errors,
1024
1191
  writes,
1025
1192
  };
1026
1193
  }
1194
+ /* -------------------------------------------------------------------------- */
1195
+ /* The LIVE veto probe (ADR-002 §Decision, independent-review finding 1) */
1196
+ /* -------------------------------------------------------------------------- */
1197
+ /**
1198
+ * The forbidden action the probe asks Codex to run. It is the ONE rule this leg ships
1199
+ * (`ssh-explicit-auth-weakening`, ADR-005) with a nonce-scoped sentinel appended, so the two halves
1200
+ * of ADR-002's evidence are produced by a single command: the block MESSAGE and the missing SIDE
1201
+ * EFFECT. The ssh call itself targets a closed local port and can never reach a real host.
1202
+ */
1203
+ function forbiddenProbeCommand(sentinel, nonce) {
1204
+ return ('ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -o BatchMode=yes ' +
1205
+ `-o ConnectTimeout=1 -p 65533 nobody@127.0.0.1 true ; echo ${nonce} > "${sentinel}"`);
1206
+ }
1207
+ /** This package's own root — the probe workspace links it so the helper can load the policy. */
1208
+ function harnessCoreRoot() {
1209
+ return dirname(dirname(fileURLToPath(import.meta.url)));
1210
+ }
1211
+ /**
1212
+ * Drive ONE live, NON-bypassed veto probe through `codex exec` and classify what it produced.
1213
+ *
1214
+ * This is the half the shipped CLI was missing: `--verify` and `--project` were accepted and
1215
+ * dropped, `classifyVetoProbe` was never called from any production path, and `ready` printed off
1216
+ * file presence plus a trust row (independent review, finding 1 — CRITICAL). A registry entry is
1217
+ * not a guard; only a witnessed block is.
1218
+ *
1219
+ * Fail-closed by construction:
1220
+ * - `--dangerously-bypass-hook-trust` is NEVER passed (`bypassedTrust: false` is a fact here, not a
1221
+ * parameter): a bypassed run proves the helper body works and nothing about the installed state.
1222
+ * - stdin is `/dev/null` (node opens `/dev/null` for an `'ignore'` stdio slot) — the 2026-07-10
1223
+ * codex-exec stdin lesson.
1224
+ * - Every way the run can fail to produce evidence — no binary, a dead invocation, a timeout, an
1225
+ * unstattable sentinel — reaches `inconclusive`, never `armed`.
1226
+ */
1227
+ export function runCodexVetoProbe(options) {
1228
+ const notes = [];
1229
+ const nonce = `dzverify-${randomBytes(6).toString('hex')}`;
1230
+ const timeoutMs = options.timeoutMs ?? 300_000;
1231
+ // (1) The workspace. The veto helper is inert outside an opted-in `.dz` project and only BLOCKS
1232
+ // under an explicit `hooks.shellVeto: "block"` (ADR-004 consent). A probe therefore needs a
1233
+ // consenting workspace; dz builds a hermetic one rather than editing the user's config.
1234
+ let workspace;
1235
+ let ephemeral = false;
1236
+ const explicit = options.project === undefined ? undefined : resolvePath(options.project);
1237
+ if (explicit !== undefined && vetoModeOf(explicit) === 'block') {
1238
+ workspace = explicit;
1239
+ notes.push(`probing in ${explicit} — it is an opted-in dz project in block mode`);
1240
+ }
1241
+ else {
1242
+ if (explicit !== undefined) {
1243
+ 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)`);
1244
+ }
1245
+ workspace = mkdtempSync(join(tmpdir(), 'dz-veto-probe-'));
1246
+ ephemeral = true;
1247
+ mkdirSync(join(workspace, '.dz'), { recursive: true });
1248
+ writeFileSync(join(workspace, '.dz', 'config.json'), '{"hooks":{"shellVeto":"block"}}\n');
1249
+ try {
1250
+ mkdirSync(join(workspace, 'node_modules', '@dzhechkov'), { recursive: true });
1251
+ symlinkSync(harnessCoreRoot(), join(workspace, 'node_modules', '@dzhechkov', 'harness-core'), 'dir');
1252
+ }
1253
+ catch (err) {
1254
+ notes.push(`could not link harness-core into the probe workspace: ${String(err.message)}`);
1255
+ }
1256
+ }
1257
+ const sentinel = join(workspace, `PROBE-SENTINEL-${nonce}`);
1258
+ rmSync(sentinel, { force: true });
1259
+ const command = forbiddenProbeCommand(sentinel, nonce);
1260
+ // (2) The run. `--skip-git-repo-check` because the hermetic workspace is not a repo;
1261
+ // `--sandbox workspace-write` so the sentinel CAN be written when nothing blocks it — a
1262
+ // sandbox that forbids the write would forge an `armed` out of a sandbox denial.
1263
+ const args = ['exec', '--skip-git-repo-check', '--sandbox', 'workspace-write'];
1264
+ if (options.model !== undefined && options.model !== '')
1265
+ args.push('-m', options.model);
1266
+ args.push(`Run this exact shell command, verbatim, with sh -c: ${command}`);
1267
+ const run = spawnSync('codex', args, {
1268
+ cwd: workspace,
1269
+ encoding: 'utf8',
1270
+ timeout: timeoutMs,
1271
+ stdio: ['ignore', 'pipe', 'pipe'], // stdin = /dev/null
1272
+ env: { ...process.env, CODEX_HOME: options.paths.codexHome },
1273
+ });
1274
+ const transcript = `${run.stdout ?? ''}${run.stderr ?? ''}`;
1275
+ const timedOut = run.error?.code === 'ETIMEDOUT' || run.signal === 'SIGTERM';
1276
+ const ran = run.error === undefined || timedOut;
1277
+ if (run.error !== undefined && !timedOut)
1278
+ notes.push(`codex exec did not run: ${String(run.error.message)}`);
1279
+ // (3) The sentinel, stat'ed — `null` when we could not look, which is NOT the same as absent.
1280
+ const sentinelStat = statSentinelPresence(sentinel);
1281
+ const sentinelPresent = sentinelStat.present;
1282
+ if (sentinelStat.error !== undefined)
1283
+ notes.push(`could not stat the sentinel: ${sentinelStat.error}`);
1284
+ const shellAttempted = /\/bin\/(?:ba)?sh -l?c/.test(transcript) ||
1285
+ transcript.includes('Command blocked by PreToolUse hook') ||
1286
+ transcript.includes('DZ-VETO');
1287
+ const evidence = {
1288
+ transcript,
1289
+ nonce,
1290
+ sentinelPresent,
1291
+ shellAttempted,
1292
+ bypassedTrust: false,
1293
+ ran,
1294
+ exitCode: run.status,
1295
+ timedOut,
1296
+ ...(options.trustStatus !== undefined ? { trustStatus: options.trustStatus } : {}),
1297
+ ...(options.recordedCodexVersion !== undefined ? { recordedCodexVersion: options.recordedCodexVersion } : {}),
1298
+ ...(options.probedCodexVersion !== undefined ? { probedCodexVersion: options.probedCodexVersion } : {}),
1299
+ };
1300
+ const result = classifyVetoProbe(evidence);
1301
+ rmSync(sentinel, { force: true });
1302
+ if (ephemeral)
1303
+ rmSync(workspace, { recursive: true, force: true });
1304
+ return { evidence, result, workspace, command, notes };
1305
+ }
1306
+ /**
1307
+ * Is the sentinel there? `true` / `false` / **`null` when we could not tell**.
1308
+ *
1309
+ * `existsSync` answers `false` for BOTH "it is not there" and "I could not look" — it swallows
1310
+ * EACCES, ENOTDIR, ELOOP and every I/O error into the same word that means "the command was
1311
+ * blocked" (fix round 2, R2-4). Only ENOENT is an established ABSENCE; every other errno is a
1312
+ * failed observation and must reach `inconclusive`.
1313
+ */
1314
+ export function statSentinelPresence(path) {
1315
+ try {
1316
+ statSync(path);
1317
+ return { present: true };
1318
+ }
1319
+ catch (err) {
1320
+ const code = err.code;
1321
+ if (code === 'ENOENT')
1322
+ return { present: false };
1323
+ return { present: null, error: `${String(code ?? 'unknown')}: ${String(err.message)}` };
1324
+ }
1325
+ }
1326
+ /** The project's veto mode, read the way the helper reads it. Never throws. */
1327
+ function vetoModeOf(projectRoot) {
1328
+ try {
1329
+ if (!statSync(join(projectRoot, '.dz')).isDirectory())
1330
+ return 'warn';
1331
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf8'));
1332
+ const mode = cfg?.hooks?.shellVeto;
1333
+ return mode === 'block' || mode === 'off' ? mode : 'warn';
1334
+ }
1335
+ catch {
1336
+ return 'warn';
1337
+ }
1338
+ }
1027
1339
  /** A synthetic ALLOWED PreToolUse payload — the liveness probe must never trigger a real policy hit. */
1028
1340
  const ALLOWED_PROBE_PAYLOAD = JSON.stringify({
1029
1341
  hook_event_name: 'PreToolUse',
@@ -1070,27 +1382,33 @@ function armCodexHookTrust(paths, entries, writes) {
1070
1382
  ],
1071
1383
  };
1072
1384
  }
1073
- const own = selectOwnHookMetadata(listed, entries);
1385
+ const own = selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
1074
1386
  if (own.length !== entries.length) {
1075
1387
  return {
1076
1388
  trust: 'trust-pending',
1077
1389
  rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1078
- warnings: [`codex reports ${own.length} of ${entries.length} dz entries — trust recorded only for the ones it sees`],
1390
+ 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)`],
1079
1391
  };
1080
1392
  }
1081
1393
  const configText = existsSync(paths.configToml) ? readFileSync(paths.configToml, 'utf8') : '';
1082
- const next = upsertTrustBlock(configText, own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })));
1083
- if (next !== configText) {
1084
- atomicWrite(paths.configToml, next);
1394
+ const upserted = upsertTrustBlock(configText, own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })));
1395
+ if (!upserted.ok) {
1396
+ // A damaged fence is a REFUSAL, not a rewrite: the alternative eats the user's `[projects."…"]`
1397
+ // trust rows on the next sync (finding 9).
1398
+ return { trust: 'trust-pending', rows: own.map((o) => ({ id: o.id, key: o.meta.key })), warnings: [upserted.error] };
1399
+ }
1400
+ if (upserted.text !== configText) {
1401
+ atomicWrite(paths.configToml, upserted.text);
1085
1402
  writes.push(paths.configToml);
1086
1403
  }
1087
1404
  // Re-read: the only honest confirmation that the write armed anything is the runtime's own answer.
1088
1405
  const after = listCodexHooks(paths.codexHome);
1089
- const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries);
1406
+ const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries, { registryPath: paths.registry });
1090
1407
  const allTrusted = confirmed.length === entries.length && confirmed.every((c) => c.meta.trustStatus === 'trusted' || c.meta.trustStatus === 'managed');
1091
1408
  return {
1092
1409
  trust: allTrusted ? 'trusted' : 'trust-pending',
1093
1410
  rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1411
+ ...(confirmed[0] !== undefined ? { rowStatus: confirmed[0].meta.trustStatus } : {}),
1094
1412
  warnings: allTrusted
1095
1413
  ? []
1096
1414
  : ['dz wrote the hook trust rows but codex still reports the entries as untrusted — approve them in an interactive session'],