@yagni-app/code-staging 1.0.6-staging.1244.1 → 1.0.6-staging.1245.1

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.
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { Text } from "@earendil-works/pi-tui";
3
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
6
  import { makeChildUsageState } from "./childUsage.js";
@@ -35,6 +37,7 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
35
37
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
36
38
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
37
39
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
40
+ import { loadPermissionRules } from "./permissionRules/loadConfig.js";
38
41
  import { registerSubagents } from "./subagents.js";
39
42
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
40
43
  import { registerTodos } from "./todos.js";
@@ -334,6 +337,8 @@ export async function registerYagni(pi, deps = {}) {
334
337
  // The static queue only carries non-connectivity notices (e.g. the
335
338
  // `.mcp.json` approval prompt), which stay one-shot.
336
339
  let connectivityNoticesFlushed = false;
340
+ // permission-rule notices are one-shot like connectivity notices.
341
+ let rulesNoticesFlushed = false;
337
342
  pi.on("after_provider_response", (_event, ctx) => {
338
343
  try {
339
344
  if (!ctx.hasUI)
@@ -347,6 +352,21 @@ export async function registerYagni(pi, deps = {}) {
347
352
  while (mcpApprovalNotices.length > 0) {
348
353
  ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
354
  }
355
+ // one-shot permission-rule config warnings (bad files,
356
+ // never-consulted tools). One consolidated line, never a wall.
357
+ if (!rulesNoticesFlushed) {
358
+ rulesNoticesFlushed = true;
359
+ const parts = [];
360
+ if (loadedRules.diagnostics.warnings.length > 0) {
361
+ parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
362
+ }
363
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
364
+ parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
365
+ }
366
+ if (parts.length > 0) {
367
+ ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
368
+ }
369
+ }
350
370
  }
351
371
  catch {
352
372
  // A notice must never break a turn.
@@ -408,6 +428,25 @@ export async function registerYagni(pi, deps = {}) {
408
428
  // the startup load is the trust boundary; live reload was reviewed and
409
429
  // rejected as a same-session self-authorization path, PR #1698).
410
430
  const sessionGrants = evalMode ? [] : loadGrants();
431
+ // settings-based permission rules, loaded once at startup (same
432
+ // trust-boundary posture as grants: no mid-session reload). User config
433
+ // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
434
+ // project allow rules are trust-gated at evaluation time, deny/ask always.
435
+ const loadedRules = evalMode
436
+ ? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
437
+ : loadPermissionRules({ cwd: process.cwd() });
438
+ for (const w of loadedRules.diagnostics.warnings) {
439
+ logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
440
+ }
441
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
442
+ logEvent({
443
+ source: "permission-rules",
444
+ level: "warn",
445
+ event: "never_consulted_tools",
446
+ fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
447
+ });
448
+ }
449
+ const rulesStateHome = codeStateHome(null, env);
411
450
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
412
451
  // YAG-506: load user-configurable lifecycle hooks config and create the
413
452
  // hook runner for the permission gate. Skipped in eval mode.
@@ -420,6 +459,27 @@ export async function registerYagni(pi, deps = {}) {
420
459
  guardianState,
421
460
  guardianLimits,
422
461
  guardianTier,
462
+ ...(loadedRules.rules.length > 0
463
+ ? {
464
+ permissionRules: loadedRules.rules,
465
+ rulesUserStateHome: rulesStateHome,
466
+ rulesProjectRoot: process.cwd(),
467
+ onRuleVerdict: (ev) => {
468
+ logEvent({
469
+ source: "permission-rules",
470
+ level: "debug",
471
+ event: `rule_${ev.verdict}`,
472
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
473
+ fields: {
474
+ tool: ev.toolName,
475
+ via: ev.matchedVia,
476
+ source: ev.rule.source,
477
+ rule: ev.rule.raw,
478
+ },
479
+ });
480
+ },
481
+ }
482
+ : {}),
423
483
  guardianDisabled,
424
484
  childUsage,
425
485
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
@@ -471,6 +531,45 @@ export async function registerYagni(pi, deps = {}) {
471
531
  if (!evalMode)
472
532
  appendGrant(grant);
473
533
  },
534
+ // persist a user-level allow rule (Guardian ask dialog's third
535
+ // option). Atomic write, never overwrites other keys, fail-soft.
536
+ persistUserRule: (ruleString) => {
537
+ if (evalMode)
538
+ return;
539
+ try {
540
+ const userPath = join(rulesStateHome, "config.json");
541
+ let parsed = {};
542
+ if (existsSync(userPath)) {
543
+ parsed = JSON.parse(readFileSync(userPath, "utf-8"));
544
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
545
+ return;
546
+ }
547
+ const perms = (parsed.permissions ?? {});
548
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
549
+ if (!allow.includes(ruleString))
550
+ allow.push(ruleString);
551
+ perms.allow = allow;
552
+ parsed.permissions = perms;
553
+ const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
554
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
555
+ renameSync(tmp, userPath);
556
+ logEvent({
557
+ source: "permission-rules",
558
+ level: "info",
559
+ event: "user_rule_saved",
560
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
561
+ fields: { rule: ruleString },
562
+ });
563
+ }
564
+ catch (err) {
565
+ logEvent({
566
+ source: "permission-rules",
567
+ level: "warn",
568
+ event: "user_rule_save_failed",
569
+ fields: { message: err instanceof Error ? err.message : "unknown" },
570
+ });
571
+ }
572
+ },
474
573
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
475
574
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
476
575
  // metadata, no command content; "raw" → adds client-REDACTED command and
@@ -94,6 +94,33 @@ export interface GateDecision {
94
94
  */
95
95
  classifyJustification?: string;
96
96
  }
97
+ /**
98
+ * What the hard floors say about an ALLOW-rule verdict — the three invariants
99
+ * that outrank any user/project permission rule. Extracted from the inline
100
+ * allow path so the gate reads linearly and the floors are directly testable.
101
+ * PURE — no I/O, no session state:
102
+ *
103
+ * "allow" no floor applies — the rule's allow short-circuits everything
104
+ * "block" the exec-policy forbidden band holds despite the allow rule
105
+ * "confirm" alwaysConfirmTools keeps its fresh-consent contract
106
+ * "hold" plan mode's no-mutation contract outranks the allow rule —
107
+ * fall through to the normal plan-mode gate
108
+ *
109
+ * Order is deliberate: plan-mode first (its outcome routes to the EXISTING
110
+ * plan-mode machinery, not a bespoke block); then the terminal forbidden-band
111
+ * block; then the confirm deferral.
112
+ */
113
+ export type AllowRuleFloorOutcome = {
114
+ kind: "allow";
115
+ } | {
116
+ kind: "block";
117
+ reason: string;
118
+ } | {
119
+ kind: "confirm";
120
+ } | {
121
+ kind: "hold";
122
+ };
123
+ export declare function allowRuleFloorVerdict(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): AllowRuleFloorOutcome;
97
124
  /**
98
125
  * Pure permission decision for one tool call under a mode + policy. Auto allows
99
126
  * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
@@ -187,12 +214,38 @@ export interface RegisterPermissionDeps {
187
214
  /** Persist a new grant (fire-and-forget; the in-memory list is updated
188
215
  * either way). index.ts wires approvedPrefixes.appendGrant. */
189
216
  persistGrant?: (grant: ApprovedPrefixGrant) => void;
217
+ /**
218
+ * persist a user-level permission rule string (e.g.
219
+ * `Bash(git push:*)`) into ~/.yagni-code/config.json permissions.allow.
220
+ * Wired by index.ts; offered as a third option on Guardian ask dialogs.
221
+ * Fail-soft: the in-session approval applies even if the write fails.
222
+ */
223
+ persistUserRule?: (ruleString: string) => void;
190
224
  /**
191
225
  * Called (fire-and-forget) at every terminal prompt-band outcome with the
192
226
  * rich storage event (raw command — the wiring layer redacts/hashes).
193
227
  * Fail-soft; never blocks.
194
228
  */
195
229
  onGuardianEvent?: (event: GuardianGateEvent) => void;
230
+ /**
231
+ * settings-based permission rules (user + project config.json).
232
+ * When present, evaluated at the TOP of the tool_call handler, before
233
+ * hooks: deny → ask → allow, first match wins. Deny/ask are final; allow
234
+ * short-circuits the Guardian but can never lift the exec-policy forbidden
235
+ * band or the alwaysConfirmTools contract (both re-checked below).
236
+ */
237
+ permissionRules?: readonly import("../permissionRules/loadConfig.js").PermissionRule[];
238
+ /** ~/.yagni-code — the user `/`-anchor base for path rules. */
239
+ rulesUserStateHome?: string;
240
+ /** Project root for project-source `/`-anchored path rules; null outside a repo. */
241
+ rulesProjectRoot?: string | null;
242
+ /** Overrides ~ expansion for path rules (tests). */
243
+ rulesHomeDir?: string;
244
+ /** Called (fire-and-soft) after every rule verdict for debug logging. */
245
+ onRuleVerdict?: (event: import("../permissionRules/engine.js").RuleEvaluation & {
246
+ toolName: string;
247
+ cwd: string;
248
+ }) => void;
196
249
  /**
197
250
  * User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
198
251
  * hooks run before decideGate and can short-circuit (allow/deny/ask),
@@ -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
@@ -300,6 +348,8 @@ export function registerPermissionGate(pi, deps = {}) {
300
348
  deps.modeHolder?.onSet((m) => {
301
349
  if (m !== mode)
302
350
  approvedCommands.clear();
351
+ if (m !== mode)
352
+ ruleAskApprovals.clear();
303
353
  mode = m;
304
354
  });
305
355
  // The session bless store is created lazily on the first tool_call (it needs
@@ -349,6 +399,23 @@ export function registerPermissionGate(pi, deps = {}) {
349
399
  // LRU-capped, cleared on every /mode transition.
350
400
  const APPROVED_CACHE_MAX = 50;
351
401
  const approvedCommands = new Map();
402
+ // Session cache for ASK-RULE approvals (the rules analog of the exact-command
403
+ // approval cache above): a user "yes" on a permission-rule ask covers an
404
+ // identical later (tool, rule, input) call for the rest of the session, so a
405
+ // retrying model cannot re-prompt the same question in a loop. Keyed by
406
+ // tool + rule raw + the primary input param; cleared with the other caches
407
+ // on every /mode transition (mode changes re-ask — the safe direction).
408
+ const ruleAskApprovals = new Map();
409
+ const ruleAskKey = (toolName, ruleRaw, params) => {
410
+ const primary = typeof params.command === "string"
411
+ ? params.command
412
+ : typeof params.path === "string"
413
+ ? params.path
414
+ : typeof params.url === "string"
415
+ ? params.url
416
+ : JSON.stringify(params);
417
+ return `${toolName}\u0000${ruleRaw}\u0000${primary}`;
418
+ };
352
419
  const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
353
420
  const rememberApproved = (cwd, command) => {
354
421
  const key = cacheKey(cwd, command);
@@ -419,6 +486,39 @@ export function registerPermissionGate(pi, deps = {}) {
419
486
  return "no";
420
487
  return ctx.signal?.aborted ? "aborted" : "dismissed";
421
488
  };
489
+ /**
490
+ * variant: the Guardian ask dialog with an optional third option
491
+ * (persist a user-level permission rule). Same semantics as askUser.
492
+ */
493
+ const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel) => {
494
+ if (ctx.signal?.aborted)
495
+ return "aborted";
496
+ const options = [
497
+ ASK_YES,
498
+ ...(rememberLabel ? [rememberLabel] : []),
499
+ ...(ruleLabel ? [ruleLabel] : []),
500
+ ASK_NO,
501
+ ];
502
+ let choice;
503
+ try {
504
+ choice = await ctx.ui.select(title, options, {
505
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
506
+ timeout: ASK_TIMEOUT_MS,
507
+ });
508
+ }
509
+ catch {
510
+ choice = undefined;
511
+ }
512
+ if (choice === ASK_YES)
513
+ return "yes";
514
+ if (rememberLabel !== null && choice === rememberLabel)
515
+ return "remember";
516
+ if (ruleLabel !== null && choice === ruleLabel)
517
+ return "rule";
518
+ if (choice === ASK_NO)
519
+ return "no";
520
+ return ctx.signal?.aborted ? "aborted" : "dismissed";
521
+ };
422
522
  const buildAskTitle = (command, rationale, riskLevel) => {
423
523
  const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
424
524
  return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
@@ -427,8 +527,121 @@ export function registerPermissionGate(pi, deps = {}) {
427
527
  // Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
428
528
  // of the closure variable would disagree with the decision already made.
429
529
  const modeAtEntry = mode;
530
+ // set when an allow-rule verdict hits an alwaysConfirmTools tool
531
+ // (Floor 2) — forces the confirm flow below instead of short-circuiting.
532
+ let ruleAskConfirm = false;
430
533
  try {
431
534
  const input = event.input ?? {};
535
+ // settings permission rules run BEFORE hooks (Claude Code
536
+ // semantics: a deny rule blocks even when a hook would allow). Order
537
+ // deny → ask → allow; the verdict is final for deny/ask, and allow
538
+ // short-circuits everything below EXCEPT the two hard floors.
539
+ const rulesDeps = deps.permissionRules;
540
+ // The guard + evaluation share ONE try: a poisoned rules array can
541
+ // throw at the `.length` guard just as easily as inside evaluateRules,
542
+ // and both are engine errors — both must log engine_error and degrade
543
+ // to the normal gate, never escape to the outer fail-open catch.
544
+ let ruleVerdict = null;
545
+ try {
546
+ if (rulesDeps && rulesDeps.length > 0) {
547
+ const cwd = ctx?.cwd ?? ".";
548
+ ruleVerdict = evaluateRules(rulesDeps, {
549
+ toolName: event.toolName,
550
+ params: input,
551
+ cwd,
552
+ isProjectTrusted: (() => { try {
553
+ return ctx?.isProjectTrusted() ?? true;
554
+ }
555
+ catch {
556
+ return true;
557
+ } })(),
558
+ userStateHome: deps.rulesUserStateHome ?? cwd,
559
+ projectRoot: deps.rulesProjectRoot ?? null,
560
+ ...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
561
+ });
562
+ }
563
+ }
564
+ catch (err) {
565
+ // Fail-soft: a rule-engine error never blocks or allows — but it
566
+ // must not be SILENT: for a would-be deny this degrades to the
567
+ // normal gate (likely an allow), so the trail needs the failure.
568
+ // Error class only — never the thrown message (it can carry
569
+ // command content) or the user content.
570
+ logEvent({
571
+ source: "permission-rules",
572
+ level: "warn",
573
+ event: "engine_error",
574
+ fields: {
575
+ tool: event.toolName,
576
+ error: err instanceof Error ? err.constructor.name : typeof err,
577
+ },
578
+ });
579
+ }
580
+ if (ruleVerdict) {
581
+ try {
582
+ deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
583
+ }
584
+ catch { /* logging must never affect the gate */ }
585
+ if (ruleVerdict.verdict === "deny") {
586
+ const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
587
+ return {
588
+ block: true,
589
+ 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.`,
590
+ };
591
+ }
592
+ if (ruleVerdict.verdict === "ask") {
593
+ // Ask is final: nothing below may auto-allow it. With a UI, the
594
+ // user arbitrates; headless (incl. /go children) fails closed.
595
+ if (!ctx?.hasUI) {
596
+ return {
597
+ block: true,
598
+ reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
599
+ };
600
+ }
601
+ if (ctx.signal?.aborted)
602
+ return { block: true };
603
+ // Session approval cache: an identical (tool, rule, input) "yes"
604
+ // earlier this session covers this call — a retrying model must
605
+ // not re-prompt the same question (dialog-storm guard).
606
+ const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
607
+ if (ruleAskApprovals.has(askKey))
608
+ return {};
609
+ const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
610
+ const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
611
+ if (choice === "yes") {
612
+ if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
613
+ ruleAskApprovals.clear();
614
+ ruleAskApprovals.set(askKey, true);
615
+ return {};
616
+ }
617
+ if (choice === "aborted")
618
+ return { block: true };
619
+ return {
620
+ block: true,
621
+ reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
622
+ };
623
+ }
624
+ // verdict === "allow": the floors decide whether the allow
625
+ // short-circuits. Linear by construction — allowRuleFloorVerdict
626
+ // owns the three invariants (plan-mode hold, forbidden band,
627
+ // alwaysConfirmTools confirm) and is unit-tested directly.
628
+ const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
629
+ if (floor.kind === "block") {
630
+ return { block: true, reason: floor.reason };
631
+ }
632
+ if (floor.kind === "confirm") {
633
+ // Defer to the existing confirm flow via the ruleAskConfirm flag
634
+ // (set below) — alwaysConfirmTools keeps its fresh-consent contract.
635
+ ruleAskConfirm = true;
636
+ }
637
+ else if (floor.kind === "hold") {
638
+ // Plan mode's no-mutation contract outranks the allow rule —
639
+ // fall through to the normal plan-mode gate below.
640
+ }
641
+ else {
642
+ return {};
643
+ }
644
+ }
432
645
  // YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
433
646
  // (allow/deny/ask) or fall through to the normal gate logic. The result
434
647
  // is cached in preToolUseResult so the "ask" check below does NOT
@@ -681,12 +894,22 @@ export function registerPermissionGate(pi, deps = {}) {
681
894
  }
682
895
  }
683
896
  // Offer "don't ask again" only when the grant would actually
684
- // cover this command (grant-time validation).
897
+ // cover this command (grant-time validation). adds a
898
+ // third option: persist a user-level permission rule (survives
899
+ // across repos, unlike the repo-scoped grant) — offered only when
900
+ // a grantCandidate also exists (the same prefix discipline; the
901
+ // rule is the same pattern in settings form).
685
902
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
903
+ const ruleCandidate = grantCandidate
904
+ ? `Bash(${grantCandidate.pattern.join(" ")}:*)`
905
+ : null;
686
906
  const rememberLabel = grantCandidate
687
907
  ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
688
908
  : null;
689
- const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
909
+ const ruleLabel = grantCandidate && deps.persistUserRule
910
+ ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in my user settings`
911
+ : null;
912
+ const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
690
913
  if (resolution === "yes") {
691
914
  rememberApproved(cwd, command);
692
915
  emitGateEvent({
@@ -699,6 +922,44 @@ export function registerPermissionGate(pi, deps = {}) {
699
922
  });
700
923
  return {};
701
924
  }
925
+ if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
926
+ let persisted = false;
927
+ try {
928
+ deps.persistUserRule(ruleCandidate);
929
+ persisted = true;
930
+ // Also covers this session like a grant would:
931
+ rememberApproved(cwd, command);
932
+ }
933
+ catch (err) {
934
+ // Fail-soft: the in-memory approval still applies THIS
935
+ // session. But the user just made an explicit durable choice
936
+ // in the dialog — a silent failure would leave them believing
937
+ // a rule exists that will not survive restart. Log the
938
+ // failure (rule string only, it is user-configured text, plus
939
+ // error class — never the thrown message) and tell the user.
940
+ logEvent({
941
+ source: "permission-rules",
942
+ level: "warn",
943
+ event: "user_rule_save_failed",
944
+ fields: {
945
+ rule: ruleCandidate,
946
+ error: err instanceof Error ? err.constructor.name : typeof err,
947
+ },
948
+ });
949
+ if (ctx?.hasUI) {
950
+ ctx.ui.notify(`Could not save the permission rule to your settings — it applies to this session only.`, "warning");
951
+ }
952
+ }
953
+ emitGateEvent({
954
+ ...eventBase,
955
+ outcome: persisted ? "ask_approved_remembered" : "ask_approved",
956
+ riskLevel: verdict.riskLevel,
957
+ rationale: verdict.rationale,
958
+ durationMs,
959
+ consulted: true,
960
+ });
961
+ return {};
962
+ }
702
963
  if (resolution === "remember" && grantCandidate) {
703
964
  const grantRecord = {
704
965
  ...grantCandidate,
@@ -823,6 +1084,11 @@ export function registerPermissionGate(pi, deps = {}) {
823
1084
  if (preToolUseResult?.decision === "ask") {
824
1085
  decision = { block: false, confirm: true };
825
1086
  }
1087
+ // an allow-rule verdict on an alwaysConfirmTools tool forced
1088
+ // the confirm flow (Floor 2 above) — same treatment as a hook "ask".
1089
+ if (ruleAskConfirm) {
1090
+ decision = { block: false, confirm: true };
1091
+ }
826
1092
  if (decision.confirm) {
827
1093
  // YAG-506: PermissionRequest hooks run before the confirm dialog.
828
1094
  if (hookRunner) {
@@ -937,6 +1203,7 @@ export function registerPermissionGate(pi, deps = {}) {
937
1203
  // A mode change is a trust-posture change: session ask-approvals do
938
1204
  // not carry across it (grants persist but are suppressed in review).
939
1205
  approvedCommands.clear();
1206
+ ruleAskApprovals.clear();
940
1207
  }
941
1208
  mode = arg;
942
1209
  paintMode(ctx);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Curated bash command → file-operation table.
3
+ *
4
+ * The bridge that makes Read/Edit deny rules bite on bash commands: for a
5
+ * curated set of commands, extract the file paths they touch, classify each
6
+ * touch as read or write, and run those paths through the same path-rule
7
+ * engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
8
+ * caught by a `Read(.env)`/`Edit(.env)` deny.
9
+ *
10
+ * Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
11
+ * COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
12
+ * posture as Claude Code):
13
+ * - unknown flags on commands where flags can change path meaning
14
+ * (mv/cp --target-directory) → the command is NOT auto-analyzed; it
15
+ * degrades to the caller's "ask" path, never silently allowed.
16
+ * - glob arguments in write position → not analyzed (write globs bypass
17
+ * checks in Claude Code; we degrade to ask).
18
+ * - anything unextractable → no opinion; caller decides.
19
+ */
20
+ export type FileOperation = "read" | "write";
21
+ /** A command's opinion: extracted (op, paths) or needs-ask. */
22
+ export type BashFileArgsResult = {
23
+ kind: "analyzed";
24
+ operation: FileOperation;
25
+ paths: string[];
26
+ } | {
27
+ kind: "unknown";
28
+ };
29
+ /** Commands whose file paths we can extract confidently. */
30
+ export declare const COMMAND_OPERATION: Record<string, FileOperation>;
31
+ /**
32
+ * Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
33
+ * opinion — the caller degrades to ask for deny-evaluation purposes (never
34
+ * to silent allow).
35
+ */
36
+ export declare function analyzeSubcommand(subcommand: string): BashFileArgsResult;
37
+ /** Split a full command into subcommand strings (reuses shellRules' splitter). */
38
+ export { splitSubcommands } from "./shellRules.js";
39
+ //# sourceMappingURL=bashFileArgs.d.ts.map