@event4u/agent-config 14.0.0 → 14.1.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.
@@ -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, STATE_FILE), ledger);
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 { STATE_FILE, type GitOp } from "../git_authorization_hook.js";
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(path.join(consumer_root, STATE_FILE), "utf8");
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 { authorized: new Set<GitOp>(), present: false };
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 { authorized: new Set<GitOp>(), present: false };
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 { authorized: new Set<GitOp>(), present: false };
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
  }