@yagni-app/code 1.0.6 → 1.0.7

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 (85) hide show
  1. package/README.md +84 -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/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +13 -0
  12. package/dist/extension/index.js +230 -43
  13. package/dist/extension/mcp/cliConfig.d.ts +1 -1
  14. package/dist/extension/mcp/cliConfig.js +1 -1
  15. package/dist/extension/mcp/config.d.ts +24 -2
  16. package/dist/extension/mcp/config.js +75 -3
  17. package/dist/extension/mcp/manager.d.ts +3 -1
  18. package/dist/extension/mcp/manager.js +2 -2
  19. package/dist/extension/mcp/panel.d.ts +0 -1
  20. package/dist/extension/mcp/panel.js +13 -3
  21. package/dist/extension/mcp/startup.js +8 -6
  22. package/dist/extension/permission/gate.d.ts +97 -2
  23. package/dist/extension/permission/gate.js +375 -26
  24. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  25. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  26. package/dist/extension/permissionRules/engine.d.ts +50 -0
  27. package/dist/extension/permissionRules/engine.js +238 -0
  28. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  29. package/dist/extension/permissionRules/loadConfig.js +90 -0
  30. package/dist/extension/permissionRules/parser.d.ts +38 -0
  31. package/dist/extension/permissionRules/parser.js +136 -0
  32. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  33. package/dist/extension/permissionRules/pathRules.js +120 -0
  34. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  35. package/dist/extension/permissionRules/shellRules.js +221 -0
  36. package/dist/extension/pipeline/invocation.d.ts +3 -6
  37. package/dist/extension/pipeline/invocation.js +3 -6
  38. package/dist/extension/pipeline/runner.d.ts +0 -1
  39. package/dist/extension/pipeline/runner.js +6 -14
  40. package/dist/extension/plugins/inventory.d.ts +88 -0
  41. package/dist/extension/plugins/inventory.js +144 -0
  42. package/dist/extension/plugins/panel.d.ts +45 -0
  43. package/dist/extension/plugins/panel.js +293 -0
  44. package/dist/extension/sandbox/bash.d.ts +99 -0
  45. package/dist/extension/sandbox/bash.js +190 -0
  46. package/dist/extension/sandbox/config.d.ts +114 -0
  47. package/dist/extension/sandbox/config.js +366 -0
  48. package/dist/extension/sandbox/manager.d.ts +98 -0
  49. package/dist/extension/sandbox/manager.js +216 -0
  50. package/dist/extension/sandbox/panel.d.ts +111 -0
  51. package/dist/extension/sandbox/panel.js +342 -0
  52. package/dist/extension/sandbox/session.d.ts +85 -0
  53. package/dist/extension/sandbox/session.js +775 -0
  54. package/dist/extension/telemetry/attrs.d.ts +96 -0
  55. package/dist/extension/telemetry/attrs.js +149 -0
  56. package/dist/extension/telemetry/config.d.ts +99 -0
  57. package/dist/extension/telemetry/config.js +193 -0
  58. package/dist/extension/telemetry/index.d.ts +7 -0
  59. package/dist/extension/telemetry/index.js +7 -0
  60. package/dist/extension/telemetry/probe.d.ts +29 -0
  61. package/dist/extension/telemetry/probe.js +122 -0
  62. package/dist/extension/telemetry/register.d.ts +40 -0
  63. package/dist/extension/telemetry/register.js +192 -0
  64. package/dist/extension/telemetry/sdk.d.ts +63 -0
  65. package/dist/extension/telemetry/sdk.js +207 -0
  66. package/dist/extension/telemetry/tracker.d.ts +131 -0
  67. package/dist/extension/telemetry/tracker.js +551 -0
  68. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  69. package/dist/extension/vendor/ignore.d.ts +86 -0
  70. package/dist/extension/vendor/ignore.js +788 -0
  71. package/dist/goHeadless.d.ts +1 -1
  72. package/dist/goHeadless.js +2 -2
  73. package/dist/launch.d.ts +4 -3
  74. package/dist/launch.js +7 -4
  75. package/dist/mcpCommand.d.ts +10 -1
  76. package/dist/mcpCommand.js +42 -10
  77. package/dist/otel.d.ts +67 -90
  78. package/dist/otel.js +152 -195
  79. package/dist/paths.d.ts +13 -0
  80. package/dist/paths.js +18 -0
  81. package/dist/pluginCommand.d.ts +43 -0
  82. package/dist/pluginCommand.js +499 -0
  83. package/dist/pluginStore.d.ts +170 -0
  84. package/dist/pluginStore.js +554 -0
  85. 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 {
@@ -419,16 +545,162 @@ export function registerPermissionGate(pi, deps = {}) {
419
545
  return "no";
420
546
  return ctx.signal?.aborted ? "aborted" : "dismissed";
421
547
  };
548
+ /**
549
+ * variant: the Guardian ask dialog with an optional third option
550
+ * (persist a user-level permission rule). Same semantics as askUser.
551
+ */
552
+ const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel) => {
553
+ if (ctx.signal?.aborted)
554
+ return "aborted";
555
+ const options = [
556
+ ASK_YES,
557
+ ...(rememberLabel ? [rememberLabel] : []),
558
+ ...(ruleLabel ? [ruleLabel] : []),
559
+ ASK_NO,
560
+ ];
561
+ let choice;
562
+ try {
563
+ choice = await ctx.ui.select(title, options, {
564
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
565
+ timeout: ASK_TIMEOUT_MS,
566
+ });
567
+ }
568
+ catch {
569
+ choice = undefined;
570
+ }
571
+ if (choice === ASK_YES)
572
+ return "yes";
573
+ if (rememberLabel !== null && choice === rememberLabel)
574
+ return "remember";
575
+ if (ruleLabel !== null && choice === ruleLabel)
576
+ return "rule";
577
+ if (choice === ASK_NO)
578
+ return "no";
579
+ return ctx.signal?.aborted ? "aborted" : "dismissed";
580
+ };
422
581
  const buildAskTitle = (command, rationale, riskLevel) => {
423
582
  const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
424
583
  return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
425
584
  };
