@dzhechkov/harness-core 0.4.5 → 0.5.1
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 +489 -105
- package/README.md +116 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +324 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +95 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +169 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +341 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +541 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +218 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +583 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -4
- 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 +37 -129
- 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 +31 -2
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +89 -12
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +86 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +101 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.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 +229 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +880 -26
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +106 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +209 -5
- 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/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- 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 +498 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1359 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +6 -5
- package/sbom.json +1099 -139
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +328 -0
- package/src/codex-hooks-verify.ts +237 -0
- package/src/codex-hooks.ts +723 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +697 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +162 -2
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +37 -127
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +84 -10
- package/src/managed-hooks.ts +150 -0
- package/src/mutation-gate.ts +24 -2
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +1121 -29
- package/src/parity.ts +295 -6
- package/src/qe-bridge.ts +737 -0
- package/src/recall-usage.ts +184 -1
- package/src/score.ts +50 -9
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1743 -0
package/src/operations.ts
CHANGED
|
@@ -6,8 +6,36 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
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';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
buildManagedEntries,
|
|
18
|
+
buildCodexHookManifest,
|
|
19
|
+
codexHooksPaths,
|
|
20
|
+
diffCodexHooks,
|
|
21
|
+
parseCodexHookManifest,
|
|
22
|
+
planCodexHooks,
|
|
23
|
+
removeCodexHooks,
|
|
24
|
+
selectOwnHookMetadata,
|
|
25
|
+
upsertTrustBlock,
|
|
26
|
+
type CodexHooksPaths,
|
|
27
|
+
type HooksListHookMetadata,
|
|
28
|
+
type ManagedEntry,
|
|
29
|
+
} from './codex-hooks.js';
|
|
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';
|
|
11
39
|
|
|
12
40
|
import { AGENTS_MD_PATH } from '@dzhechkov/adapter-agents-md';
|
|
13
41
|
import { claudeAdapter } from '@dzhechkov/adapter-claude';
|
|
@@ -19,14 +47,27 @@ import { OPENCLAUDE_SKILLS_ROOT } from '@dzhechkov/adapter-openclaude';
|
|
|
19
47
|
import { COPILOT_INSTRUCTIONS_ROOT } from '@dzhechkov/adapter-copilot';
|
|
20
48
|
import { CURSOR_RULES_ROOT } from '@dzhechkov/adapter-cursor';
|
|
21
49
|
import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
|
|
22
|
-
import { mergeAgentsMd, mergeGeminiMd, renderAgentsMdSection } from '@dzhechkov/core';
|
|
50
|
+
import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
|
|
23
51
|
import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
|
|
24
52
|
import { computeRiskScore } from './risk-scoring.js';
|
|
25
53
|
|
|
26
54
|
import { applyEmitResult } from './apply.js';
|
|
27
|
-
import { discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
55
|
+
import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
56
|
+
import type { SkillApplyFailure, SkillLoadFailure } from './skills.js';
|
|
28
57
|
import { TARGETS } from './targets.js';
|
|
29
58
|
import type { TargetName } from './targets.js';
|
|
59
|
+
import {
|
|
60
|
+
AGENTS_MD_BUDGET_WARN_FRACTION,
|
|
61
|
+
CODEX_PROJECT_DOC_MAX_BYTES,
|
|
62
|
+
POLICY_SOURCES,
|
|
63
|
+
detectPolicyDrift,
|
|
64
|
+
extractPolicyBlocks,
|
|
65
|
+
measureAgentsMdBudget,
|
|
66
|
+
renderPolicySections,
|
|
67
|
+
type AgentsMdBudget,
|
|
68
|
+
type PolicyDriftFinding,
|
|
69
|
+
type PolicySource,
|
|
70
|
+
} from './agents-policy.js';
|
|
30
71
|
|
|
31
72
|
// ---------------------------------------------------------------------------
|
|
32
73
|
// init — compile a skills directory for a target and write it (additively)
|
|
@@ -67,6 +108,27 @@ export interface InitReport {
|
|
|
67
108
|
* installing fewer skills than the selection asked for.
|
|
68
109
|
*/
|
|
69
110
|
readonly missing: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Skill directories that could not be LOADED (unparseable `SKILL.md`, unreadable
|
|
113
|
+
* file, schema rejection). Additive and always present — empty when nothing failed.
|
|
114
|
+
*
|
|
115
|
+
* Before feature dz-cli-defects a single unloadable skill threw out of the whole
|
|
116
|
+
* loop, so `dz init`/`dz install` reported nothing at all. Skipping without a record
|
|
117
|
+
* would only trade a loud failure for a silent one; this field is the record.
|
|
118
|
+
*/
|
|
119
|
+
readonly failures: readonly SkillLoadFailure[];
|
|
120
|
+
/**
|
|
121
|
+
* Skills that LOADED cleanly but could not be compiled or written (fix round 1,
|
|
122
|
+
* QE F4). Kept separate from {@link InitReport.failures} because the two name
|
|
123
|
+
* different subjects: a load failure accuses the source `SKILL.md`, an apply
|
|
124
|
+
* failure accuses the target tree. Merging them made `EEXIST: mkdir …` print under
|
|
125
|
+
* the header "unparseable SKILL.md" against a perfectly valid file.
|
|
126
|
+
*
|
|
127
|
+
* Additive and always present — empty when nothing failed. Only `runInit` can
|
|
128
|
+
* populate it; the single-file targets render in memory and write once, outside
|
|
129
|
+
* any per-skill loop.
|
|
130
|
+
*/
|
|
131
|
+
readonly applyFailures: readonly SkillApplyFailure[];
|
|
70
132
|
}
|
|
71
133
|
|
|
72
134
|
// ---------------------------------------------------------------------------
|
|
@@ -212,6 +274,40 @@ interface SingleFileMdConfig {
|
|
|
212
274
|
readonly lossyWarning: string;
|
|
213
275
|
}
|
|
214
276
|
|
|
277
|
+
export interface ManagedMarkdownWriteReport {
|
|
278
|
+
readonly filePath: string;
|
|
279
|
+
readonly changed: boolean;
|
|
280
|
+
readonly bytes: number;
|
|
281
|
+
/** The complete candidate bytes, returned so callers can run pure post-merge checks. */
|
|
282
|
+
readonly content: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The one read/merge/write path for every root managed-Markdown projection.
|
|
287
|
+
* `write:false` is check-only and never creates a directory or target file.
|
|
288
|
+
* A max-byte refusal happens before mkdir/write, leaving the target byte-identical.
|
|
289
|
+
*/
|
|
290
|
+
export function writeManagedMarkdown(
|
|
291
|
+
projectRoot: string,
|
|
292
|
+
sections: readonly string[],
|
|
293
|
+
config: Pick<SingleFileMdConfig, 'filePath' | 'merge'>,
|
|
294
|
+
options: { readonly write?: boolean; readonly maxBytes?: number } = {},
|
|
295
|
+
): ManagedMarkdownWriteReport {
|
|
296
|
+
const fileAbs = join(projectRoot, config.filePath);
|
|
297
|
+
const existing = existsSync(fileAbs) ? readFileSync(fileAbs, 'utf-8') : null;
|
|
298
|
+
const merged = config.merge(existing, sections);
|
|
299
|
+
const bytes = Buffer.byteLength(merged, 'utf8');
|
|
300
|
+
if (options.maxBytes !== undefined && bytes > options.maxBytes) {
|
|
301
|
+
throw new Error(`${config.filePath} policy emit exceeds the ${options.maxBytes.toLocaleString('en-US')} byte Codex project-document budget (${bytes.toLocaleString('en-US')} bytes); target left unchanged`);
|
|
302
|
+
}
|
|
303
|
+
const changed = merged !== existing;
|
|
304
|
+
if (options.write !== false && changed) {
|
|
305
|
+
mkdirSync(dirname(fileAbs), { recursive: true });
|
|
306
|
+
writeFileSync(fileAbs, merged, 'utf-8');
|
|
307
|
+
}
|
|
308
|
+
return { filePath: config.filePath, changed, bytes, content: merged };
|
|
309
|
+
}
|
|
310
|
+
|
|
215
311
|
/**
|
|
216
312
|
* The lossy warning surfaced ONCE per agents-md install (not once per skill):
|
|
217
313
|
* agents-md flattens every selected skill into plain-Markdown sections of a
|
|
@@ -265,24 +361,25 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
|
|
|
265
361
|
}
|
|
266
362
|
const missing = selection === undefined ? [] : selection.filter((id) => !discovered.has(id));
|
|
267
363
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (picked.length > 0 && changed) {
|
|
278
|
-
mkdirSync(dirname(fileAbs), { recursive: true });
|
|
279
|
-
writeFileSync(fileAbs, merged, 'utf-8');
|
|
364
|
+
// Skip-and-collect: one unloadable skill must not discard the whole aggregation.
|
|
365
|
+
const failures: SkillLoadFailure[] = [];
|
|
366
|
+
const loaded: { id: string; section: string }[] = [];
|
|
367
|
+
for (const { id, skillsDir } of picked) {
|
|
368
|
+
try {
|
|
369
|
+
loaded.push({ id, section: renderAgentsMdSection(loadSkillFromDir(skillsDir, id)) });
|
|
370
|
+
} catch (error) {
|
|
371
|
+
failures.push(describeSkillLoadFailure(skillsDir, id, error));
|
|
372
|
+
}
|
|
280
373
|
}
|
|
374
|
+
const sections = loaded.map((entry) => entry.section);
|
|
375
|
+
|
|
376
|
+
const managed = writeManagedMarkdown(options.projectRoot, sections, config, { write: loaded.length > 0 });
|
|
377
|
+
const changed = managed.changed;
|
|
281
378
|
|
|
282
379
|
// Report the shared root file as written/skipped on the FIRST contributing
|
|
283
380
|
// skill only, so callers that SUM per-skill file counts don't count the one
|
|
284
381
|
// shared file N times. The lossy warning is surfaced ONCE, on that same skill.
|
|
285
|
-
const skills: InitSkillResult[] =
|
|
382
|
+
const skills: InitSkillResult[] = loaded.map(({ id }, index) => {
|
|
286
383
|
const owns = index === 0;
|
|
287
384
|
return {
|
|
288
385
|
id,
|
|
@@ -292,7 +389,9 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
|
|
|
292
389
|
};
|
|
293
390
|
});
|
|
294
391
|
|
|
295
|
-
|
|
392
|
+
// No apply failures are possible here: the single write is outside every per-skill
|
|
393
|
+
// loop, so a write error propagates as itself rather than being attributed to a skill.
|
|
394
|
+
return { target: config.target, skillsDir: joinedDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures: [] };
|
|
296
395
|
}
|
|
297
396
|
|
|
298
397
|
/**
|
|
@@ -324,6 +423,114 @@ export function runInitGeminiMd(options: AgentsMdInitOptions): InitReport {
|
|
|
324
423
|
});
|
|
325
424
|
}
|
|
326
425
|
|
|
426
|
+
export interface AgentsPolicySyncOptions {
|
|
427
|
+
readonly projectRoot: string;
|
|
428
|
+
/** Verify only. No directory or file is created, even when AGENTS.md is absent. */
|
|
429
|
+
readonly check?: boolean;
|
|
430
|
+
/** Test/extension seam; production uses the fixed ordered registry. */
|
|
431
|
+
readonly sources?: readonly PolicySource[];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface AgentsPolicySyncReport {
|
|
435
|
+
readonly filePath: 'AGENTS.md';
|
|
436
|
+
/** In check mode: the target would change. In write mode: the target did change. */
|
|
437
|
+
readonly changed: boolean;
|
|
438
|
+
readonly written: boolean;
|
|
439
|
+
readonly inSync: boolean;
|
|
440
|
+
readonly blocks: readonly { readonly id: string; readonly sha: string }[];
|
|
441
|
+
readonly missing: readonly string[];
|
|
442
|
+
readonly drift: readonly PolicyDriftFinding[];
|
|
443
|
+
readonly budget: AgentsMdBudget;
|
|
444
|
+
readonly warnings: readonly string[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Refresh or verify the policy fence in root AGENTS.md. Source reads are an
|
|
449
|
+
* explicit I/O shell around the pure agents-policy module; missing/unreadable
|
|
450
|
+
* input returns inconclusive evidence and never writes a partial projection.
|
|
451
|
+
*/
|
|
452
|
+
export function runSyncAgentsPolicy(options: AgentsPolicySyncOptions): AgentsPolicySyncReport {
|
|
453
|
+
const sources = options.sources ?? POLICY_SOURCES;
|
|
454
|
+
const sourceFiles = new Map<string, string | null>();
|
|
455
|
+
for (const file of new Set(sources.map((source) => source.file))) {
|
|
456
|
+
try {
|
|
457
|
+
sourceFiles.set(file, readFileSync(join(options.projectRoot, file), 'utf8'));
|
|
458
|
+
} catch {
|
|
459
|
+
sourceFiles.set(file, null);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const agentsPath = join(options.projectRoot, AGENTS_MD_PATH);
|
|
463
|
+
let currentAgents: string | null = null;
|
|
464
|
+
try { currentAgents = readFileSync(agentsPath, 'utf8'); } catch { /* absent target is a normal first sync */ }
|
|
465
|
+
|
|
466
|
+
const extracted = extractPolicyBlocks(sourceFiles, sources);
|
|
467
|
+
if (extracted.missing.length > 0) {
|
|
468
|
+
const drift = detectPolicyDrift(sourceFiles, currentAgents, sources);
|
|
469
|
+
return {
|
|
470
|
+
filePath: AGENTS_MD_PATH,
|
|
471
|
+
changed: true,
|
|
472
|
+
written: false,
|
|
473
|
+
inSync: false,
|
|
474
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
475
|
+
missing: extracted.missing,
|
|
476
|
+
drift: drift.findings,
|
|
477
|
+
budget: measureAgentsMdBudget(currentAgents ?? ''),
|
|
478
|
+
warnings: [`policy source evidence is incomplete: ${extracted.missing.join(', ')}`],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const currentDrift = detectPolicyDrift(sourceFiles, currentAgents, sources);
|
|
483
|
+
const malformedOuterFence = currentDrift.findings.some((finding) => finding.id === 'dz:policies');
|
|
484
|
+
if (options.check !== true && malformedOuterFence) {
|
|
485
|
+
return {
|
|
486
|
+
filePath: AGENTS_MD_PATH,
|
|
487
|
+
changed: true,
|
|
488
|
+
written: false,
|
|
489
|
+
inSync: false,
|
|
490
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
491
|
+
missing: [],
|
|
492
|
+
drift: currentDrift.findings,
|
|
493
|
+
budget: measureAgentsMdBudget(currentAgents ?? ''),
|
|
494
|
+
warnings: ['refusing to rewrite malformed or duplicate dz:policies fence markers; repair their cardinality first'],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const sections = renderPolicySections(extracted.blocks, sources);
|
|
499
|
+
const rendered = sections.join('\n\n');
|
|
500
|
+
const claudeOnlyTokens = ['Workflow({', 'subagent_type', 'mcp__', '.claude/agents/v3/'];
|
|
501
|
+
const leaked = claudeOnlyTokens.find((token) => rendered.includes(token));
|
|
502
|
+
if (leaked !== undefined) throw new Error(`AGENTS.md policy block contains Claude-only token ${JSON.stringify(leaked)}; target left unchanged`);
|
|
503
|
+
|
|
504
|
+
const managed = writeManagedMarkdown(
|
|
505
|
+
options.projectRoot,
|
|
506
|
+
sections,
|
|
507
|
+
{ filePath: AGENTS_MD_PATH, merge: mergePolicyBlock },
|
|
508
|
+
{ write: options.check !== true, maxBytes: CODEX_PROJECT_DOC_MAX_BYTES },
|
|
509
|
+
);
|
|
510
|
+
const budget = measureAgentsMdBudget(managed.content);
|
|
511
|
+
const drift = detectPolicyDrift(sourceFiles, options.check === true ? currentAgents : managed.content, sources);
|
|
512
|
+
const warnings: string[] = [];
|
|
513
|
+
if (budget.bytes >= budget.cap * AGENTS_MD_BUDGET_WARN_FRACTION) {
|
|
514
|
+
warnings.push(`AGENTS.md uses ${budget.pct}% of the measured ${budget.cap.toLocaleString('en-US')} byte Codex project-document budget`);
|
|
515
|
+
}
|
|
516
|
+
const skillsBegin = managed.content.indexOf(AGENTS_MD_BLOCK_BEGIN);
|
|
517
|
+
if (skillsBegin !== -1 && budget.policyBlockEndsAtByte > Buffer.byteLength(managed.content.slice(0, skillsBegin), 'utf8')) {
|
|
518
|
+
warnings.push('policy fence must end before the skills fence because Codex truncates the tail');
|
|
519
|
+
}
|
|
520
|
+
const nonOk = drift.findings.filter((finding) => finding.status !== 'ok');
|
|
521
|
+
return {
|
|
522
|
+
filePath: AGENTS_MD_PATH,
|
|
523
|
+
changed: managed.changed,
|
|
524
|
+
written: options.check !== true && managed.changed,
|
|
525
|
+
inSync: options.check === true ? !managed.changed && nonOk.length === 0 : nonOk.length === 0,
|
|
526
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
527
|
+
missing: [],
|
|
528
|
+
drift: drift.findings,
|
|
529
|
+
budget,
|
|
530
|
+
warnings,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
327
534
|
/** Compile every skill in `skillsDir` for `target` and apply it under `projectRoot`. */
|
|
328
535
|
export async function runInit(options: InitOptions): Promise<InitReport> {
|
|
329
536
|
// agents-md is a flattening single-file target — aggregate all selected skills
|
|
@@ -355,19 +562,41 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
|
|
|
355
562
|
const missing = selection === undefined
|
|
356
563
|
? []
|
|
357
564
|
: selection.filter((id) => !discovered.includes(id));
|
|
565
|
+
// Skip-and-collect (D1): one unparseable SKILL.md must not discard the whole install.
|
|
566
|
+
// Same shape as `runVerify`'s long-standing per-id try/catch below.
|
|
567
|
+
//
|
|
568
|
+
// The `try` is scoped to `loadSkillFromDir` ALONE (ADR-001 §Decision part 2, restored
|
|
569
|
+
// in fix round 1 / QE F4). It used to wrap compile + apply too, so an EEXIST from
|
|
570
|
+
// `mkdir` was described by `describeSkillLoadFailure` and printed under the
|
|
571
|
+
// "unparseable SKILL.md" header, naming the source file — which was valid. Compile
|
|
572
|
+
// and write failures are a SECOND kind with their own subject and their own header;
|
|
573
|
+
// they are collected, not thrown, so one unwritable target directory still cannot
|
|
574
|
+
// discard the rest of the install.
|
|
575
|
+
const failures: SkillLoadFailure[] = [];
|
|
576
|
+
const applyFailures: SkillApplyFailure[] = [];
|
|
358
577
|
for (const id of ids) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
578
|
+
let skill;
|
|
579
|
+
try {
|
|
580
|
+
skill = loadSkillFromDir(options.skillsDir, id);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
failures.push(describeSkillLoadFailure(options.skillsDir, id, error));
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
let emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
|
|
587
|
+
if (options.enrich === true) {
|
|
588
|
+
emit = enrichEmitForTarget(emit, options.target, id, skill);
|
|
589
|
+
}
|
|
590
|
+
const applied = applyEmitResult(emit, {
|
|
591
|
+
targetRoot: options.projectRoot,
|
|
592
|
+
force: options.force === true,
|
|
593
|
+
});
|
|
594
|
+
skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
|
|
595
|
+
} catch (error) {
|
|
596
|
+
applyFailures.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
363
597
|
}
|
|
364
|
-
const applied = applyEmitResult(emit, {
|
|
365
|
-
targetRoot: options.projectRoot,
|
|
366
|
-
force: options.force === true,
|
|
367
|
-
});
|
|
368
|
-
skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
|
|
369
598
|
}
|
|
370
|
-
return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing };
|
|
599
|
+
return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures };
|
|
371
600
|
}
|
|
372
601
|
|
|
373
602
|
// ---------------------------------------------------------------------------
|
|
@@ -457,15 +686,28 @@ export interface SyncReport {
|
|
|
457
686
|
readonly dryRun: boolean;
|
|
458
687
|
readonly skills: SyncSkillResult[];
|
|
459
688
|
readonly summary: { total: number; inSync: number; missing: number; drift: number };
|
|
689
|
+
/**
|
|
690
|
+
* Canonical skill directories that could not be LOADED. Additive, always present.
|
|
691
|
+
* See {@link InitReport.failures} — same contract, same reason.
|
|
692
|
+
*/
|
|
693
|
+
readonly failures: readonly SkillLoadFailure[];
|
|
460
694
|
}
|
|
461
695
|
|
|
462
696
|
/** Compare each canonical skill (compiled for Claude Code) to the legacy tree. */
|
|
463
697
|
export async function runSync(options: SyncOptions): Promise<SyncReport> {
|
|
464
698
|
const dirs = options.canonicalDirs ?? (options.canonicalDir ? [options.canonicalDir] : []);
|
|
465
699
|
const skills: SyncSkillResult[] = [];
|
|
700
|
+
// Skip-and-collect (D1): a broken canonical skill must not hide every other pack.
|
|
701
|
+
const failures: SkillLoadFailure[] = [];
|
|
466
702
|
for (const canonicalDir of dirs) {
|
|
467
703
|
for (const id of discoverSkillIds(canonicalDir)) {
|
|
468
|
-
|
|
704
|
+
let skill;
|
|
705
|
+
try {
|
|
706
|
+
skill = loadSkillFromDir(canonicalDir, id);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
failures.push(describeSkillLoadFailure(canonicalDir, id, error));
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
469
711
|
const emit = await claudeAdapter.compile(skill, { targetRoot: options.projectRoot });
|
|
470
712
|
|
|
471
713
|
let differ = false;
|
|
@@ -490,6 +732,7 @@ export async function runSync(options: SyncOptions): Promise<SyncReport> {
|
|
|
490
732
|
return {
|
|
491
733
|
dryRun: options.dryRun === true,
|
|
492
734
|
skills,
|
|
735
|
+
failures,
|
|
493
736
|
summary: {
|
|
494
737
|
total: skills.length,
|
|
495
738
|
inSync: skills.filter((skill) => skill.status === 'in-sync').length,
|
|
@@ -738,6 +981,38 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
738
981
|
});
|
|
739
982
|
}
|
|
740
983
|
} catch { /* settings absent/unreadable — covered by other checks */ }
|
|
984
|
+
// CODEX APPLY-LEG (AM-19, fact CORRECTED by AM-30). This block is deliberately OUTSIDE the
|
|
985
|
+
// `existsSync(writerPath)` guard below. MEASURED: the Claude apply-leg check has THREE
|
|
986
|
+
// preconditions, not two — the `.claude/settings.json` text check is NESTED inside
|
|
987
|
+
// `if (existsSync(join(root,'.dz','agentdb-writer.mjs')))`. A Codex-only machine has no writer
|
|
988
|
+
// file and no `.claude/settings.json`, so a Codex branch added *in place* would report nothing at
|
|
989
|
+
// all — reproducing the exact 19-day dark-leg shape this check exists to kill.
|
|
990
|
+
//
|
|
991
|
+
// A WIRED-BUT-SILENT leg is an explicit non-OK row, never an absent one.
|
|
992
|
+
try {
|
|
993
|
+
const { codexHooksPaths: codexPaths, parseCodexHookManifest: parseCodexManifest } = await import('./codex-hooks.js');
|
|
994
|
+
const codexHome = process.env['CODEX_HOME'] ?? join(homedir(), '.codex');
|
|
995
|
+
const manifestPath = codexPaths(codexHome).manifest;
|
|
996
|
+
if (existsSync(manifestPath)) {
|
|
997
|
+
const manifest = parseCodexManifest(readFileSync(manifestPath, 'utf-8'));
|
|
998
|
+
const claimsRecall = manifest?.entries.some((e) => e.id === 'codex-recall') === true;
|
|
999
|
+
if (claimsRecall) {
|
|
1000
|
+
const usageLog = join(root, '.dz', 'recall-usage.jsonl');
|
|
1001
|
+
const { newest, hasCodexRow } = newestRecallUsageRuntime(usageLog);
|
|
1002
|
+
const fresh = newest !== undefined && Date.now() - Date.parse(newest) < 14 * 24 * 60 * 60 * 1000;
|
|
1003
|
+
checks.push({
|
|
1004
|
+
name: 'codex apply-leg (recall hook)',
|
|
1005
|
+
ok: hasCodexRow && fresh,
|
|
1006
|
+
detail: hasCodexRow
|
|
1007
|
+
? fresh
|
|
1008
|
+
? `codex recall rows present, newest ${newest}`
|
|
1009
|
+
: `codex recall hook is WIRED but SILENT: newest recall-usage row is ${String(newest)} — the entry may have lost hook trust (re-run dz hooks-sync --target codex --verify)`
|
|
1010
|
+
: 'codex recall hook is WIRED but has NEVER written a row — a dead leg looks exactly like a correctly-silent one, so this is reported non-OK until one lands',
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
} catch { /* doctor never throws on a diagnostic */ }
|
|
1015
|
+
|
|
741
1016
|
const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
|
|
742
1017
|
if (existsSync(writerPath)) {
|
|
743
1018
|
const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
|
|
@@ -832,3 +1107,820 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
832
1107
|
|
|
833
1108
|
return { node: process.version, checks, ok: checks.every((check) => check.ok) };
|
|
834
1109
|
}
|
|
1110
|
+
|
|
1111
|
+
/* ========================================================================== */
|
|
1112
|
+
/* Codex hook install (`crossrt-2-codex-hooks`, M7 · ADR-001/ADR-002) */
|
|
1113
|
+
/* ========================================================================== */
|
|
1114
|
+
|
|
1115
|
+
export interface CodexHooksSyncOptions {
|
|
1116
|
+
/** Defaults to `$CODEX_HOME`, then `~/.codex`. Every automated probe passes a temp dir (AM-11). */
|
|
1117
|
+
readonly codexHome?: string;
|
|
1118
|
+
/** Verify only. NOTHING is created — not even the helper directory. */
|
|
1119
|
+
readonly check?: boolean;
|
|
1120
|
+
/** Remove manifest-attributed entries and stop. */
|
|
1121
|
+
readonly remove?: boolean;
|
|
1122
|
+
/** Absolute interpreter for the emitted command. Defaults to `process.execPath` (AM-32). */
|
|
1123
|
+
readonly nodePath?: string;
|
|
1124
|
+
/**
|
|
1125
|
+
* Test seam: `false` skips EVERY live runtime call — the `$SHELL -lc` liveness self-probe AND the
|
|
1126
|
+
* `hooks/list` trust query. Both spawn processes (the RPC drives a real `codex app-server`), which
|
|
1127
|
+
* is seconds per install; a test that installs six times to check backup pruning is measuring
|
|
1128
|
+
* pruning, not the runtime. With it off, `trust` is reported `'unknown'` rather than assumed.
|
|
1129
|
+
*/
|
|
1130
|
+
readonly liveness?: boolean;
|
|
1131
|
+
/** Test seam. Production probes the real binary. */
|
|
1132
|
+
readonly codexVersion?: string | null;
|
|
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;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export interface CodexHooksSyncReport {
|
|
1157
|
+
readonly codexHome: string;
|
|
1158
|
+
readonly registryPath: string;
|
|
1159
|
+
/** Both managed entries are present in the file (recomputed from the FILE, not the manifest). */
|
|
1160
|
+
readonly installed: boolean;
|
|
1161
|
+
/** The emitted command actually RUNS through `$SHELL -lc`. A hook that cannot execute is not armed. */
|
|
1162
|
+
readonly executable: boolean;
|
|
1163
|
+
readonly written: boolean;
|
|
1164
|
+
readonly removed: number;
|
|
1165
|
+
readonly foreignPreserved: number;
|
|
1166
|
+
readonly unattributable: number;
|
|
1167
|
+
readonly drift: readonly string[];
|
|
1168
|
+
readonly trust: 'trusted' | 'trust-pending' | 'unknown';
|
|
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;
|
|
1179
|
+
/** 0 = armed+trusted · 1 = not armed / drift / refusal · 3 = inconclusive. */
|
|
1180
|
+
readonly exitCode: 0 | 1 | 3;
|
|
1181
|
+
readonly warnings: readonly string[];
|
|
1182
|
+
readonly errors: readonly string[];
|
|
1183
|
+
/** Written paths, for the declared-write-set test. */
|
|
1184
|
+
readonly writes: readonly string[];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
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 {
|
|
1191
|
+
if (typeof explicit === 'string' && explicit !== '') return explicit;
|
|
1192
|
+
const env = process.env['CODEX_HOME'];
|
|
1193
|
+
if (typeof env === 'string' && env !== '') return env;
|
|
1194
|
+
return join(homedir(), '.codex');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** `codex --version` → `codex-cli 0.147.0`. `null` when the binary is absent or silent. */
|
|
1198
|
+
function probeCodexVersion(): string | null {
|
|
1199
|
+
try {
|
|
1200
|
+
const out = execFileSync('codex', ['--version'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1201
|
+
const trimmed = out.trim();
|
|
1202
|
+
return trimmed === '' ? null : trimmed;
|
|
1203
|
+
} catch {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Run the emitted command THE WAY THE RUNTIME WILL — through `$SHELL -lc` (AM-32 / G-L).
|
|
1210
|
+
*
|
|
1211
|
+
* MEASURED: the codex hook runner spawns via `$SHELL -lc`, and under nvm/asdf/volta a
|
|
1212
|
+
* non-interactive login shell frequently lacks `node`. The helper then exits **127**, which the
|
|
1213
|
+
* runtime reads as **ALLOW** — a blocking guard that is silently dead in the fail-open direction,
|
|
1214
|
+
* with the helper's own self-failure note unable to fire because the process never started.
|
|
1215
|
+
* Grading on file presence would call that "installed".
|
|
1216
|
+
*/
|
|
1217
|
+
export function probeHookLiveness(command: string, payload: string): { readonly status: number | null; readonly stderr: string } {
|
|
1218
|
+
const shell = process.env['SHELL'] ?? '/bin/sh';
|
|
1219
|
+
try {
|
|
1220
|
+
const res = spawnSync(shell, ['-lc', command], {
|
|
1221
|
+
input: payload,
|
|
1222
|
+
encoding: 'utf8',
|
|
1223
|
+
timeout: 20_000,
|
|
1224
|
+
env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
|
|
1225
|
+
});
|
|
1226
|
+
return { status: res.status, stderr: res.stderr ?? '' };
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
return { status: null, stderr: String((err as Error)?.message ?? err) };
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Keep the newest N backups; older ones are litter in the user's home (AM-35b). */
|
|
1233
|
+
function pruneBackups(dir: string, prefix: string, keep: number): void {
|
|
1234
|
+
try {
|
|
1235
|
+
const found = readdirSync(dir).filter((f) => f.startsWith(prefix)).sort();
|
|
1236
|
+
for (const stale of found.slice(0, Math.max(0, found.length - keep))) {
|
|
1237
|
+
rmSync(join(dir, stale), { force: true });
|
|
1238
|
+
}
|
|
1239
|
+
} catch { /* pruning is hygiene, never a failure */ }
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Install, verify or remove the dz Codex hooks.
|
|
1244
|
+
*
|
|
1245
|
+
* Order is load-bearing: every REFUSAL happens before any `mkdir` or write, so a machine without
|
|
1246
|
+
* codex, or with an unparseable registry, is left byte-untouched (AM-35a).
|
|
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
|
+
|
|
1318
|
+
export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHooksSyncReport {
|
|
1319
|
+
const codexHome = resolveCodexHome(options.codexHome);
|
|
1320
|
+
const paths = codexHooksPaths(codexHome);
|
|
1321
|
+
const warnings: string[] = [];
|
|
1322
|
+
const errors: string[] = [];
|
|
1323
|
+
const writes: string[] = [];
|
|
1324
|
+
const now = options.now ?? new Date().toISOString();
|
|
1325
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
1326
|
+
|
|
1327
|
+
const base = {
|
|
1328
|
+
codexHome,
|
|
1329
|
+
registryPath: paths.registry,
|
|
1330
|
+
installed: false,
|
|
1331
|
+
executable: false,
|
|
1332
|
+
written: false,
|
|
1333
|
+
removed: 0,
|
|
1334
|
+
foreignPreserved: 0,
|
|
1335
|
+
unattributable: 0,
|
|
1336
|
+
drift: [] as string[],
|
|
1337
|
+
trust: 'unknown' as const,
|
|
1338
|
+
codexVersion: null as string | null,
|
|
1339
|
+
writes: [] as string[],
|
|
1340
|
+
verify: null as VetoProbeResult | null,
|
|
1341
|
+
verified: false,
|
|
1342
|
+
ready: false,
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
// (1) REFUSE when codex is not installed — dz does not create user-global config for a runtime
|
|
1346
|
+
// that is not there (AM-35a). `--check` is allowed to answer, but it writes nothing anyway.
|
|
1347
|
+
const codexVersion = options.codexVersion === undefined ? probeCodexVersion() : options.codexVersion;
|
|
1348
|
+
if (codexVersion === null) {
|
|
1349
|
+
return { ...base, exitCode: 3, warnings, errors: ['no `codex` binary on PATH — nothing was written'] };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// (2) REFUSE an unquotable CODEX_HOME rather than emit a broken `$SHELL -lc` string (AM-35d).
|
|
1353
|
+
let entries;
|
|
1354
|
+
try {
|
|
1355
|
+
entries = buildManagedEntries({ nodePath, paths });
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
return { ...base, codexVersion, exitCode: 1, warnings, errors: [String((err as Error).message)] };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const criticalSection = options.criticalSection ?? (<T>(fn: () => T): T => fn());
|
|
1361
|
+
|
|
1362
|
+
const currentText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
|
|
1363
|
+
const manifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
|
|
1364
|
+
|
|
1365
|
+
// (3) --remove: delete only what the manifest proves is ours.
|
|
1366
|
+
if (options.remove === true) {
|
|
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
|
+
});
|
|
1387
|
+
if (!removal.ok) return { ...base, codexVersion, exitCode: 1, warnings, errors: [removal.error] };
|
|
1388
|
+
return {
|
|
1389
|
+
...base,
|
|
1390
|
+
codexVersion,
|
|
1391
|
+
written: removal.result.changed,
|
|
1392
|
+
removed: removal.result.removed,
|
|
1393
|
+
unattributable: removal.result.unattributable,
|
|
1394
|
+
exitCode: 0,
|
|
1395
|
+
warnings:
|
|
1396
|
+
removal.result.unattributable > 0
|
|
1397
|
+
? [`${removal.result.unattributable} entr(ies) resemble dz hooks but are not manifest-attributed — KEPT, remove them by hand if you want them gone`]
|
|
1398
|
+
: warnings,
|
|
1399
|
+
errors,
|
|
1400
|
+
writes,
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// (4) --check: recompute from the FILE and FIRE the helper. Writes nothing, and stays SILENT in
|
|
1405
|
+
// a home that never opted in (the leg-1 F12 lesson).
|
|
1406
|
+
if (options.check === true) {
|
|
1407
|
+
const drift = diffCodexHooks(currentText, entries, manifest);
|
|
1408
|
+
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
|
|
1409
|
+
const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
|
|
1410
|
+
// `--check` must report the TRUST axis too. Without it the report said `installed && executable`
|
|
1411
|
+
// with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
|
|
1412
|
+
// failure ("no success word without armed AND trusted"), reached through the read-only path.
|
|
1413
|
+
const listed = drift.installed && options.liveness !== false ? listCodexHooks(codexHome) : null;
|
|
1414
|
+
const own = listed === null ? [] : selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
|
|
1415
|
+
const checkTrust: 'trusted' | 'trust-pending' | 'unknown' =
|
|
1416
|
+
listed === null
|
|
1417
|
+
? 'unknown'
|
|
1418
|
+
: own.length === entries.length && own.every((o) => o.meta.trustStatus === 'trusted' || o.meta.trustStatus === 'managed')
|
|
1419
|
+
? 'trusted'
|
|
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;
|
|
1448
|
+
return {
|
|
1449
|
+
...base,
|
|
1450
|
+
codexVersion,
|
|
1451
|
+
installed: drift.installed,
|
|
1452
|
+
executable,
|
|
1453
|
+
trust: checkTrust,
|
|
1454
|
+
foreignPreserved: drift.foreignPreserved,
|
|
1455
|
+
unattributable: drift.unattributable,
|
|
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),
|
|
1460
|
+
// A home that never opted in is NOT a failure: `--check` is silent and exits 0 there (the
|
|
1461
|
+
// leg-1 F12 lesson — a check that chatters in every unrelated project trains its reader to
|
|
1462
|
+
// ignore it). Installed-but-broken is exit 1; installed-and-live-and-PROVEN is exit 0.
|
|
1463
|
+
exitCode: checkExit,
|
|
1464
|
+
warnings,
|
|
1465
|
+
errors,
|
|
1466
|
+
writes,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// (5) Install. Parse-refuse BEFORE any mkdir (I1: an unparseable file must not be overwritten).
|
|
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;
|
|
1492
|
+
if (!planned.ok) {
|
|
1493
|
+
return {
|
|
1494
|
+
...base,
|
|
1495
|
+
codexVersion,
|
|
1496
|
+
exitCode: 1,
|
|
1497
|
+
warnings,
|
|
1498
|
+
errors: [
|
|
1499
|
+
planned.error,
|
|
1500
|
+
`heal by hand: fix ${paths.registry} (or move it aside) and re-run — dz refuses to overwrite a registry it cannot read, because foreign entries would be lost`,
|
|
1501
|
+
],
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
// (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
|
|
1507
|
+
const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
|
|
1508
|
+
const executable = live.status === 0 || live.status === 2;
|
|
1509
|
+
if (!executable) {
|
|
1510
|
+
warnings.push(
|
|
1511
|
+
`install liveness probe FAILED: the emitted command exited ${String(live.status)} through \`${process.env['SHELL'] ?? '/bin/sh'} -lc\` (127 = interpreter not found). The entry is registered but NOT armed; resolved interpreter: ${nodePath}`,
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// (7) TRUST: arm the entry unattended by writing codex's own key+hash into config.toml.
|
|
1516
|
+
// Both values are READ from `hooks/list` — `currentHash`'s preimage is internal to codex, so
|
|
1517
|
+
// a computed hash would arm nothing while reading like success (M0 spike §4).
|
|
1518
|
+
const trustResult =
|
|
1519
|
+
options.liveness === false
|
|
1520
|
+
? { trust: 'unknown' as const, rows: [] as { id: string; key: string }[], warnings: [] as string[] }
|
|
1521
|
+
: armCodexHookTrust(paths, entries, writes);
|
|
1522
|
+
for (const w of trustResult.warnings) warnings.push(w);
|
|
1523
|
+
|
|
1524
|
+
const trustKeys: Record<string, string> = {};
|
|
1525
|
+
for (const row of trustResult.rows) trustKeys[row.id] = row.key;
|
|
1526
|
+
const manifestText = `${JSON.stringify(
|
|
1527
|
+
buildCodexHookManifest({ entries, paths, codexVersion, writtenAt: now, nodePath, trustKeys }),
|
|
1528
|
+
null,
|
|
1529
|
+
2,
|
|
1530
|
+
)}\n`;
|
|
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
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const drift = manifestOutcome.drift;
|
|
1561
|
+
const installed = drift.installed;
|
|
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;
|
|
1583
|
+
return {
|
|
1584
|
+
codexHome,
|
|
1585
|
+
registryPath: paths.registry,
|
|
1586
|
+
installed,
|
|
1587
|
+
executable,
|
|
1588
|
+
written: planned.plan.changed || writes.length > 0,
|
|
1589
|
+
removed: 0,
|
|
1590
|
+
foreignPreserved: planned.plan.foreignPreserved,
|
|
1591
|
+
unattributable: planned.plan.unattributable,
|
|
1592
|
+
drift: drift.drifted,
|
|
1593
|
+
trust: trustResult.trust,
|
|
1594
|
+
codexVersion,
|
|
1595
|
+
verify: verification.result,
|
|
1596
|
+
verified: verification.result !== null && isReadyVerdict(verification.result),
|
|
1597
|
+
ready: armedState && verification.result !== null && isReadyVerdict(verification.result),
|
|
1598
|
+
exitCode,
|
|
1599
|
+
warnings,
|
|
1600
|
+
errors,
|
|
1601
|
+
writes,
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
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
|
+
|
|
1778
|
+
/** A synthetic ALLOWED PreToolUse payload — the liveness probe must never trigger a real policy hit. */
|
|
1779
|
+
const ALLOWED_PROBE_PAYLOAD = JSON.stringify({
|
|
1780
|
+
hook_event_name: 'PreToolUse',
|
|
1781
|
+
tool_name: 'Bash',
|
|
1782
|
+
tool_input: { command: 'true' },
|
|
1783
|
+
cwd: '/nonexistent-dz-liveness-probe',
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
function atomicWrite(path: string, text: string): void {
|
|
1787
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
1788
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
1789
|
+
renameSync(tmp, path);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** 0600: the helpers are invoked as `node <path>`, never executed directly (AM-35c). */
|
|
1793
|
+
function writeHelperIfChanged(path: string, body: string, writes: string[]): void {
|
|
1794
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
1795
|
+
if (current === body) return; // byte-idempotence: no change ⇒ no write ⇒ trust survives (FR-2)
|
|
1796
|
+
atomicWrite(path, body);
|
|
1797
|
+
writes.push(path);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function backupRegistry(paths: CodexHooksPaths, currentText: string | undefined, now: string, writes: string[]): void {
|
|
1801
|
+
if (currentText === undefined) return;
|
|
1802
|
+
const backup = `${paths.registry}.bak-${now.replace(/[:.]/g, '-')}`;
|
|
1803
|
+
writeFileSync(backup, currentText, { mode: 0o600 });
|
|
1804
|
+
writes.push(backup);
|
|
1805
|
+
pruneBackups(paths.codexHome, 'hooks.json.bak-', 3);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Ask the runtime which of OUR entries it sees, then persist trust for exactly those.
|
|
1810
|
+
*
|
|
1811
|
+
* Matching is on the exact emitted command string, so a third party's entry can never inherit dz's
|
|
1812
|
+
* trust write. A failure here is a WARNING, never an install failure: the registry is written and
|
|
1813
|
+
* the user can approve interactively (the `ARMED = NO` branch, AM-17 — both branches ship).
|
|
1814
|
+
*/
|
|
1815
|
+
function armCodexHookTrust(
|
|
1816
|
+
paths: CodexHooksPaths,
|
|
1817
|
+
entries: readonly ManagedEntry[],
|
|
1818
|
+
writes: string[],
|
|
1819
|
+
): {
|
|
1820
|
+
readonly trust: 'trusted' | 'trust-pending' | 'unknown';
|
|
1821
|
+
readonly rows: readonly { id: string; key: string }[];
|
|
1822
|
+
readonly warnings: readonly string[];
|
|
1823
|
+
/** The runtime's own word for OUR entry, handed to the veto classifier's trust axis. */
|
|
1824
|
+
readonly rowStatus?: CodexHookTrustStatus | undefined;
|
|
1825
|
+
} {
|
|
1826
|
+
const listed = listCodexHooks(paths.codexHome);
|
|
1827
|
+
if (listed === null) {
|
|
1828
|
+
return {
|
|
1829
|
+
trust: 'unknown',
|
|
1830
|
+
rows: [],
|
|
1831
|
+
warnings: [
|
|
1832
|
+
'could not read the codex `hooks/list` RPC — hook trust could not be recorded. ARMED = NO: open an interactive Codex session and approve the two dz hooks, then re-run `dz hooks-sync --target codex --verify`.',
|
|
1833
|
+
],
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
const own = selectOwnHookMetadata(listed, entries, { registryPath: paths.registry });
|
|
1837
|
+
if (own.length !== entries.length) {
|
|
1838
|
+
return {
|
|
1839
|
+
trust: 'trust-pending',
|
|
1840
|
+
rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
|
|
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)`],
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
const configText = existsSync(paths.configToml) ? readFileSync(paths.configToml, 'utf8') : '';
|
|
1845
|
+
const upserted = upsertTrustBlock(
|
|
1846
|
+
configText,
|
|
1847
|
+
own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })),
|
|
1848
|
+
);
|
|
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);
|
|
1856
|
+
writes.push(paths.configToml);
|
|
1857
|
+
}
|
|
1858
|
+
// Re-read: the only honest confirmation that the write armed anything is the runtime's own answer.
|
|
1859
|
+
const after = listCodexHooks(paths.codexHome);
|
|
1860
|
+
const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries, { registryPath: paths.registry });
|
|
1861
|
+
const allTrusted = confirmed.length === entries.length && confirmed.every((c) => c.meta.trustStatus === 'trusted' || c.meta.trustStatus === 'managed');
|
|
1862
|
+
return {
|
|
1863
|
+
trust: allTrusted ? 'trusted' : 'trust-pending',
|
|
1864
|
+
rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
|
|
1865
|
+
...(confirmed[0] !== undefined ? { rowStatus: confirmed[0].meta.trustStatus } : {}),
|
|
1866
|
+
warnings: allTrusted
|
|
1867
|
+
? []
|
|
1868
|
+
: ['dz wrote the hook trust rows but codex still reports the entries as untrusted — approve them in an interactive session'],
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Drive `codex app-server` over stdio for one `hooks/list` call.
|
|
1874
|
+
*
|
|
1875
|
+
* MEASURED headless (M0 spike, probe 1): `initialize` → `initialized` → `hooks/list` answers with
|
|
1876
|
+
* `key`, `currentHash`, `trustStatus` and `sourcePath` for every discovered entry, no TUI involved.
|
|
1877
|
+
*/
|
|
1878
|
+
export function listCodexHooks(codexHome: string, cwd: string = process.cwd()): readonly HooksListHookMetadata[] | null {
|
|
1879
|
+
const script = [
|
|
1880
|
+
"const{spawn}=require('node:child_process');",
|
|
1881
|
+
"const p=spawn('codex',['app-server'],{env:{...process.env,CODEX_HOME:process.argv[1]},stdio:['pipe','pipe','ignore']});",
|
|
1882
|
+
"let b='';const send=o=>p.stdin.write(JSON.stringify(o)+'\\n');",
|
|
1883
|
+
"const bail=()=>{try{p.kill()}catch(e){};process.stdout.write('null');process.exit(0)};",
|
|
1884
|
+
"const t=setTimeout(bail,45000);",
|
|
1885
|
+
"p.on('error',bail);",
|
|
1886
|
+
"p.stdout.on('data',d=>{b+=d.toString();let i;while((i=b.indexOf('\\n'))>=0){const l=b.slice(0,i);b=b.slice(i+1);if(!l.trim())continue;let m;try{m=JSON.parse(l)}catch(e){continue}",
|
|
1887
|
+
"if(m.id===1){send({jsonrpc:'2.0',method:'initialized'});send({jsonrpc:'2.0',id:2,method:'hooks/list',params:{cwds:[process.argv[2]]}})}",
|
|
1888
|
+
"else if(m.id===2){clearTimeout(t);process.stdout.write(JSON.stringify(m.result&&m.result.data?m.result.data:null));try{p.kill()}catch(e){};process.exit(0)}}});",
|
|
1889
|
+
"send({jsonrpc:'2.0',id:1,method:'initialize',params:{clientInfo:{name:'dz',version:'1.0.0'}}});",
|
|
1890
|
+
].join('');
|
|
1891
|
+
try {
|
|
1892
|
+
const out = execFileSync(process.execPath, ['-e', script, codexHome, cwd], {
|
|
1893
|
+
encoding: 'utf8',
|
|
1894
|
+
timeout: 60_000,
|
|
1895
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1896
|
+
});
|
|
1897
|
+
const data = JSON.parse(out) as { hooks?: HooksListHookMetadata[] }[] | null;
|
|
1898
|
+
if (data === null || !Array.isArray(data)) return null;
|
|
1899
|
+
return data.flatMap((entry) => entry.hooks ?? []);
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** Newest recall-usage row timestamp and whether any row carries `runtime: 'codex'`. Never throws. */
|
|
1906
|
+
function newestRecallUsageRuntime(logPath: string): { readonly newest?: string; readonly hasCodexRow: boolean } {
|
|
1907
|
+
try {
|
|
1908
|
+
if (!existsSync(logPath)) return { hasCodexRow: false };
|
|
1909
|
+
const lines = readFileSync(logPath, 'utf-8').split('\n');
|
|
1910
|
+
let newest: string | undefined;
|
|
1911
|
+
let hasCodexRow = false;
|
|
1912
|
+
for (const line of lines) {
|
|
1913
|
+
if (line.trim() === '') continue;
|
|
1914
|
+
try {
|
|
1915
|
+
const rec = JSON.parse(line) as { ts?: unknown; runtime?: unknown };
|
|
1916
|
+
if (rec.runtime === 'codex' && typeof rec.ts === 'string') {
|
|
1917
|
+
hasCodexRow = true;
|
|
1918
|
+
if (newest === undefined || rec.ts > newest) newest = rec.ts;
|
|
1919
|
+
}
|
|
1920
|
+
} catch { /* a malformed line is counted elsewhere */ }
|
|
1921
|
+
}
|
|
1922
|
+
return newest === undefined ? { hasCodexRow } : { newest, hasCodexRow };
|
|
1923
|
+
} catch {
|
|
1924
|
+
return { hasCodexRow: false };
|
|
1925
|
+
}
|
|
1926
|
+
}
|