@yagni-app/code 1.0.6 → 1.0.8

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.
Files changed (92) hide show
  1. package/README.md +91 -46
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -5
  5. package/dist/claudePlugins.js +129 -21
  6. package/dist/cli.js +16 -4
  7. package/dist/doctor.d.ts +21 -8
  8. package/dist/doctor.js +53 -28
  9. package/dist/extension/askUserQuestionTool.js +7 -2
  10. package/dist/extension/condensedTools.d.ts +12 -1
  11. package/dist/extension/condensedTools.js +17 -9
  12. package/dist/extension/config.d.ts +6 -0
  13. package/dist/extension/hooks.d.ts +3 -3
  14. package/dist/extension/hooks.js +30 -5
  15. package/dist/extension/index.d.ts +19 -0
  16. package/dist/extension/index.js +248 -43
  17. package/dist/extension/mcp/cliConfig.d.ts +1 -1
  18. package/dist/extension/mcp/cliConfig.js +1 -1
  19. package/dist/extension/mcp/config.d.ts +24 -2
  20. package/dist/extension/mcp/config.js +75 -3
  21. package/dist/extension/mcp/manager.d.ts +3 -1
  22. package/dist/extension/mcp/manager.js +2 -2
  23. package/dist/extension/mcp/panel.d.ts +0 -1
  24. package/dist/extension/mcp/panel.js +13 -3
  25. package/dist/extension/mcp/startup.js +8 -6
  26. package/dist/extension/permission/gate.d.ts +101 -2
  27. package/dist/extension/permission/gate.js +485 -42
  28. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  29. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  30. package/dist/extension/permissionRules/engine.d.ts +50 -0
  31. package/dist/extension/permissionRules/engine.js +238 -0
  32. package/dist/extension/permissionRules/loadConfig.d.ts +64 -0
  33. package/dist/extension/permissionRules/loadConfig.js +105 -0
  34. package/dist/extension/permissionRules/parser.d.ts +38 -0
  35. package/dist/extension/permissionRules/parser.js +136 -0
  36. package/dist/extension/permissionRules/pathRules.d.ts +60 -0
  37. package/dist/extension/permissionRules/pathRules.js +122 -0
  38. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  39. package/dist/extension/permissionRules/shellRules.js +221 -0
  40. package/dist/extension/pipeline/invocation.d.ts +3 -6
  41. package/dist/extension/pipeline/invocation.js +3 -6
  42. package/dist/extension/pipeline/runner.d.ts +0 -1
  43. package/dist/extension/pipeline/runner.js +6 -14
  44. package/dist/extension/plugins/inventory.d.ts +88 -0
  45. package/dist/extension/plugins/inventory.js +144 -0
  46. package/dist/extension/plugins/panel.d.ts +45 -0
  47. package/dist/extension/plugins/panel.js +293 -0
  48. package/dist/extension/sandbox/bash.d.ts +99 -0
  49. package/dist/extension/sandbox/bash.js +190 -0
  50. package/dist/extension/sandbox/config.d.ts +115 -0
  51. package/dist/extension/sandbox/config.js +388 -0
  52. package/dist/extension/sandbox/manager.d.ts +108 -0
  53. package/dist/extension/sandbox/manager.js +243 -0
  54. package/dist/extension/sandbox/panel.d.ts +111 -0
  55. package/dist/extension/sandbox/panel.js +342 -0
  56. package/dist/extension/sandbox/session.d.ts +85 -0
  57. package/dist/extension/sandbox/session.js +829 -0
  58. package/dist/extension/settingsFiles.d.ts +50 -0
  59. package/dist/extension/settingsFiles.js +206 -0
  60. package/dist/extension/telemetry/attrs.d.ts +96 -0
  61. package/dist/extension/telemetry/attrs.js +149 -0
  62. package/dist/extension/telemetry/config.d.ts +103 -0
  63. package/dist/extension/telemetry/config.js +193 -0
  64. package/dist/extension/telemetry/index.d.ts +7 -0
  65. package/dist/extension/telemetry/index.js +7 -0
  66. package/dist/extension/telemetry/probe.d.ts +29 -0
  67. package/dist/extension/telemetry/probe.js +122 -0
  68. package/dist/extension/telemetry/register.d.ts +47 -0
  69. package/dist/extension/telemetry/register.js +207 -0
  70. package/dist/extension/telemetry/sdk.d.ts +63 -0
  71. package/dist/extension/telemetry/sdk.js +207 -0
  72. package/dist/extension/telemetry/tracker.d.ts +131 -0
  73. package/dist/extension/telemetry/tracker.js +551 -0
  74. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  75. package/dist/extension/vendor/ignore.d.ts +86 -0
  76. package/dist/extension/vendor/ignore.js +788 -0
  77. package/dist/goHeadless.d.ts +1 -1
  78. package/dist/goHeadless.js +2 -2
  79. package/dist/launch.d.ts +4 -3
  80. package/dist/launch.js +7 -4
  81. package/dist/mcpCommand.d.ts +10 -1
  82. package/dist/mcpCommand.js +42 -10
  83. package/dist/otel.d.ts +67 -90
  84. package/dist/otel.js +152 -195
  85. package/dist/paths.d.ts +13 -0
  86. package/dist/paths.js +18 -0
  87. package/dist/pluginCommand.d.ts +43 -0
  88. package/dist/pluginCommand.js +499 -0
  89. package/dist/pluginStore.d.ts +170 -0
  90. package/dist/pluginStore.js +554 -0
  91. package/dist/upgrade.js +10 -1
  92. package/package.json +19 -3
