@yagni-app/code 1.0.5 → 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 (111) hide show
  1. package/README.md +89 -27
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -3
  5. package/dist/claudePlugins.js +129 -19
  6. package/dist/cli.js +28 -4
  7. package/dist/doctor.d.ts +42 -4
  8. package/dist/doctor.js +150 -15
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +18 -5
  12. package/dist/extension/index.js +319 -72
  13. package/dist/extension/mcp/approval.d.ts +45 -0
  14. package/dist/extension/mcp/approval.js +164 -0
  15. package/dist/extension/mcp/auth.d.ts +124 -0
  16. package/dist/extension/mcp/auth.js +560 -0
  17. package/dist/extension/mcp/authStore.d.ts +61 -0
  18. package/dist/extension/mcp/authStore.js +105 -0
  19. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  20. package/dist/extension/mcp/callbackPage.js +222 -0
  21. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  22. package/dist/extension/mcp/cliConfig.js +12 -0
  23. package/dist/extension/mcp/config.d.ts +153 -0
  24. package/dist/extension/mcp/config.js +381 -0
  25. package/dist/extension/mcp/log.d.ts +28 -0
  26. package/dist/extension/mcp/log.js +82 -0
  27. package/dist/extension/mcp/manager.d.ts +100 -0
  28. package/dist/extension/mcp/manager.js +273 -0
  29. package/dist/extension/mcp/names.d.ts +25 -0
  30. package/dist/extension/mcp/names.js +40 -0
  31. package/dist/extension/mcp/panel.d.ts +33 -0
  32. package/dist/extension/mcp/panel.js +268 -0
  33. package/dist/extension/mcp/prompts.d.ts +23 -0
  34. package/dist/extension/mcp/prompts.js +93 -0
  35. package/dist/extension/mcp/startup.d.ts +55 -0
  36. package/dist/extension/mcp/startup.js +152 -0
  37. package/dist/extension/mcp/tools.d.ts +31 -0
  38. package/dist/extension/mcp/tools.js +117 -0
  39. package/dist/extension/mcp/transports.d.ts +17 -0
  40. package/dist/extension/mcp/transports.js +44 -0
  41. package/dist/extension/permission/gate.d.ts +104 -2
  42. package/dist/extension/permission/gate.js +387 -31
  43. package/dist/extension/permission/guardian.d.ts +24 -5
  44. package/dist/extension/permission/guardian.js +162 -24
  45. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  46. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  47. package/dist/extension/permissionRules/engine.d.ts +50 -0
  48. package/dist/extension/permissionRules/engine.js +238 -0
  49. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  50. package/dist/extension/permissionRules/loadConfig.js +90 -0
  51. package/dist/extension/permissionRules/parser.d.ts +38 -0
  52. package/dist/extension/permissionRules/parser.js +136 -0
  53. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  54. package/dist/extension/permissionRules/pathRules.js +120 -0
  55. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  56. package/dist/extension/permissionRules/shellRules.js +221 -0
  57. package/dist/extension/pipeline/invocation.d.ts +3 -6
  58. package/dist/extension/pipeline/invocation.js +3 -6
  59. package/dist/extension/pipeline/personas.js +5 -0
  60. package/dist/extension/pipeline/runner.d.ts +0 -1
  61. package/dist/extension/pipeline/runner.js +6 -14
  62. package/dist/extension/plugins/inventory.d.ts +88 -0
  63. package/dist/extension/plugins/inventory.js +144 -0
  64. package/dist/extension/plugins/panel.d.ts +45 -0
  65. package/dist/extension/plugins/panel.js +293 -0
  66. package/dist/extension/sandbox/bash.d.ts +99 -0
  67. package/dist/extension/sandbox/bash.js +190 -0
  68. package/dist/extension/sandbox/config.d.ts +114 -0
  69. package/dist/extension/sandbox/config.js +366 -0
  70. package/dist/extension/sandbox/manager.d.ts +98 -0
  71. package/dist/extension/sandbox/manager.js +216 -0
  72. package/dist/extension/sandbox/panel.d.ts +111 -0
  73. package/dist/extension/sandbox/panel.js +342 -0
  74. package/dist/extension/sandbox/session.d.ts +85 -0
  75. package/dist/extension/sandbox/session.js +775 -0
  76. package/dist/extension/telemetry/attrs.d.ts +96 -0
  77. package/dist/extension/telemetry/attrs.js +149 -0
  78. package/dist/extension/telemetry/config.d.ts +99 -0
  79. package/dist/extension/telemetry/config.js +193 -0
  80. package/dist/extension/telemetry/index.d.ts +7 -0
  81. package/dist/extension/telemetry/index.js +7 -0
  82. package/dist/extension/telemetry/probe.d.ts +29 -0
  83. package/dist/extension/telemetry/probe.js +122 -0
  84. package/dist/extension/telemetry/register.d.ts +40 -0
  85. package/dist/extension/telemetry/register.js +192 -0
  86. package/dist/extension/telemetry/sdk.d.ts +63 -0
  87. package/dist/extension/telemetry/sdk.js +207 -0
  88. package/dist/extension/telemetry/tracker.d.ts +131 -0
  89. package/dist/extension/telemetry/tracker.js +551 -0
  90. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  91. package/dist/extension/vendor/ignore.d.ts +86 -0
  92. package/dist/extension/vendor/ignore.js +788 -0
  93. package/dist/goHeadless.d.ts +1 -1
  94. package/dist/goHeadless.js +2 -2
  95. package/dist/launch.d.ts +4 -3
  96. package/dist/launch.js +7 -4
  97. package/dist/mcpCommand.d.ts +122 -0
  98. package/dist/mcpCommand.js +787 -0
  99. package/dist/otel.d.ts +69 -63
  100. package/dist/otel.js +154 -119
  101. package/dist/paths.d.ts +13 -0
  102. package/dist/paths.js +18 -0
  103. package/dist/pluginCommand.d.ts +43 -0
  104. package/dist/pluginCommand.js +499 -0
  105. package/dist/pluginStore.d.ts +170 -0
  106. package/dist/pluginStore.js +554 -0
  107. package/dist/upgrade.d.ts +11 -2
  108. package/dist/upgrade.js +48 -8
  109. package/package.json +20 -3
  110. package/dist/extension/mcpTools.d.ts +0 -57
  111. package/dist/extension/mcpTools.js +0 -132