426
- pi.on("tool_call", async (event, ctx) => {
585
+ const gateToolCall = async (event, ctx, slot) => {
427
586
  // Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
428
587
  // of the closure variable would disagree with the decision already made.
429
588
  const modeAtEntry = mode;
589
+ // set when an allow-rule verdict hits an alwaysConfirmTools tool
590
+ // (Floor 2) — forces the confirm flow below instead of short-circuiting.
591
+ let ruleAskConfirm = false;
430
592
  try {
431
593
  const input = event.input ?? {};
594
+ // settings permission rules run BEFORE hooks (Claude Code
595
+ // semantics: a deny rule blocks even when a hook would allow). Order
596
+ // deny → ask → allow; the verdict is final for deny/ask, and allow
597
+ // short-circuits everything below EXCEPT the two hard floors.
598
+ const rulesDeps = deps.permissionRules;
599
+ // The guard + evaluation share ONE try: a poisoned rules array can
600
+ // throw at the `.length` guard just as easily as inside evaluateRules,
601
+ // and both are engine errors — both must log engine_error and degrade
602
+ // to the normal gate, never escape to the outer fail-open catch.
603
+ let ruleVerdict = null;
604
+ try {
605
+ if (rulesDeps && rulesDeps.length > 0) {
606
+ const cwd = ctx?.cwd ?? ".";
607
+ ruleVerdict = evaluateRules(rulesDeps, {
608
+ toolName: event.toolName,
609
+ params: input,
610
+ cwd,
611
+ isProjectTrusted: (() => { try {
612
+ return ctx?.isProjectTrusted() ?? true;
613
+ }
614
+ catch {
615
+ return true;
616
+ } })(),
617
+ userStateHome: deps.rulesUserStateHome ?? cwd,
618
+ projectRoot: deps.rulesProjectRoot ?? null,
619
+ ...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
620
+ });
621
+ }
622
+ }
623
+ catch (err) {
624
+ // Fail-soft: a rule-engine error never blocks or allows — but it
625
+ // must not be SILENT: for a would-be deny this degrades to the
626
+ // normal gate (likely an allow), so the trail needs the failure.
627
+ // Error class only — never the thrown message (it can carry
628
+ // command content) or the user content.
629
+ logEvent({
630
+ source: "permission-rules",
631
+ level: "warn",
632
+ event: "engine_error",
633
+ fields: {
634
+ tool: event.toolName,
635
+ error: err instanceof Error ? err.constructor.name : typeof err,
636
+ },
637
+ });
638
+ }
639
+ if (ruleVerdict) {
640
+ try {
641
+ deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
642
+ }
643
+ catch { /* logging must never affect the gate */ }
644
+ if (ruleVerdict.verdict === "deny") {
645
+ const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
646
+ return {
647
+ block: true,
648
+ 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.`,
649
+ };
650
+ }
651
+ if (ruleVerdict.verdict === "ask") {
652
+ // Ask is final: nothing below may auto-allow it. With a UI, the
653
+ // user arbitrates; headless (incl. /go children) fails closed.
654
+ if (!ctx?.hasUI) {
655
+ return {
656
+ block: true,
657
+ reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
658
+ };
659
+ }
660
+ if (ctx.signal?.aborted)
661
+ return { block: true };
662
+ // Session approval cache: an identical (tool, rule, input) "yes"
663
+ // earlier this session covers this call — a retrying model must
664
+ // not re-prompt the same question (dialog-storm guard).
665
+ const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
666
+ if (ruleAskApprovals.has(askKey))
667
+ return {};
668
+ const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
669
+ const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
670
+ if (choice === "yes") {
671
+ if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
672
+ ruleAskApprovals.clear();
673
+ ruleAskApprovals.set(askKey, true);
674
+ return {};
675
+ }
676
+ if (choice === "aborted")
677
+ return { block: true };
678
+ return {
679
+ block: true,
680
+ reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
681
+ };
682
+ }
683
+ // verdict === "allow": the floors decide whether the allow
684
+ // short-circuits. Linear by construction — allowRuleFloorVerdict
685
+ // owns the three invariants (plan-mode hold, forbidden band,
686
+ // alwaysConfirmTools confirm) and is unit-tested directly.
687
+ const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
688
+ if (floor.kind === "block") {
689
+ return { block: true, reason: floor.reason };
690
+ }
691
+ if (floor.kind === "confirm") {
692
+ // Defer to the existing confirm flow via the ruleAskConfirm flag
693
+ // (set below) — alwaysConfirmTools keeps its fresh-consent contract.
694
+ ruleAskConfirm = true;
695
+ }
696
+ else if (floor.kind === "hold") {
697
+ // Plan mode's no-mutation contract outranks the allow rule —
698
+ // fall through to the normal plan-mode gate below.
699
+ }
700
+ else {
701
+ return {};
702
+ }
703
+ }
432
704
  // YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
433
705
  // (allow/deny/ask) or fall through to the normal gate logic. The result
434
706
  // is cached in preToolUseResult so the "ask" check below does NOT
@@ -439,6 +711,9 @@ export function registerPermissionGate(pi, deps = {}) {
439
711
  try {
440
712
  preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
441
713
  if (preToolUseResult) {
714
+ if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
715
+ slot.source = "hook";
716
+ }
442
717
  if (preToolUseResult.decision === "deny") {
443
718
  return { block: true, reason: preToolUseResult.reason };
444
719
  }
@@ -495,7 +770,7 @@ export function registerPermissionGate(pi, deps = {}) {
495
770
  if (modeAtEntry === "auto" && command) {
496
771
  const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
497
772
  if (grant) {
498
- emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
773
+ emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
499
774
  return {};
500
775
  }
501
776
  }
@@ -503,7 +778,20 @@ export function registerPermissionGate(pi, deps = {}) {
503
778
  // Skipped in plan mode: a cached approval can cover a write command,
504
779
  // and plan mode's contract is no mutations without Guardian review.
505
780
  if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
506
- emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
781
+ emitGateEvent(slot, { ...eventBase, outcome: "cached_allow", consulted: false });
782
+ return {};
783
+ }
784
+ // 2b. Sandbox auto-allow (Claude's autoAllowBashIfSandboxed):
785
+ // the command will run inside the OS sandbox, so pre-approval is
786
+ // unnecessary. Positioned AFTER grants/cache (user intent outranks
787
+ // mechanism) and BEFORE the cap/breaker (a capped Guardian must not
788
+ // block a sandbox-safe command). Never active in plan mode — the
789
+ // session-side predicate is not consulted there at all, and even a
790
+ // true answer cannot widen plan's no-mutation contract.
791
+ if (modeAtEntry !== "plan" &&
792
+ command &&
793
+ deps.sandboxAutoAllow?.(event.toolName, input)) {
794
+ emitGateEvent(slot, { ...eventBase, outcome: "sandbox_auto_allow", consulted: false });
507
795
  return {};
508
796
  }
509
797
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
@@ -532,15 +820,15 @@ export function registerPermissionGate(pi, deps = {}) {
532
820
  if (resolution === "yes") {
533
821
  guardianState.resetTurn();
534
822
  rememberApproved(cwd, command);
535
- emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
823
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
536
824
  return {};
537
825
  }
538
826
  if (resolution === "aborted") {
539
- emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
827
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
540
828
  return { block: true };
541
829
  }
542
830
  }
543
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
831
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
544
832
  if (ctx?.hasUI)
545
833
  ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
546
834
  return { block: true, reason: breaker.reason };
@@ -591,7 +879,7 @@ export function registerPermissionGate(pi, deps = {}) {
591
879
  // telemetry can count salvage hits (the rawOutput is already
592
880
  // scrubbed + capped at the source).
593
881
  emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
594
- emitGateEvent({
882
+ emitGateEvent(slot, {
595
883
  ...eventBase,
596
884
  outcome: "allow",
597
885
  riskLevel: verdict.riskLevel,
@@ -605,7 +893,7 @@ export function registerPermissionGate(pi, deps = {}) {
605
893
  guardianState.recordReview("deny");
606
894
  const rationale = verdict.rationale;
607
895
  emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
608
- emitGateEvent({
896
+ emitGateEvent(slot, {
609
897
  ...eventBase,
610
898
  outcome: "deny",
611
899
  riskLevel: verdict.riskLevel,
@@ -632,7 +920,7 @@ export function registerPermissionGate(pi, deps = {}) {
632
920
  emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
633
921
  if (!ctx?.hasUI) {
634
922
  // Headless (includes every /go child stage): fail closed.
635
- emitGateEvent({
923
+ emitGateEvent(slot, {
636
924
  ...eventBase,
637
925
  outcome: "ask_headless_blocked",
638
926
  riskLevel: verdict.riskLevel,
@@ -653,7 +941,7 @@ export function registerPermissionGate(pi, deps = {}) {
653
941
  if (hookResult) {
654
942
  if (hookResult.decision === "allow") {
655
943
  rememberApproved(cwd, command);
656
- emitGateEvent({
944
+ emitGateEvent(slot, {
657
945
  ...eventBase,
658
946
  outcome: "ask_approved",
659
947
  riskLevel: verdict.riskLevel,
@@ -664,7 +952,7 @@ export function registerPermissionGate(pi, deps = {}) {
664
952
  return {};
665
953
  }
666
954
  if (hookResult.decision === "deny") {
667
- emitGateEvent({
955
+ emitGateEvent(slot, {
668
956
  ...eventBase,
669
957
  outcome: "ask_denied",
670
958
  riskLevel: verdict.riskLevel,
@@ -681,15 +969,25 @@ export function registerPermissionGate(pi, deps = {}) {
681
969
  }
682
970
  }
683
971
  // Offer "don't ask again" only when the grant would actually
684
- // cover this command (grant-time validation).
972
+ // cover this command (grant-time validation). adds a
973
+ // third option: persist a user-level permission rule (survives
974
+ // across repos, unlike the repo-scoped grant) — offered only when
975
+ // a grantCandidate also exists (the same prefix discipline; the
976
+ // rule is the same pattern in settings form).
685
977
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
978
+ const ruleCandidate = grantCandidate
979
+ ? `Bash(${grantCandidate.pattern.join(" ")}:*)`
980
+ : null;
686
981
  const rememberLabel = grantCandidate
687
982
  ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
688
983
  : null;
689
- const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
984
+ const ruleLabel = grantCandidate && deps.persistUserRule
985
+ ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in my user settings`
986
+ : null;
987
+ const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
690
988
  if (resolution === "yes") {
691
989
  rememberApproved(cwd, command);
692
- emitGateEvent({
990
+ emitGateEvent(slot, {
693
991
  ...eventBase,
694
992
  outcome: "ask_approved",
695
993
  riskLevel: verdict.riskLevel,
@@ -699,6 +997,44 @@ export function registerPermissionGate(pi, deps = {}) {
699
997
  });
700
998
  return {};
701
999
  }
1000
+ if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
1001
+ let persisted = false;
1002
+ try {
1003
+ deps.persistUserRule(ruleCandidate);
1004
+ persisted = true;
1005
+ // Also covers this session like a grant would:
1006
+ rememberApproved(cwd, command);
1007
+ }
1008
+ catch (err) {
1009
+ // Fail-soft: the in-memory approval still applies THIS
1010
+ // session. But the user just made an explicit durable choice
1011
+ // in the dialog — a silent failure would leave them believing
1012
+ // a rule exists that will not survive restart. Log the
1013
+ // failure (rule string only, it is user-configured text, plus
1014
+ // error class — never the thrown message) and tell the user.
1015
+ logEvent({
1016
+ source: "permission-rules",
1017
+ level: "warn",
1018
+ event: "user_rule_save_failed",
1019
+ fields: {
1020
+ rule: ruleCandidate,
1021
+ error: err instanceof Error ? err.constructor.name : typeof err,
1022
+ },
1023
+ });
1024
+ if (ctx?.hasUI) {
1025
+ ctx.ui.notify(`Could not save the permission rule to your settings — it applies to this session only.`, "warning");
1026
+ }
1027
+ }
1028
+ emitGateEvent(slot, {
1029
+ ...eventBase,
1030
+ outcome: persisted ? "ask_approved_remembered" : "ask_approved",
1031
+ riskLevel: verdict.riskLevel,
1032
+ rationale: verdict.rationale,
1033
+ durationMs,
1034
+ consulted: true,
1035
+ });
1036
+ return {};
1037
+ }
702
1038
  if (resolution === "remember" && grantCandidate) {
703
1039
  const grantRecord = {
704
1040
  ...grantCandidate,
@@ -712,7 +1048,7 @@ export function registerPermissionGate(pi, deps = {}) {
712
1048
  catch {
713
1049
  // Fail-soft: the in-memory grant still applies this session.
714
1050
  }
715
- emitGateEvent({
1051
+ emitGateEvent(slot, {
716
1052
  ...eventBase,
717
1053
  outcome: "ask_approved_remembered",
718
1054
  riskLevel: verdict.riskLevel,
@@ -725,10 +1061,10 @@ export function registerPermissionGate(pi, deps = {}) {
725
1061
  if (resolution === "aborted") {
726
1062
  // The user is abandoning the turn — no steering text (do not
727
1063
  // tell an aborting model it was "denied").
728
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
1064
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
729
1065
  return { block: true };
730
1066
  }
731
- emitGateEvent({
1067
+ emitGateEvent(slot, {
732
1068
  ...eventBase,
733
1069
  outcome: "ask_denied",
734
1070
  riskLevel: verdict.riskLevel,
@@ -757,7 +1093,7 @@ export function registerPermissionGate(pi, deps = {}) {
757
1093
  // killed. (Belt and braces with reviewCommand's own aborted
758
1094
  // detection — an aborted child can die in shapes that look like
759
1095
  // other errors.)
760
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1096
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
761
1097
  return { block: true };
762
1098
  }
763
1099
  if (modeAtEntry === "review" && decision.confirm) {
@@ -773,14 +1109,14 @@ export function registerPermissionGate(pi, deps = {}) {
773
1109
  const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
774
1110
  if (resolution === "yes") {
775
1111
  rememberApproved(cwd, command);
776
- emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
1112
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
777
1113
  return {};
778
1114
  }
779
1115
  if (resolution === "aborted") {
780
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1116
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
781
1117
  return { block: true };
782
1118
  }
783
- emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
1119
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
784
1120
  const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
785
1121
  return {
786
1122
  block: true,
@@ -789,7 +1125,7 @@ export function registerPermissionGate(pi, deps = {}) {
789
1125
  }
790
1126
  else {
791
1127
  const errorMsg = guardianErrorMessage(error);
792
- emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
1128
+ emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
793
1129
  if (ctx?.hasUI)
794
1130
  ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
795
1131
  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 +1149,7 @@ export function registerPermissionGate(pi, deps = {}) {
813
1149
  // the Guardian to verify the command is non-mutating, the plan-mode
814
1150
  // contract (no changes) cannot be upheld. The user can switch to
815
1151
  // /mode auto or /mode review to proceed.
816
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
1152
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
817
1153
  return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
818
1154
  }
819
1155
  // review mode with Guardian disabled/capped: fall through to confirm.
@@ -823,6 +1159,11 @@ export function registerPermissionGate(pi, deps = {}) {
823
1159
  if (preToolUseResult?.decision === "ask") {
824
1160
  decision = { block: false, confirm: true };
825
1161
  }
1162
+ // an allow-rule verdict on an alwaysConfirmTools tool forced
1163
+ // the confirm flow (Floor 2 above) — same treatment as a hook "ask".
1164
+ if (ruleAskConfirm) {
1165
+ decision = { block: false, confirm: true };
1166
+ }
826
1167
  if (decision.confirm) {
827
1168
  // YAG-506: PermissionRequest hooks run before the confirm dialog.
828
1169
  if (hookRunner) {
@@ -830,6 +1171,8 @@ export function registerPermissionGate(pi, deps = {}) {
830
1171
  const cwd = ctx?.cwd ?? ".";
831
1172
  const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
832
1173
  if (hookResult) {
1174
+ if (hookResult.decision === "allow" || hookResult.decision === "deny")
1175
+ slot.source = "hook";
833
1176
  if (hookResult.decision === "allow")
834
1177
  return {};
835
1178
  if (hookResult.decision === "deny") {
@@ -864,9 +1207,12 @@ export function registerPermissionGate(pi, deps = {}) {
864
1207
  const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
865
1208
  ? externalTrackerPrompt(event.toolName, input)
866
1209
  : "YAGNI Code review mode", options);
867
- if (choice === yes)
1210
+ if (choice === yes) {
1211
+ slot.source = "user_temporary";
868
1212
  return {};
1213
+ }
869
1214
  if (blessable && choice === remember) {
1215
+ slot.source = "user_permanent";
870
1216
  // Session-scoped bless, then a fire-and-forget decision capture so the
871
1217
  // approved tool call is never delayed by the capture prompt.
872
1218
  blessStore.add(event.toolName, input);
@@ -876,6 +1222,7 @@ export function registerPermissionGate(pi, deps = {}) {
876
1222
  return {};
877
1223
  }
878
1224
  // "No" or a dismissed dialog (undefined) → held.
1225
+ slot.source = choice === undefined ? "user_abort" : "user_reject";
879
1226
  return { block: true, reason: `declined ${event.toolName} in review mode` };
880
1227
  }
881
1228
  return {};
@@ -889,7 +1236,8 @@ export function registerPermissionGate(pi, deps = {}) {
889
1236
  }
890
1237
  return {};
891
1238
  }
892
- });
1239
+ };
1240
+ wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
893
1241
  // Model awareness: every agent turn carries a hidden mode-context message so
894
1242
  // the model knows what it can do (auto: verify proactively, review: wait for
895
1243
  // approval, plan: hold writes). The context hook strips stale mode context
@@ -937,6 +1285,7 @@ export function registerPermissionGate(pi, deps = {}) {
937
1285
  // A mode change is a trust-posture change: session ask-approvals do
938
1286
  // not carry across it (grants persist but are suppressed in review).
939
1287
  approvedCommands.clear();
1288
+ ruleAskApprovals.clear();
940
1289
  }
941
1290
  mode = arg;
942
1291
  paintMode(ctx);