@@ -27,8 +27,10 @@
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
29
  import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
30
+ import { logEvent } from "../errorSink.js";
30
31
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
31
32
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
33
+ import { evaluateRules } from "../permissionRules/engine.js";
32
34
  import { isDebug } from "../diagnostics.js";
33
35
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
34
36
  export function createModeHolder(initial = "auto") {
@@ -49,6 +51,52 @@ export const DEFAULT_PERMISSION_POLICY = {
49
51
  reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
50
52
  alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
51
53
  };
54
+ export function allowRuleFloorVerdict(toolName, params, mode, policy) {
55
+ // Floor 0 — plan mode: the mode's contract (no mutations without Guardian
56
+ // review; grants are already skipped in plan) applies BEFORE any allow
57
+ // rule. Side-effect tools route to the existing plan-mode gate below; a
58
+ // non-side-effect tool (read/grep/ask_yagni/…) allows — plan mode never
59
+ // held reads. Deny/ask rules fired before this point (they only restrict).
60
+ if (mode === "plan" && sideEffectToolsFor(policy).has(toolName)) {
61
+ return { kind: "hold" };
62
+ }
63
+ // Floor 1 — the exec-policy forbidden band (bash only). An allow rule can
64
+ // never un-forbid a destructive command.
65
+ if (toolName === "bash") {
66
+ const cmdRaw = params.command;
67
+ const command = typeof cmdRaw === "string" ? cmdRaw.trim() : "";
68
+ if (command) {
69
+ try {
70
+ const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
71
+ const classification = classifyCommand(command, execPolicy);
72
+ if (classification.decision === "forbidden") {
73
+ return {
74
+ kind: "block",
75
+ reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
76
+ };
77
+ }
78
+ }
79
+ catch {
80
+ // classifyCommand threw — no floor opinion from here (the normal gate
81
+ // path re-runs classification with its own fail-closed handling).
82
+ }
83
+ }
84
+ }
85
+ // Floor 2 — alwaysConfirmTools keeps its fresh-consent contract
86
+ // (file_ticket / update_ticket_status) in every mode.
87
+ if (policy.alwaysConfirmTools?.includes(toolName)) {
88
+ return { kind: "confirm" };
89
+ }
90
+ return { kind: "allow" };
91
+ }
92
+ /** The side-effect tool set for a policy (plan-mode hold decision). */
93
+ function sideEffectToolsFor(policy) {
94
+ return new Set([
95
+ ...policy.planBlockTools,
96
+ ...policy.reviewConfirmTools,
97
+ ...(policy.alwaysConfirmTools ?? []),
98
+ ]);
99
+ }
52
100
  /**
53
101
  * Pure permission decision for one tool call under a mode + policy. Auto allows
54
102
  * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
@@ -288,6 +336,56 @@ function guardianErrorMessage(error) {
288
336
  default: return "unknown error";
289
337
  }
290
338
  }
339
+ /**
340
+ * The pi tool_call handler wiring (crash-honest attribution). Extracted so
341
+ * the telemetry contract is testable against the production path: a thrown
342
+ * gate records reject — never accept — logs a gate_crashed line (metadata
343
+ * only), and rethrows so pi's handling of a crashed handler is unchanged.
344
+ */
345
+ export function wireToolCallGate(pi, run, onToolDecision) {
346
+ pi.on("tool_call", async (event, ctx) => {
347
+ const slot = { source: "config" };
348
+ let result;
349
+ let crashed = false;
350
+ try {
351
+ result = await run(event, ctx, slot);
352
+ return result;
353
+ }
354
+ catch (err) {
355
+ crashed = true;
356
+ logEvent({
357
+ source: "permission-rules",
358
+ level: "error",
359
+ event: "gate_crashed",
360
+ fields: {
361
+ tool: event.toolName,
362
+ errorClass: err instanceof Error ? err.constructor.name : String(err),
363
+ },
364
+ });
365
+ throw err;
366
+ }
367
+ finally {
368
+ if (onToolDecision) {
369
+ try {
370
+ const input = (event.input ?? {});
371
+ const filePath = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : undefined;
372
+ onToolDecision({
373
+ toolName: event.toolName,
374
+ ...(event.toolCallId ? { toolCallId: event.toolCallId } : {}),
375
+ // An explicit block is a reject; an allow is an accept; a CRASH
376
+ // is a reject (fail-closed attribution) — never an accept.
377
+ decision: result?.block || crashed ? "reject" : "accept",
378
+ source: slot.source,
379
+ ...(filePath ? { filePath } : {}),
380
+ });
381
+ }
382
+ catch {
383
+ // Telemetry must never affect the gate.
384
+ }
385
+ }
386
+ }
387
+ });
388
+ }
291
389
  /**
292
390
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
293
391
  * auto, so absent any /mode this is a no-op over today's behavior.
@@ -300,6 +398,8 @@ export function registerPermissionGate(pi, deps = {}) {
300
398
  deps.modeHolder?.onSet((m) => {
301
399
  if (m !== mode)
302
400
  approvedCommands.clear();
401
+ if (m !== mode)
402
+ ruleAskApprovals.clear();
303
403
  mode = m;
304
404
  });
305
405
  // The session bless store is created lazily on the first tool_call (it needs
@@ -349,6 +449,23 @@ export function registerPermissionGate(pi, deps = {}) {
349
449
  // LRU-capped, cleared on every /mode transition.
350
450
  const APPROVED_CACHE_MAX = 50;
351
451
  const approvedCommands = new Map();
452
+ // Session cache for ASK-RULE approvals (the rules analog of the exact-command
453
+ // approval cache above): a user "yes" on a permission-rule ask covers an
454
+ // identical later (tool, rule, input) call for the rest of the session, so a
455
+ // retrying model cannot re-prompt the same question in a loop. Keyed by
456
+ // tool + rule raw + the primary input param; cleared with the other caches
457
+ // on every /mode transition (mode changes re-ask — the safe direction).
458
+ const ruleAskApprovals = new Map();
459
+ const ruleAskKey = (toolName, ruleRaw, params) => {
460
+ const primary = typeof params.command === "string"
461
+ ? params.command
462
+ : typeof params.path === "string"
463
+ ? params.path
464
+ : typeof params.url === "string"
465
+ ? params.url
466
+ : JSON.stringify(params);
467
+ return `${toolName}\u0000${ruleRaw}\u0000${primary}`;
468
+ };
352
469
  const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
353
470
  const rememberApproved = (cwd, command) => {
354
471
  const key = cacheKey(cwd, command);
@@ -372,7 +489,16 @@ export function registerPermissionGate(pi, deps = {}) {
372
489
  // calls can start their reviews in the same millisecond, and a timestamp
373
490
  // alone would silently drop the second review's spend as a "duplicate".
374
491
  let reviewSpendSeq = 0;
375
- const emitGateEvent = (event) => {
492
+ const GUARDIAN_OUTCOME_SOURCE = {
493
+ ask_approved: "user_temporary",
494
+ breaker_ask_approved: "user_temporary",
495
+ ask_approved_remembered: "user_permanent",
496
+ ask_denied: "user_reject",
497
+ };
498
+ const emitGateEvent = (slot, event) => {
499
+ const mapped = GUARDIAN_OUTCOME_SOURCE[event.outcome];
500
+ if (mapped)
501
+ slot.source = mapped;
376
502
  if (!deps.onGuardianEvent)
377
503
  return;
378
504
  try {
@@ -387,48 +513,279 @@ export function registerPermissionGate(pi, deps = {}) {
387
513
  const flat = command.replace(/\s+/g, " ").trim();
388
514
  return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
389
515
  };
390
- const ASK_TIMEOUT_MS = 120_000;
391
516
  const ASK_YES = "Yes, run it";
392
517
  const ASK_NO = "No";
518
+ /**
519
+ * The ask dialog now waits indefinitely, so an unanswered ask is a silent
520
+ * unbounded pause: a lost RPC client or an abandoned dialog hangs the
521
+ * session with nothing in the trail to explain why. Emit one sanitized
522
+ * event when the dialog OPENS — kind + tool ONLY. The sink's default-on
523
+ * contract is content-free (raw content is gated behind YAGNI_DEBUG), and
524
+ * even the rule string stays out: it is user-authored free text that can
525
+ * embed secret-bearing fragments (env assignments, URL credentials), and
526
+ * a rule ask is already fully identified by kind "rule" + the tool. Fail-
527
+ * soft; the sink never throws into the gate.
528
+ */
529
+ const logAskOpened = (kind, toolName) => {
530
+ try {
531
+ logEvent({
532
+ source: "guardian",
533
+ level: "info",
534
+ event: "guardian_ask_opened",
535
+ fields: { kind, tool: toolName },
536
+ });
537
+ }
538
+ catch {
539
+ /* telemetry must never affect the gate */
540
+ }
541
+ };
542
+ /**
543
+ * The paired resolution event: with no dialog timeout, the pause DURATION
544
+ * and its OUTCOME are the story (answered vs ESC-dismissed vs turn-aborted),
545
+ * and a dismissed dialog would otherwise leave no trail line of its own.
546
+ * kind + tool + outcome only — all enum values, nothing content-bearing.
547
+ * durationMs makes the pause directly readable instead of timestamp-diffing
548
+ * two lines. Same fail-soft contract as the open event.
549
+ */
550
+ const logAskResolved = (kind, toolName, outcome, openedAt) => {
551
+ try {
552
+ logEvent({
553
+ source: "guardian",
554
+ level: "info",
555
+ event: "guardian_ask_resolved",
556
+ fields: { kind, tool: toolName, outcome, durationMs: Date.now() - openedAt },
557
+ });
558
+ }
559
+ catch {
560
+ /* telemetry must never affect the gate */
561
+ }
562
+ };
563
+ /**
564
+ * The single choice-to-resolution mapping BOTH ask dialogs share — one
565
+ * place, so the two surfaces can never drift. The option labels the dialog
566
+ * actually offered are passed in; the rule rung is null when the plain
567
+ * dialog didn't offer it. An aborted turn wins over any choice value; a
568
+ * real user ESC maps to "dismissed" (the no-abort, no-answer bucket —
569
+ * ESC resolves undefined in pi). A THROWN select never reaches this
570
+ * function: the dialogs route it to thrownChoiceResolution, which is the
571
+ * ONLY source of outcome "error". Both fail closed identically
572
+ * downstream — only the telemetry distinguishes them.
573
+ */
574
+ const resolveChoice = (choice, opts) => {
575
+ if (opts.aborted)
576
+ return "aborted";
577
+ if (choice === ASK_YES)
578
+ return "yes";
579
+ if (opts.rememberLabel !== null && choice === opts.rememberLabel)
580
+ return "remember";
581
+ if (opts.ruleLabel != null && choice === opts.ruleLabel)
582
+ return "rule";
583
+ if (choice === ASK_NO)
584
+ return "no";
585
+ // No abort and no recognizable answer — a real ESC (pi resolves a
586
+ // dismissed dialog as undefined) or any unmatched value. NEVER the
587
+ // thrown-select path: that maps to "error" in thrownChoiceResolution.
588
+ return "dismissed";
589
+ };
590
+ /**
591
+ * Map a THROWN select to its resolution: the UI/RPC layer failed (dead
592
+ * client, disposed dialog) — never a human answer. Distinct from ESC in
593
+ * the trail so a lost client is attributable; blocks exactly like a
594
+ * dismissal downstream (fail closed).
595
+ */
596
+ const thrownChoiceResolution = (aborted) => aborted ? "aborted" : "error";
393
597
  /**
394
598
  * The single human-in-the-loop ask surface (YAG-510): used for ask
395
599
  * verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
396
600
  * escalation — one UI, one cache, one event stream. Always passes the
397
- * turn's abort signal (without it a turn-abort leaves the dialog hanging)
398
- * and a timeout (pi renders a countdown; expiry fails closed).
601
+ * turn's abort signal (without it a turn-abort leaves the dialog hanging).
602
+ *
603
+ * No dialog timeout — Claude Code parity: a permission ask waits
604
+ * indefinitely for the human. The only dismissal paths are the user
605
+ * answering, dismissing the dialog (ESC), the turn aborting (Ctrl-C /
606
+ * interrupt), or the UI layer failing (outcome "error", fail closed).
607
+ * The old 120s auto-fail-closed cap is gone; an unattended dialog pauses
608
+ * the session rather than denying the command.
399
609
  */
400
- const askUser = async (ctx, title, rememberLabel) => {
610
+ const askUser = async (ctx, title, rememberLabel, kind = "guardian_verdict", toolName = "bash") => {
401
611
  if (ctx.signal?.aborted)
402
612
  return "aborted";
613
+ logAskOpened(kind, toolName);
614
+ const openedAt = Date.now();
403
615
  const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
404
616
  let choice;
617
+ let selectThrew = false;
405
618
  try {
406
619
  choice = await ctx.ui.select(title, options, {
407
620
  ...(ctx.signal ? { signal: ctx.signal } : {}),
408
- timeout: ASK_TIMEOUT_MS,
409
621
  });
410
622
  }
411
623
  catch {
412
- choice = undefined;
624
+ selectThrew = true;
413
625
  }
414
- if (choice === ASK_YES)
415
- return "yes";
416
- if (rememberLabel !== null && choice === rememberLabel)
417
- return "remember";
418
- if (choice === ASK_NO)
419
- return "no";
420
- return ctx.signal?.aborted ? "aborted" : "dismissed";
626
+ const resolution = selectThrew
627
+ ? thrownChoiceResolution(ctx.signal?.aborted ?? false)
628
+ : resolveChoice(choice, { rememberLabel, aborted: ctx.signal?.aborted ?? false });
629
+ logAskResolved(kind, toolName, resolution, openedAt);
630
+ return resolution;
631
+ };
632
+ /**
633
+ * variant: the Guardian ask dialog with an optional third option
634
+ * (persist a user-level permission rule). Same semantics as askUser
635
+ * (indefinite wait, turn-abort signal only). kind/toolName are threaded
636
+ * through — never hardcoded here — so a future non-bash caller cannot
637
+ * silently misattribute the dialog in the trail.
638
+ */
639
+ const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel, kind = "guardian_verdict", toolName = "bash") => {
640
+ if (ctx.signal?.aborted)
641
+ return "aborted";
642
+ logAskOpened(kind, toolName);
643
+ const openedAt = Date.now();
644
+ const options = [
645
+ ASK_YES,
646
+ ...(rememberLabel ? [rememberLabel] : []),
647
+ ...(ruleLabel ? [ruleLabel] : []),
648
+ ASK_NO,
649
+ ];
650
+ let choice;
651
+ let selectThrew = false;
652
+ try {
653
+ choice = await ctx.ui.select(title, options, {
654
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
655
+ });
656
+ }
657
+ catch {
658
+ selectThrew = true;
659
+ }
660
+ const resolution = selectThrew
661
+ ? thrownChoiceResolution(ctx.signal?.aborted ?? false)
662
+ : resolveChoice(choice, { rememberLabel, ruleLabel, aborted: ctx.signal?.aborted ?? false });
663
+ logAskResolved(kind, toolName, resolution, openedAt);
664
+ return resolution;
421
665
  };
422
666
  const buildAskTitle = (command, rationale, riskLevel) => {
423
667
  const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
424
668
  return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
425
669
  };
426
- pi.on("tool_call", async (event, ctx) => {
670
+ const gateToolCall = async (event, ctx, slot) => {
427
671
  // Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
428
672
  // of the closure variable would disagree with the decision already made.
429
673
  const modeAtEntry = mode;
674
+ // set when an allow-rule verdict hits an alwaysConfirmTools tool
675
+ // (Floor 2) — forces the confirm flow below instead of short-circuiting.
676
+ let ruleAskConfirm = false;
430
677
  try {
431
678
  const input = event.input ?? {};
679
+ // settings permission rules run BEFORE hooks (Claude Code
680
+ // semantics: a deny rule blocks even when a hook would allow). Order
681
+ // deny → ask → allow; the verdict is final for deny/ask, and allow
682
+ // short-circuits everything below EXCEPT the two hard floors.
683
+ const rulesDeps = deps.permissionRules;
684
+ // The guard + evaluation share ONE try: a poisoned rules array can
685
+ // throw at the `.length` guard just as easily as inside evaluateRules,
686
+ // and both are engine errors — both must log engine_error and degrade
687
+ // to the normal gate, never escape to the outer fail-open catch.
688
+ let ruleVerdict = null;
689
+ try {
690
+ if (rulesDeps && rulesDeps.length > 0) {
691
+ const cwd = ctx?.cwd ?? ".";
692
+ ruleVerdict = evaluateRules(rulesDeps, {
693
+ toolName: event.toolName,
694
+ params: input,
695
+ cwd,
696
+ isProjectTrusted: (() => { try {
697
+ return ctx?.isProjectTrusted() ?? true;
698
+ }
699
+ catch {
700
+ return true;
701
+ } })(),
702
+ userStateHome: deps.rulesUserStateHome ?? cwd,
703
+ projectRoot: deps.rulesProjectRoot ?? null,
704
+ ...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
705
+ });
706
+ }
707
+ }
708
+ catch (err) {
709
+ // Fail-soft: a rule-engine error never blocks or allows — but it
710
+ // must not be SILENT: for a would-be deny this degrades to the
711
+ // normal gate (likely an allow), so the trail needs the failure.
712
+ // Error class only — never the thrown message (it can carry
713
+ // command content) or the user content.
714
+ logEvent({
715
+ source: "permission-rules",
716
+ level: "warn",
717
+ event: "engine_error",
718
+ fields: {
719
+ tool: event.toolName,
720
+ error: err instanceof Error ? err.constructor.name : typeof err,
721
+ },
722
+ });
723
+ }
724
+ if (ruleVerdict) {
725
+ try {
726
+ deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
727
+ }
728
+ catch { /* logging must never affect the gate */ }
729
+ if (ruleVerdict.verdict === "deny") {
730
+ const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
731
+ return {
732
+ block: true,
733
+ reason: `${event.toolName} was denied by a permission rule in ${origin} (${ruleVerdict.rule.raw}). Do not attempt the same outcome via a workaround or indirect execution — ask the user to change the rule if this action is genuinely needed.`,
734
+ };
735
+ }
736
+ if (ruleVerdict.verdict === "ask") {
737
+ // Ask is final: nothing below may auto-allow it. With a UI, the
738
+ // user arbitrates; headless (incl. /go children) fails closed.
739
+ if (!ctx?.hasUI) {
740
+ return {
741
+ block: true,
742
+ reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
743
+ };
744
+ }
745
+ if (ctx.signal?.aborted)
746
+ return { block: true };
747
+ // Session approval cache: an identical (tool, rule, input) "yes"
748
+ // earlier this session covers this call — a retrying model must
749
+ // not re-prompt the same question (dialog-storm guard).
750
+ const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
751
+ if (ruleAskApprovals.has(askKey))
752
+ return {};
753
+ const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
754
+ const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null, "rule", event.toolName);
755
+ if (choice === "yes") {
756
+ if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
757
+ ruleAskApprovals.clear();
758
+ ruleAskApprovals.set(askKey, true);
759
+ return {};
760
+ }
761
+ if (choice === "aborted")
762
+ return { block: true };
763
+ return {
764
+ block: true,
765
+ reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
766
+ };
767
+ }
768
+ // verdict === "allow": the floors decide whether the allow
769
+ // short-circuits. Linear by construction — allowRuleFloorVerdict
770
+ // owns the three invariants (plan-mode hold, forbidden band,
771
+ // alwaysConfirmTools confirm) and is unit-tested directly.
772
+ const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
773
+ if (floor.kind === "block") {
774
+ return { block: true, reason: floor.reason };
775
+ }
776
+ if (floor.kind === "confirm") {
777
+ // Defer to the existing confirm flow via the ruleAskConfirm flag
778
+ // (set below) — alwaysConfirmTools keeps its fresh-consent contract.
779
+ ruleAskConfirm = true;
780
+ }
781
+ else if (floor.kind === "hold") {
782
+ // Plan mode's no-mutation contract outranks the allow rule —
783
+ // fall through to the normal plan-mode gate below.
784
+ }
785
+ else {
786
+ return {};
787
+ }
788
+ }
432
789
  // YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
433
790
  // (allow/deny/ask) or fall through to the normal gate logic. The result
434
791
  // is cached in preToolUseResult so the "ask" check below does NOT
@@ -439,6 +796,9 @@ export function registerPermissionGate(pi, deps = {}) {
439
796
  try {
440
797
  preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
441
798
  if (preToolUseResult) {
799
+ if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
800
+ slot.source = "hook";
801
+ }
442
802
  if (preToolUseResult.decision === "deny") {
443
803
  return { block: true, reason: preToolUseResult.reason };
444
804
  }
@@ -495,7 +855,7 @@ export function registerPermissionGate(pi, deps = {}) {
495
855
  if (modeAtEntry === "auto" && command) {
496
856
  const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
497
857
  if (grant) {
498
- emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
858
+ emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
499
859
  return {};
500
860
  }
501
861
  }
@@ -503,7 +863,20 @@ export function registerPermissionGate(pi, deps = {}) {
503
863
  // Skipped in plan mode: a cached approval can cover a write command,
504
864
  // and plan mode's contract is no mutations without Guardian review.
505
865
  if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
506
- emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
866
+ emitGateEvent(slot, { ...eventBase, outcome: "cached_allow", consulted: false });
867
+ return {};
868
+ }
869
+ // 2b. Sandbox auto-allow (Claude's autoAllowBashIfSandboxed):
870
+ // the command will run inside the OS sandbox, so pre-approval is
871
+ // unnecessary. Positioned AFTER grants/cache (user intent outranks
872
+ // mechanism) and BEFORE the cap/breaker (a capped Guardian must not
873
+ // block a sandbox-safe command). Never active in plan mode — the
874
+ // session-side predicate is not consulted there at all, and even a
875
+ // true answer cannot widen plan's no-mutation contract.
876
+ if (modeAtEntry !== "plan" &&
877
+ command &&
878
+ deps.sandboxAutoAllow?.(event.toolName, input)) {
879
+ emitGateEvent(slot, { ...eventBase, outcome: "sandbox_auto_allow", consulted: false });
507
880
  return {};
508
881
  }
509
882
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
@@ -528,19 +901,19 @@ export function registerPermissionGate(pi, deps = {}) {
528
901
  if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
529
902
  breakerEscalationOffered = true;
530
903
  const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
531
- const resolution = await askUser(ctx, title, null);
904
+ const resolution = await askUser(ctx, title, null, "breaker");
532
905
  if (resolution === "yes") {
533
906
  guardianState.resetTurn();
534
907
  rememberApproved(cwd, command);
535
- emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
908
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
536
909
  return {};
537
910
  }
538
911
  if (resolution === "aborted") {
539
- emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
912
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
540
913
  return { block: true };
541
914
  }
542
915
  }
543
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
916
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
544
917
  if (ctx?.hasUI)
545
918
  ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
546
919
  return { block: true, reason: breaker.reason };
@@ -591,7 +964,7 @@ export function registerPermissionGate(pi, deps = {}) {
591
964
  // telemetry can count salvage hits (the rawOutput is already
592
965
  // scrubbed + capped at the source).
593
966
  emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
594
- emitGateEvent({
967
+ emitGateEvent(slot, {
595
968
  ...eventBase,
596
969
  outcome: "allow",
597
970
  riskLevel: verdict.riskLevel,
@@ -605,7 +978,7 @@ export function registerPermissionGate(pi, deps = {}) {
605
978
  guardianState.recordReview("deny");
606
979
  const rationale = verdict.rationale;
607
980
  emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
608
- emitGateEvent({
981
+ emitGateEvent(slot, {
609
982
  ...eventBase,
610
983
  outcome: "deny",
611
984
  riskLevel: verdict.riskLevel,
@@ -632,7 +1005,7 @@ export function registerPermissionGate(pi, deps = {}) {
632
1005
  emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
633
1006
  if (!ctx?.hasUI) {
634
1007
  // Headless (includes every /go child stage): fail closed.
635
- emitGateEvent({
1008
+ emitGateEvent(slot, {
636
1009
  ...eventBase,
637
1010
  outcome: "ask_headless_blocked",
638
1011
  riskLevel: verdict.riskLevel,
@@ -653,7 +1026,7 @@ export function registerPermissionGate(pi, deps = {}) {
653
1026
  if (hookResult) {
654
1027
  if (hookResult.decision === "allow") {
655
1028
  rememberApproved(cwd, command);
656
- emitGateEvent({
1029
+ emitGateEvent(slot, {
657
1030
  ...eventBase,
658
1031
  outcome: "ask_approved",
659
1032
  riskLevel: verdict.riskLevel,
@@ -664,7 +1037,7 @@ export function registerPermissionGate(pi, deps = {}) {
664
1037
  return {};
665
1038
  }
666
1039
  if (hookResult.decision === "deny") {
667
- emitGateEvent({
1040
+ emitGateEvent(slot, {
668
1041
  ...eventBase,
669
1042
  outcome: "ask_denied",
670
1043
  riskLevel: verdict.riskLevel,
@@ -681,15 +1054,25 @@ export function registerPermissionGate(pi, deps = {}) {
681
1054
  }
682
1055
  }
683
1056
  // Offer "don't ask again" only when the grant would actually
684
- // cover this command (grant-time validation).
1057
+ // cover this command (grant-time validation). adds a
1058
+ // third option: persist a user-level permission rule (survives
1059
+ // across repos, unlike the repo-scoped grant) — offered only when
1060
+ // a grantCandidate also exists (the same prefix discipline; the
1061
+ // rule is the same pattern in settings form).
685
1062
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
1063
+ const ruleCandidate = grantCandidate
1064
+ ? `Bash(${grantCandidate.pattern.join(" ")}:*)`
1065
+ : null;
686
1066
  const rememberLabel = grantCandidate
687
1067
  ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
688
1068
  : null;
689
- const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
1069
+ const ruleLabel = grantCandidate && deps.persistUserRule
1070
+ ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in this project's local settings`
1071
+ : null;
1072
+ const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel, "guardian_verdict", "bash");
690
1073
  if (resolution === "yes") {
691
1074
  rememberApproved(cwd, command);
692
- emitGateEvent({
1075
+ emitGateEvent(slot, {
693
1076
  ...eventBase,
694
1077
  outcome: "ask_approved",
695
1078
  riskLevel: verdict.riskLevel,
@@ -699,6 +1082,50 @@ export function registerPermissionGate(pi, deps = {}) {
699
1082
  });
700
1083
  return {};
701
1084
  }
1085
+ if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
1086
+ let persisted = false;
1087
+ try {
1088
+ deps.persistUserRule(ruleCandidate, cwd);
1089
+ persisted = true;
1090
+ // Also covers this session like a grant would:
1091
+ rememberApproved(cwd, command);
1092
+ }
1093
+ catch (err) {
1094
+ // Fail-soft: the in-memory approval covers ONLY this call —
1095
+ // rememberApproved sits after the throwing call, so a failed
1096
+ // persist does NOT remember the command for the session (a
1097
+ // narrower fallback than a grant, deliberately: the user's
1098
+ // durable choice did not land). But the user just made an
1099
+ // explicit durable choice in the dialog — a silent failure
1100
+ // would leave them believing a rule exists that will not
1101
+ // survive restart. Log the failure (rule string + the thrown
1102
+ // message — it is mutateConfigJson's own path/reason text,
1103
+ // never the raw parse error or file content) and tell the
1104
+ // user.
1105
+ logEvent({
1106
+ source: "permission-rules",
1107
+ level: "warn",
1108
+ event: "rule_save_failed",
1109
+ fields: {
1110
+ rule: ruleCandidate,
1111
+ destination: "local",
1112
+ error: err instanceof Error ? err.message : String(err),
1113
+ },
1114
+ });
1115
+ if (ctx?.hasUI) {
1116
+ ctx.ui.notify(`Could not save the permission rule to your settings — it did not persist; you'll be asked again on the next identical command.`, "warning");
1117
+ }
1118
+ }
1119
+ emitGateEvent(slot, {
1120
+ ...eventBase,
1121
+ outcome: persisted ? "ask_approved_remembered" : "ask_approved",
1122
+ riskLevel: verdict.riskLevel,
1123
+ rationale: verdict.rationale,
1124
+ durationMs,
1125
+ consulted: true,
1126
+ });
1127
+ return {};
1128
+ }
702
1129
  if (resolution === "remember" && grantCandidate) {
703
1130
  const grantRecord = {
704
1131
  ...grantCandidate,
@@ -712,7 +1139,7 @@ export function registerPermissionGate(pi, deps = {}) {
712
1139
  catch {
713
1140
  // Fail-soft: the in-memory grant still applies this session.
714
1141
  }
715
- emitGateEvent({
1142
+ emitGateEvent(slot, {
716
1143
  ...eventBase,
717
1144
  outcome: "ask_approved_remembered",
718
1145
  riskLevel: verdict.riskLevel,
@@ -725,10 +1152,10 @@ export function registerPermissionGate(pi, deps = {}) {
725
1152
  if (resolution === "aborted") {
726
1153
  // The user is abandoning the turn — no steering text (do not
727
1154
  // tell an aborting model it was "denied").
728
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
1155
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
729
1156
  return { block: true };
730
1157
  }
731
- emitGateEvent({
1158
+ emitGateEvent(slot, {
732
1159
  ...eventBase,
733
1160
  outcome: "ask_denied",
734
1161
  riskLevel: verdict.riskLevel,
@@ -742,7 +1169,10 @@ export function registerPermissionGate(pi, deps = {}) {
742
1169
  reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
743
1170
  };
744
1171
  }
745
- // dismissed / dialog timeout — neutral reason, no "denied" spin.
1172
+ // dismissed or UI-layer error — neutral reason, no "denied"
1173
+ // spin. (No timeout can land here anymore: the dialog waits
1174
+ // indefinitely, so this is only a real ESC/dismiss — or the
1175
+ // select itself failed, which fails closed the same way.)
746
1176
  return {
747
1177
  block: true,
748
1178
  reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
@@ -757,7 +1187,7 @@ export function registerPermissionGate(pi, deps = {}) {
757
1187
  // killed. (Belt and braces with reviewCommand's own aborted
758
1188
  // detection — an aborted child can die in shapes that look like
759
1189
  // other errors.)
760
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1190
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
761
1191
  return { block: true };
762
1192
  }
763
1193
  if (modeAtEntry === "review" && decision.confirm) {
@@ -770,17 +1200,17 @@ export function registerPermissionGate(pi, deps = {}) {
770
1200
  // bounded per prompt so an outage can't become an ask storm.
771
1201
  errorFallbackAsks += 1;
772
1202
  const errorMsg = guardianErrorMessage(error);
773
- const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
1203
+ const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null, "error_fallback");
774
1204
  if (resolution === "yes") {
775
1205
  rememberApproved(cwd, command);
776
- emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
1206
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
777
1207
  return {};
778
1208
  }
779
1209
  if (resolution === "aborted") {
780
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1210
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
781
1211
  return { block: true };
782
1212
  }
783
- emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
1213
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
784
1214
  const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
785
1215
  return {
786
1216
  block: true,
@@ -789,7 +1219,7 @@ export function registerPermissionGate(pi, deps = {}) {
789
1219
  }
790
1220
  else {
791
1221
  const errorMsg = guardianErrorMessage(error);
792
- emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
1222
+ emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
793
1223
  if (ctx?.hasUI)
794
1224
  ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
795
1225
  const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
@@ -813,7 +1243,7 @@ export function registerPermissionGate(pi, deps = {}) {
813
1243
  // the Guardian to verify the command is non-mutating, the plan-mode
814
1244
  // contract (no changes) cannot be upheld. The user can switch to
815
1245
  // /mode auto or /mode review to proceed.
816
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
1246
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
817
1247
  return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
818
1248
  }
819
1249
  // review mode with Guardian disabled/capped: fall through to confirm.
@@ -823,6 +1253,11 @@ export function registerPermissionGate(pi, deps = {}) {
823
1253
  if (preToolUseResult?.decision === "ask") {
824
1254
  decision = { block: false, confirm: true };
825
1255
  }
1256
+ // an allow-rule verdict on an alwaysConfirmTools tool forced
1257
+ // the confirm flow (Floor 2 above) — same treatment as a hook "ask".
1258
+ if (ruleAskConfirm) {
1259
+ decision = { block: false, confirm: true };
1260
+ }
826
1261
  if (decision.confirm) {
827
1262
  // YAG-506: PermissionRequest hooks run before the confirm dialog.
828
1263
  if (hookRunner) {
@@ -830,6 +1265,8 @@ export function registerPermissionGate(pi, deps = {}) {
830
1265
  const cwd = ctx?.cwd ?? ".";
831
1266
  const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
832
1267
  if (hookResult) {
1268
+ if (hookResult.decision === "allow" || hookResult.decision === "deny")
1269
+ slot.source = "hook";
833
1270
  if (hookResult.decision === "allow")
834
1271
  return {};
835
1272
  if (hookResult.decision === "deny") {
@@ -864,9 +1301,12 @@ export function registerPermissionGate(pi, deps = {}) {
864
1301
  const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
865
1302
  ? externalTrackerPrompt(event.toolName, input)
866
1303
  : "YAGNI Code review mode", options);
867
- if (choice === yes)
1304
+ if (choice === yes) {
1305
+ slot.source = "user_temporary";
868
1306
  return {};
1307
+ }
869
1308
  if (blessable && choice === remember) {
1309
+ slot.source = "user_permanent";
870
1310
  // Session-scoped bless, then a fire-and-forget decision capture so the
871
1311
  // approved tool call is never delayed by the capture prompt.
872
1312
  blessStore.add(event.toolName, input);
@@ -876,6 +1316,7 @@ export function registerPermissionGate(pi, deps = {}) {
876
1316
  return {};
877
1317
  }
878
1318
  // "No" or a dismissed dialog (undefined) → held.
1319
+ slot.source = choice === undefined ? "user_abort" : "user_reject";
879
1320
  return { block: true, reason: `declined ${event.toolName} in review mode` };
880
1321
  }
881
1322
  return {};
@@ -889,7 +1330,8 @@ export function registerPermissionGate(pi, deps = {}) {
889
1330
  }
890
1331
  return {};
891
1332
  }
892
- });
1333
+ };
1334
+ wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
893
1335
  // Model awareness: every agent turn carries a hidden mode-context message so
894
1336
  // the model knows what it can do (auto: verify proactively, review: wait for
895
1337
  // approval, plan: hold writes). The context hook strips stale mode context
@@ -937,6 +1379,7 @@ export function registerPermissionGate(pi, deps = {}) {
937
1379
  // A mode change is a trust-posture change: session ask-approvals do
938
1380
  // not carry across it (grants persist but are suppressed in review).
939
1381
  approvedCommands.clear();
1382
+ ruleAskApprovals.clear();
940
1383
  }
941
1384
  mode = arg;
942
1385
  paintMode(ctx);