@dzhechkov/harness-core 0.4.4 → 0.5.0
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 +283 -103
- package/LICENSE +21 -0
- package/README.md +73 -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 +287 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +74 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +140 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +258 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +391 -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 +196 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +538 -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 +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/managed-hooks.d.ts +76 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +89 -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/operations.d.ts +153 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +560 -24
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +38 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +78 -5
- package/dist/parity.js.map +1 -1
- 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/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/package.json +20 -19
- package/sbom.json +552 -102
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +291 -0
- package/src/codex-hooks-verify.ts +184 -0
- package/src/codex-hooks.ts +571 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +642 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +118 -2
- package/src/loop-blobs.generated.ts +2 -2
- package/src/managed-hooks.ts +129 -0
- package/src/mutation-gate.ts +24 -2
- package/src/operations.ts +719 -28
- package/src/parity.ts +120 -6
- package/src/recall-usage.ts +184 -1
- 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/parity.ts
CHANGED
|
@@ -23,7 +23,13 @@ import type { TargetName } from './targets.js';
|
|
|
23
23
|
export type RuntimeCapability =
|
|
24
24
|
| 'shell' // can run the dz CLI, i.e. a shell WITH Node.js (what `npm i -g` implies on a dev machine)
|
|
25
25
|
| 'skills' // consumes compiled skills (all adapters emit them; agents-md as one merged file)
|
|
26
|
-
|
|
26
|
+
// The blanket `hooks` capability is deliberately GONE (AM-21). The matrix is COMPUTED, so one
|
|
27
|
+
// word here moves several cells: granting a target `hooks` promoted BOTH `claim-check` (a
|
|
28
|
+
// GATE_FEATURE_IDS member) and `learning-apply` to `full` in one edit, and a caveat comment
|
|
29
|
+
// cannot move a computed cell back.
|
|
30
|
+
| 'hooks-write' // PreToolUse on FILE writes — what claim-check's automatic form needs
|
|
31
|
+
| 'hooks-shell' // PreToolUse on SHELL commands — what the codex veto leg ships
|
|
32
|
+
| 'hooks-prompt' // UserPromptSubmit — what the auto-recall apply leg ships
|
|
27
33
|
| 'mcp' // Model Context Protocol client exists on the platform (servers still need configuring)
|
|
28
34
|
| 'mcp-configured' // our MCP servers (AgentDB / agentic-qe) are wired up out of the box (.mcp.json)
|
|
29
35
|
| 'workflows' // deterministic multi-agent Workflow runtime (ultracode feature-adr, delivery gate)
|
|
@@ -33,7 +39,9 @@ export type RuntimeCapability =
|
|
|
33
39
|
export const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
|
|
34
40
|
'shell',
|
|
35
41
|
'skills',
|
|
36
|
-
'hooks',
|
|
42
|
+
'hooks-write',
|
|
43
|
+
'hooks-shell',
|
|
44
|
+
'hooks-prompt',
|
|
37
45
|
'mcp',
|
|
38
46
|
'mcp-configured',
|
|
39
47
|
'workflows',
|
|
@@ -47,9 +55,18 @@ export const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
|
|
|
47
55
|
export const TARGET_CAPABILITIES: Record<TargetName, readonly RuntimeCapability[]> = {
|
|
48
56
|
// Daily-driven harness: hooks (claim-check PreToolUse, recall UserPromptSubmit), MCP servers,
|
|
49
57
|
// Workflow runtime and the statusline are all exercised in this repo every session.
|
|
50
|
-
'claude-code': ['shell', 'skills', 'hooks', 'mcp', 'mcp-configured', 'workflows', 'statusline'],
|
|
58
|
+
'claude-code': ['shell', 'skills', 'hooks-write', 'hooks-shell', 'hooks-prompt', 'mcp', 'mcp-configured', 'workflows', 'statusline'],
|
|
51
59
|
// MCP subsystem probed live: `codex mcp --help` + `codex mcp list` answer (2026-07-19).
|
|
52
|
-
|
|
60
|
+
// hooks-shell + hooks-prompt: user-global `$CODEX_HOME/hooks.json`, installed and ARMED
|
|
61
|
+
// unattended, proved by a live two-sided block probe (2026-08-19, codex-cli 0.147.0 — the
|
|
62
|
+
// transcripts named in CAPABILITY_EVIDENCE). `hooks-write` is NOT granted: this leg ships no
|
|
63
|
+
// PreToolUse guard on Write/Edit, so `claim-check` stays `manual` on codex.
|
|
64
|
+
//
|
|
65
|
+
// Fact CORRECTED 2026-08-19: a project-level `<repo>/.codex/hooks.json` DOES load on 0.147.0
|
|
66
|
+
// (`source: "project"`, MEASURED). The earlier note that project-level files are ignored was
|
|
67
|
+
// measured on 0.144.6 and is stale. dz still writes only the user-global registry, but that is
|
|
68
|
+
// now a DECISION (one carrier, one removable unit) rather than a description of the runtime.
|
|
69
|
+
codex: ['shell', 'skills', 'mcp', 'hooks-shell', 'hooks-prompt'],
|
|
53
70
|
// Conservative v1: skills emission verified by the adapters; richer runtimes unproven.
|
|
54
71
|
opencode: ['shell', 'skills'],
|
|
55
72
|
hermes: ['shell', 'skills'],
|
|
@@ -62,6 +79,103 @@ export const TARGET_CAPABILITIES: Record<TargetName, readonly RuntimeCapability[
|
|
|
62
79
|
windsurf: ['shell', 'skills'],
|
|
63
80
|
};
|
|
64
81
|
|
|
82
|
+
/**
|
|
83
|
+
* WHY each capability grant is believed, as machine-readable DATA (AM-22).
|
|
84
|
+
*
|
|
85
|
+
* `parity.ts` has declared an honesty contract in prose since day one — *"a capability flag states
|
|
86
|
+
* only what has been VERIFIED, each with its source in a comment"*. A comment is documentation, not
|
|
87
|
+
* a gate. The pinned map in `parity.test.ts` catches an ACCIDENTAL capability, but updating a pin is
|
|
88
|
+
* a mechanical edit that demands no evidence, so the contract had no layer-1 half.
|
|
89
|
+
*
|
|
90
|
+
* This is that half. `parity_no_capability_grant_without_evidence` fails when a target declares a
|
|
91
|
+
* capability with no record here, when a `transcript` record points at a file that does not exist,
|
|
92
|
+
* or when **any cell computes `full` on a target whose deciding capability has no transcript**.
|
|
93
|
+
*
|
|
94
|
+
* `kind: 'transcript'` means a recorded live run is on disk at `evidence`; `kind: 'reproducer'`
|
|
95
|
+
* means `evidence` is a command anyone can re-run.
|
|
96
|
+
*/
|
|
97
|
+
export interface CapabilityEvidence {
|
|
98
|
+
readonly evidence: string;
|
|
99
|
+
readonly kind: 'transcript' | 'reproducer';
|
|
100
|
+
/** ISO date the evidence was produced. Stale evidence is still evidence — silence is not. */
|
|
101
|
+
readonly at: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const CAPABILITY_EVIDENCE: Record<TargetName, Partial<Record<RuntimeCapability, CapabilityEvidence>>> = {
|
|
105
|
+
'claude-code': {
|
|
106
|
+
shell: { evidence: 'dz --version', kind: 'reproducer', at: '2026-07-19' },
|
|
107
|
+
skills: { evidence: 'dz compose --target claude-code', kind: 'reproducer', at: '2026-07-19' },
|
|
108
|
+
'hooks-write': { evidence: 'grep -n claim-check-hook .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
|
|
109
|
+
'hooks-shell': { evidence: 'grep -n PreToolUse .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
|
|
110
|
+
'hooks-prompt': { evidence: 'grep -n recall-hook.cjs .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
|
|
111
|
+
mcp: { evidence: 'cat .mcp.json', kind: 'reproducer', at: '2026-07-19' },
|
|
112
|
+
'mcp-configured': { evidence: 'cat .mcp.json', kind: 'reproducer', at: '2026-07-19' },
|
|
113
|
+
workflows: { evidence: 'ls .claude/workflows/feature-adr.js', kind: 'reproducer', at: '2026-07-19' },
|
|
114
|
+
statusline: { evidence: 'dz statusline', kind: 'reproducer', at: '2026-07-19' },
|
|
115
|
+
},
|
|
116
|
+
codex: {
|
|
117
|
+
shell: { evidence: 'codex exec -m <id> "Reply with exactly: OK"', kind: 'reproducer', at: '2026-07-19' },
|
|
118
|
+
skills: { evidence: 'dz compose --target codex', kind: 'reproducer', at: '2026-07-19' },
|
|
119
|
+
mcp: { evidence: 'codex mcp list', kind: 'reproducer', at: '2026-07-19' },
|
|
120
|
+
// The two-sided live block: our marker in the transcript AND the sentinel side effect absent,
|
|
121
|
+
// in a NON-bypassed run, with the entry reported `trusted` by codex's own `hooks/list`.
|
|
122
|
+
'hooks-shell': {
|
|
123
|
+
evidence: 'features/crossrt-2-codex-hooks/07_code_changes/probe-results/veto-armed.txt',
|
|
124
|
+
kind: 'transcript',
|
|
125
|
+
at: '2026-08-19',
|
|
126
|
+
},
|
|
127
|
+
'hooks-prompt': {
|
|
128
|
+
evidence: 'features/crossrt-2-codex-hooks/07_code_changes/probe-results/recall-canary.md',
|
|
129
|
+
kind: 'transcript',
|
|
130
|
+
at: '2026-08-19',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
opencode: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target opencode', kind: 'reproducer', at: '2026-07-19' } },
|
|
134
|
+
hermes: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target hermes', kind: 'reproducer', at: '2026-07-19' } },
|
|
135
|
+
openclaude: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target openclaude', kind: 'reproducer', at: '2026-07-19' } },
|
|
136
|
+
copilot: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target copilot', kind: 'reproducer', at: '2026-07-19' } },
|
|
137
|
+
'agents-md': { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target agents-md', kind: 'reproducer', at: '2026-07-19' } },
|
|
138
|
+
cursor: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target cursor', kind: 'reproducer', at: '2026-07-19' } },
|
|
139
|
+
gemini: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target gemini', kind: 'reproducer', at: '2026-07-19' } },
|
|
140
|
+
windsurf: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target windsurf', kind: 'reproducer', at: '2026-07-19' } },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export interface UnbackedCapability {
|
|
144
|
+
readonly target: TargetName;
|
|
145
|
+
readonly capability: RuntimeCapability;
|
|
146
|
+
readonly reason: 'no-evidence-record' | 'dangling-transcript';
|
|
147
|
+
readonly evidence?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every capability grant that is NOT backed by usable evidence.
|
|
152
|
+
*
|
|
153
|
+
* PURE, with the filesystem injected as `transcriptExists`. That is deliberate: the property this
|
|
154
|
+
* enforces — *a grant with a dangling transcript is not a grant* — has to be provable without a
|
|
155
|
+
* repository on disk, or the mutation gate (which copies the PACKAGE, not the repo) could never
|
|
156
|
+
* turn its mutant red, and an unkillable mutant is a false green wearing a gate's clothes.
|
|
157
|
+
*/
|
|
158
|
+
export function findUnbackedCapabilities(
|
|
159
|
+
transcriptExists: (path: string) => boolean,
|
|
160
|
+
capabilities: Record<TargetName, readonly RuntimeCapability[]> = TARGET_CAPABILITIES,
|
|
161
|
+
evidence: Record<TargetName, Partial<Record<RuntimeCapability, CapabilityEvidence>>> = CAPABILITY_EVIDENCE,
|
|
162
|
+
): UnbackedCapability[] {
|
|
163
|
+
const out: UnbackedCapability[] = [];
|
|
164
|
+
for (const target of Object.keys(capabilities) as TargetName[]) {
|
|
165
|
+
for (const capability of capabilities[target]) {
|
|
166
|
+
const record = evidence[target]?.[capability];
|
|
167
|
+
if (record === undefined) {
|
|
168
|
+
out.push({ target, capability, reason: 'no-evidence-record' });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (record.kind === 'transcript' && !transcriptExists(record.evidence)) {
|
|
172
|
+
out.push({ target, capability, reason: 'dangling-transcript', evidence: record.evidence });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
65
179
|
/** How a feature manifests on a platform: a concrete FORM with its runtime requirements. */
|
|
66
180
|
export interface FeatureForm {
|
|
67
181
|
/** Human-readable name of the form, shown as the `via` of a parity cell (AM-2). */
|
|
@@ -124,7 +238,7 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
|
|
|
124
238
|
id: 'claim-check',
|
|
125
239
|
title: 'Integrity claim-check',
|
|
126
240
|
forms: [
|
|
127
|
-
{ form: 'PreToolUse hook (automatic on Write/Edit)', requires: ['hooks'], level: 'full' },
|
|
241
|
+
{ form: 'PreToolUse hook (automatic on Write/Edit)', requires: ['hooks-write'], level: 'full' },
|
|
128
242
|
{ form: 'dz claim-check (CLI) + publish gate', requires: ['shell'], level: 'manual' },
|
|
129
243
|
],
|
|
130
244
|
},
|
|
@@ -137,7 +251,7 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
|
|
|
137
251
|
id: 'learning-apply',
|
|
138
252
|
title: 'Self-learning: automatic apply-leg',
|
|
139
253
|
forms: [
|
|
140
|
-
{ form: 'UserPromptSubmit hook (auto recall)', requires: ['hooks'], level: 'full' },
|
|
254
|
+
{ form: 'UserPromptSubmit hook (auto recall)', requires: ['hooks-prompt'], level: 'full' },
|
|
141
255
|
{ form: 'manual dz recall before a task', requires: ['shell'], level: 'manual' },
|
|
142
256
|
],
|
|
143
257
|
},
|
package/src/recall-usage.ts
CHANGED
|
@@ -13,13 +13,26 @@
|
|
|
13
13
|
* @packageDocumentation
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// STATIC node: imports, deliberately (2026-08-19). A deferred `require('node:fs')` here compiled
|
|
17
|
+
// into an ESM dist, where `require` is NOT in scope: every call from the emitted `.cjs` hook threw
|
|
18
|
+
// ReferenceError into `appendRecallUsage`'s own catch and returned **0 rows appended, silently**.
|
|
19
|
+
// The Codex recall leg looked wired and correctly-silent for exactly the reason AM-4's forced-hit
|
|
20
|
+
// canary exists to expose. Importing node: modules at the top costs nothing — they are built in.
|
|
21
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
16
24
|
import {
|
|
25
|
+
EMPTY_LOG_TAIL,
|
|
17
26
|
EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
|
|
18
27
|
EVENT_CHAIN_LEDGER_KIND,
|
|
28
|
+
EVENT_CHAIN_TAIL_BYTES,
|
|
29
|
+
appendChainedLines,
|
|
19
30
|
chainRewrite,
|
|
20
31
|
defaultEventWeight,
|
|
32
|
+
readTailInfo,
|
|
21
33
|
verifyEventChainText,
|
|
22
34
|
type EventChainDefect,
|
|
35
|
+
type LogTail,
|
|
23
36
|
} from './event-chain.js';
|
|
24
37
|
|
|
25
38
|
export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
|
|
@@ -45,6 +58,35 @@ export interface RecallUsageReadRecord {
|
|
|
45
58
|
readonly eventId?: string;
|
|
46
59
|
/** True when the stored query is a PREFIX of the real prompt — not replayable. */
|
|
47
60
|
readonly queryTruncated?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* WHICH HOST injected the lesson (ADR-003 §1, H-B additive). Absent ⇒ `claude-code`, so every row
|
|
63
|
+
* written before the Codex leg keeps its meaning without a migration.
|
|
64
|
+
*/
|
|
65
|
+
readonly runtime?: Runtime;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The hosts that run the apply leg. Additive by design: an old reader ignores the field. */
|
|
69
|
+
export type Runtime = 'claude-code' | 'codex';
|
|
70
|
+
|
|
71
|
+
export const RUNTIMES: readonly Runtime[] = ['claude-code', 'codex'];
|
|
72
|
+
|
|
73
|
+
/** A row without `runtime` predates the Codex leg and is Claude Code's by construction. */
|
|
74
|
+
export function runtimeOf(record: RecallUsageReadRecord): Runtime {
|
|
75
|
+
return record.runtime ?? 'claude-code';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeRuntime(value: unknown): Runtime | undefined {
|
|
79
|
+
return value === 'claude-code' || value === 'codex' ? value : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeRuntimes(value: unknown): readonly Runtime[] | undefined {
|
|
83
|
+
if (!Array.isArray(value)) return undefined;
|
|
84
|
+
const set = new Set<Runtime>();
|
|
85
|
+
for (const v of value) {
|
|
86
|
+
const r = normalizeRuntime(v);
|
|
87
|
+
if (r !== undefined) set.add(r);
|
|
88
|
+
}
|
|
89
|
+
return set.size === 0 ? undefined : [...set].sort();
|
|
48
90
|
}
|
|
49
91
|
|
|
50
92
|
/** Query text is capped so a pasted wall of text cannot bloat the log. */
|
|
@@ -59,6 +101,21 @@ export interface RecallUsageAggregateRecord {
|
|
|
59
101
|
readonly maxScore: number;
|
|
60
102
|
readonly totalScore: number;
|
|
61
103
|
readonly compactedAt: string;
|
|
104
|
+
/**
|
|
105
|
+
* The SET UNION of the runtimes whose reads were folded into this record (sorted, deduped).
|
|
106
|
+
*
|
|
107
|
+
* AM-27, and the whole reason it exists: `compactVerifiedRecallUsageLog` keeps verbatim only the
|
|
108
|
+
* newest {@link RECALL_USAGE_REPLAY_KEEP} read rows that carry a `query`. Everything else — a
|
|
109
|
+
* *fresh* row without `query` included — is folded into this record, whose merge key is `dzId`
|
|
110
|
+
* alone. The allowlist on the READ record therefore never touches the path where provenance is
|
|
111
|
+
* actually lost. The union does.
|
|
112
|
+
*
|
|
113
|
+
* Residual accepted loss, registered in `architecture/degradations.md`: this says WHICH runtimes
|
|
114
|
+
* used a lesson, not HOW OFTEN each did. Widening the merge key to `(dzId, runtime)` would split
|
|
115
|
+
* the stats rows `buildRecallUsageReport` looks up by `dzId` alone — a cross-feature blast radius
|
|
116
|
+
* this leg has no mandate for.
|
|
117
|
+
*/
|
|
118
|
+
readonly runtimes?: readonly Runtime[];
|
|
62
119
|
}
|
|
63
120
|
|
|
64
121
|
export type RecallUsageRecord = RecallUsageReadRecord | RecallUsageAggregateRecord;
|
|
@@ -76,6 +133,8 @@ export interface RecallUsageStat {
|
|
|
76
133
|
readonly lastReadAt: string;
|
|
77
134
|
readonly maxScore: number;
|
|
78
135
|
readonly avgScore: number;
|
|
136
|
+
/** Set union of contributing runtimes (AM-27). Empty ⇒ nothing carried provenance. */
|
|
137
|
+
readonly runtimes: readonly Runtime[];
|
|
79
138
|
}
|
|
80
139
|
|
|
81
140
|
export interface RecallPatternUsageRef {
|
|
@@ -115,6 +174,7 @@ interface Acc {
|
|
|
115
174
|
lastMs: number;
|
|
116
175
|
maxScore: number;
|
|
117
176
|
totalScore: number;
|
|
177
|
+
runtimes: Set<Runtime>;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
export interface RecallUsageRecordInput {
|
|
@@ -125,6 +185,7 @@ export interface RecallUsageRecordInput {
|
|
|
125
185
|
readonly runId?: unknown;
|
|
126
186
|
readonly eventId?: unknown;
|
|
127
187
|
readonly queryTruncated?: unknown;
|
|
188
|
+
readonly runtime?: unknown;
|
|
128
189
|
}
|
|
129
190
|
|
|
130
191
|
/**
|
|
@@ -182,6 +243,7 @@ export function aggregateRecallUsage(records: readonly RecallUsageRecord[]): rea
|
|
|
182
243
|
lastReadAt: a.lastReadAt,
|
|
183
244
|
maxScore: a.maxScore,
|
|
184
245
|
avgScore: a.totalScore / a.reads,
|
|
246
|
+
runtimes: [...a.runtimes].sort(),
|
|
185
247
|
}))
|
|
186
248
|
.sort(compareStats);
|
|
187
249
|
}
|
|
@@ -397,6 +459,10 @@ function normalizeReadRecord(value: unknown): RecallUsageReadRecord | undefined
|
|
|
397
459
|
...(typeof runId === 'string' && runId.trim() !== '' ? { runId: runId.trim() } : {}),
|
|
398
460
|
...(typeof value['eventId'] === 'string' && (value['eventId'] as string).trim() !== '' ? { eventId: (value['eventId'] as string).trim() } : {}),
|
|
399
461
|
...(value['queryTruncated'] === true ? { queryTruncated: true } : {}),
|
|
462
|
+
// ALLOWLIST (AM-20): the normaliser is an allowlist and `compactVerifiedRecallUsageLog`
|
|
463
|
+
// re-serialises every retained row through it — a non-allowlisted field dies at the first
|
|
464
|
+
// compaction. Necessary, but NOT sufficient: see `runtimes` on the aggregate (AM-27).
|
|
465
|
+
...(normalizeRuntime(value['runtime']) !== undefined ? { runtime: normalizeRuntime(value['runtime'])! } : {}),
|
|
400
466
|
};
|
|
401
467
|
}
|
|
402
468
|
|
|
@@ -413,7 +479,18 @@ function normalizeAggregateRecord(value: Record<string, unknown>): RecallUsageAg
|
|
|
413
479
|
if (!validTs(firstReadAt) || !validTs(lastReadAt) || !validTs(compactedAt)) return undefined;
|
|
414
480
|
if (typeof maxScore !== 'number' || !Number.isFinite(maxScore)) return undefined;
|
|
415
481
|
if (typeof totalScore !== 'number' || !Number.isFinite(totalScore)) return undefined;
|
|
416
|
-
|
|
482
|
+
const runtimes = normalizeRuntimes(value['runtimes']);
|
|
483
|
+
return {
|
|
484
|
+
kind: 'aggregate',
|
|
485
|
+
dzId: dzId.trim(),
|
|
486
|
+
reads,
|
|
487
|
+
firstReadAt,
|
|
488
|
+
lastReadAt,
|
|
489
|
+
maxScore,
|
|
490
|
+
totalScore,
|
|
491
|
+
compactedAt,
|
|
492
|
+
...(runtimes !== undefined ? { runtimes } : {}),
|
|
493
|
+
};
|
|
417
494
|
}
|
|
418
495
|
|
|
419
496
|
function mergeRead(byId: Map<string, Acc>, rec: RecallUsageReadRecord): void {
|
|
@@ -429,9 +506,11 @@ function mergeRead(byId: Map<string, Acc>, rec: RecallUsageReadRecord): void {
|
|
|
429
506
|
lastMs: ms,
|
|
430
507
|
maxScore: rec.score,
|
|
431
508
|
totalScore: rec.score,
|
|
509
|
+
runtimes: new Set<Runtime>([runtimeOf(rec)]),
|
|
432
510
|
});
|
|
433
511
|
return;
|
|
434
512
|
}
|
|
513
|
+
prev.runtimes.add(runtimeOf(rec));
|
|
435
514
|
prev.reads += 1;
|
|
436
515
|
prev.totalScore += rec.score;
|
|
437
516
|
prev.maxScore = Math.max(prev.maxScore, rec.score);
|
|
@@ -459,9 +538,11 @@ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord)
|
|
|
459
538
|
lastMs,
|
|
460
539
|
maxScore: rec.maxScore,
|
|
461
540
|
totalScore: rec.totalScore,
|
|
541
|
+
runtimes: new Set<Runtime>(rec.runtimes ?? []),
|
|
462
542
|
});
|
|
463
543
|
return;
|
|
464
544
|
}
|
|
545
|
+
for (const r of rec.runtimes ?? []) prev.runtimes.add(r);
|
|
465
546
|
prev.reads += rec.reads;
|
|
466
547
|
prev.totalScore += rec.totalScore;
|
|
467
548
|
prev.maxScore = Math.max(prev.maxScore, rec.maxScore);
|
|
@@ -485,6 +566,9 @@ function aggregateRecord(stat: RecallUsageStat, compactedAt: string): RecallUsag
|
|
|
485
566
|
maxScore: stat.maxScore,
|
|
486
567
|
totalScore: stat.avgScore * stat.reads,
|
|
487
568
|
compactedAt,
|
|
569
|
+
// AM-27's mutant: dropping this union silently destroys per-runtime provenance at the first
|
|
570
|
+
// compaction, and the READ-record allowlist cannot save it because this path never sees one.
|
|
571
|
+
...(stat.runtimes.length > 0 ? { runtimes: [...stat.runtimes].sort() } : {}),
|
|
488
572
|
};
|
|
489
573
|
}
|
|
490
574
|
|
|
@@ -535,3 +619,102 @@ function joinLines(lines: readonly string[]): string {
|
|
|
535
619
|
function byteLength(text: string): number {
|
|
536
620
|
return text.length;
|
|
537
621
|
}
|
|
622
|
+
|
|
623
|
+
/* ========================================================================== */
|
|
624
|
+
/* The SHARED writer (`crossrt-2-codex-hooks`, ADR-003 · AM-6/AM-7) */
|
|
625
|
+
/* ========================================================================== */
|
|
626
|
+
/*
|
|
627
|
+
* Everything above this line is pure. What follows is the ONE writer both hosts call, and it is
|
|
628
|
+
* here rather than in a helper because the alternative — a second inline appender per runtime — is
|
|
629
|
+
* exactly the drift AM-7 forbids: the Claude helper had the only implementation for 19 days and the
|
|
630
|
+
* Codex leg could not exist without either importing it or copying it.
|
|
631
|
+
*
|
|
632
|
+
* It NEVER throws. A hook that dies on a logging failure costs the user their turn, and the log is
|
|
633
|
+
* telemetry: losing a row is a smaller harm than losing a prompt.
|
|
634
|
+
*/
|
|
635
|
+
|
|
636
|
+
export interface AppendRecallUsageHit {
|
|
637
|
+
readonly dzId: unknown;
|
|
638
|
+
readonly score: unknown;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export interface AppendRecallUsageInput {
|
|
642
|
+
/** Project ROOT (the helper walks up to it; a cwd-relative writer splits the log — AM-5). */
|
|
643
|
+
readonly projectRoot: string;
|
|
644
|
+
readonly hits: readonly AppendRecallUsageHit[];
|
|
645
|
+
/** The host that injected. Omitted ⇒ `claude-code`, matching every pre-Codex row. */
|
|
646
|
+
readonly runtime?: Runtime;
|
|
647
|
+
readonly query?: string | undefined;
|
|
648
|
+
readonly runId?: string | undefined;
|
|
649
|
+
/** One id per PROMPT. Generated when absent so a multi-hit prompt still counts as one event. */
|
|
650
|
+
readonly eventId?: string | undefined;
|
|
651
|
+
readonly now?: string;
|
|
652
|
+
/** Test seam. Production leaves it unset and the path is derived from `projectRoot`. */
|
|
653
|
+
readonly logPath?: string;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Append one prompt's injected hits as CHAINED rows.
|
|
658
|
+
*
|
|
659
|
+
* @returns the number of rows appended; **0** on any failure (unwritable directory, unreadable
|
|
660
|
+
* tail, empty input). Never throws.
|
|
661
|
+
*/
|
|
662
|
+
export function appendRecallUsage(input: AppendRecallUsageInput): number {
|
|
663
|
+
try {
|
|
664
|
+
const hits = Array.isArray(input?.hits) ? input.hits : [];
|
|
665
|
+
if (hits.length === 0) return 0;
|
|
666
|
+
const ts = validTs(input.now) ? input.now : new Date().toISOString();
|
|
667
|
+
const eventId =
|
|
668
|
+
typeof input.eventId === 'string' && input.eventId.trim() !== ''
|
|
669
|
+
? input.eventId.trim()
|
|
670
|
+
: `${ts}:${Math.random().toString(36).slice(2, 10)}`;
|
|
671
|
+
const full = typeof input.query === 'string' ? input.query.trim() : '';
|
|
672
|
+
const query = full !== '' ? full.slice(0, RECALL_USAGE_QUERY_MAX_CHARS) : undefined;
|
|
673
|
+
const queryTruncated = full.length > RECALL_USAGE_QUERY_MAX_CHARS ? true : undefined;
|
|
674
|
+
|
|
675
|
+
const records: RecallUsageReadRecord[] = [];
|
|
676
|
+
for (const hit of hits) {
|
|
677
|
+
const rec = normalizeReadRecord({
|
|
678
|
+
dzId: hit?.dzId,
|
|
679
|
+
score: hit?.score,
|
|
680
|
+
ts,
|
|
681
|
+
query,
|
|
682
|
+
runId: input.runId,
|
|
683
|
+
eventId,
|
|
684
|
+
queryTruncated,
|
|
685
|
+
runtime: input.runtime ?? 'claude-code',
|
|
686
|
+
});
|
|
687
|
+
if (rec !== undefined) records.push(rec);
|
|
688
|
+
}
|
|
689
|
+
if (records.length === 0) return 0;
|
|
690
|
+
|
|
691
|
+
const logPath = input.logPath ?? join(input.projectRoot, ...RECALL_USAGE_LOG_RELATIVE.split('/'));
|
|
692
|
+
const payload = appendChainedLines(records, readLogTailSync(logPath));
|
|
693
|
+
if (payload === '') return 0;
|
|
694
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
695
|
+
appendFileSync(logPath, payload, 'utf-8');
|
|
696
|
+
return records.length;
|
|
697
|
+
} catch {
|
|
698
|
+
return 0; // never-block outranks telemetry completeness
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Read the chain tail. An UNREADABLE tail is a torn tail, never an empty one (event-chain AM-6). */
|
|
703
|
+
function readLogTailSync(file: string): LogTail {
|
|
704
|
+
try {
|
|
705
|
+
if (!existsSync(file)) return EMPTY_LOG_TAIL;
|
|
706
|
+
const fd = openSync(file, 'r');
|
|
707
|
+
try {
|
|
708
|
+
const size = fstatSync(fd).size;
|
|
709
|
+
if (!Number.isFinite(size) || size <= 0) return EMPTY_LOG_TAIL;
|
|
710
|
+
const want = Math.min(size, EVENT_CHAIN_TAIL_BYTES);
|
|
711
|
+
const buf = Buffer.alloc(want);
|
|
712
|
+
readSync(fd, buf, 0, want, size - want);
|
|
713
|
+
return readTailInfo(buf.toString('utf-8'), { partial: want < size });
|
|
714
|
+
} finally {
|
|
715
|
+
try { closeSync(fd); } catch { /* nothing to do */ }
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
return EMPTY_LOG_TAIL;
|
|
719
|
+
}
|
|
720
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { execSync } from 'node:child_process';
|
|
21
21
|
|
|
22
|
+
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
23
|
+
|
|
22
24
|
/** Memory backend type. */
|
|
23
25
|
export type MemoryBackend = 'jsonl' | 'agentdb';
|
|
24
26
|
|
|
@@ -567,35 +569,32 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
567
569
|
} else {
|
|
568
570
|
try {
|
|
569
571
|
const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
572
|
+
// ONE merge implementation, shared with the Codex target (AM-3 / G-E). The Claude path's
|
|
573
|
+
// historical SUBSTRING attribution is passed IN verbatim rather than reimplemented, so the
|
|
574
|
+
// emitted bytes, the report tail string and the no-write path all stay identical (AM-37).
|
|
575
|
+
const plan = mergeManagedHookEntries(
|
|
576
|
+
(existing['hooks'] ?? {}) as Record<string, unknown[]>,
|
|
577
|
+
generated.hooks as unknown as Record<string, unknown[]>,
|
|
578
|
+
{
|
|
579
|
+
// Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
|
|
580
|
+
// untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
|
|
581
|
+
// matcher-group shape appended below.
|
|
582
|
+
isManaged: (entry) =>
|
|
583
|
+
commandsOf(entry).some(
|
|
584
|
+
(cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'),
|
|
585
|
+
),
|
|
586
|
+
isLegacy: (entry) =>
|
|
587
|
+
!Array.isArray((entry as { hooks?: unknown[] })?.hooks) ||
|
|
588
|
+
commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
|
|
589
|
+
reportLabel: backend,
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
if (plan.changed) {
|
|
593
|
+
existing['hooks'] = plan.hooks;
|
|
591
594
|
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
592
|
-
steps.push({
|
|
593
|
-
name: 'Configure hooks',
|
|
594
|
-
status: 'done',
|
|
595
|
-
detail: replacedLegacy ? `replaced legacy dz hooks with ${backend} hooks` : `merged ${backend} hooks (user hooks preserved)`,
|
|
596
|
-
});
|
|
595
|
+
steps.push({ name: 'Configure hooks', status: 'done', detail: plan.report });
|
|
597
596
|
} else {
|
|
598
|
-
steps.push({ name: 'Configure hooks', status: 'skipped', detail:
|
|
597
|
+
steps.push({ name: 'Configure hooks', status: 'skipped', detail: plan.report });
|
|
599
598
|
}
|
|
600
599
|
} catch {
|
|
601
600
|
steps.push({ name: 'Configure hooks', status: 'error', detail: 'could not parse existing settings.json — fix it and re-run' });
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell veto policy (`crossrt-2-codex-hooks`, ADR-004 + ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* ONE rule, judged on the raw command string, with no I/O of any kind. The mode — what the CALLER
|
|
5
|
+
* does with a hit — is decided by the caller from project config, never here.
|
|
6
|
+
*
|
|
7
|
+
* Polarity, in one sentence (ADR-004): **a policy hit ⇒ WARN by default, BLOCK only when the
|
|
8
|
+
* project opted in; our own failure ⇒ always ALLOW.**
|
|
9
|
+
*
|
|
10
|
+
* ## Why exactly one rule, and why this one
|
|
11
|
+
*
|
|
12
|
+
* `shell-veto-policy.ts` is NOT a general shell-guardrail engine (plan §0.1's C4 fence). Its
|
|
13
|
+
* charter is a single rule whose violation is **unambiguous**: the command line explicitly asks for
|
|
14
|
+
* WEAKER authentication than the ssh default. Every token below is one a safe invocation never
|
|
15
|
+
* contains, so a hit always means the user deliberately disabled a protection.
|
|
16
|
+
*
|
|
17
|
+
* The withdrawn v1 (`ssh-no-identity`, AM-23) judged the ABSENCE of `-i`/`IdentityFile=`, which
|
|
18
|
+
* blocked `ssh myhost` whenever the identity came from `~/.ssh/config` or `ssh-agent` — the normal
|
|
19
|
+
* secure case — from a user-global registry that reaches every directory on the machine. Absence of
|
|
20
|
+
* a token is not evidence of intent; presence of these four is.
|
|
21
|
+
*
|
|
22
|
+
* `StrictHostKeyChecking=no` and `UserKnownHostsFile=/dev/null` are deliberately NOT rules: they are
|
|
23
|
+
* a real weakening, but CI images use them legitimately, so no unambiguous verdict is available.
|
|
24
|
+
* They are the first candidates for a future rule with its own ADR — **no second rule ships in this
|
|
25
|
+
* leg**.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What the caller does with a hit. `warn` is the shipped default (ADR-004). */
|
|
31
|
+
export type VetoMode = 'off' | 'warn' | 'block';
|
|
32
|
+
|
|
33
|
+
/** A policy hit. `null` from {@link vetoShellCommand} means allow. */
|
|
34
|
+
export interface VetoHit {
|
|
35
|
+
readonly rule: string;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The one rule id this leg ships. Exported so tests and the probe cannot drift from it. */
|
|
40
|
+
export const SHELL_VETO_RULE_ID = 'ssh-explicit-auth-weakening';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shell word boundaries. Both ends of every token are anchored, so `echo "sshpassword"` and
|
|
44
|
+
* `--my-passwordauthentication=yes` cannot hit: a user-global guard that matches on a bare
|
|
45
|
+
* substring is one common word away from a machine-wide outage.
|
|
46
|
+
*/
|
|
47
|
+
const BOUNDARY = String.raw`[\s;&|()'"\`]`;
|
|
48
|
+
const OPT = String.raw`(?:^|${BOUNDARY})-o\s*`;
|
|
49
|
+
const END = String.raw`(?=$|${BOUNDARY})`;
|
|
50
|
+
|
|
51
|
+
/** `-o PasswordAuthentication=yes` — the user turns ON password auth against the ssh default. */
|
|
52
|
+
const PASSWORD_AUTHENTICATION = new RegExp(`${OPT}passwordauthentication\\s*=\\s*yes${END}`, 'i');
|
|
53
|
+
|
|
54
|
+
/** `-o PubkeyAuthentication=no` — the user turns OFF key auth. */
|
|
55
|
+
const PUBKEY_AUTHENTICATION = new RegExp(`${OPT}pubkeyauthentication\\s*=\\s*no${END}`, 'i');
|
|
56
|
+
|
|
57
|
+
/** `-o PreferredAuthentications=…password…` — the user ORDERS password auth ahead of pubkey. */
|
|
58
|
+
const PREFERRED_AUTHENTICATIONS = new RegExp(
|
|
59
|
+
`${OPT}preferredauthentications\\s*=\\s*` +
|
|
60
|
+
`(?:[a-z][a-z0-9-]*\\s*,\\s*)*` +
|
|
61
|
+
`(?:password|keyboard-interactive)` +
|
|
62
|
+
`(?:\\s*,\\s*[a-z][a-z0-9-]*)*${END}`,
|
|
63
|
+
'i',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** `sshpass` as a COMMAND TOKEN — a password is fed to ssh from the command line by construction. */
|
|
67
|
+
const SSHPASS_TOKEN = new RegExp(`(?:^|${BOUNDARY})sshpass${END}`, 'i');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Judge one raw shell command.
|
|
71
|
+
*
|
|
72
|
+
* @returns a {@link VetoHit} when the command EXPLICITLY weakens ssh authentication, else `null`.
|
|
73
|
+
* Pure: no filesystem, no environment, no config, no clock.
|
|
74
|
+
*/
|
|
75
|
+
export function vetoShellCommand(command: string): VetoHit | null {
|
|
76
|
+
if (typeof command !== 'string' || command === '') return null;
|
|
77
|
+
|
|
78
|
+
if (SSHPASS_TOKEN.test(command)) {
|
|
79
|
+
return {
|
|
80
|
+
rule: SHELL_VETO_RULE_ID,
|
|
81
|
+
reason: 'sshpass feeds an ssh password from the command line, disabling key-based auth by construction',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (PASSWORD_AUTHENTICATION.test(command)) {
|
|
85
|
+
return {
|
|
86
|
+
rule: SHELL_VETO_RULE_ID,
|
|
87
|
+
reason: 'PasswordAuthentication=yes turns ON password auth against the ssh default',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (PUBKEY_AUTHENTICATION.test(command)) {
|
|
91
|
+
return {
|
|
92
|
+
rule: SHELL_VETO_RULE_ID,
|
|
93
|
+
reason: 'PubkeyAuthentication=no turns OFF key auth',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (PREFERRED_AUTHENTICATIONS.test(command)) {
|
|
97
|
+
return {
|
|
98
|
+
rule: SHELL_VETO_RULE_ID,
|
|
99
|
+
reason: 'PreferredAuthentications orders password/keyboard-interactive auth ahead of pubkey',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the enforcement mode from a parsed `.dz/config.json`.
|
|
107
|
+
*
|
|
108
|
+
* ABSENT, unknown, non-string, or malformed ⇒ `'warn'`. Never `'block'` by default: fail-closed is
|
|
109
|
+
* a decision the owner makes, not one a user-global hook install imposes on every directory
|
|
110
|
+
* (ADR-004 / AM-24). A mutant that flips this default is registered in the mutation registry.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveVetoMode(projectConfig: unknown): VetoMode {
|
|
113
|
+
if (typeof projectConfig !== 'object' || projectConfig === null) return 'warn';
|
|
114
|
+
const hooks = (projectConfig as Record<string, unknown>)['hooks'];
|
|
115
|
+
if (typeof hooks !== 'object' || hooks === null) return 'warn';
|
|
116
|
+
const mode = (hooks as Record<string, unknown>)['shellVeto'];
|
|
117
|
+
if (mode === 'off' || mode === 'warn' || mode === 'block') return mode;
|
|
118
|
+
return 'warn';
|
|
119
|
+
}
|