@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/src/operations.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
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 =
|
|
1560
|
+
const drift = manifestOutcome.drift;
|
|
1368
1561
|
const installed = drift.installed;
|
|
1369
|
-
const
|
|
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
|
-
): {
|
|
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
|
|
1845
|
+
const upserted = upsertTrustBlock(
|
|
1451
1846
|
configText,
|
|
1452
1847
|
own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })),
|
|
1453
1848
|
);
|
|
1454
|
-
if (
|
|
1455
|
-
|
|
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'],
|