@event4u/agent-config 14.0.0 → 14.2.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 +95 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +112 -8
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- 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 +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +199 -18
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +114 -9
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/hook-token-budget.json +6 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +86 -3
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/install-hooks.sh +26 -0
|
@@ -426,35 +426,170 @@ export function expectedHashes(args: {
|
|
|
426
426
|
return {
|
|
427
427
|
scope_hash: sha256(args.scopeDiffText),
|
|
428
428
|
roadmap_hash: args.roadmapText == null ? 'none' : sha256(args.roadmapText),
|
|
429
|
-
|
|
429
|
+
// ONE predicate, shared with the prompt builder. An empty extraction
|
|
430
|
+
// used to hash to e3b0c44298fc…b855 — the SHA-256 of the empty string,
|
|
431
|
+
// which looks like a real hash in the manifest and re-derives
|
|
432
|
+
// identically on `--verify-current`, so the gate confirmed a criteria
|
|
433
|
+
// set that was never there. `'none'` is the value that already means
|
|
434
|
+
// "no input" for `roadmap_hash`; an absent AC block gets the same word.
|
|
435
|
+
ac_hash: hasAcceptanceCriteria(args.acText) ? sha256(args.acText as string) : 'none',
|
|
430
436
|
};
|
|
431
437
|
}
|
|
432
438
|
|
|
433
439
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
440
|
+
* Does an extraction carry acceptance criteria?
|
|
441
|
+
*
|
|
442
|
+
* The single predicate behind BOTH the manifest's `ac_hash` and the reviewer
|
|
443
|
+
* prompt's roadmap line. It exists because the first version of this fix encoded
|
|
444
|
+
* the same question twice — truthiness in {@link expectedHashes}, an explicit
|
|
445
|
+
* `!== null && !== ''` at the call site — and the two agreed only for as long as
|
|
446
|
+
* `''` stayed the sole reachable falsy value. Refining one alone (this `.trim()`,
|
|
447
|
+
* for instance) would then have made the manifest record a hash while the prompt
|
|
448
|
+
* told the reviewer there were no criteria: the same two-places-disagree failure
|
|
449
|
+
* this module was being repaired for, one level up. Caught by the R2 review of
|
|
450
|
+
* that very fix.
|
|
451
|
+
*
|
|
452
|
+
* Whitespace-only counts as absent. The inline collector can emit blank lines
|
|
453
|
+
* between a criterion and its loose-list continuation, so a result that is
|
|
454
|
+
* structurally non-empty and semantically empty is reachable.
|
|
455
|
+
*/
|
|
456
|
+
export function hasAcceptanceCriteria(acText: string | null | undefined): boolean {
|
|
457
|
+
return typeof acText === 'string' && acText.trim() !== '';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Extract a roadmap's acceptance criteria in either form the tree uses.
|
|
462
|
+
*
|
|
463
|
+
* Two forms, because roadmap authors write both and the reviewer needs whichever
|
|
464
|
+
* one is present:
|
|
465
|
+
*
|
|
466
|
+
* 1. A `## Acceptance Criteria` section — from that heading (inclusive) to the
|
|
467
|
+
* next `## ` heading or EOF.
|
|
468
|
+
* 2. Inline `- **AC-n:**` bullets, declared per phase with no section heading —
|
|
469
|
+
* collected with their indented continuation lines.
|
|
470
|
+
*
|
|
471
|
+
* Neither form → empty string, and every caller must treat that as "no criteria"
|
|
472
|
+
* rather than as an extracted-and-empty block: see {@link expectedHashes} and the
|
|
473
|
+
* prompt's roadmap line.
|
|
474
|
+
*
|
|
475
|
+
* The heading form is tried first and wins outright when found. Measured
|
|
476
|
+
* 2026-08-18 over the 44 active roadmaps: 21 heading-only, 7 inline-only, 0
|
|
477
|
+
* carrying both, 16 carrying neither — so the two forms do not currently
|
|
478
|
+
* co-occur and a precedence rule is a guard against a future file rather than a
|
|
479
|
+
* choice between live populations.
|
|
480
|
+
*
|
|
481
|
+
* WHY THE INLINE FORM WAS MISSING, stated because this is the second instance of
|
|
482
|
+
* the same defect class in this one function. The first was case sensitivity
|
|
483
|
+
* (`## Acceptance criteria` extracted nothing, found by the zcs-close R2 review
|
|
484
|
+
* 2026-08-09). The second is this: an inline-only roadmap yielded `''`, nothing
|
|
485
|
+
* downstream noticed, and the reviewer was handed a 0-byte
|
|
486
|
+
* `acceptance-criteria.md` under a prompt that said the criteria had been
|
|
487
|
+
* extracted. The R2 reviewer is the independent check on AC conformance, so
|
|
488
|
+
* blinding it while the artefact reads as though it checked is the
|
|
489
|
+
* gate-that-scans-nothing-exits-green shape one layer up.
|
|
436
490
|
*/
|
|
437
491
|
export function extractAcceptanceCriteria(roadmapText: string): string {
|
|
438
492
|
const lines = roadmapText.split('\n');
|
|
439
493
|
let start = -1;
|
|
440
494
|
for (let i = 0; i < lines.length; i++) {
|
|
441
|
-
// Case-insensitive
|
|
495
|
+
// Case-insensitive, and NOT anchored at the end of the line.
|
|
496
|
+
//
|
|
497
|
+
// Case first: the tree carries both `## Acceptance Criteria` and
|
|
442
498
|
// `## Acceptance criteria`; a case-sensitive match silently extracted
|
|
443
499
|
// nothing for the latter (found by the zcs-close R2 review, 2026-08-09).
|
|
444
|
-
|
|
500
|
+
//
|
|
501
|
+
// The end-anchor was the same defect wearing a different mask, and it was
|
|
502
|
+
// LIVE: `\s*$` rejected a heading carrying a qualifier, so
|
|
503
|
+
// `## Acceptance criteria (per phase, on promotion to ready)` and
|
|
504
|
+
// `## Acceptance criteria (anti-dump — the review-s own rule)` extracted
|
|
505
|
+
// nothing. Two roadmaps, measured 2026-08-18 — and they had been counted
|
|
506
|
+
// as "declares no criteria" by the very census that motivated this fix,
|
|
507
|
+
// which is how a matcher tuned to its own measurement hides its misses.
|
|
508
|
+
if (/^##\s+acceptance criteria\b/i.test(lines[i] as string)) {
|
|
445
509
|
start = i;
|
|
446
510
|
break;
|
|
447
511
|
}
|
|
448
512
|
}
|
|
449
|
-
if (start
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
513
|
+
if (start !== -1) {
|
|
514
|
+
let end = lines.length;
|
|
515
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
516
|
+
if (/^## /.test(lines[i] as string)) {
|
|
517
|
+
end = i;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return lines.slice(start, end).join('\n');
|
|
522
|
+
}
|
|
523
|
+
return extractInlineAcceptanceCriteria(lines);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* An inline criterion bullet, at any indent and with either bullet marker.
|
|
528
|
+
*
|
|
529
|
+
* Indent-tolerant so a nested criterion is emitted as its own row rather than
|
|
530
|
+
* folded into its parent as a continuation line — folding made the extraction
|
|
531
|
+
* shape, and therefore `ac_hash`, a function of indentation depth. Neither the
|
|
532
|
+
* `*` marker nor a nested criterion occurs in the tree today (measured
|
|
533
|
+
* 2026-08-18 across all 44 active roadmaps: zero of each), so this is a guard
|
|
534
|
+
* against a future file, not a fix for a live population.
|
|
535
|
+
*/
|
|
536
|
+
const AC_BULLET_RE = /^\s*[-*]\s+\*\*AC-/;
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Collect inline `- **AC-n:**` bullets and their continuation lines.
|
|
540
|
+
*
|
|
541
|
+
* A continuation line is indented and non-blank. Bullets are emitted in file
|
|
542
|
+
* order and separated by nothing but their own newline: each one is
|
|
543
|
+
* self-labelling (`AC-0`, `AC-1`, …), so a synthetic section heading would be
|
|
544
|
+
* text this function invented rather than text the roadmap declared.
|
|
545
|
+
*
|
|
546
|
+
* Two stopping rules, and the second is the subtle one:
|
|
547
|
+
*
|
|
548
|
+
* - another criterion bullet ends the current one, at any indent — see
|
|
549
|
+
* {@link AC_BULLET_RE};
|
|
550
|
+
* - a blank line ends it ONLY when the next non-blank line is not an indented
|
|
551
|
+
* continuation. Markdown loose lists separate a bullet from its second
|
|
552
|
+
* paragraph with exactly that blank line, so breaking on the blank
|
|
553
|
+
* unconditionally truncated the criterion and emitted a PARTIAL extraction —
|
|
554
|
+
* which then hashed to a real value and read as complete. That is the
|
|
555
|
+
* looks-like-success shape this whole function exists to remove, relocated
|
|
556
|
+
* from "empty but claimed" to "half but claimed"; caught by the R2 review of
|
|
557
|
+
* the first version. No roadmap uses a loose continuation today (measured
|
|
558
|
+
* 2026-08-18: zero across all 44), so this is latent-defect removal.
|
|
559
|
+
*/
|
|
560
|
+
function extractInlineAcceptanceCriteria(lines: readonly string[]): string {
|
|
561
|
+
const out: string[] = [];
|
|
562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
563
|
+
if (!AC_BULLET_RE.test(lines[i] as string)) continue;
|
|
564
|
+
out.push(lines[i] as string);
|
|
565
|
+
let j = i + 1;
|
|
566
|
+
while (j < lines.length) {
|
|
567
|
+
const line = lines[j] as string;
|
|
568
|
+
// A sibling or nested criterion owns itself; hand it back to the
|
|
569
|
+
// outer loop rather than absorbing it.
|
|
570
|
+
if (AC_BULLET_RE.test(line)) break;
|
|
571
|
+
if (/^\s+\S/.test(line)) {
|
|
572
|
+
out.push(line);
|
|
573
|
+
j++;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (line.trim() !== '') break;
|
|
577
|
+
// Blank: look past the run of blanks for an indented continuation.
|
|
578
|
+
let k = j;
|
|
579
|
+
while (k < lines.length && (lines[k] as string).trim() === '') k++;
|
|
580
|
+
const resumes =
|
|
581
|
+
k < lines.length &&
|
|
582
|
+
/^\s+\S/.test(lines[k] as string) &&
|
|
583
|
+
!AC_BULLET_RE.test(lines[k] as string);
|
|
584
|
+
if (!resumes) break;
|
|
585
|
+
for (let b = j; b < k; b++) out.push(lines[b] as string);
|
|
586
|
+
j = k;
|
|
455
587
|
}
|
|
588
|
+
// Resume the outer scan AT the line that stopped the walk, so a
|
|
589
|
+
// criterion bullet that ended this one is still matched.
|
|
590
|
+
i = j - 1;
|
|
456
591
|
}
|
|
457
|
-
return
|
|
592
|
+
return out.join('\n');
|
|
458
593
|
}
|
|
459
594
|
|
|
460
595
|
/**
|
|
@@ -576,11 +711,29 @@ function reviewerPrompt(args: {
|
|
|
576
711
|
headSha: string;
|
|
577
712
|
scopeHash: string;
|
|
578
713
|
roadmapGiven: boolean;
|
|
714
|
+
acGiven: boolean;
|
|
579
715
|
changedFiles: readonly string[];
|
|
580
716
|
}): string {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
717
|
+
// Three states, not two. Branching on `roadmapGiven` alone told the reviewer
|
|
718
|
+
// "Acceptance Criteria extracted" whenever a roadmap was supplied — including
|
|
719
|
+
// when the extraction returned nothing and the file beside it was 0 bytes. A
|
|
720
|
+
// reviewer told the criteria are in a file it finds empty has no way to tell
|
|
721
|
+
// an extraction failure from a roadmap that genuinely declares none, so it
|
|
722
|
+
// cannot report the gap.
|
|
723
|
+
//
|
|
724
|
+
// The empty branch reports the EXTRACTION, never the roadmap. Its first
|
|
725
|
+
// version said "it declares NO acceptance criteria" — a claim the extractor
|
|
726
|
+
// cannot establish, since an unrecognised shape produces the identical empty
|
|
727
|
+
// result. That turned a silent failure into an affirmative false statement
|
|
728
|
+
// handed to the one independent check on AC conformance, which is strictly
|
|
729
|
+
// worse: silence invites a look, a false assertion forecloses it. Caught by
|
|
730
|
+
// the R2 review of the first version, and it was not hypothetical — the
|
|
731
|
+
// trailing-text heading it names was live in two roadmaps.
|
|
732
|
+
const roadmapLine = !args.roadmapGiven
|
|
733
|
+
? '- roadmap under review: none (`acceptance-criteria.md` is empty)'
|
|
734
|
+
: args.acGiven
|
|
735
|
+
? '- roadmap under review: `roadmap.md` (Acceptance Criteria extracted to `acceptance-criteria.md`)'
|
|
736
|
+
: '- roadmap under review: `roadmap.md` — NO acceptance criteria could be EXTRACTED from it, in either recognised form (an `## Acceptance criteria` heading, or inline `- **AC-n:**` bullets), so `acceptance-criteria.md` is empty. Two different things produce that result and the dispatcher cannot tell them apart: the roadmap declares none, or it declares them in a shape the extractor does not recognise. Open `roadmap.md`, decide which, and report a finding if the criteria are there.';
|
|
584
737
|
return [
|
|
585
738
|
`# R2 completion review — ${args.slug}`,
|
|
586
739
|
'',
|
|
@@ -1037,6 +1190,7 @@ function runDispatch(args: Args): number {
|
|
|
1037
1190
|
headSha,
|
|
1038
1191
|
scopeHash: hashes.scope_hash,
|
|
1039
1192
|
roadmapGiven: roadmapText !== null,
|
|
1193
|
+
acGiven: hasAcceptanceCriteria(acText),
|
|
1040
1194
|
changedFiles,
|
|
1041
1195
|
});
|
|
1042
1196
|
const manifest = deriveManifest({
|
|
@@ -43,8 +43,102 @@ import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
|
43
43
|
|
|
44
44
|
const EXIT_ALLOW = 0;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Legacy single-file ledger path — one file for the whole project root.
|
|
48
|
+
*
|
|
49
|
+
* Kept for direct invocations and for reading a ledger written by an older
|
|
50
|
+
* build, NOT as the write target when a session id is known. See
|
|
51
|
+
* `ledgerFileFor` for why one shared file was the defect.
|
|
52
|
+
*/
|
|
46
53
|
export const STATE_FILE = path.join("agents", "state", "git-authorization.json");
|
|
47
54
|
|
|
55
|
+
/** Per-session ledger directory. */
|
|
56
|
+
export const STATE_DIR = path.join("agents", "state", "git-authorization");
|
|
57
|
+
|
|
58
|
+
/** Per-session pending-refusal directory. */
|
|
59
|
+
export const PENDING_DIR = path.join("agents", "state", "git-authorization-pending");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A session id reduced to a safe path component.
|
|
63
|
+
*
|
|
64
|
+
* The id reaches this from the host's envelope, so it is untrusted input on a
|
|
65
|
+
* path — `..` or a separator in it would place the ledger outside the state
|
|
66
|
+
* directory. Anything outside the allowed set collapses to `_`.
|
|
67
|
+
*/
|
|
68
|
+
export function sessionSlug(session_id: string): string {
|
|
69
|
+
return session_id.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 80);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where THIS session's ledger lives.
|
|
74
|
+
*
|
|
75
|
+
* MEASURED DEFECT (2026-08-18, during the 14.0.0 release): the ledger was a
|
|
76
|
+
* single file per project root, so every prompt from every concurrent session
|
|
77
|
+
* in the repo overwrote it. A second session typed an 11-character prompt
|
|
78
|
+
* between the user's `merge den pr` and the guard's read; the guard found a
|
|
79
|
+
* foreign session id, discarded the ledger as another conversation's consent,
|
|
80
|
+
* and refused. The refusal then said "no authorization in this turn's prompt",
|
|
81
|
+
* which was false and unfalsifiable from the message alone — the authorization
|
|
82
|
+
* had existed and been clobbered. Three consecutive refusals, and the user
|
|
83
|
+
* re-authorized each time.
|
|
84
|
+
*
|
|
85
|
+
* This repo runs dozens of worktrees against one root, so the race is not an
|
|
86
|
+
* edge case there; it is the normal condition. Scoping the file by session is
|
|
87
|
+
* what makes "this turn's prompt" mean this conversation's turn.
|
|
88
|
+
*/
|
|
89
|
+
export function ledgerFileFor(session_id: string): string {
|
|
90
|
+
if (!session_id) {
|
|
91
|
+
return STATE_FILE;
|
|
92
|
+
}
|
|
93
|
+
return path.join(STATE_DIR, `${sessionSlug(session_id)}.json`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Where THIS session's refused-operation record lives. */
|
|
97
|
+
export function pendingFileFor(session_id: string): string {
|
|
98
|
+
if (!session_id) {
|
|
99
|
+
return PENDING_FILE;
|
|
100
|
+
}
|
|
101
|
+
return path.join(PENDING_DIR, `${sessionSlug(session_id)}.json`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One refused irreversible operation, waiting for the answer to the question
|
|
106
|
+
* the guard just told the agent to ask.
|
|
107
|
+
*
|
|
108
|
+
* WHY THIS FILE EXISTS — the measured deadlock (2026-08-18, 14.0.0 release):
|
|
109
|
+
* `user-interaction` requires a decision to be handed to the user as a
|
|
110
|
+
* NUMBERED-OPTIONS block, and the answer to one is a bare `1`. The classifier
|
|
111
|
+
* below reads the prompt in isolation, and `1` carries no operation, so the
|
|
112
|
+
* user's consent was real, visible, and unrecordable. The release deadlocked
|
|
113
|
+
* for three turns: the guard refused, the agent asked as instructed, the user
|
|
114
|
+
* answered, and the next attempt refused identically. Two mechanisms this repo
|
|
115
|
+
* mandates were structurally incompatible on exactly the irreversible subset.
|
|
116
|
+
*
|
|
117
|
+
* The fix is deliberately the narrowest thing that resolves it. Widening the
|
|
118
|
+
* prose list with bare affirmatives was rejected: `ja` would then authorize
|
|
119
|
+
* `npm publish` on any turn, which is reach, not severity. Instead the guard
|
|
120
|
+
* records WHICH op it refused, and only that one op can be confirmed, once, by
|
|
121
|
+
* the immediately following prompt. Everything the user is consenting to has
|
|
122
|
+
* already been named to them in the refusal they just read.
|
|
123
|
+
*/
|
|
124
|
+
export const PENDING_FILE = path.join("agents", "state", "git-authorization-pending.json");
|
|
125
|
+
|
|
126
|
+
/** A refused operation carried to exactly one following prompt. */
|
|
127
|
+
export interface PendingRefusal {
|
|
128
|
+
op: GitOp;
|
|
129
|
+
session_id: string;
|
|
130
|
+
refused_at: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The window in which a refusal is still the thing the user is answering.
|
|
135
|
+
*
|
|
136
|
+
* Short on purpose: this is "the user read the refusal and replied", not "the
|
|
137
|
+
* user said yes to something at some point today". A stale record expires into
|
|
138
|
+
* the same not-authorized state the guard starts from.
|
|
139
|
+
*/
|
|
140
|
+
export const PENDING_MAX_AGE_MS = 10 * 60 * 1000;
|
|
141
|
+
|
|
48
142
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
49
143
|
type JsonObject = { [k: string]: JsonValue };
|
|
50
144
|
|
|
@@ -142,6 +236,36 @@ export function isInterrogative(prose: string): boolean {
|
|
|
142
236
|
);
|
|
143
237
|
}
|
|
144
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Is this prompt an ANSWER to the refusal the user was just shown?
|
|
241
|
+
*
|
|
242
|
+
* Accepts the two shapes a numbered-options block actually produces — a bare
|
|
243
|
+
* selection (`1`, `1.`, `Option 2`) and a bare affirmative (`ja`, `ok`, `mach`,
|
|
244
|
+
* `go`, `do it`) — and nothing else. The bar is that the prompt says yes and
|
|
245
|
+
* says NOTHING ELSE: a turn that also carries new instructions is a new turn,
|
|
246
|
+
* not an answer, and must name its operation like any other.
|
|
247
|
+
*
|
|
248
|
+
* That last clause is the whole safety argument, so it is enforced by length
|
|
249
|
+
* rather than by good intentions. `release und fixe auch den schess bug` reads
|
|
250
|
+
* as consent to a human and is correctly NOT an affirmative here — it opens new
|
|
251
|
+
* work, and the op it authorizes is the one it names.
|
|
252
|
+
*/
|
|
253
|
+
export function isAffirmative(prose: string): boolean {
|
|
254
|
+
const t = prose.trim().replace(/[.!]+$/u, "");
|
|
255
|
+
if (!t || t.length > 24) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (isInterrogative(t)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return (
|
|
262
|
+
/^(?:option\s*)?\d{1,2}[.)]?$/iu.test(t) ||
|
|
263
|
+
/^(ja|jo|jep|yes|yep|yeah|ok|okay|okey|klar|passt|gut|los|go|go ahead|mach|mach das|mach es|leg los|do it|proceed|bitte)$/iu.test(
|
|
264
|
+
t,
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
145
269
|
/** Executable commands a user may paste, mapped to the op they authorize. */
|
|
146
270
|
const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
147
271
|
{ op: "publish", re: /\bnpm\s+publish\b/i },
|
|
@@ -242,6 +366,55 @@ export function classifyAuthorization(prompt: string): {
|
|
|
242
366
|
return { authorized: [...authorized], evidence };
|
|
243
367
|
}
|
|
244
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Read the refused operation, and consume it in the same breath.
|
|
371
|
+
*
|
|
372
|
+
* Consuming unconditionally is the single-use guarantee: the record survives
|
|
373
|
+
* exactly one following prompt whether or not that prompt confirmed it, so a
|
|
374
|
+
* `ja` three turns later cannot reach back to a refusal the user has long
|
|
375
|
+
* stopped thinking about.
|
|
376
|
+
*/
|
|
377
|
+
export function takePending(
|
|
378
|
+
consumer_root: string,
|
|
379
|
+
session_id: string,
|
|
380
|
+
now: number,
|
|
381
|
+
): PendingRefusal | null {
|
|
382
|
+
const file = path.join(consumer_root, pendingFileFor(session_id));
|
|
383
|
+
let raw: string;
|
|
384
|
+
try {
|
|
385
|
+
raw = fs.readFileSync(file, "utf8");
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
fs.rmSync(file, { force: true });
|
|
391
|
+
} catch {
|
|
392
|
+
/* consumed in memory either way — a stale file expires by age below */
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const decoded = JSON.parse(raw) as unknown;
|
|
396
|
+
if (!_isObject(decoded)) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const op = decoded["op"];
|
|
400
|
+
if (typeof op !== "string" || !(ALL_OPS as readonly string[]).includes(op)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const recorded = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
404
|
+
// A refusal from another conversation is another conversation's question.
|
|
405
|
+
if (session_id && recorded && recorded !== session_id) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const at = Date.parse(String(decoded["refused_at"] ?? ""));
|
|
409
|
+
if (!Number.isFinite(at) || now - at > PENDING_MAX_AGE_MS) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return { op: op as GitOp, session_id: recorded, refused_at: String(decoded["refused_at"]) };
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
245
418
|
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
246
419
|
let envelope: JsonObject = {};
|
|
247
420
|
if (stdin_text.trim()) {
|
|
@@ -269,6 +442,18 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
269
442
|
}
|
|
270
443
|
|
|
271
444
|
const { authorized, evidence } = classifyAuthorization(prompt);
|
|
445
|
+
|
|
446
|
+
// The answer to the guard's own question. Read (and consume) on EVERY prompt,
|
|
447
|
+
// so the record cannot outlive the turn it was raised in.
|
|
448
|
+
const session = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
449
|
+
const pending = takePending(options.consumer_root, session, Date.now());
|
|
450
|
+
if (pending && !authorized.includes(pending.op) && isAffirmative(prompt)) {
|
|
451
|
+
authorized.push(pending.op);
|
|
452
|
+
evidence[pending.op] =
|
|
453
|
+
`confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): ` +
|
|
454
|
+
`"${prompt.trim().slice(0, 40)}"`;
|
|
455
|
+
}
|
|
456
|
+
|
|
272
457
|
const ledger: Ledger = {
|
|
273
458
|
session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
|
|
274
459
|
detected_at: new Date().toISOString(),
|
|
@@ -278,7 +463,7 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
278
463
|
};
|
|
279
464
|
|
|
280
465
|
try {
|
|
281
|
-
atomic_write_json(path.join(options.consumer_root,
|
|
466
|
+
atomic_write_json(path.join(options.consumer_root, ledgerFileFor(session)), ledger);
|
|
282
467
|
} catch {
|
|
283
468
|
// Observability only — a failed write degrades the gate to "no ledger",
|
|
284
469
|
// which the pre_tool_use concern treats as "not authorized" for the
|
|
@@ -61,7 +61,13 @@ import path from "node:path";
|
|
|
61
61
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
62
62
|
|
|
63
63
|
import { readHookStdin } from "./hook_stdin.js";
|
|
64
|
-
import {
|
|
64
|
+
import { atomic_write_json } from "./state_io.js";
|
|
65
|
+
import {
|
|
66
|
+
ledgerFileFor,
|
|
67
|
+
pendingFileFor,
|
|
68
|
+
STATE_FILE,
|
|
69
|
+
type GitOp,
|
|
70
|
+
} from "../git_authorization_hook.js";
|
|
65
71
|
|
|
66
72
|
const EXIT_ALLOW = 0;
|
|
67
73
|
// MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
|
|
@@ -497,18 +503,45 @@ function _loadLedger(
|
|
|
497
503
|
session_id: string,
|
|
498
504
|
now: number,
|
|
499
505
|
): { authorized: Set<GitOp>; present: boolean } {
|
|
506
|
+
// This session's own ledger first; the flat legacy file only as a fallback,
|
|
507
|
+
// so a ledger written by an older build still counts. Reading the flat file
|
|
508
|
+
// FIRST was the defect: any concurrent session in the repo overwrites it, and
|
|
509
|
+
// the session check below then discarded a real authorization as foreign.
|
|
510
|
+
const candidates = [ledgerFileFor(session_id)];
|
|
511
|
+
if (!candidates.includes(STATE_FILE)) {
|
|
512
|
+
candidates.push(STATE_FILE);
|
|
513
|
+
}
|
|
514
|
+
for (const rel of candidates) {
|
|
515
|
+
const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
|
|
516
|
+
if (found !== null) {
|
|
517
|
+
return found;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return { authorized: new Set<GitOp>(), present: false };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Read one ledger file. `null` means "this file does not answer the question" —
|
|
525
|
+
* absent, malformed, foreign session, or expired — so the caller may try the
|
|
526
|
+
* next candidate. A parsed, in-session, fresh ledger answers definitively.
|
|
527
|
+
*/
|
|
528
|
+
function _readLedgerFile(
|
|
529
|
+
file: string,
|
|
530
|
+
session_id: string,
|
|
531
|
+
now: number,
|
|
532
|
+
): { authorized: Set<GitOp>; present: boolean } | null {
|
|
500
533
|
try {
|
|
501
|
-
const raw = fs.readFileSync(
|
|
534
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
502
535
|
const decoded = JSON.parse(raw) as unknown;
|
|
503
536
|
if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
|
|
504
537
|
const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
505
538
|
// A ledger from a different session is another conversation's consent.
|
|
506
539
|
if (session_id && ledgerSession && ledgerSession !== session_id) {
|
|
507
|
-
return
|
|
540
|
+
return null;
|
|
508
541
|
}
|
|
509
542
|
const at = Date.parse(String(decoded["detected_at"] ?? ""));
|
|
510
543
|
if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
|
|
511
|
-
return
|
|
544
|
+
return null;
|
|
512
545
|
}
|
|
513
546
|
return {
|
|
514
547
|
authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
|
|
@@ -518,13 +551,21 @@ function _loadLedger(
|
|
|
518
551
|
} catch {
|
|
519
552
|
/* fall through */
|
|
520
553
|
}
|
|
521
|
-
return
|
|
554
|
+
return null;
|
|
522
555
|
}
|
|
523
556
|
|
|
524
557
|
export interface Decision {
|
|
525
558
|
exit: number;
|
|
526
559
|
stdout: string;
|
|
527
560
|
stderr: string;
|
|
561
|
+
/**
|
|
562
|
+
* The op this decision refused, when it refused one.
|
|
563
|
+
*
|
|
564
|
+
* `decide` stays pure; `run` turns this into the pending record that lets the
|
|
565
|
+
* user's answer to the refusal count as authorization for that one op. See
|
|
566
|
+
* `git_authorization_hook.PENDING_FILE` for why that record exists at all.
|
|
567
|
+
*/
|
|
568
|
+
blockedOp?: GitOp;
|
|
528
569
|
}
|
|
529
570
|
|
|
530
571
|
export function decide(
|
|
@@ -547,7 +588,7 @@ export function decide(
|
|
|
547
588
|
`This operation is irreversible and non-destructive-by-default already declares it ` +
|
|
548
589
|
`never-autonomous. Command: ${shown}. ` +
|
|
549
590
|
`Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
550
|
-
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n
|
|
591
|
+
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
|
|
551
592
|
}
|
|
552
593
|
|
|
553
594
|
if (WARN_OPS.has(op)) {
|
|
@@ -582,6 +623,21 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
582
623
|
extractCommand(envelope),
|
|
583
624
|
_loadLedger(options.consumer_root, session_id, Date.now()),
|
|
584
625
|
);
|
|
626
|
+
if (decision.blockedOp) {
|
|
627
|
+
// Record WHAT was refused, so the user's answer to the question this
|
|
628
|
+
// refusal just told the agent to ask can authorize that one operation.
|
|
629
|
+
// Best-effort: a failed write degrades to the previous behaviour, where a
|
|
630
|
+
// numbered answer is unrecordable and the refusal simply repeats.
|
|
631
|
+
try {
|
|
632
|
+
atomic_write_json(path.join(options.consumer_root, pendingFileFor(session_id)), {
|
|
633
|
+
op: decision.blockedOp,
|
|
634
|
+
session_id,
|
|
635
|
+
refused_at: new Date().toISOString(),
|
|
636
|
+
});
|
|
637
|
+
} catch {
|
|
638
|
+
/* observability only — see above */
|
|
639
|
+
}
|
|
640
|
+
}
|
|
585
641
|
if (decision.stdout) {
|
|
586
642
|
process.stdout.write(decision.stdout);
|
|
587
643
|
}
|