@@ -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
@@ -92,7 +140,7 @@ export function decideGate(toolName, params, mode, policy) {
92
140
  }
93
141
  return { block: false };
94
142
  }
95
- if (policy.planBlockTools.includes(toolName)) {
143
+ if (policy.planBlockTools.includes(toolName) || policy.isMutating?.(toolName)) {
96
144
  return {
97
145
  block: true,
98
146
  reason: `plan mode: ${toolName} is a write or exec action and is held. Switch to /mode auto to apply changes.`,
@@ -142,7 +190,7 @@ export function decideGate(toolName, params, mode, policy) {
142
190
  if (mode === "auto")
143
191
  return { block: false };
144
192
  // review
145
- if (policy.reviewConfirmTools.includes(toolName)) {
193
+ if (policy.reviewConfirmTools.includes(toolName) || policy.isMutating?.(toolName)) {
146
194
  if (policy.isBlessed?.(toolName, params))
147
195
  return { block: false };
148
196
  return { block: false, confirm: true };
@@ -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
@@ -314,6 +414,8 @@ export function registerPermissionGate(pi, deps = {}) {
314
414
  // and dropping it here silently reverts every custom policy to the
315
415
  // default (round-2 review blocker).
316
416
  execPolicy: basePolicy.execPolicy,
417
+ // Same carry-through for the mutating-tool predicate (dynamic mcp__* tools).
418
+ isMutating: basePolicy.isMutating,
317
419
  isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
318
420
  };
319
421
  const sideEffects = sideEffectTools(effectivePolicy);
@@ -347,6 +449,23 @@ export function registerPermissionGate(pi, deps = {}) {
347
449
  // LRU-capped, cleared on every /mode transition.
348
450
  const APPROVED_CACHE_MAX = 50;
349
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
+ };
350
469
  const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
351
470
  const rememberApproved = (cwd, command) => {
352
471
  const key = cacheKey(cwd, command);
@@ -370,7 +489,16 @@ export function registerPermissionGate(pi, deps = {}) {
370
489
  // calls can start their reviews in the same millisecond, and a timestamp
371
490
  // alone would silently drop the second review's spend as a "duplicate".
372
491
  let reviewSpendSeq = 0;
373
- 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;
374
502
  if (!deps.onGuardianEvent)
375
503
  return;
376
504
  try {
@@ -417,16 +545,162 @@ export function registerPermissionGate(pi, deps = {}) {
417
545
  return "no";
418
546
  return ctx.signal?.aborted ? "aborted" : "dismissed";
419
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
+ };
420
581
  const buildAskTitle = (command, rationale, riskLevel) => {
421
582
  const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
422
583
  return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
423
584
  };
424
- pi.on("tool_call", async (event, ctx) => {
585
+ const gateToolCall = async (event, ctx, slot) => {
425
586
  // Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
426
587
  // of the closure variable would disagree with the decision already made.
427
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;
428
592
  try {
429
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
+ }
430
704
  // YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
431
705
  // (allow/deny/ask) or fall through to the normal gate logic. The result
432
706
  // is cached in preToolUseResult so the "ask" check below does NOT
@@ -437,6 +711,9 @@ export function registerPermissionGate(pi, deps = {}) {
437
711
  try {
438
712
  preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
439
713
  if (preToolUseResult) {
714
+ if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
715
+ slot.source = "hook";
716
+ }
440
717
  if (preToolUseResult.decision === "deny") {
441
718
  return { block: true, reason: preToolUseResult.reason };
442
719
  }
@@ -493,7 +770,7 @@ export function registerPermissionGate(pi, deps = {}) {
493
770
  if (modeAtEntry === "auto" && command) {
494
771
  const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
495
772
  if (grant) {
496
- emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
773
+ emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
497
774
  return {};
498
775
  }
499
776
  }
@@ -501,7 +778,20 @@ export function registerPermissionGate(pi, deps = {}) {
501
778
  // Skipped in plan mode: a cached approval can cover a write command,
502
779
  // and plan mode's contract is no mutations without Guardian review.
503
780
  if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
504
- 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 });
505
795
  return {};
506
796
  }
507
797
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
@@ -530,15 +820,15 @@ export function registerPermissionGate(pi, deps = {}) {
530
820
  if (resolution === "yes") {
531
821
  guardianState.resetTurn();
532
822
  rememberApproved(cwd, command);
533
- emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
823
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
534
824
  return {};
535
825
  }
536
826
  if (resolution === "aborted") {
537
- emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
827
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
538
828
  return { block: true };
539
829
  }
540
830
  }
541
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
831
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
542
832
  if (ctx?.hasUI)
543
833
  ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
544
834
  return { block: true, reason: breaker.reason };
@@ -575,6 +865,7 @@ export function registerPermissionGate(pi, deps = {}) {
575
865
  void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
576
866
  durationMs,
577
867
  tier: guardianTier,
868
+ ...(reviewResult.repaired ? { repaired: true } : {}),
578
869
  ...(rationale ? { rationale } : {}),
579
870
  ...(rawOutput ? { rawOutput } : {}),
580
871
  debug: isDebug(),
@@ -583,8 +874,12 @@ export function registerPermissionGate(pi, deps = {}) {
583
874
  const verdict = reviewResult.verdict;
584
875
  if (verdict?.outcome === "allow") {
585
876
  guardianState.recordReview("allow");
586
- emitDiag("allow", verdict.rationale);
587
- emitGateEvent({
877
+ // A repaired verdict is a REAL allow: it flows through the normal
878
+ // path; the diag event carries `repaired` + the pre-repair shape so
879
+ // telemetry can count salvage hits (the rawOutput is already
880
+ // scrubbed + capped at the source).
881
+ emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
882
+ emitGateEvent(slot, {
588
883
  ...eventBase,
589
884
  outcome: "allow",
590
885
  riskLevel: verdict.riskLevel,
@@ -597,8 +892,8 @@ export function registerPermissionGate(pi, deps = {}) {
597
892
  if (verdict?.outcome === "deny") {
598
893
  guardianState.recordReview("deny");
599
894
  const rationale = verdict.rationale;
600
- emitDiag("deny", rationale);
601
- emitGateEvent({
895
+ emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
896
+ emitGateEvent(slot, {
602
897
  ...eventBase,
603
898
  outcome: "deny",
604
899
  riskLevel: verdict.riskLevel,
@@ -622,10 +917,10 @@ export function registerPermissionGate(pi, deps = {}) {
622
917
  }
623
918
  if (verdict?.outcome === "ask") {
624
919
  guardianState.recordReview("ask");
625
- emitDiag("ask", verdict.rationale);
920
+ emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
626
921
  if (!ctx?.hasUI) {
627
922
  // Headless (includes every /go child stage): fail closed.
628
- emitGateEvent({
923
+ emitGateEvent(slot, {
629
924
  ...eventBase,
630
925
  outcome: "ask_headless_blocked",
631
926
  riskLevel: verdict.riskLevel,
@@ -646,7 +941,7 @@ export function registerPermissionGate(pi, deps = {}) {
646
941
  if (hookResult) {
647
942
  if (hookResult.decision === "allow") {
648
943
  rememberApproved(cwd, command);
649
- emitGateEvent({
944
+ emitGateEvent(slot, {
650
945
  ...eventBase,
651
946
  outcome: "ask_approved",
652
947
  riskLevel: verdict.riskLevel,
@@ -657,7 +952,7 @@ export function registerPermissionGate(pi, deps = {}) {
657
952
  return {};
658
953
  }
659
954
  if (hookResult.decision === "deny") {
660
- emitGateEvent({
955
+ emitGateEvent(slot, {
661
956
  ...eventBase,
662
957
  outcome: "ask_denied",
663
958
  riskLevel: verdict.riskLevel,
@@ -674,15 +969,25 @@ export function registerPermissionGate(pi, deps = {}) {
674
969
  }
675
970
  }
676
971
  // Offer "don't ask again" only when the grant would actually
677
- // 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).
678
977
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
978
+ const ruleCandidate = grantCandidate
979
+ ? `Bash(${grantCandidate.pattern.join(" ")}:*)`
980
+ : null;
679
981
  const rememberLabel = grantCandidate
680
982
  ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
681
983
  : null;
682
- 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);
683
988
  if (resolution === "yes") {
684
989
  rememberApproved(cwd, command);
685
- emitGateEvent({
990
+ emitGateEvent(slot, {
686
991
  ...eventBase,
687
992
  outcome: "ask_approved",
688
993
  riskLevel: verdict.riskLevel,
@@ -692,6 +997,44 @@ export function registerPermissionGate(pi, deps = {}) {
692
997
  });
693
998
  return {};
694
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
+ }
695
1038
  if (resolution === "remember" && grantCandidate) {
696
1039
  const grantRecord = {
697
1040
  ...grantCandidate,
@@ -705,7 +1048,7 @@ export function registerPermissionGate(pi, deps = {}) {
705
1048
  catch {
706
1049
  // Fail-soft: the in-memory grant still applies this session.
707
1050
  }
708
- emitGateEvent({
1051
+ emitGateEvent(slot, {
709
1052
  ...eventBase,
710
1053
  outcome: "ask_approved_remembered",
711
1054
  riskLevel: verdict.riskLevel,
@@ -718,10 +1061,10 @@ export function registerPermissionGate(pi, deps = {}) {
718
1061
  if (resolution === "aborted") {
719
1062
  // The user is abandoning the turn — no steering text (do not
720
1063
  // tell an aborting model it was "denied").
721
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
1064
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
722
1065
  return { block: true };
723
1066
  }
724
- emitGateEvent({
1067
+ emitGateEvent(slot, {
725
1068
  ...eventBase,
726
1069
  outcome: "ask_denied",
727
1070
  riskLevel: verdict.riskLevel,
@@ -750,7 +1093,7 @@ export function registerPermissionGate(pi, deps = {}) {
750
1093
  // killed. (Belt and braces with reviewCommand's own aborted
751
1094
  // detection — an aborted child can die in shapes that look like
752
1095
  // other errors.)
753
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1096
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
754
1097
  return { block: true };
755
1098
  }
756
1099
  if (modeAtEntry === "review" && decision.confirm) {
@@ -766,14 +1109,14 @@ export function registerPermissionGate(pi, deps = {}) {
766
1109
  const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
767
1110
  if (resolution === "yes") {
768
1111
  rememberApproved(cwd, command);
769
- emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
1112
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
770
1113
  return {};
771
1114
  }
772
1115
  if (resolution === "aborted") {
773
- emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
1116
+ emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
774
1117
  return { block: true };
775
1118
  }
776
- emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
1119
+ emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
777
1120
  const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
778
1121
  return {
779
1122
  block: true,
@@ -782,7 +1125,7 @@ export function registerPermissionGate(pi, deps = {}) {
782
1125
  }
783
1126
  else {
784
1127
  const errorMsg = guardianErrorMessage(error);
785
- emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
1128
+ emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
786
1129
  if (ctx?.hasUI)
787
1130
  ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
788
1131
  const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
@@ -806,7 +1149,7 @@ export function registerPermissionGate(pi, deps = {}) {
806
1149
  // the Guardian to verify the command is non-mutating, the plan-mode
807
1150
  // contract (no changes) cannot be upheld. The user can switch to
808
1151
  // /mode auto or /mode review to proceed.
809
- emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
1152
+ emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
810
1153
  return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
811
1154
  }
812
1155
  // review mode with Guardian disabled/capped: fall through to confirm.
@@ -816,6 +1159,11 @@ export function registerPermissionGate(pi, deps = {}) {
816
1159
  if (preToolUseResult?.decision === "ask") {
817
1160
  decision = { block: false, confirm: true };
818
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
+ }
819
1167
  if (decision.confirm) {
820
1168
  // YAG-506: PermissionRequest hooks run before the confirm dialog.
821
1169
  if (hookRunner) {
@@ -823,6 +1171,8 @@ export function registerPermissionGate(pi, deps = {}) {
823
1171
  const cwd = ctx?.cwd ?? ".";
824
1172
  const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
825
1173
  if (hookResult) {
1174
+ if (hookResult.decision === "allow" || hookResult.decision === "deny")
1175
+ slot.source = "hook";
826
1176
  if (hookResult.decision === "allow")
827
1177
  return {};
828
1178
  if (hookResult.decision === "deny") {
@@ -857,9 +1207,12 @@ export function registerPermissionGate(pi, deps = {}) {
857
1207
  const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
858
1208
  ? externalTrackerPrompt(event.toolName, input)
859
1209
  : "YAGNI Code review mode", options);
860
- if (choice === yes)
1210
+ if (choice === yes) {
1211
+ slot.source = "user_temporary";
861
1212
  return {};
1213
+ }
862
1214
  if (blessable && choice === remember) {
1215
+ slot.source = "user_permanent";
863
1216
  // Session-scoped bless, then a fire-and-forget decision capture so the
864
1217
  // approved tool call is never delayed by the capture prompt.
865
1218
  blessStore.add(event.toolName, input);
@@ -869,6 +1222,7 @@ export function registerPermissionGate(pi, deps = {}) {
869
1222
  return {};
870
1223
  }
871
1224
  // "No" or a dismissed dialog (undefined) → held.
1225
+ slot.source = choice === undefined ? "user_abort" : "user_reject";
872
1226
  return { block: true, reason: `declined ${event.toolName} in review mode` };
873
1227
  }
874
1228
  return {};
@@ -882,7 +1236,8 @@ export function registerPermissionGate(pi, deps = {}) {
882
1236
  }
883
1237
  return {};
884
1238
  }
885
- });
1239
+ };
1240
+ wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
886
1241
  // Model awareness: every agent turn carries a hidden mode-context message so
887
1242
  // the model knows what it can do (auto: verify proactively, review: wait for
888
1243
  // approval, plan: hold writes). The context hook strips stale mode context
@@ -930,6 +1285,7 @@ export function registerPermissionGate(pi, deps = {}) {
930
1285
  // A mode change is a trust-posture change: session ask-approvals do
931
1286
  // not carry across it (grants persist but are suppressed in review).
932
1287
  approvedCommands.clear();
1288
+ ruleAskApprovals.clear();
933
1289
  }
934
1290
  mode = arg;
935
1291
  paintMode(ctx);