@event4u/agent-config 9.34.0 → 9.35.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +91 -0
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-2TGMGLBL.js} +2 -2
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +259 -78
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +74 -2
- package/dist/server/schemas/settings.js +1 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +3 -2
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +11 -2
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +3 -0
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
* is deliberately not another reminder. It is a check at the point of
|
|
10
10
|
* delivery that can say no.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three detectors ride on one guard, because building the unsafe part twice
|
|
13
13
|
* is how a second detector becomes a second outage:
|
|
14
14
|
*
|
|
15
15
|
* A — promissory closing (FC-5, 20 measured occurrences)
|
|
16
16
|
* B — language mismatch (19 measured occurrences, fresh pin present)
|
|
17
|
+
* C — unverified edit (the turn changed a file and ran nothing that could
|
|
18
|
+
* have checked it — verify-before-complete's gate,
|
|
19
|
+
* read off the turn's TOOL CALLS rather than its
|
|
20
|
+
* prose, which is why `readTranscriptTail` collects
|
|
21
|
+
* them: `_messageText` keeps `type === 'text'` blocks
|
|
22
|
+
* only, so tool activity is invisible to A and B.)
|
|
17
23
|
*
|
|
18
24
|
* ## Re-entrancy — the shape, stated before registration
|
|
19
25
|
*
|
|
@@ -60,10 +66,11 @@
|
|
|
60
66
|
* mechanism exists and soaks before it binds" — two prior hook-severity
|
|
61
67
|
* mistakes plus a turn-end blast radius made default-on uninsurable in
|
|
62
68
|
* their reading. Both are satisfied literally: the MASTER switch
|
|
63
|
-
* (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it
|
|
64
|
-
*
|
|
69
|
+
* (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it every
|
|
70
|
+
* detector is default ON. A maintainer who opts in gets all of them without
|
|
65
71
|
* further configuration; until then this concern is a no-op that costs one
|
|
66
|
-
* settings-cascade read per turn-end.
|
|
72
|
+
* settings-cascade read per turn-end. Detector C landed under the same rule —
|
|
73
|
+
* the master switch was NOT flipped when it shipped.
|
|
67
74
|
*
|
|
68
75
|
* CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
|
|
69
76
|
* otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
|
|
@@ -71,6 +78,16 @@
|
|
|
71
78
|
* crash to a block is the outage this whole file is arranged to avoid.
|
|
72
79
|
* Every unreadable input, missing transcript, absent pin, or malformed
|
|
73
80
|
* state resolves to silence.
|
|
81
|
+
*
|
|
82
|
+
* The severity mapping was re-probed when C landed, because
|
|
83
|
+
* `dispatch_hook.ts` defines `EXIT_WARN = 2` beside `EXIT_BLOCK = 1` and an
|
|
84
|
+
* advisory finding delivered on the wrong code becomes a hard deny. Result:
|
|
85
|
+
* this file emits ONLY 0 and 1 internally, `concern_block_exit_parity` pins the
|
|
86
|
+
* 1, and the dispatcher translates a stop-slot block to host exit **2** —
|
|
87
|
+
* which a spawned test in `turn_end_gate_hook.test.ts` asserts, because on the
|
|
88
|
+
* stop slot exit 1 would let the turn end anyway. C is a refusal like A and B,
|
|
89
|
+
* not an advisory, so there is no advisory path here to put on the wrong code.
|
|
90
|
+
* Probed, not assumed.
|
|
74
91
|
*/
|
|
75
92
|
import * as crypto from 'node:crypto';
|
|
76
93
|
import * as fs from 'node:fs';
|
|
@@ -104,7 +121,7 @@ const EXIT_ALLOW = 0;
|
|
|
104
121
|
export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
|
105
122
|
|
|
106
123
|
/** Detector identity, used in the state marker and the refusal text. */
|
|
107
|
-
export type DetectorId = 'promissory' | 'language';
|
|
124
|
+
export type DetectorId = 'promissory' | 'language' | 'verification';
|
|
108
125
|
|
|
109
126
|
export interface Finding {
|
|
110
127
|
detector: DetectorId;
|
|
@@ -360,6 +377,71 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
|
|
|
360
377
|
};
|
|
361
378
|
}
|
|
362
379
|
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Detector 3 — an edit the turn never verified
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/** Tools that CHANGE the tree. The three write tools, nothing inferred. */
|
|
385
|
+
const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Shell commands that count as verification.
|
|
389
|
+
*
|
|
390
|
+
* A positive list, and deliberately narrow. The alternative — treating any
|
|
391
|
+
* `Bash` call as verification — was rejected because `ls` would then clear an
|
|
392
|
+
* unverified edit, which is the failure mode `verify-before-complete` names
|
|
393
|
+
* ("relying on partial verification"). The cost of narrowness is a MISSED
|
|
394
|
+
* detection when a project verifies by some command not listed here, and that is
|
|
395
|
+
* the safe direction for a gate that can refuse a turn: a false negative costs
|
|
396
|
+
* one unguarded turn, a false positive teaches the user to switch the gate off.
|
|
397
|
+
*/
|
|
398
|
+
const _VERIFY_RE =
|
|
399
|
+
/\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|typecheck|lint|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
|
|
400
|
+
|
|
401
|
+
export function isVerificationCommand(command: string): boolean {
|
|
402
|
+
return _VERIFY_RE.test(command);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Fire when the turn changed a file and then ran nothing that could have
|
|
407
|
+
* checked it.
|
|
408
|
+
*
|
|
409
|
+
* The window is "after the LAST edit", not "anywhere in the turn": a test run
|
|
410
|
+
* before the final edit demonstrably did not exercise it, which is the same
|
|
411
|
+
* freshness argument `verify-before-complete` makes about trusting an earlier
|
|
412
|
+
* run ("no verification command run in this message → you cannot claim it
|
|
413
|
+
* passes"). Ordering is the only thing that distinguishes the two, so the
|
|
414
|
+
* detector reads the sequence rather than a pair of counts.
|
|
415
|
+
*
|
|
416
|
+
* Silent when the turn edited nothing — a read-only or conversational turn has
|
|
417
|
+
* no claim to verify, and refusing one would make the gate fire on the majority
|
|
418
|
+
* of turns, which is how a guard gets disabled.
|
|
419
|
+
*/
|
|
420
|
+
export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding | null {
|
|
421
|
+
let lastEdit = -1;
|
|
422
|
+
for (let i = 0; i < toolCalls.length; i += 1) {
|
|
423
|
+
if (_EDIT_TOOLS.has(toolCalls[i]!.name)) lastEdit = i;
|
|
424
|
+
}
|
|
425
|
+
if (lastEdit === -1) return null;
|
|
426
|
+
for (let i = lastEdit + 1; i < toolCalls.length; i += 1) {
|
|
427
|
+
const c = toolCalls[i]!;
|
|
428
|
+
if (c.name === 'Bash' && c.command !== undefined && isVerificationCommand(c.command)) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const edited = toolCalls[lastEdit]!.path;
|
|
433
|
+
return {
|
|
434
|
+
detector: 'verification',
|
|
435
|
+
// The path, never the diff: the evidence span is quoted into a refusal,
|
|
436
|
+
// and `ToolCall` is shaped so a file body cannot reach it.
|
|
437
|
+
evidence: edited ?? toolCalls[lastEdit]!.name,
|
|
438
|
+
reason:
|
|
439
|
+
'this turn changed a file and then ran no verification command — ' +
|
|
440
|
+
'no test, type-check, lint or build call follows the last edit ' +
|
|
441
|
+
'(verify-before-complete: a claim without a fresh run is unverified)',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
363
445
|
// ---------------------------------------------------------------------------
|
|
364
446
|
// Re-entrancy — the guard, keyed on the turn, not on the reply
|
|
365
447
|
// ---------------------------------------------------------------------------
|
|
@@ -450,6 +532,24 @@ function markRefusedTurn(
|
|
|
450
532
|
// Transcript
|
|
451
533
|
// ---------------------------------------------------------------------------
|
|
452
534
|
|
|
535
|
+
/**
|
|
536
|
+
* One tool call the assistant made, reduced to what a detector can reason
|
|
537
|
+
* about: the tool's name, the shell command when it is a shell call, and the
|
|
538
|
+
* target path when it is a file write.
|
|
539
|
+
*
|
|
540
|
+
* Nothing else is kept. A tool input can hold a whole file body, and this
|
|
541
|
+
* struct is what a refusal quotes back — so it is shaped to be INCAPABLE of
|
|
542
|
+
* carrying one, the same PII-exclusion-by-construction discipline
|
|
543
|
+
* `domain-safety-pii` § Surface 2 asks for in a log line.
|
|
544
|
+
*/
|
|
545
|
+
export interface ToolCall {
|
|
546
|
+
name: string;
|
|
547
|
+
/** `Bash` only — the command line, so "did anything verify" is answerable. */
|
|
548
|
+
command?: string;
|
|
549
|
+
/** Edit/Write only — the file the turn changed, used as the evidence span. */
|
|
550
|
+
path?: string;
|
|
551
|
+
}
|
|
552
|
+
|
|
453
553
|
export interface TranscriptTail {
|
|
454
554
|
lastAssistant: string;
|
|
455
555
|
/**
|
|
@@ -458,6 +558,14 @@ export interface TranscriptTail {
|
|
|
458
558
|
* identity; see `alreadyRefusedTurn` for why the prompt's text is not.
|
|
459
559
|
*/
|
|
460
560
|
turnOrdinal: number;
|
|
561
|
+
/**
|
|
562
|
+
* The tool calls of the CURRENT turn, in order — reset at every genuine user
|
|
563
|
+
* prompt, so a verification from three turns ago cannot vouch for an edit
|
|
564
|
+
* made now. `_messageText` keeps only `type === 'text'` blocks, which is why
|
|
565
|
+
* this needed its own extraction rather than a reading of `lastAssistant`:
|
|
566
|
+
* tool activity is not in the prose at all.
|
|
567
|
+
*/
|
|
568
|
+
toolCalls: ToolCall[];
|
|
461
569
|
}
|
|
462
570
|
|
|
463
571
|
/**
|
|
@@ -475,7 +583,7 @@ export function readTranscriptTail(
|
|
|
475
583
|
transcriptPath: string,
|
|
476
584
|
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
477
585
|
): TranscriptTail {
|
|
478
|
-
const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
|
|
586
|
+
const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0, toolCalls: [] };
|
|
479
587
|
if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
|
|
480
588
|
let lines: string[];
|
|
481
589
|
try {
|
|
@@ -494,6 +602,7 @@ export function readTranscriptTail(
|
|
|
494
602
|
}
|
|
495
603
|
let lastAssistant = '';
|
|
496
604
|
let turnOrdinal = 0;
|
|
605
|
+
let toolCalls: ToolCall[] = [];
|
|
497
606
|
for (const rawLine of lines) {
|
|
498
607
|
const line = rawLine.trim();
|
|
499
608
|
if (!line) continue;
|
|
@@ -514,15 +623,55 @@ export function readTranscriptTail(
|
|
|
514
623
|
if (role !== 'assistant' && role !== 'user') continue;
|
|
515
624
|
const msg = entry['message'];
|
|
516
625
|
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
|
|
517
|
-
const
|
|
626
|
+
const content = (msg as Record<string, unknown>)['content'];
|
|
627
|
+
// Tool calls are read BEFORE the text guard: an assistant entry that is
|
|
628
|
+
// only a tool_use block has no text at all, so `continue`-ing on a null
|
|
629
|
+
// text would drop exactly the entries this detector exists to see.
|
|
630
|
+
if (role === 'assistant') {
|
|
631
|
+
toolCalls.push(..._toolCalls(content));
|
|
632
|
+
}
|
|
633
|
+
const text = _messageText(content);
|
|
518
634
|
if (text === null) continue;
|
|
519
635
|
if (role === 'assistant') {
|
|
520
636
|
lastAssistant = text;
|
|
521
637
|
} else if (!isSyntheticPrompt(text)) {
|
|
522
638
|
turnOrdinal += 1;
|
|
639
|
+
// A genuine user prompt starts a new turn, so the previous turn's
|
|
640
|
+
// tool activity stops counting. Without this reset, a verification
|
|
641
|
+
// run three turns ago would vouch for an edit made now — the
|
|
642
|
+
// "fresh" in edit-without-FRESH-verification is this line.
|
|
643
|
+
toolCalls = [];
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { lastAssistant: lastAssistant.trim(), turnOrdinal, toolCalls };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Extract this entry's tool calls, keeping only name, shell command and target
|
|
651
|
+
* path. A tool input can hold a whole file body; nothing but those three fields
|
|
652
|
+
* is carried forward.
|
|
653
|
+
*/
|
|
654
|
+
function _toolCalls(content: unknown): ToolCall[] {
|
|
655
|
+
if (!Array.isArray(content)) return [];
|
|
656
|
+
const out: ToolCall[] = [];
|
|
657
|
+
for (const blk of content) {
|
|
658
|
+
if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
|
|
659
|
+
const b = blk as Record<string, unknown>;
|
|
660
|
+
if (b['type'] !== 'tool_use') continue;
|
|
661
|
+
const name = b['name'];
|
|
662
|
+
if (typeof name !== 'string') continue;
|
|
663
|
+
const input = b['input'];
|
|
664
|
+
const call: ToolCall = { name };
|
|
665
|
+
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
|
|
666
|
+
const inp = input as Record<string, unknown>;
|
|
667
|
+
const cmd = inp['command'];
|
|
668
|
+
if (typeof cmd === 'string') call.command = cmd;
|
|
669
|
+
const p = inp['file_path'] ?? inp['path'] ?? inp['notebook_path'];
|
|
670
|
+
if (typeof p === 'string') call.path = p;
|
|
523
671
|
}
|
|
672
|
+
out.push(call);
|
|
524
673
|
}
|
|
525
|
-
return
|
|
674
|
+
return out;
|
|
526
675
|
}
|
|
527
676
|
|
|
528
677
|
function _messageText(content: unknown): string | null {
|
|
@@ -547,6 +696,7 @@ export interface GateSettings {
|
|
|
547
696
|
enabled: boolean;
|
|
548
697
|
promissory: boolean;
|
|
549
698
|
language: boolean;
|
|
699
|
+
verification: boolean;
|
|
550
700
|
}
|
|
551
701
|
|
|
552
702
|
/**
|
|
@@ -581,7 +731,12 @@ function _isOn(v: unknown): boolean | null {
|
|
|
581
731
|
* a turn-end outranks one settings read per turn-end.
|
|
582
732
|
*/
|
|
583
733
|
export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
584
|
-
const off: GateSettings = {
|
|
734
|
+
const off: GateSettings = {
|
|
735
|
+
enabled: false,
|
|
736
|
+
promissory: true,
|
|
737
|
+
language: true,
|
|
738
|
+
verification: true,
|
|
739
|
+
};
|
|
585
740
|
let settings: Record<string, unknown>;
|
|
586
741
|
try {
|
|
587
742
|
settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
|
|
@@ -601,6 +756,7 @@ export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
|
601
756
|
enabled: flag('enabled', false),
|
|
602
757
|
promissory: flag('promissory', true),
|
|
603
758
|
language: flag('language', true),
|
|
759
|
+
verification: flag('verification', true),
|
|
604
760
|
};
|
|
605
761
|
}
|
|
606
762
|
|
|
@@ -644,7 +800,7 @@ export function main(): number {
|
|
|
644
800
|
// in production while its comment claimed it was enforced — the same
|
|
645
801
|
// fixed-the-definition-not-the-caller shape as two other findings in that
|
|
646
802
|
// round, which is why this line exists rather than a default parameter.
|
|
647
|
-
const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
|
|
803
|
+
const { lastAssistant, turnOrdinal, toolCalls } = readTranscriptTail(transcriptPath, {
|
|
648
804
|
maxBytes: TRANSCRIPT_READ_MAX_BYTES,
|
|
649
805
|
});
|
|
650
806
|
if (!lastAssistant) return EXIT_ALLOW;
|
|
@@ -671,6 +827,10 @@ export function main(): number {
|
|
|
671
827
|
const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
|
|
672
828
|
if (f) findings.push(f);
|
|
673
829
|
}
|
|
830
|
+
if (settings.verification) {
|
|
831
|
+
const f = detectUnverifiedEdit(toolCalls);
|
|
832
|
+
if (f) findings.push(f);
|
|
833
|
+
}
|
|
674
834
|
if (findings.length === 0) return EXIT_ALLOW;
|
|
675
835
|
|
|
676
836
|
markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
|
|
@@ -680,7 +840,8 @@ export function main(): number {
|
|
|
680
840
|
);
|
|
681
841
|
process.stderr.write(
|
|
682
842
|
`turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
|
|
683
|
-
' Do the promised work now,
|
|
843
|
+
' Do the promised work now, correct the language, or run the ' +
|
|
844
|
+
'verification the edit needs, then end the turn.\n' +
|
|
684
845
|
' This turn will not be refused a second time.\n' +
|
|
685
846
|
' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
|
|
686
847
|
);
|
|
@@ -299,14 +299,21 @@ interface AllowlistEntry {
|
|
|
299
299
|
file?: string;
|
|
300
300
|
lines?: number[] | '*';
|
|
301
301
|
/**
|
|
302
|
-
* Content anchor — a substring of the exempted line.
|
|
302
|
+
* Content anchor — a substring of the exempted line. The only per-line key.
|
|
303
303
|
*
|
|
304
|
-
* Position
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
304
|
+
* Position keying (`lines: [100]`) is retired, not merely discouraged.
|
|
305
|
+
* Inserting a paragraph above line 100 re-fires the ratchet on an entry
|
|
306
|
+
* nobody touched — recorded twice in one pull request — and, worse, it rots
|
|
307
|
+
* in silence: the migration that introduced this comment found three shipped
|
|
308
|
+
* entries that had stopped exempting anything, one naming line 100 of a
|
|
309
|
+
* 68-line file and two naming blank lines. Nothing reported it, because a
|
|
310
|
+
* drifted position key still parses.
|
|
311
|
+
*
|
|
312
|
+
* An anchor keys the exemption to the content being exempted, so it survives
|
|
313
|
+
* the edit that moves it and fails loudly when that content is gone.
|
|
314
|
+
* `validate_allowlist` additionally requires it to match exactly one line:
|
|
315
|
+
* `includes` is file-scoped, so a too-short anchor would silently exempt a
|
|
316
|
+
* second line nobody reviewed.
|
|
310
317
|
*/
|
|
311
318
|
anchor?: string;
|
|
312
319
|
reason?: string;
|
|
@@ -328,35 +335,124 @@ function _load_allowlist(): Allowlist {
|
|
|
328
335
|
}
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Every allowlist entry must still exempt something, and exactly one thing.
|
|
340
|
+
*
|
|
341
|
+
* A position-keyed entry rots in silence: the line it names drifts, the entry
|
|
342
|
+
* keeps parsing, and nothing reports that an exemption stopped exempting. This
|
|
343
|
+
* repository shipped three such entries — one naming line 100 of a 68-line file,
|
|
344
|
+
* two naming blank lines — and the rot was invisible until the whole set was read
|
|
345
|
+
* at once. An anchor cannot drift, but it can over-reach: `includes` is
|
|
346
|
+
* file-scoped, so an anchor occurring twice silently exempts a second line nobody
|
|
347
|
+
* reviewed.
|
|
348
|
+
*
|
|
349
|
+
* Both failures are decidable from the file, so they are checked rather than
|
|
350
|
+
* trusted. `lines: '*'` stays legal — a deliberate whole-file exemption is a
|
|
351
|
+
* different thing from a position key, and it cannot drift.
|
|
352
|
+
*/
|
|
353
|
+
/**
|
|
354
|
+
* The exact text `_allowlisted` compares an anchor against.
|
|
355
|
+
*
|
|
356
|
+
* The matcher sees `Hit.snippet`, not the raw line — trimmed and cut at 160
|
|
357
|
+
* chars. A validator reading the raw line would clear an anchor copied with its
|
|
358
|
+
* indentation, or drawn from past column 160, and the gate would then fail to
|
|
359
|
+
* suppress a hit the maintainer was told is exempt. One function, both callers.
|
|
360
|
+
*/
|
|
361
|
+
export function anchor_haystack(line: string): string {
|
|
362
|
+
return line.trim().slice(0, 160);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function validate_allowlist(allowlist: Allowlist, repo_root?: string): string[] {
|
|
366
|
+
// Default to the MUTABLE root, not the real one: the scan resolves entry
|
|
367
|
+
// paths against `REPO_ROOT`, and a validator resolving them anywhere else
|
|
368
|
+
// would report every entry missing the moment a caller redirects the tree.
|
|
369
|
+
const root = repo_root ?? REPO_ROOT;
|
|
370
|
+
const problems: string[] = [];
|
|
371
|
+
for (const entry of allowlist.entries ?? []) {
|
|
372
|
+
const file = entry.file;
|
|
373
|
+
if (typeof file !== 'string' || file === '') {
|
|
374
|
+
problems.push('entry without a `file`');
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (Array.isArray(entry.lines)) {
|
|
378
|
+
problems.push(
|
|
379
|
+
`${file}: position-keyed (\`lines: [${entry.lines.join(', ')}]\`) — ` +
|
|
380
|
+
'use `anchor` with a substring of the exempted line; a line number ' +
|
|
381
|
+
'drifts out from under its own exemption on any edit above it',
|
|
382
|
+
);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (entry.lines === '*') {
|
|
386
|
+
// A whole-file exemption never consults the anchor, so validating one
|
|
387
|
+
// here would stop every scan for a field the matcher does not read.
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const anchor = entry.anchor;
|
|
391
|
+
if (typeof anchor !== 'string' || anchor === '') {
|
|
392
|
+
problems.push(`${file}: neither an \`anchor\` nor \`lines: "*"\``);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const abs = path.join(root, file);
|
|
396
|
+
if (!_isFile(abs)) {
|
|
397
|
+
problems.push(`${file}: anchored entry names a file that does not exist`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const matches = fs
|
|
401
|
+
.readFileSync(abs, 'utf-8')
|
|
402
|
+
.split('\n')
|
|
403
|
+
.filter((l) => anchor_haystack(l).includes(anchor)).length;
|
|
404
|
+
if (matches === 0) {
|
|
405
|
+
problems.push(
|
|
406
|
+
`${file}: anchor matches no line — the exempted content is gone, so the ` +
|
|
407
|
+
`entry exempts nothing: ${JSON.stringify(anchor.slice(0, 50))}`,
|
|
408
|
+
);
|
|
409
|
+
} else if (matches > 1) {
|
|
410
|
+
problems.push(
|
|
411
|
+
`${file}: anchor matches ${matches} lines — it would exempt more than the ` +
|
|
412
|
+
`reviewed one; lengthen it: ${JSON.stringify(anchor.slice(0, 50))}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return problems;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Index of the entry that exempts this hit, or `-1`.
|
|
421
|
+
*
|
|
422
|
+
* Returns the index rather than a boolean so the run can report which
|
|
423
|
+
* exemptions were never used. An entry that suppresses nothing is the failure
|
|
424
|
+
* this gate shipped: three had rotted under position keys, and re-keying them to
|
|
425
|
+
* anchors preserved the rot, because an anchor resolving to a line says nothing
|
|
426
|
+
* about that line producing a hit.
|
|
427
|
+
*/
|
|
428
|
+
function _allowlist_index(
|
|
332
429
|
rel_path: string,
|
|
333
430
|
line_no: number,
|
|
334
431
|
allowlist: Allowlist,
|
|
335
432
|
line_text = '',
|
|
336
|
-
):
|
|
433
|
+
): number {
|
|
337
434
|
const logical = strip_source_prefix(rel_path);
|
|
338
|
-
|
|
435
|
+
const entries = allowlist.entries ?? [];
|
|
436
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
437
|
+
const entry = entries[i] as AllowlistEntry;
|
|
339
438
|
const entry_file = entry.file;
|
|
340
439
|
const entry_logical =
|
|
341
440
|
typeof entry_file === 'string' ? strip_source_prefix(entry_file) : null;
|
|
342
441
|
if (entry_file !== rel_path && (logical === null || entry_logical !== logical)) {
|
|
343
442
|
continue;
|
|
344
443
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return true;
|
|
444
|
+
if (entry.lines === '*') {
|
|
445
|
+
return i;
|
|
348
446
|
}
|
|
349
|
-
// Content anchor
|
|
350
|
-
//
|
|
447
|
+
// Content anchor, and nothing else. A numeric `lines` entry never reaches
|
|
448
|
+
// this point — `validate_allowlist` stops the run on one — so matching it
|
|
449
|
+
// here would be a branch that only fires for input the gate rejects.
|
|
351
450
|
const anchor = entry.anchor;
|
|
352
451
|
if (typeof anchor === 'string' && anchor !== '' && line_text.includes(anchor)) {
|
|
353
|
-
return
|
|
354
|
-
}
|
|
355
|
-
if (Array.isArray(lines) && lines.includes(line_no)) {
|
|
356
|
-
return true;
|
|
452
|
+
return i;
|
|
357
453
|
}
|
|
358
454
|
}
|
|
359
|
-
return
|
|
455
|
+
return -1;
|
|
360
456
|
}
|
|
361
457
|
|
|
362
458
|
function _families_in_window(lines: string[], idx: number, radius = 10): Set<string> {
|
|
@@ -502,7 +598,12 @@ function* iter_md_files(paths: Iterable<string>): Generator<string> {
|
|
|
502
598
|
}
|
|
503
599
|
}
|
|
504
600
|
|
|
505
|
-
function parse_args(argv: readonly string[]): {
|
|
601
|
+
function parse_args(argv: readonly string[]): {
|
|
602
|
+
json: boolean;
|
|
603
|
+
quiet: boolean;
|
|
604
|
+
narrowed: boolean;
|
|
605
|
+
paths: string[];
|
|
606
|
+
} {
|
|
506
607
|
let json = false;
|
|
507
608
|
let quiet = false;
|
|
508
609
|
const paths: string[] = [];
|
|
@@ -535,16 +636,26 @@ function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; p
|
|
|
535
636
|
process.exit(2);
|
|
536
637
|
}
|
|
537
638
|
}
|
|
538
|
-
|
|
639
|
+
// `narrowed` distinguishes an explicit `--paths` from the default full set.
|
|
640
|
+
// `paths.length === 0` cannot: the default is three subdirectories, so a full
|
|
641
|
+
// run also arrives here with a non-empty list — which silently disabled the
|
|
642
|
+
// unused-exemption check the first time it was written against that test.
|
|
643
|
+
return {
|
|
644
|
+
json,
|
|
645
|
+
quiet,
|
|
646
|
+
narrowed: paths.length > 0,
|
|
647
|
+
paths: paths.length > 0 ? paths : _default_paths(),
|
|
648
|
+
};
|
|
539
649
|
}
|
|
540
650
|
|
|
541
651
|
/**
|
|
542
652
|
* The allowlist entry that would silence one finding, ready to paste.
|
|
543
653
|
*
|
|
544
|
-
* Anchored on the matched token rather than on the line number: a
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
654
|
+
* Anchored on the matched token rather than on the line number: a position key
|
|
655
|
+
* re-fires the moment an insertion above moves the line, on an entry nobody
|
|
656
|
+
* touched. The form is no longer merely discouraged — `validate_allowlist`
|
|
657
|
+
* refuses it, and the last 18 position-keyed entries were migrated to anchors
|
|
658
|
+
* in the change that introduced the refusal.
|
|
548
659
|
*/
|
|
549
660
|
export function suppressionKey(rel: string, hit: Hit): string {
|
|
550
661
|
const anchor = hit.snippet.trim().slice(0, 60);
|
|
@@ -613,7 +724,19 @@ export function main(argv?: readonly string[]): number {
|
|
|
613
724
|
const args = parse_args(raw);
|
|
614
725
|
|
|
615
726
|
const allowlist = _load_allowlist();
|
|
727
|
+
// Judge the exemptions before judging any file. A rotted entry produces a
|
|
728
|
+
// green run that means nothing, which is the one failure mode a gate must
|
|
729
|
+
// not have. Structural problems are collected here and reported at the end,
|
|
730
|
+
// NOT returned on early: an early return skipped the `scanned:` line the
|
|
731
|
+
// gate-coverage collector consumes and left `--json` emitting nothing, so a
|
|
732
|
+
// consumer parsing the envelope threw on exactly the runs where the gate
|
|
733
|
+
// reports itself unusable.
|
|
734
|
+
const allowlist_problems = validate_allowlist(allowlist);
|
|
616
735
|
const file_hits: Array<[string, Hit[]]> = [];
|
|
736
|
+
// Which exemptions actually suppressed something. Only meaningful over a full
|
|
737
|
+
// scan: with `--paths` narrowing the tree, an entry for a file outside the
|
|
738
|
+
// scope is unused for a reason that says nothing about the entry.
|
|
739
|
+
const used_entries = new Set<number>();
|
|
617
740
|
let total_hits = 0;
|
|
618
741
|
let allowlisted_total = 0;
|
|
619
742
|
// Every enumerated file is planned up front, so the five exemption branches
|
|
@@ -661,7 +784,9 @@ export function main(argv?: readonly string[]): number {
|
|
|
661
784
|
if (h.cross_stack) {
|
|
662
785
|
continue;
|
|
663
786
|
}
|
|
664
|
-
|
|
787
|
+
const idx = _allowlist_index(rel, h.line, allowlist, h.snippet);
|
|
788
|
+
if (idx >= 0) {
|
|
789
|
+
used_entries.add(idx);
|
|
665
790
|
h.allowlisted = true;
|
|
666
791
|
allowlisted_total += 1;
|
|
667
792
|
continue;
|
|
@@ -679,10 +804,31 @@ export function main(argv?: readonly string[]): number {
|
|
|
679
804
|
}
|
|
680
805
|
}
|
|
681
806
|
|
|
807
|
+
// An exemption that suppressed nothing over a FULL scan exempts nothing at
|
|
808
|
+
// all — the rot the anchor migration was supposed to end, and which anchoring
|
|
809
|
+
// alone does not end: an anchor resolving to a line says nothing about that
|
|
810
|
+
// line producing a hit. Only checked on a full scan, because `--paths` makes
|
|
811
|
+
// "unused" mean "out of scope" instead.
|
|
812
|
+
if (!args.narrowed) {
|
|
813
|
+
const entries = allowlist.entries ?? [];
|
|
814
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
815
|
+
if (used_entries.has(i)) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const e = entries[i] as AllowlistEntry;
|
|
819
|
+
allowlist_problems.push(
|
|
820
|
+
`${String(e.file)}: exemption suppressed nothing this run — the line it names ` +
|
|
821
|
+
'produces no leakage hit, so it exempts nothing: ' +
|
|
822
|
+
JSON.stringify(String(e.anchor ?? e.lines).slice(0, 50)),
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
682
827
|
const summary = {
|
|
683
828
|
total_hits,
|
|
684
829
|
files: file_hits.length,
|
|
685
830
|
allowlisted: allowlisted_total,
|
|
831
|
+
allowlist_problems: allowlist_problems.length,
|
|
686
832
|
};
|
|
687
833
|
|
|
688
834
|
// Finalize before either output branch: an unaccounted target must throw
|
|
@@ -696,10 +842,11 @@ export function main(argv?: readonly string[]): number {
|
|
|
696
842
|
hits.map((h) => ({ file: relToRepo(p), ...h })),
|
|
697
843
|
),
|
|
698
844
|
summary,
|
|
845
|
+
allowlist_problems,
|
|
699
846
|
ledger: tally,
|
|
700
847
|
};
|
|
701
848
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
702
|
-
return total_hits ? 1 : 0;
|
|
849
|
+
return total_hits || allowlist_problems.length ? 1 : 0;
|
|
703
850
|
}
|
|
704
851
|
|
|
705
852
|
if (!args.quiet) {
|
|
@@ -731,6 +878,26 @@ export function main(argv?: readonly string[]): number {
|
|
|
731
878
|
// judged) and is the same "reading M as coverage" trap the human summary
|
|
732
879
|
// carries. The ledger makes the honest number the cheap one to publish.
|
|
733
880
|
process.stderr.write(`scanned: ${String(tally.completed + tally.failed)}\n`);
|
|
881
|
+
|
|
882
|
+
// Reported AFTER the ledger and the `scanned:` line, so a broken allowlist
|
|
883
|
+
// still produces both output contracts. An exemption problem fails the run
|
|
884
|
+
// on its own: a gate that scans cleanly through unusable exemptions is
|
|
885
|
+
// reporting on a question nobody asked.
|
|
886
|
+
if (allowlist_problems.length > 0) {
|
|
887
|
+
process.stderr.write(
|
|
888
|
+
`\n❌ ${allowlist_problems.length} unusable allowlist entr` +
|
|
889
|
+
`${allowlist_problems.length === 1 ? 'y' : 'ies'}:\n`,
|
|
890
|
+
);
|
|
891
|
+
for (const p of allowlist_problems) {
|
|
892
|
+
process.stderr.write(` · ${p}\n`);
|
|
893
|
+
}
|
|
894
|
+
process.stderr.write(
|
|
895
|
+
' An exemption that names nothing, suppresses nothing, or reaches further\n' +
|
|
896
|
+
' than the line it was reviewed for is not an exemption. Remove it or\n' +
|
|
897
|
+
' re-anchor it to the content it is meant to cover.\n',
|
|
898
|
+
);
|
|
899
|
+
return 1;
|
|
900
|
+
}
|
|
734
901
|
return total_hits ? 1 : 0;
|
|
735
902
|
}
|
|
736
903
|
|