@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.
- package/.dz-manifest.json +339 -75
- package/README.md +47 -4
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +39 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks-verify.d.ts +23 -2
- package/dist/codex-hooks-verify.d.ts.map +1 -1
- package/dist/codex-hooks-verify.js +29 -0
- package/dist/codex-hooks-verify.js.map +1 -1
- package/dist/codex-hooks.d.ts +90 -7
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +171 -21
- package/dist/codex-hooks.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +22 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +45 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +70 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +103 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +38 -130
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-run-semantics.d.ts +130 -0
- package/dist/loop-run-semantics.d.ts.map +1 -0
- package/dist/loop-run-semantics.js +257 -0
- package/dist/loop-run-semantics.js.map +1 -0
- package/dist/loop-trace.d.ts +106 -5
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +151 -18
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +10 -0
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +17 -5
- package/dist/managed-hooks.js.map +1 -1
- package/dist/named-lock.d.ts +57 -0
- package/dist/named-lock.d.ts.map +1 -0
- package/dist/named-lock.js +247 -0
- package/dist/named-lock.js.map +1 -0
- package/dist/operations.d.ts +81 -5
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +356 -38
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +70 -2
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +133 -2
- package/dist/parity.js.map +1 -1
- package/dist/qe-bridge.d.ts +291 -0
- package/dist/qe-bridge.d.ts.map +1 -0
- package/dist/qe-bridge.js +538 -0
- package/dist/qe-bridge.js.map +1 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/trace-corroborate.d.ts +48 -0
- package/dist/trace-corroborate.d.ts.map +1 -0
- package/dist/trace-corroborate.js +172 -0
- package/dist/trace-corroborate.js.map +1 -0
- package/dist/workflow-run-dispatch.d.ts +230 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -0
- package/dist/workflow-run-dispatch.js +363 -0
- package/dist/workflow-run-dispatch.js.map +1 -0
- package/dist/workflow-run.d.ts +513 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1377 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +2 -2
- package/sbom.json +740 -80
- package/src/codex-hooks-assets.ts +39 -2
- package/src/codex-hooks-verify.ts +55 -2
- package/src/codex-hooks.ts +172 -20
- package/src/feature-adr-routing.ts +55 -0
- package/src/index.ts +46 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +38 -128
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +207 -16
- package/src/managed-hooks.ts +26 -5
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +441 -40
- package/src/parity.ts +177 -2
- package/src/qe-bridge.ts +737 -0
- package/src/score.ts +50 -9
- package/src/trace-corroborate.ts +205 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1773 -0
package/dist/operations.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
956
|
-
warnings
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
|
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
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
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'],
|