@dzhechkov/harness-core 0.8.11 → 0.8.21
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 +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.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-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
|
@@ -29,18 +29,26 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { DZ_HOOK_HELPER_VERSION } from './codex-hooks.js';
|
|
32
|
+
import { harnessCoreDistDir } from './harness-core-location.js';
|
|
32
33
|
|
|
33
34
|
/** Shared prologue: payload read, project-root walk, never-throw error note. */
|
|
34
|
-
function preamble(): string {
|
|
35
|
+
function preamble(installedDistDir: string | null): string {
|
|
35
36
|
return `#!/usr/bin/env node
|
|
36
37
|
// GENERATED by @dzhechkov/harness-core — dz-hook-helper-version: ${DZ_HOOK_HELPER_VERSION}
|
|
37
38
|
// Do not edit: dz rewrites this file, and any edit disarms the hook until it is re-trusted.
|
|
38
39
|
'use strict';
|
|
39
40
|
const fs = require('node:fs');
|
|
40
41
|
const path = require('node:path');
|
|
42
|
+
const { pathToFileURL } = require('node:url');
|
|
41
43
|
|
|
42
44
|
const HELPER_VERSION = ${DZ_HOOK_HELPER_VERSION};
|
|
43
45
|
const CODEX_HOME = process.env.CODEX_HOME || path.join(require('node:os').homedir(), '.codex');
|
|
46
|
+
// The harness-core installation that EMITTED this helper, baked in at emission time.
|
|
47
|
+
// MEASURED 2026-09-05: with only the two project-relative candidates below, the documented
|
|
48
|
+
// global install ('npm i -g @dzhechkov/harness-cli') left every target repository without a
|
|
49
|
+
// resolvable module — every shell call reached 'guard not loaded' and was ALLOWED. It is null
|
|
50
|
+
// when the emitter could not name its own location; the project candidates still apply.
|
|
51
|
+
const INSTALLED_CORE_DIST = ${installedDistDir === null ? 'null' : JSON.stringify(installedDistDir)};
|
|
44
52
|
const ERROR_LOG = path.join(CODEX_HOME, 'dz-hooks', 'helper-errors.jsonl');
|
|
45
53
|
|
|
46
54
|
/**
|
|
@@ -141,35 +149,77 @@ function readProjectConfig(root) {
|
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Load a built module from the project's own harness-core. Never throws.
|
|
154
|
+
*
|
|
155
|
+
* \`import()\`, NEVER \`require()\`. \`@dzhechkov/harness-core\` is \`"type": "module"\`, so every file in
|
|
156
|
+
* its \`dist/\` is an ES module, while this package supports \`node >= 20\`. \`require()\` of an ES
|
|
157
|
+
* module was only unflagged in Node 20.19 / 22.12 — below that it throws ERR_REQUIRE_ESM, this
|
|
158
|
+
* loader turned that into \`null\`, and a \`null\` policy is a SILENT allow. MEASURED 2026-09-05 on
|
|
159
|
+
* this machine, reproducing the old loader with the flag that turns the unflagging back off:
|
|
160
|
+
* node --no-experimental-require-module <veto helper> on \`rm -rf .agentic-qe\`
|
|
161
|
+
* ⇒ exit 0 and an EMPTY stderr — the guard was not weak there, it was absent and mute.
|
|
162
|
+
* \`import()\` loads BOTH module kinds on every supported Node, so there is one loader instead of one
|
|
163
|
+
* that happens to work on the runtime that ran the tests.
|
|
164
|
+
*
|
|
165
|
+
* Returns \`{ mod, reason }\`. \`reason\` is the cause that was OBSERVED — the module id plus the code
|
|
166
|
+
* Node reported for it — never a cause we assumed: a warning that says "no build" about an
|
|
167
|
+
* ERR_REQUIRE_ESM sends the reader to fix something that is not broken.
|
|
168
|
+
*/
|
|
169
|
+
async function loadCoreDetailed(root, fileName, predicate) {
|
|
146
170
|
const candidates = [
|
|
147
171
|
path.join(root, 'node_modules', '@dzhechkov', 'harness-core', 'dist', fileName),
|
|
148
172
|
path.join(root, 'packages', '@dzhechkov', 'harness-core', 'dist', fileName),
|
|
149
173
|
];
|
|
174
|
+
// The emitting installation is the LAST resort, never the first: a project that ships its own
|
|
175
|
+
// harness-core must keep using its own copy rather than being pinned to whatever emitted the hook.
|
|
176
|
+
if (INSTALLED_CORE_DIST !== null) candidates.push(path.join(INSTALLED_CORE_DIST, fileName));
|
|
177
|
+
const failures = [];
|
|
150
178
|
for (const candidate of candidates) {
|
|
151
179
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (predicate(mod)) return mod;
|
|
180
|
+
const mod = await import(pathToFileURL(candidate).href);
|
|
181
|
+
if (predicate(mod)) return { mod: mod, reason: null };
|
|
182
|
+
if (mod.default && predicate(mod.default)) return { mod: mod.default, reason: null };
|
|
183
|
+
failures.push(candidate + ': загружен, но нужный экспорт отсутствует');
|
|
155
184
|
} catch (err) {
|
|
156
|
-
|
|
185
|
+
const code = (err && err.code) || 'ERR_UNKNOWN';
|
|
186
|
+
failures.push(candidate + ': ' + code + ' ' + String((err && err.message) || err));
|
|
187
|
+
// An absent optional module is the ORDINARY case (a project may not ship every policy), so it
|
|
188
|
+
// does not earn a durable note; anything else is a real load failure and does.
|
|
189
|
+
if (code !== 'ERR_MODULE_NOT_FOUND') note('load-core:' + fileName, err);
|
|
157
190
|
}
|
|
158
191
|
}
|
|
159
|
-
return null;
|
|
192
|
+
return { mod: null, reason: failures.join(' | ').replace(/\\s*\\n\\s*/g, ' ') };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The module only, for callers that have nothing to say about WHY it is missing. */
|
|
196
|
+
async function loadCore(root, fileName, predicate) {
|
|
197
|
+
return (await loadCoreDetailed(root, fileName, predicate)).mod;
|
|
160
198
|
}
|
|
161
199
|
`;
|
|
162
200
|
}
|
|
163
201
|
|
|
164
202
|
/**
|
|
165
|
-
* The PreToolUse veto helper.
|
|
203
|
+
* The PreToolUse veto helper — TWO checks behind one registry entry.
|
|
204
|
+
*
|
|
205
|
+
* 1. The destructive-command guard (feature `destructive-command-guard`, ADR-001): a deletion by a
|
|
206
|
+
* LITERAL path into a protected store (`.dz/`, `.agentic-qe/`, a database file). It blocks
|
|
207
|
+
* UNCONDITIONALLY, with no config mode, and that asymmetry with the shell veto below is
|
|
208
|
+
* deliberate: the shell veto's default is `warn` because its class is broad and its false-hit
|
|
209
|
+
* rate is unmeasured, while this guard's scope was cut by measurement over 20 938 real commands
|
|
210
|
+
* down to one refusal per ~510 commands (0,196 %) — narrow enough that a refusal is a fact about
|
|
211
|
+
* the command's FORM, not a judgement about its intent. The verdict, the text and the limits all
|
|
212
|
+
* come from `destructive-guard-hook.js`, the SAME module the Claude host's hook consumes, so
|
|
213
|
+
* both runtimes refuse the same commands in the same words.
|
|
214
|
+
* 2. The shell veto policy: fail-OPEN on OUR failure, mode-driven on a POLICY hit. Exit 2 only when
|
|
215
|
+
* the project explicitly opted in — the shipped default warns and returns 0 (AM-24 / G-J).
|
|
166
216
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
217
|
+
* The destructive check runs FIRST and is loaded SEPARATELY, so a project whose build lacks the
|
|
218
|
+
* shell-veto policy still gets it, and vice versa.
|
|
169
219
|
*/
|
|
170
|
-
export function generateCodexVetoHelper(): string {
|
|
171
|
-
return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-veto';\nconst HELPER_VERSION")}
|
|
172
|
-
function main() {
|
|
220
|
+
export function generateCodexVetoHelper(installedDistDir: string | null = harnessCoreDistDir()): string {
|
|
221
|
+
return `${preamble(installedDistDir).replace('const HELPER_VERSION', "const HELPER = 'dz-codex-veto';\nconst HELPER_VERSION")}
|
|
222
|
+
async function main() {
|
|
173
223
|
const payload = readPayload();
|
|
174
224
|
if (payload === null) return 0;
|
|
175
225
|
|
|
@@ -183,7 +233,58 @@ function main() {
|
|
|
183
233
|
const root = findProjectRoot(cwd);
|
|
184
234
|
if (root === null) return 0; // inert outside an opted-in dz project: no decision, no output, no write
|
|
185
235
|
|
|
186
|
-
|
|
236
|
+
// (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
|
|
237
|
+
// or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
|
|
238
|
+
const destructiveLoad = await loadCoreDetailed(root, 'destructive-guard-hook.js', (m) => typeof m.decideDestructiveHook === 'function');
|
|
239
|
+
if (destructiveLoad.mod === null) {
|
|
240
|
+
// NEVER silence. A guard that could not load is indistinguishable from a guard that decided
|
|
241
|
+
// "allow" unless it SAYS so, and the measurement that produced this branch is exactly that:
|
|
242
|
+
// exit 0 with an empty stderr on \`rm -rf .agentic-qe\`. The exit code stays 0 — our failure is
|
|
243
|
+
// not the user's violation — but it is announced on ONE line in BOTH places a human can read
|
|
244
|
+
// it: the transcript, and the durable notes log (codex swallows an exit-0 stderr, M9).
|
|
245
|
+
const line = 'DZ-DESTRUCTIVE-WARN: guard not loaded — ' + destructiveLoad.reason;
|
|
246
|
+
process.stderr.write(line + '\\n');
|
|
247
|
+
note('destructive-not-loaded', null, Object.assign(
|
|
248
|
+
{ marker: 'DZ-DESTRUCTIVE-WARN', reason: destructiveLoad.reason },
|
|
249
|
+
commandFacts(command),
|
|
250
|
+
));
|
|
251
|
+
} else {
|
|
252
|
+
const destructive = destructiveLoad.mod;
|
|
253
|
+
let decision = null;
|
|
254
|
+
try {
|
|
255
|
+
decision = destructive.decideDestructiveHook(payload, 'codex');
|
|
256
|
+
// CHECKED, not assumed: a decision the helper cannot read must reach the loud path with a
|
|
257
|
+
// NAME. Both \`null\` and \`{}\` are falsy at \`decision.stderr\` further down, so an unreadable
|
|
258
|
+
// shape used to make the verdict simply vanish — a second door into the same silence.
|
|
259
|
+
if (!decision || typeof decision.stderr !== 'string') {
|
|
260
|
+
throw new TypeError('решение стража нечитаемо: ' + JSON.stringify(decision));
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
// ONE line, always (round 9). The Claude hook already failed open LOUDLY here; this helper
|
|
264
|
+
// only wrote a note, so in the transcript a thrown decider was indistinguishable from a clean
|
|
265
|
+
// allow — the round-2 defect class (absent AND mute) reached through another door. Exit stays
|
|
266
|
+
// 0, because our failure is not the user's violation, and the record lands in BOTH places a
|
|
267
|
+
// human can read: the transcript, and the notes log (codex swallows an exit-0 stderr, M9).
|
|
268
|
+
const why = String((err && err.message) || err).replace(/\\s*\\n\\s*/g, ' ');
|
|
269
|
+
process.stderr.write('DZ-DESTRUCTIVE-WARN: classifier threw — ' + why + '\\n');
|
|
270
|
+
note('destructive-threw', err, Object.assign({ marker: 'DZ-DESTRUCTIVE-WARN' }, commandFacts(command)));
|
|
271
|
+
decision = null;
|
|
272
|
+
}
|
|
273
|
+
if (decision && decision.stderr) process.stderr.write(decision.stderr);
|
|
274
|
+
if (decision && decision.exitCode === 2) {
|
|
275
|
+
// The note carries the RULE and the command's hash, never the command line itself (R2-8).
|
|
276
|
+
note('destructive-block', null, Object.assign({ marker: 'DZ-DESTRUCTIVE', rule: decision.rule }, commandFacts(command)));
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
if (decision && decision.stderr) {
|
|
280
|
+
// MEASURED (M9): codex swallows an exit-0 hook's stderr, so a non-blocking word from this
|
|
281
|
+
// guard is unobservable unless it is ALSO written where it can be read back.
|
|
282
|
+
note('destructive-' + decision.outcome, null, Object.assign({ marker: 'DZ-DESTRUCTIVE-WARN' }, commandFacts(command)));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// (2) The shell veto policy.
|
|
287
|
+
const policy = await loadCore(root, 'shell-veto-policy.js', (m) => typeof m.vetoShellCommand === 'function');
|
|
187
288
|
if (policy === null) return 0; // no logic available => allow (never block on OUR failure)
|
|
188
289
|
|
|
189
290
|
let hit = null;
|
|
@@ -210,14 +311,12 @@ function main() {
|
|
|
210
311
|
return 0;
|
|
211
312
|
}
|
|
212
313
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
note('main', err);
|
|
218
|
-
code
|
|
219
|
-
}
|
|
220
|
-
process.exit(code === 2 ? 2 : 0);
|
|
314
|
+
// \`main\` is async because the policy loader is (\`import()\`, not \`require()\` — see loadCoreDetailed).
|
|
315
|
+
// The exit code still comes from exactly one place, and a rejected promise is the same fail-open as
|
|
316
|
+
// a thrown error was: note it, exit 0.
|
|
317
|
+
main()
|
|
318
|
+
.catch((err) => { note('main', err); return 0; })
|
|
319
|
+
.then((code) => process.exit(code === 2 ? 2 : 0));
|
|
221
320
|
`;
|
|
222
321
|
}
|
|
223
322
|
|
|
@@ -228,8 +327,8 @@ process.exit(code === 2 ? 2 : 0);
|
|
|
228
327
|
* relevant, which is exactly why the acceptance canary is a FORCED HIT plus a removed-hook twin
|
|
229
328
|
* (AM-4) — a dead hook and a correctly-silent one are indistinguishable from the outside.
|
|
230
329
|
*/
|
|
231
|
-
export function generateCodexRecallHelper(): string {
|
|
232
|
-
return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-recall';\nconst HELPER_VERSION")}
|
|
330
|
+
export function generateCodexRecallHelper(installedDistDir: string | null = harnessCoreDistDir()): string {
|
|
331
|
+
return `${preamble(installedDistDir).replace('const HELPER_VERSION', "const HELPER = 'dz-codex-recall';\nconst HELPER_VERSION")}
|
|
233
332
|
const net = require('node:net');
|
|
234
333
|
const DAEMON_TIMEOUT_MS = 2000;
|
|
235
334
|
|
|
@@ -275,7 +374,7 @@ async function main() {
|
|
|
275
374
|
const root = findProjectRoot(cwd);
|
|
276
375
|
if (root === null) return; // inert outside an opted-in dz project
|
|
277
376
|
|
|
278
|
-
const policy = loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
|
|
377
|
+
const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
|
|
279
378
|
if (policy === null) return;
|
|
280
379
|
|
|
281
380
|
const candidates = await askDaemon(root, prompt);
|
|
@@ -301,7 +400,7 @@ async function main() {
|
|
|
301
400
|
// The usage row carries runtime: 'codex' (ADR-003 §3) through the SHARED chained appender in
|
|
302
401
|
// harness-core — ONE writer implementation, two callers (AM-6/AM-7). Written AFTER stdout so a
|
|
303
402
|
// logging failure can never cost the user their injection.
|
|
304
|
-
const usage = loadCore(root, 'recall-usage.js', (m) => typeof m.appendRecallUsage === 'function');
|
|
403
|
+
const usage = await loadCore(root, 'recall-usage.js', (m) => typeof m.appendRecallUsage === 'function');
|
|
305
404
|
if (usage === null) return;
|
|
306
405
|
try {
|
|
307
406
|
usage.appendRecallUsage({
|
|
@@ -322,7 +421,18 @@ main()
|
|
|
322
421
|
`;
|
|
323
422
|
}
|
|
324
423
|
|
|
325
|
-
/**
|
|
326
|
-
|
|
327
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Both helper bodies, keyed by the file they are written to.
|
|
426
|
+
*
|
|
427
|
+
* `installedDistDir` defaults to the harness-core doing the emitting; pass `null` for a body with
|
|
428
|
+
* no absolute fallback (the pre-fix behaviour, kept so a test can prove the fallback is what makes
|
|
429
|
+
* a global install work rather than asserting it).
|
|
430
|
+
*/
|
|
431
|
+
export function generateCodexHelpers(
|
|
432
|
+
installedDistDir: string | null = harnessCoreDistDir(),
|
|
433
|
+
): Readonly<Record<'veto' | 'recall', string>> {
|
|
434
|
+
return {
|
|
435
|
+
veto: generateCodexVetoHelper(installedDistDir),
|
|
436
|
+
recall: generateCodexRecallHelper(installedDistDir),
|
|
437
|
+
};
|
|
328
438
|
}
|
package/src/codex-hooks.ts
CHANGED
|
@@ -36,8 +36,28 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
|
36
36
|
* 3 — fix round 2: the note's `commandSynopsis` is a binary NAME or `(redacted)` — an
|
|
37
37
|
* env-assignment first token (`SECRET=xyz ssh …`) carried the credential the redaction removed
|
|
38
38
|
* everywhere else (R2-8).
|
|
39
|
+
* 4 — the destructive-command guard (feature `destructive-command-guard`, ADR-001, task T8): the
|
|
40
|
+
* veto helper consults `destructive-guard-hook.js` BEFORE the shell veto and blocks
|
|
41
|
+
* unconditionally on a literal path into a protected store. A changed body changes codex's
|
|
42
|
+
* `currentHash`, so the entry must be re-trusted — re-run `dz setup --target codex`.
|
|
43
|
+
* 5 — round 2 of the cross-family review, MEASURED: the helper's module loader was `require()`, and
|
|
44
|
+
* every file in this package's `dist/` is an ES module, so on Node below 20.19 / 22.12 the load
|
|
45
|
+
* threw ERR_REQUIRE_ESM, the catch returned `null`, and the helper exited 0 with an EMPTY stderr on
|
|
46
|
+
* `rm -rf .agentic-qe` — absent AND mute on a Node the package claims to support. The loader is now
|
|
47
|
+
* `import()`, `main` is async, and a decider that still cannot load says so on one line instead of
|
|
48
|
+
* staying silent. A changed body ⇒ a changed `currentHash` ⇒ re-trust.
|
|
49
|
+
* 6 — round 3, MEASURED: the body's module candidates were both PROJECT-relative, so in the
|
|
50
|
+
* documented global install (`npm i -g @dzhechkov/harness-cli`) an ordinary target repository
|
|
51
|
+
* resolved neither — every shell call reached `guard not loaded` and was ALLOWED, while the tests
|
|
52
|
+
* hid it by symlinking the package into the temp project. The emitting installation's absolute
|
|
53
|
+
* dist path is now baked in as the LAST candidate. A changed body ⇒ re-trust.
|
|
54
|
+
* 7 — round 9, MEASURED: when the decider THREW (or returned a shape the helper could not read),
|
|
55
|
+
* the helper wrote a note and printed NOTHING — stderr came back empty, so in the transcript the
|
|
56
|
+
* run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
|
|
57
|
+
* Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
|
|
58
|
+
* A changed body ⇒ re-trust.
|
|
39
59
|
*/
|
|
40
|
-
export const DZ_HOOK_HELPER_VERSION =
|
|
60
|
+
export const DZ_HOOK_HELPER_VERSION = 7;
|
|
41
61
|
|
|
42
62
|
/** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
|
|
43
63
|
export const DZ_HOOK_TIMEOUT_SECONDS = 5;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { compareVersions } from './publish.js';
|
|
2
|
+
|
|
3
|
+
export type CourseStalenessState =
|
|
4
|
+
| 'S0_SHIPPED'
|
|
5
|
+
| 'S3_TUTORIAL_STALE'
|
|
6
|
+
| 'S4_PACKAGE_BEHIND'
|
|
7
|
+
| 'E2_UNSTAMPED'
|
|
8
|
+
| 'E3_INVALID_VERSION'
|
|
9
|
+
| 'E4_PACKAGE_MISMATCH'
|
|
10
|
+
| 'E5_REGISTRY_UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export interface CourseStalenessInput {
|
|
13
|
+
readonly source: unknown;
|
|
14
|
+
readonly expectedPackage?: string | null;
|
|
15
|
+
readonly registryVersion?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CourseStalenessResult {
|
|
19
|
+
readonly state: CourseStalenessState;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly courseVersion?: string;
|
|
22
|
+
readonly registryVersion?: string;
|
|
23
|
+
readonly package?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
27
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classify only the version evidence supplied by the caller. Obtaining a registry version is a
|
|
35
|
+
* caller responsibility; this function performs no filesystem, network, clock, or environment I/O.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyCourseStaleness(input: CourseStalenessInput): CourseStalenessResult {
|
|
38
|
+
const candidate = isRecord(input) ? input : undefined;
|
|
39
|
+
const source = candidate?.source;
|
|
40
|
+
|
|
41
|
+
// Presence and shape come first. Unknown values must never reach an equality comparison where
|
|
42
|
+
// two absences could be mistaken for parity.
|
|
43
|
+
if (!isRecord(source)
|
|
44
|
+
|| typeof source.package !== 'string'
|
|
45
|
+
|| source.package.trim() === ''
|
|
46
|
+
|| typeof source.version !== 'string'
|
|
47
|
+
|| source.version.trim() === '') {
|
|
48
|
+
return {
|
|
49
|
+
state: 'E2_UNSTAMPED',
|
|
50
|
+
reason: 'course.source.package and course.source.version must both be non-empty strings',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const packageName = source.package.trim();
|
|
55
|
+
const courseVersion = source.version.trim();
|
|
56
|
+
if (!SEMVER.test(courseVersion)) {
|
|
57
|
+
return {
|
|
58
|
+
state: 'E3_INVALID_VERSION',
|
|
59
|
+
reason: `course.source.version is not valid semver: ${courseVersion}`,
|
|
60
|
+
courseVersion,
|
|
61
|
+
package: packageName,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const expectedPackage = typeof candidate?.expectedPackage === 'string'
|
|
66
|
+
? candidate.expectedPackage.trim()
|
|
67
|
+
: candidate?.expectedPackage;
|
|
68
|
+
if (!PACKAGE_NAME.test(packageName)
|
|
69
|
+
|| (expectedPackage !== null && expectedPackage !== undefined
|
|
70
|
+
&& (!PACKAGE_NAME.test(expectedPackage) || packageName !== expectedPackage))) {
|
|
71
|
+
return {
|
|
72
|
+
state: 'E4_PACKAGE_MISMATCH',
|
|
73
|
+
reason: expectedPackage
|
|
74
|
+
? `course.source.package ${packageName} does not match expectedPackage ${expectedPackage}`
|
|
75
|
+
: `course.source.package is not a valid npm package name: ${packageName}`,
|
|
76
|
+
courseVersion,
|
|
77
|
+
package: packageName,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const registryVersion = candidate?.registryVersion;
|
|
82
|
+
if (registryVersion === null || registryVersion === undefined) {
|
|
83
|
+
return {
|
|
84
|
+
state: 'E5_REGISTRY_UNKNOWN',
|
|
85
|
+
reason: 'registryVersion is absent, so package parity is unknown',
|
|
86
|
+
courseVersion,
|
|
87
|
+
package: packageName,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (typeof registryVersion !== 'string' || !SEMVER.test(registryVersion.trim())) {
|
|
91
|
+
return {
|
|
92
|
+
state: 'E3_INVALID_VERSION',
|
|
93
|
+
reason: `registryVersion is not valid semver: ${String(registryVersion)}`,
|
|
94
|
+
courseVersion,
|
|
95
|
+
package: packageName,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalizedRegistryVersion = registryVersion.trim();
|
|
100
|
+
const ordering = compareVersions(courseVersion, normalizedRegistryVersion);
|
|
101
|
+
const common = {
|
|
102
|
+
courseVersion,
|
|
103
|
+
registryVersion: normalizedRegistryVersion,
|
|
104
|
+
package: packageName,
|
|
105
|
+
};
|
|
106
|
+
if (ordering === 0) {
|
|
107
|
+
return {
|
|
108
|
+
state: 'S0_SHIPPED',
|
|
109
|
+
reason: 'course.source.version equals registryVersion',
|
|
110
|
+
...common,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (ordering < 0) {
|
|
114
|
+
return {
|
|
115
|
+
state: 'S3_TUTORIAL_STALE',
|
|
116
|
+
reason: 'course.source.version is below registryVersion',
|
|
117
|
+
...common,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
state: 'S4_PACKAGE_BEHIND',
|
|
122
|
+
reason: 'course.source.version is above registryVersion',
|
|
123
|
+
...common,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// destructive-guard-hook — the CONSUMER half of the destructive-command guard
|
|
2
|
+
// (feature `destructive-command-guard`, ADR-001, plan task T8).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS AT ALL. The classifier (`destructive-guard.ts`) already returned a verdict,
|
|
5
|
+
// and nothing on either host read it: the export existed, the consumer did not, so
|
|
6
|
+
// `rm -rf .agentic-qe` still executed on both runtimes. A verdict nobody consumes is a document,
|
|
7
|
+
// not a guard. This module is that consumer — and it is ONE module rather than two hook bodies,
|
|
8
|
+
// because the two hosts must give the SAME answer in the SAME words. A refusal text duplicated in
|
|
9
|
+
// two shell-spawned strings drifts silently the first time either is edited.
|
|
10
|
+
//
|
|
11
|
+
// PURE BY CONTRACT, exactly like the classifier it wraps: a payload in, an exit code and a piece of
|
|
12
|
+
// text out. No filesystem access, no subprocess, no environment. The hook bodies that call it read
|
|
13
|
+
// stdin, hand the parsed object over, print what comes back and exit with the code — nothing else.
|
|
14
|
+
//
|
|
15
|
+
// FAIL-OPEN ON OUR OWN FAILURE. Every branch that is not a decided `refuse` exits 0. An unparseable
|
|
16
|
+
// payload, a classifier that throws, a tool that is not a shell — none of them is evidence of a
|
|
17
|
+
// violation, and a guard that blocks on its own confusion is a guard the user switches off. This is
|
|
18
|
+
// the same polarity the sibling guards ship with (`claim-check-hook.cjs`, `dz-codex-veto.cjs`).
|
|
19
|
+
|
|
20
|
+
import { classifyDestructive } from './destructive-guard.js';
|
|
21
|
+
import type { DestructiveVerdict } from './destructive-guard.js';
|
|
22
|
+
|
|
23
|
+
/** Prefix of a BLOCKING message. Must appear verbatim for a block to be attributed to this guard. */
|
|
24
|
+
export const DESTRUCTIVE_REFUSE_MARKER = 'DZ-DESTRUCTIVE:';
|
|
25
|
+
|
|
26
|
+
/** Prefix of a non-blocking message: an `undecidable` verdict, or our own failure to read. */
|
|
27
|
+
export const DESTRUCTIVE_WARN_MARKER = 'DZ-DESTRUCTIVE-WARN:';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which host contract the payload follows. The two differ in ONE measured respect, so the
|
|
31
|
+
* difference is a parameter rather than a guess:
|
|
32
|
+
*
|
|
33
|
+
* - `claude` — Claude Code sends `tool_name` for every tool and the registered matcher is `^Bash$`.
|
|
34
|
+
* A payload whose `tool_name` is not a shell tool is skipped WITHOUT looking at any command field,
|
|
35
|
+
* which is what the host contract promises and what the test pins.
|
|
36
|
+
* - `codex` — AM-8 of `crossrt-2-codex-hooks`: key on the PRESENCE of `tool_input.command`, never on
|
|
37
|
+
* `tool_name` equality. Codex's shell tool has been observed under more than one name
|
|
38
|
+
* (`Bash|shell|local_shell` is the matcher the emitted registry entry already carries), so a
|
|
39
|
+
* tool_name test there would silently disarm the guard the day the runtime renames its tool.
|
|
40
|
+
*/
|
|
41
|
+
export type DestructiveHookHost = 'claude' | 'codex';
|
|
42
|
+
|
|
43
|
+
/** Tool names that ARE a shell on the Claude host. */
|
|
44
|
+
const SHELL_TOOL_NAMES: ReadonlySet<string> = new Set(['Bash', 'bash', 'shell', 'local_shell']);
|
|
45
|
+
|
|
46
|
+
export interface DestructiveHookDecision {
|
|
47
|
+
/** 2 = block the call (the host contract on BOTH runtimes). 0 = let it through. */
|
|
48
|
+
readonly exitCode: 0 | 2;
|
|
49
|
+
/** Exactly what the hook body writes to stderr. Empty string ⇒ write nothing at all. */
|
|
50
|
+
readonly stderr: string;
|
|
51
|
+
/**
|
|
52
|
+
* What happened, for tests and for the helper's own notes log:
|
|
53
|
+
* `refuse` / `allow` / `undecidable` are the classifier's three outcomes;
|
|
54
|
+
* `skipped` = not a shell command, the classifier was never consulted;
|
|
55
|
+
* `unreadable` = the payload or the classifier failed us, and we passed rather than guessed.
|
|
56
|
+
*/
|
|
57
|
+
readonly outcome: 'refuse' | 'allow' | 'undecidable' | 'skipped' | 'unreadable';
|
|
58
|
+
/** The rule id from a refusal, so a note can record WHICH rule fired without the command line. */
|
|
59
|
+
readonly rule: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the limits block from THE VERDICT's own list — never from a copy kept here.
|
|
64
|
+
*
|
|
65
|
+
* ADR-001 obliges the guard to NAME what it does not decide, in its own text, on every verdict it
|
|
66
|
+
* speaks about: a guard silent about its limits reads as a guarantee that recursive deletion is
|
|
67
|
+
* covered, and it is not. Re-listing them in this file would be a second copy free to drift from
|
|
68
|
+
* the classifier's; `verdict.limits` is the single source.
|
|
69
|
+
*/
|
|
70
|
+
function renderLimits(limits: readonly string[]): string {
|
|
71
|
+
const head = 'Чего этот страж НЕ решает (печатается при каждом вердикте, чтобы он не читался как гарантия):';
|
|
72
|
+
return [head, ...limits.map((l) => ` - ${l}`)].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderRefusal(verdict: DestructiveVerdict): string {
|
|
76
|
+
return [
|
|
77
|
+
`${DESTRUCTIVE_REFUSE_MARKER} ОТКАЗ — удаление по буквальному пути в защищаемое хранилище.`,
|
|
78
|
+
'',
|
|
79
|
+
`Путь: ${verdict.path ?? '(не назван)'}`,
|
|
80
|
+
`Правило: ${verdict.rule ?? '(не названо)'}`,
|
|
81
|
+
`Причина: ${verdict.reason}`,
|
|
82
|
+
'',
|
|
83
|
+
renderLimits(verdict.limits),
|
|
84
|
+
'',
|
|
85
|
+
'Санкционированное удаление защищаемого хранилища выполняет владелец вручную, вне сеанса агента.',
|
|
86
|
+
'',
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderUndecidable(verdict: DestructiveVerdict): string {
|
|
91
|
+
return [
|
|
92
|
+
`${DESTRUCTIVE_WARN_MARKER} команду не удалось разобрать (${verdict.reason}) — ПРОПУЩЕНА без отказа: «проверить не удалось» никогда не выдаётся за «запрещено» (AC-10).`,
|
|
93
|
+
renderLimits(verdict.limits),
|
|
94
|
+
'',
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unreadable(message: string): DestructiveHookDecision {
|
|
99
|
+
return {
|
|
100
|
+
exitCode: 0,
|
|
101
|
+
// One line, and no limits block: there is no verdict here to quote limits FROM, and inventing
|
|
102
|
+
// one would be the same lie in the other direction.
|
|
103
|
+
stderr: `${DESTRUCTIVE_WARN_MARKER} ${message} — пропуск без отказа; отсутствие вердикта нарушением не является.\n`,
|
|
104
|
+
outcome: 'unreadable',
|
|
105
|
+
rule: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const SKIP: DestructiveHookDecision = { exitCode: 0, stderr: '', outcome: 'skipped', rule: null };
|
|
110
|
+
const ALLOW: DestructiveHookDecision = { exitCode: 0, stderr: '', outcome: 'allow', rule: null };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Turn one hook payload into an exit code and a piece of text. The whole policy, both hosts.
|
|
114
|
+
*
|
|
115
|
+
* @param payload the parsed stdin object, or `null`/anything else when it did not parse.
|
|
116
|
+
* @param host which host contract the payload follows — see {@link DestructiveHookHost}.
|
|
117
|
+
*/
|
|
118
|
+
export function decideDestructiveHook(payload: unknown, host: DestructiveHookHost): DestructiveHookDecision {
|
|
119
|
+
if (payload === null || typeof payload !== 'object') {
|
|
120
|
+
return unreadable('полезная нагрузка хука не прочиталась');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const p = payload as { tool_name?: unknown; tool_input?: unknown };
|
|
124
|
+
|
|
125
|
+
if (host === 'claude') {
|
|
126
|
+
// The Claude contract: decide from the tool NAME first, and on a non-shell tool return before
|
|
127
|
+
// any command field is even looked at.
|
|
128
|
+
const toolName = typeof p.tool_name === 'string' ? p.tool_name : '';
|
|
129
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) return SKIP;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const input = p.tool_input;
|
|
133
|
+
const command = input !== null && typeof input === 'object' ? (input as { command?: unknown }).command : undefined;
|
|
134
|
+
if (typeof command !== 'string' || command === '') return SKIP;
|
|
135
|
+
|
|
136
|
+
let verdict: DestructiveVerdict;
|
|
137
|
+
try {
|
|
138
|
+
verdict = classifyDestructive(command);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// Our own failure. Never a refusal — see the FAIL-OPEN note at the top of the file.
|
|
141
|
+
return unreadable(`классификатор не смог вынести вердикт (${String((err as Error)?.message ?? err)})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (verdict.outcome === 'refuse') {
|
|
145
|
+
return { exitCode: 2, stderr: renderRefusal(verdict), outcome: 'refuse', rule: verdict.rule };
|
|
146
|
+
}
|
|
147
|
+
if (verdict.outcome === 'undecidable') {
|
|
148
|
+
return { exitCode: 0, stderr: renderUndecidable(verdict), outcome: 'undecidable', rule: null };
|
|
149
|
+
}
|
|
150
|
+
return ALLOW;
|
|
151
|
+
}
|