@gotgenes/pi-permission-system 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.11.0](https://github.com/gotgenes/pi-permission-system/compare/v3.10.0...v3.11.0) (2026-05-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * add "session" source to PermissionCheckResult ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([039ae26](https://github.com/gotgenes/pi-permission-system/commit/039ae26c5756ae51d97da27aee53a7ca8b55fa91))
14
+ * add synthesize module (synthesizeDefaults, synthesizeOverrides, synthesizeBaseline, composeRuleset) ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([e0469b2](https://github.com/gotgenes/pi-permission-system/commit/e0469b26a49cdb2858b1d441615f5511d5c271d5))
15
+ * compose ruleset with synthesized defaults and overrides ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([dac47c1](https://github.com/gotgenes/pi-permission-system/commit/dac47c1ce4fe6b98ee657e2cb7dca46c1dbf5c89))
16
+ * remove separate session pre-check from tool_call ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([d156e9b](https://github.com/gotgenes/pi-permission-system/commit/d156e9be08c053ebe62bb5f198d3f0ee411c6728))
17
+ * tag session rules with layer metadata ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([2346f95](https://github.com/gotgenes/pi-permission-system/commit/2346f957e0e552846870576c129f1fc8a620ef0d))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * drop backward-compat language for config format ([#66](https://github.com/gotgenes/pi-permission-system/issues/66)) ([fabde91](https://github.com/gotgenes/pi-permission-system/commit/fabde91e86afc08ac8ccf11c211a701d01a4d91a))
23
+ * plan generalized session approvals and update target architecture ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([23a019a](https://github.com/gotgenes/pi-permission-system/commit/23a019af3ef381f89a145ab8d435c2447b538894))
24
+ * plan synthesize defaults into ruleset and unify evaluate path ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([295fd10](https://github.com/gotgenes/pi-permission-system/commit/295fd10d77827b547ad14c50d73709c3f45cbebf))
25
+ * **retro:** add retro notes for issue [#57](https://github.com/gotgenes/pi-permission-system/issues/57) ([cffb3a5](https://github.com/gotgenes/pi-permission-system/commit/cffb3a56ee8059015f89bbe7ba2922eee45d3dda))
26
+ * update architecture for synthesized defaults and deprecate getSurfaceDefault() ([#65](https://github.com/gotgenes/pi-permission-system/issues/65)) ([e703809](https://github.com/gotgenes/pi-permission-system/commit/e7038090ce9e8b42e6f4c4423bcf8c03fb1aa3ea))
27
+
8
28
  ## [3.10.0](https://github.com/gotgenes/pi-permission-system/compare/v3.9.0...v3.10.0) (2026-05-04)
9
29
 
10
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
package/src/defaults.ts CHANGED
@@ -26,6 +26,12 @@ const SURFACE_TO_DEFAULT_KEY: Record<string, keyof PermissionDefaultPolicy> = {
26
26
  * - "bash", "mcp", "skill" → dedicated defaultPolicy key
27
27
  * - special-key surfaces (e.g. "external_directory") → defaults.special
28
28
  * - everything else (tool-name surfaces) → defaults.tools
29
+ *
30
+ * @deprecated Default policy is now synthesized into the composed ruleset via
31
+ * `synthesizeDefaults()` in `src/synthesize.ts`. Call-sites that previously
32
+ * consulted this function can evaluate against the composed ruleset instead.
33
+ * This function is kept for backward compatibility and will be removed in a
34
+ * future cleanup.
29
35
  */
30
36
  export function getSurfaceDefault(
31
37
  surface: string,
@@ -29,7 +29,6 @@ import {
29
29
  formatUnknownToolReason,
30
30
  formatUserDeniedReason,
31
31
  } from "../permission-prompts";
32
- import { evaluate } from "../rule";
33
32
  import { deriveApprovalPattern } from "../session-rules";
34
33
  import { findSkillPathMatch } from "../skill-prompt-sanitizer";
35
34
  import { getPermissionLogContext } from "../tool-input-preview";
@@ -170,15 +169,14 @@ export async function handleToolCall(
170
169
  externalDirectoryPath,
171
170
  ctx.cwd,
172
171
  );
173
- const sessionRuleset = deps.runtime.sessionRules.getRuleset();
174
- const sessionMatch = evaluate(
172
+ const extCheck = deps.runtime.permissionManager.checkPermission(
175
173
  "external_directory",
176
- normalizedExtPath,
177
- sessionRuleset,
174
+ { path: normalizedExtPath },
175
+ agentName ?? undefined,
176
+ deps.runtime.sessionRules.getRuleset(),
178
177
  );
179
- const isSessionApproved = sessionRuleset.includes(sessionMatch);
180
178
 
181
- if (isSessionApproved) {
179
+ if (extCheck.source === "session") {
182
180
  deps.runtime.writeReviewLog("permission_request.session_approved", {
183
181
  source: "tool_call",
184
182
  toolCallId: (event as { toolCallId: string }).toolCallId,
@@ -186,16 +184,10 @@ export async function handleToolCall(
186
184
  agentName,
187
185
  path: externalDirectoryPath,
188
186
  resolution: "session_approved",
189
- sessionApprovalPattern: sessionMatch.pattern,
187
+ sessionApprovalPattern: extCheck.matchedPattern,
190
188
  });
191
189
  // Fall through to normal permission check
192
190
  } else {
193
- const extCheck = deps.runtime.permissionManager.checkPermission(
194
- "external_directory",
195
- {},
196
- agentName ?? undefined,
197
- );
198
-
199
191
  let extDirDecision: PermissionPromptDecision | null = null;
200
192
  const extDirMessage = formatExternalDirectoryAskPrompt(
201
193
  toolName,
@@ -265,12 +257,15 @@ export async function handleToolCall(
265
257
  ctx.cwd,
266
258
  );
267
259
  if (externalPaths.length > 0) {
268
- const bashSessionRuleset = deps.runtime.sessionRules.getRuleset();
260
+ const bashSessionRules = deps.runtime.sessionRules.getRuleset();
269
261
  const uncoveredPaths = externalPaths.filter(
270
262
  (p) =>
271
- !bashSessionRuleset.includes(
272
- evaluate("external_directory", p, bashSessionRuleset),
273
- ),
263
+ deps.runtime.permissionManager.checkPermission(
264
+ "external_directory",
265
+ { path: p },
266
+ agentName ?? undefined,
267
+ bashSessionRules,
268
+ ).source !== "session",
274
269
  );
275
270
 
276
271
  if (uncoveredPaths.length === 0) {
@@ -285,6 +280,7 @@ export async function handleToolCall(
285
280
  });
286
281
  // Fall through to normal bash permission check
287
282
  } else {
283
+ // Get the config-level policy (no path → no session check).
288
284
  const extCheck = deps.runtime.permissionManager.checkPermission(
289
285
  "external_directory",
290
286
  {},
@@ -361,6 +357,7 @@ export async function handleToolCall(
361
357
  toolName,
362
358
  input,
363
359
  agentName ?? undefined,
360
+ deps.runtime.sessionRules.getRuleset(),
364
361
  );
365
362
  const permissionLogContext = getPermissionLogContext(
366
363
  check,
@@ -15,6 +15,12 @@ import { mergeDefaults } from "./defaults";
15
15
  import { normalizeConfig } from "./normalize";
16
16
  import type { Ruleset } from "./rule";
17
17
  import { evaluate } from "./rule";
18
+ import {
19
+ composeRuleset,
20
+ synthesizeBaseline,
21
+ synthesizeDefaults,
22
+ synthesizeOverrides,
23
+ } from "./synthesize";
18
24
  import type {
19
25
  PermissionCheckResult,
20
26
  PermissionDefaultPolicy,
@@ -42,14 +48,6 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
42
48
  "ls",
43
49
  ]);
44
50
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
45
- const MCP_BASELINE_TARGETS = new Set([
46
- "mcp_status",
47
- "mcp_list",
48
- "mcp_search",
49
- "mcp_describe",
50
- "mcp_connect",
51
- ]);
52
-
53
51
  const DEFAULT_POLICY: PermissionDefaultPolicy = {
54
52
  tools: "ask",
55
53
  bash: "ask",
@@ -364,13 +362,11 @@ export interface ResolvedPolicyPaths {
364
362
  }
365
363
 
366
364
  type ResolvedPermissions = {
367
- rules: Ruleset;
368
- defaults: PermissionDefaultPolicy;
369
- /** tools.bash fallback: tools.bash || defaults.bash */
370
- bashDefault: PermissionState;
371
- /** tools.mcp fallback (undefined = no explicit tools.mcp) */
372
- mcpToolLevel: PermissionState | undefined;
373
- hasAnyMcpAllowRule: boolean;
365
+ /**
366
+ * Fully composed ruleset: synthesized defaults → baseline → overrides → config.
367
+ * Session rules are appended at call-time inside checkPermission().
368
+ */
369
+ composedRules: Ruleset;
374
370
  };
375
371
 
376
372
  type FileCacheEntry<TValue> = {
@@ -599,16 +595,20 @@ export class PermissionManager {
599
595
  const agentConfig = this.loadScopeConfig(agentName);
600
596
  const projectAgentConfig = this.loadProjectScopeConfig(agentName);
601
597
 
602
- // Normalize each scope into a flat Ruleset and concatenate.
603
- // Later scopes appear last higher priority via last-match-wins.
604
- const rules: Ruleset = [
605
- ...normalizeConfig(globalConfig),
606
- ...normalizeConfig(projectConfig),
607
- ...normalizeConfig(agentConfig),
608
- ...normalizeConfig(projectAgentConfig),
598
+ // Tag config rules with layer "config" so checkPermission() can derive
599
+ // the result source and matchedPattern without positional arithmetic.
600
+ const tagConfig = (r: import("./rule").Rule) => ({
601
+ ...r,
602
+ layer: "config" as const,
603
+ });
604
+ const configRules: Ruleset = [
605
+ ...normalizeConfig(globalConfig).map(tagConfig),
606
+ ...normalizeConfig(projectConfig).map(tagConfig),
607
+ ...normalizeConfig(agentConfig).map(tagConfig),
608
+ ...normalizeConfig(projectAgentConfig).map(tagConfig),
609
609
  ];
610
610
 
611
- // Merge defaults separately (shallow spread, same precedence order).
611
+ // Merge defaultPolicy across scopes (shallow spread, same precedence).
612
612
  const defaults = mergeDefaults(
613
613
  globalConfig.defaultPolicy,
614
614
  projectConfig.defaultPolicy,
@@ -616,32 +616,26 @@ export class PermissionManager {
616
616
  projectAgentConfig.defaultPolicy,
617
617
  );
618
618
 
619
- // tools.bash / tools.mcp are fallback overrides, not catch-all rules.
620
- // Extract with last-scope-wins precedence.
621
- const toolBash =
622
- projectAgentConfig.tools?.bash ??
623
- agentConfig.tools?.bash ??
624
- projectConfig.tools?.bash ??
625
- globalConfig.tools?.bash;
626
- const bashDefault = toolBash ?? defaults.bash;
627
-
628
- const mcpToolLevel =
629
- projectAgentConfig.tools?.mcp ??
630
- agentConfig.tools?.mcp ??
631
- projectConfig.tools?.mcp ??
632
- globalConfig.tools?.mcp;
633
-
634
- const hasAnyMcpAllowRule = rules.some(
635
- (r) => r.surface === "mcp" && r.action === "allow",
619
+ // tools.bash / tools.mcp overrides: per-scope, lowest-priority first so
620
+ // that later scopes win via last-match-wins in synthesizeOverrides.
621
+ const overrideScopes = [
622
+ { bash: globalConfig.tools?.bash, mcp: globalConfig.tools?.mcp },
623
+ { bash: projectConfig.tools?.bash, mcp: projectConfig.tools?.mcp },
624
+ { bash: agentConfig.tools?.bash, mcp: agentConfig.tools?.mcp },
625
+ {
626
+ bash: projectAgentConfig.tools?.bash,
627
+ mcp: projectAgentConfig.tools?.mcp,
628
+ },
629
+ ];
630
+
631
+ const composedRules = composeRuleset(
632
+ synthesizeDefaults(defaults),
633
+ synthesizeBaseline(configRules),
634
+ synthesizeOverrides(overrideScopes),
635
+ configRules,
636
636
  );
637
637
 
638
- const value: ResolvedPermissions = {
639
- rules,
640
- defaults,
641
- bashDefault,
642
- mcpToolLevel,
643
- hasAnyMcpAllowRule,
644
- };
638
+ const value: ResolvedPermissions = { composedRules };
645
639
 
646
640
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
647
641
  return value;
@@ -679,47 +673,69 @@ export class PermissionManager {
679
673
  * @returns The permission state for the tool at the tool level
680
674
  */
681
675
  getToolPermission(toolName: string, agentName?: string): PermissionState {
682
- const { rules, defaults, bashDefault, mcpToolLevel } =
683
- this.resolvePermissions(agentName);
676
+ const { composedRules } = this.resolvePermissions(agentName);
684
677
  const normalizedToolName = toolName.trim();
685
678
 
686
- // Special keys use the special default.
679
+ // Special surfaces: evaluate via the "special" surface used by config rules
680
+ // and the synthesized special default.
687
681
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
688
- const rule = evaluate("special", normalizedToolName, rules);
689
- if (rules.includes(rule)) return rule.action;
690
- return defaults.special;
682
+ return evaluate("special", normalizedToolName, composedRules).action;
691
683
  }
692
684
 
693
- // Bash and MCP have dedicated fallback overrides from tools.bash / tools.mcp.
694
- if (normalizedToolName === "bash") return bashDefault;
695
- if (normalizedToolName === "mcp") return mcpToolLevel ?? defaults.mcp;
696
-
697
- // Skills use the skills default.
698
- if (normalizedToolName === "skill") return defaults.skills;
685
+ // For bash, mcp, skill: evaluate with "*" value against composedRules.
686
+ // The synthesized override/default rules are catch-alls (pattern "*") that
687
+ // respond correctly to a "*" lookup without matching specific patterns.
688
+ if (normalizedToolName === "bash") {
689
+ return evaluate("bash", "*", composedRules).action;
690
+ }
691
+ if (normalizedToolName === "mcp") {
692
+ return evaluate("mcp", "*", composedRules).action;
693
+ }
694
+ if (normalizedToolName === "skill") {
695
+ return evaluate("skill", "*", composedRules).action;
696
+ }
699
697
 
700
- // Tool-name surfaces: check rules, fall back to tools default.
701
- const rule = evaluate(normalizedToolName, "*", rules);
702
- if (rules.includes(rule)) return rule.action;
703
- return defaults.tools;
698
+ // Tool-name surfaces (read, write, etc. and extension tools).
699
+ return evaluate(normalizedToolName, "*", composedRules).action;
704
700
  }
705
701
 
706
702
  checkPermission(
707
703
  toolName: string,
708
704
  input: unknown,
709
705
  agentName?: string,
706
+ sessionRules?: Ruleset,
710
707
  ): PermissionCheckResult {
711
- const { rules, defaults, bashDefault, mcpToolLevel, hasAnyMcpAllowRule } =
712
- this.resolvePermissions(agentName);
708
+ const { composedRules } = this.resolvePermissions(agentName);
713
709
  const normalizedToolName = toolName.trim();
714
710
 
715
711
  // --- Special surfaces (external_directory) ---
712
+ // Config/default rules use surface "special"; session rules use surface
713
+ // "external_directory" with path patterns. Check each independently.
716
714
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
717
- const rule = evaluate("special", normalizedToolName, rules);
718
- const explicit = rules.includes(rule);
715
+ // Session check: match by specific normalized path.
716
+ const record = toRecord(input);
717
+ const pathValue = typeof record.path === "string" ? record.path : null;
718
+ if (pathValue && sessionRules && sessionRules.length > 0) {
719
+ const sessionRule = evaluate(
720
+ "external_directory",
721
+ pathValue,
722
+ sessionRules,
723
+ );
724
+ if (sessionRules.includes(sessionRule)) {
725
+ return {
726
+ toolName,
727
+ state: "allow",
728
+ matchedPattern: sessionRule.pattern,
729
+ source: "session",
730
+ };
731
+ }
732
+ }
733
+ // Config/default check.
734
+ const rule = evaluate("special", normalizedToolName, composedRules);
719
735
  return {
720
736
  toolName,
721
- state: explicit ? rule.action : defaults.special,
722
- matchedPattern: explicit ? rule.pattern : undefined,
737
+ state: rule.action,
738
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
723
739
  source: "special",
724
740
  };
725
741
  }
@@ -727,19 +743,12 @@ export class PermissionManager {
727
743
  // --- Skills ---
728
744
  if (normalizedToolName === "skill") {
729
745
  const skillName = toRecord(input).name;
730
- if (typeof skillName === "string") {
731
- const rule = evaluate("skill", skillName, rules);
732
- const explicit = rules.includes(rule);
733
- return {
734
- toolName,
735
- state: explicit ? rule.action : defaults.skills,
736
- matchedPattern: explicit ? rule.pattern : undefined,
737
- source: explicit ? "skill" : "skill",
738
- };
739
- }
746
+ const lookupValue = typeof skillName === "string" ? skillName : "*";
747
+ const rule = evaluate("skill", lookupValue, composedRules);
740
748
  return {
741
749
  toolName,
742
- state: defaults.skills,
750
+ state: rule.action,
751
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
743
752
  source: "skill",
744
753
  };
745
754
  }
@@ -748,13 +757,12 @@ export class PermissionManager {
748
757
  if (normalizedToolName === "bash") {
749
758
  const record = toRecord(input);
750
759
  const command = typeof record.command === "string" ? record.command : "";
751
- const rule = evaluate("bash", command, rules);
752
- const explicit = rules.includes(rule);
760
+ const rule = evaluate("bash", command, composedRules);
753
761
  return {
754
762
  toolName,
755
- state: explicit ? rule.action : bashDefault,
763
+ state: rule.action,
756
764
  command,
757
- matchedPattern: explicit ? rule.pattern : undefined,
765
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
758
766
  source: "bash",
759
767
  };
760
768
  }
@@ -770,67 +778,39 @@ export class PermissionManager {
770
778
  ];
771
779
  const fallbackTarget = mcpTargets[0] || "mcp";
772
780
 
773
- // Try each candidate target against the merged rules.
781
+ // Try each candidate target. Stop on the first non-default match
782
+ // (config, override, or baseline rule). Default rules are catch-alls
783
+ // that would fire on every target — skip them so more-specific targets
784
+ // can be checked first.
774
785
  for (const target of mcpTargets) {
775
- const rule = evaluate("mcp", target, rules);
776
- if (rules.includes(rule)) {
786
+ const rule = evaluate("mcp", target, composedRules);
787
+ if (rule.layer !== "default") {
777
788
  return {
778
789
  toolName,
779
790
  state: rule.action,
780
- matchedPattern: rule.pattern,
791
+ matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
781
792
  target,
782
- source: "mcp",
783
- };
784
- }
785
- }
786
-
787
- // tools.mcp fallback (e.g. tools: { mcp: "allow" }).
788
- if (mcpToolLevel) {
789
- return {
790
- toolName,
791
- state: mcpToolLevel,
792
- target: fallbackTarget,
793
- source: "tool",
794
- };
795
- }
796
-
797
- // Baseline auto-allow: if this is a metadata operation and at least one
798
- // MCP rule allows something (or the default is allow), auto-allow.
799
- const baselineTarget = mcpTargets.find((target) =>
800
- MCP_BASELINE_TARGETS.has(target),
801
- );
802
- if (baselineTarget) {
803
- if (hasAnyMcpAllowRule || defaults.mcp === "allow") {
804
- return {
805
- toolName,
806
- state: "allow",
807
- target: baselineTarget,
808
- source: "mcp",
793
+ source: rule.layer === "override" ? "tool" : "mcp",
809
794
  };
810
795
  }
811
796
  }
812
797
 
798
+ // All targets matched only the synthesized default.
799
+ const defaultRule = evaluate("mcp", fallbackTarget, composedRules);
813
800
  return {
814
801
  toolName,
815
- state: defaults.mcp,
802
+ state: defaultRule.action,
816
803
  target: fallbackTarget,
817
804
  source: "default",
818
805
  };
819
806
  }
820
807
 
821
808
  // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
822
- const rule = evaluate(normalizedToolName, "*", rules);
823
- const explicit = rules.includes(rule);
809
+ const rule = evaluate(normalizedToolName, "*", composedRules);
824
810
 
811
+ // Built-in tools always report source "tool" regardless of which layer
812
+ // supplied the decision (matches current behaviour).
825
813
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
826
- return {
827
- toolName,
828
- state: explicit ? rule.action : defaults.tools,
829
- source: "tool",
830
- };
831
- }
832
-
833
- if (explicit) {
834
814
  return {
835
815
  toolName,
836
816
  state: rule.action,
@@ -838,10 +818,12 @@ export class PermissionManager {
838
818
  };
839
819
  }
840
820
 
821
+ // Extension tools: "default" layer → source "default"; any explicit rule
822
+ // (config or override) → source "tool".
841
823
  return {
842
824
  toolName,
843
- state: defaults.tools,
844
- source: "default",
825
+ state: rule.action,
826
+ source: rule.layer === "default" ? "default" : "tool",
845
827
  };
846
828
  }
847
829
  }
package/src/rule.ts CHANGED
@@ -9,6 +9,11 @@ export interface Rule {
9
9
  pattern: string;
10
10
  /** The permission decision. */
11
11
  action: PermissionState;
12
+ /**
13
+ * Origin layer — used to derive PermissionCheckResult.source after evaluation.
14
+ * Not used by evaluate(); purely informational metadata.
15
+ */
16
+ layer?: "default" | "override" | "baseline" | "config" | "session";
12
17
  }
13
18
 
14
19
  /** An ordered list of rules. Later rules take priority (last-match-wins). */
@@ -15,7 +15,7 @@ export class SessionRules {
15
15
 
16
16
  /** Record a wildcard pattern as approved for the given surface. */
17
17
  approve(surface: string, pattern: string): void {
18
- this.rules.push({ surface, pattern, action: "allow" });
18
+ this.rules.push({ surface, pattern, action: "allow", layer: "session" });
19
19
  }
20
20
 
21
21
  /** Return a defensive copy of the current session ruleset. */
@@ -0,0 +1,152 @@
1
+ import type { Rule, Ruleset } from "./rule";
2
+ import type { PermissionDefaultPolicy, PermissionState } from "./types";
3
+
4
+ /**
5
+ * Convert the merged `defaultPolicy` into catch-all rules at the lowest
6
+ * priority position in the composed ruleset.
7
+ *
8
+ * Produces 5 rules:
9
+ * 1. `{ surface: "*", pattern: "*" }` — universal fallback (tools default)
10
+ * 2. `{ surface: "bash", pattern: "*" }` — bash default
11
+ * 3. `{ surface: "mcp", pattern: "*" }` — mcp default
12
+ * 4. `{ surface: "skill", pattern: "*" }` — skill default
13
+ * 5. `{ surface: "special", pattern: "*" }` — special / external_directory default
14
+ *
15
+ * All rules carry `layer: "default"`. `evaluate()` ignores this field.
16
+ * The specific per-surface rules come after the universal rule so they win
17
+ * via last-match-wins when a surface-specific default differs from the
18
+ * tools default.
19
+ */
20
+ export function synthesizeDefaults(defaults: PermissionDefaultPolicy): Ruleset {
21
+ return [
22
+ { surface: "*", pattern: "*", action: defaults.tools, layer: "default" },
23
+ { surface: "bash", pattern: "*", action: defaults.bash, layer: "default" },
24
+ { surface: "mcp", pattern: "*", action: defaults.mcp, layer: "default" },
25
+ {
26
+ surface: "skill",
27
+ pattern: "*",
28
+ action: defaults.skills,
29
+ layer: "default",
30
+ },
31
+ {
32
+ surface: "special",
33
+ pattern: "*",
34
+ action: defaults.special,
35
+ layer: "default",
36
+ },
37
+ ];
38
+ }
39
+
40
+ /**
41
+ * Per-scope override shape — the relevant keys extracted from `tools`.
42
+ * `undefined` means the scope did not configure that override.
43
+ */
44
+ export interface OverrideScope {
45
+ bash?: PermissionState;
46
+ mcp?: PermissionState;
47
+ }
48
+
49
+ /**
50
+ * Convert per-scope `tools.bash` / `tools.mcp` entries into catch-all rules
51
+ * placed between defaults and config rules.
52
+ *
53
+ * Scopes must be passed in precedence order (lowest first, e.g. global →
54
+ * project → agent → project-agent). Later scopes produce later rules and
55
+ * therefore win via last-match-wins — identical to the current last-scope-wins
56
+ * logic for `bashDefault` / `mcpToolLevel`.
57
+ *
58
+ * Only scopes that explicitly define a value contribute a rule; `undefined`
59
+ * entries are skipped.
60
+ *
61
+ * All rules carry `layer: "override"`.
62
+ */
63
+ export function synthesizeOverrides(
64
+ scopes: ReadonlyArray<OverrideScope>,
65
+ ): Ruleset {
66
+ const rules: Rule[] = [];
67
+ for (const scope of scopes) {
68
+ if (scope.bash !== undefined) {
69
+ rules.push({
70
+ surface: "bash",
71
+ pattern: "*",
72
+ action: scope.bash,
73
+ layer: "override",
74
+ });
75
+ }
76
+ if (scope.mcp !== undefined) {
77
+ rules.push({
78
+ surface: "mcp",
79
+ pattern: "*",
80
+ action: scope.mcp,
81
+ layer: "override",
82
+ });
83
+ }
84
+ }
85
+ return rules;
86
+ }
87
+
88
+ /**
89
+ * MCP metadata operation targets that are auto-allowed when any explicit MCP
90
+ * allow rule exists in the config layer.
91
+ */
92
+ const MCP_BASELINE_TARGETS: readonly string[] = [
93
+ "mcp_status",
94
+ "mcp_list",
95
+ "mcp_search",
96
+ "mcp_describe",
97
+ "mcp_connect",
98
+ ];
99
+
100
+ /**
101
+ * Conditionally synthesize MCP baseline auto-allow rules.
102
+ *
103
+ * Emits allow rules for the 5 MCP metadata targets only when `configRules`
104
+ * contains at least one `surface: "mcp", action: "allow"` rule. This replicates
105
+ * the `hasAnyMcpAllowRule` heuristic as actual rules.
106
+ *
107
+ * When `defaults.mcp === "allow"`, the synthesized default catch-all already
108
+ * covers all MCP targets — no separate baseline rules are needed (and this
109
+ * function is not called in that case).
110
+ *
111
+ * Baseline rules are placed BEFORE override rules in the composed array so
112
+ * that `tools.mcp` overrides beat baseline (preserving current behaviour where
113
+ * an explicit `tools.mcp` value always terminates the MCP decision).
114
+ *
115
+ * All rules carry `layer: "baseline"`.
116
+ */
117
+ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
118
+ const hasAnyMcpAllow = configRules.some(
119
+ (r) => r.surface === "mcp" && r.action === "allow",
120
+ );
121
+ if (!hasAnyMcpAllow) {
122
+ return [];
123
+ }
124
+ return MCP_BASELINE_TARGETS.map(
125
+ (target): Rule => ({
126
+ surface: "mcp",
127
+ pattern: target,
128
+ action: "allow",
129
+ layer: "baseline",
130
+ }),
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Concatenate all rule layers into a single flat ruleset.
136
+ *
137
+ * Priority order (lowest → highest, i.e. earlier index → later index):
138
+ * defaults → baseline → overrides → config
139
+ *
140
+ * Session rules are NOT included here — they are appended at call-time inside
141
+ * `checkPermission()` so that the cached composed ruleset remains session-agnostic.
142
+ *
143
+ * `evaluate()` scans from the end, so later layers override earlier ones.
144
+ */
145
+ export function composeRuleset(
146
+ defaults: Ruleset,
147
+ baseline: Ruleset,
148
+ overrides: Ruleset,
149
+ config: Ruleset,
150
+ ): Ruleset {
151
+ return [...defaults, ...baseline, ...overrides, ...config];
152
+ }
package/src/types.ts CHANGED
@@ -41,5 +41,5 @@ export interface PermissionCheckResult {
41
41
  matchedPattern?: string;
42
42
  command?: string;
43
43
  target?: string;
44
- source: "tool" | "bash" | "mcp" | "skill" | "special" | "default";
44
+ source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
45
45
  }
@@ -46,7 +46,11 @@ import {
46
46
  checkRequestedToolRegistration,
47
47
  getToolNameFromValue,
48
48
  } from "../src/tool-registry";
49
- import type { PermissionState, ScopeConfig } from "../src/types";
49
+ import type {
50
+ PermissionCheckResult,
51
+ PermissionState,
52
+ ScopeConfig,
53
+ } from "../src/types";
50
54
  import {
51
55
  canResolveAskPermissionRequest,
52
56
  shouldAutoApprovePermissionState,
@@ -2969,3 +2973,193 @@ test("session approval: regular 'Yes' does not create session approval", async (
2969
2973
  rmSync(rootDir, { recursive: true, force: true });
2970
2974
  }
2971
2975
  });
2976
+
2977
+ // ---------------------------------------------------------------------------
2978
+ // Session-aware checkPermission() integration
2979
+ // ---------------------------------------------------------------------------
2980
+
2981
+ test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
2982
+ const { manager, cleanup } = createManager({
2983
+ defaultPolicy: {
2984
+ tools: "allow",
2985
+ bash: "allow",
2986
+ mcp: "allow",
2987
+ skills: "allow",
2988
+ special: "ask",
2989
+ },
2990
+ });
2991
+
2992
+ try {
2993
+ const sessionRules = [
2994
+ {
2995
+ surface: "external_directory",
2996
+ pattern: "/other/project/*",
2997
+ action: "allow" as const,
2998
+ layer: "session" as const,
2999
+ },
3000
+ ];
3001
+
3002
+ const result = manager.checkPermission(
3003
+ "external_directory",
3004
+ { path: "/other/project/src/foo.ts" },
3005
+ undefined,
3006
+ sessionRules,
3007
+ );
3008
+ assert.equal(result.state, "allow");
3009
+ assert.equal(result.source, "session");
3010
+ assert.equal(result.matchedPattern, "/other/project/*");
3011
+ } finally {
3012
+ cleanup();
3013
+ }
3014
+ });
3015
+
3016
+ test("checkPermission falls back to config policy when session rules do not cover the path", () => {
3017
+ const { manager, cleanup } = createManager({
3018
+ defaultPolicy: {
3019
+ tools: "allow",
3020
+ bash: "allow",
3021
+ mcp: "allow",
3022
+ skills: "allow",
3023
+ special: "deny",
3024
+ },
3025
+ });
3026
+
3027
+ try {
3028
+ const sessionRules = [
3029
+ {
3030
+ surface: "external_directory",
3031
+ pattern: "/other/project/*",
3032
+ action: "allow" as const,
3033
+ layer: "session" as const,
3034
+ },
3035
+ ];
3036
+
3037
+ // Path NOT under /other/project/ — session rules don't match.
3038
+ const result = manager.checkPermission(
3039
+ "external_directory",
3040
+ { path: "/completely/different/path.ts" },
3041
+ undefined,
3042
+ sessionRules,
3043
+ );
3044
+ assert.equal(result.state, "deny");
3045
+ assert.equal(result.source, "special");
3046
+ } finally {
3047
+ cleanup();
3048
+ }
3049
+ });
3050
+
3051
+ test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
3052
+ const { manager, cleanup } = createManager({
3053
+ defaultPolicy: {
3054
+ tools: "allow",
3055
+ bash: "allow",
3056
+ mcp: "allow",
3057
+ skills: "allow",
3058
+ special: "ask",
3059
+ },
3060
+ special: { external_directory: "deny" },
3061
+ });
3062
+
3063
+ try {
3064
+ const withEmpty = manager.checkPermission(
3065
+ "external_directory",
3066
+ { path: "/other/project/foo.ts" },
3067
+ undefined,
3068
+ [],
3069
+ );
3070
+ const withoutArg = manager.checkPermission("external_directory", {
3071
+ path: "/other/project/foo.ts",
3072
+ });
3073
+ const expected: PermissionCheckResult = {
3074
+ toolName: "external_directory",
3075
+ state: "deny",
3076
+ matchedPattern: "external_directory",
3077
+ source: "special",
3078
+ };
3079
+ assert.deepEqual(withEmpty, expected);
3080
+ assert.deepEqual(withoutArg, expected);
3081
+ } finally {
3082
+ cleanup();
3083
+ }
3084
+ });
3085
+
3086
+ test("session rules for one surface do not affect checks on other surfaces", () => {
3087
+ const { manager, cleanup } = createManager({
3088
+ defaultPolicy: {
3089
+ tools: "ask",
3090
+ bash: "ask",
3091
+ mcp: "ask",
3092
+ skills: "ask",
3093
+ special: "ask",
3094
+ },
3095
+ });
3096
+
3097
+ try {
3098
+ const sessionRules = [
3099
+ {
3100
+ surface: "external_directory",
3101
+ pattern: "/other/project/*",
3102
+ action: "allow" as const,
3103
+ layer: "session" as const,
3104
+ },
3105
+ ];
3106
+
3107
+ // Bash check — session rules should not affect bash decisions.
3108
+ const bashResult = manager.checkPermission(
3109
+ "bash",
3110
+ { command: "git status" },
3111
+ undefined,
3112
+ sessionRules,
3113
+ );
3114
+ assert.equal(bashResult.state, "ask");
3115
+ assert.equal(bashResult.source, "bash");
3116
+
3117
+ // MCP check — session rules should not affect MCP decisions.
3118
+ const mcpResult = manager.checkPermission(
3119
+ "mcp",
3120
+ { tool: "exa:search" },
3121
+ undefined,
3122
+ sessionRules,
3123
+ );
3124
+ assert.equal(mcpResult.state, "ask");
3125
+ assert.equal(mcpResult.source, "default");
3126
+ } finally {
3127
+ cleanup();
3128
+ }
3129
+ });
3130
+
3131
+ test("session rules override config deny for external_directory", () => {
3132
+ const { manager, cleanup } = createManager({
3133
+ defaultPolicy: {
3134
+ tools: "allow",
3135
+ bash: "allow",
3136
+ mcp: "allow",
3137
+ skills: "allow",
3138
+ special: "ask",
3139
+ },
3140
+ special: { external_directory: "deny" },
3141
+ });
3142
+
3143
+ try {
3144
+ const sessionRules = [
3145
+ {
3146
+ surface: "external_directory",
3147
+ pattern: "/other/project/*",
3148
+ action: "allow" as const,
3149
+ layer: "session" as const,
3150
+ },
3151
+ ];
3152
+
3153
+ // Session approval overrides config deny for the covered path.
3154
+ const result = manager.checkPermission(
3155
+ "external_directory",
3156
+ { path: "/other/project/src/foo.ts" },
3157
+ undefined,
3158
+ sessionRules,
3159
+ );
3160
+ assert.equal(result.state, "allow");
3161
+ assert.equal(result.source, "session");
3162
+ } finally {
3163
+ cleanup();
3164
+ }
3165
+ });
@@ -137,4 +137,35 @@ describe("evaluate", () => {
137
137
  expect(result.pattern).toBe("git status");
138
138
  expect(result.action).toBe("ask");
139
139
  });
140
+
141
+ test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => {
142
+ const withLayer: Rule = {
143
+ surface: "bash",
144
+ pattern: "git *",
145
+ action: "allow",
146
+ layer: "config",
147
+ };
148
+ const withoutLayer: Rule = {
149
+ surface: "bash",
150
+ pattern: "git *",
151
+ action: "allow",
152
+ };
153
+ const withDefault: Rule = {
154
+ surface: "bash",
155
+ pattern: "*",
156
+ action: "ask",
157
+ layer: "default",
158
+ };
159
+ // Both rules with and without layer field produce the same match.
160
+ expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
161
+ expect(evaluate("bash", "git status", [withoutLayer]).action).toBe("allow");
162
+ // Layer metadata does not affect last-match-wins ordering.
163
+ const ruleset: Rule[] = [withDefault, withLayer];
164
+ expect(evaluate("bash", "git status", ruleset)).toEqual(withLayer);
165
+ // A rule with layer: "default" still wins if it is last in the array.
166
+ const reversedRuleset: Rule[] = [withLayer, withDefault];
167
+ expect(evaluate("bash", "git status", reversedRuleset)).toEqual(
168
+ withDefault,
169
+ );
170
+ });
140
171
  });
@@ -20,6 +20,7 @@ describe("SessionRules", () => {
20
20
  surface: "external_directory",
21
21
  pattern: "/other/project/*",
22
22
  action: "allow",
23
+ layer: "session",
23
24
  },
24
25
  ]);
25
26
  });
@@ -0,0 +1,413 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { evaluate } from "../src/rule";
3
+ import {
4
+ composeRuleset,
5
+ synthesizeBaseline,
6
+ synthesizeDefaults,
7
+ synthesizeOverrides,
8
+ } from "../src/synthesize";
9
+ import type { PermissionDefaultPolicy } from "../src/types";
10
+
11
+ const ALL_ASK: PermissionDefaultPolicy = {
12
+ tools: "ask",
13
+ bash: "ask",
14
+ mcp: "ask",
15
+ skills: "ask",
16
+ special: "ask",
17
+ };
18
+
19
+ const ALL_ALLOW: PermissionDefaultPolicy = {
20
+ tools: "allow",
21
+ bash: "allow",
22
+ mcp: "allow",
23
+ skills: "allow",
24
+ special: "allow",
25
+ };
26
+
27
+ // ── synthesizeDefaults ─────────────────────────────────────────────────────
28
+
29
+ describe("synthesizeDefaults", () => {
30
+ test("emits 5 catch-all rules with layer 'default'", () => {
31
+ const rules = synthesizeDefaults(ALL_ASK);
32
+ expect(rules).toHaveLength(5);
33
+ for (const rule of rules) {
34
+ expect(rule.layer).toBe("default");
35
+ expect(rule.pattern).toBe("*");
36
+ }
37
+ });
38
+
39
+ test("emits universal catch-all for tools default", () => {
40
+ const rules = synthesizeDefaults(ALL_ASK);
41
+ const universal = rules.find((r) => r.surface === "*");
42
+ expect(universal).toEqual({
43
+ surface: "*",
44
+ pattern: "*",
45
+ action: "ask",
46
+ layer: "default",
47
+ });
48
+ });
49
+
50
+ test("emits per-surface catch-alls for bash, mcp, skill, special", () => {
51
+ const rules = synthesizeDefaults(ALL_ASK);
52
+ const surfaces = rules.map((r) => r.surface);
53
+ expect(surfaces).toContain("bash");
54
+ expect(surfaces).toContain("mcp");
55
+ expect(surfaces).toContain("skill");
56
+ expect(surfaces).toContain("special");
57
+ });
58
+
59
+ test("reflects non-ask actions correctly", () => {
60
+ const rules = synthesizeDefaults(ALL_ALLOW);
61
+ for (const rule of rules) {
62
+ expect(rule.action).toBe("allow");
63
+ }
64
+ });
65
+
66
+ test("mixed defaults produce correct per-surface actions", () => {
67
+ const mixed: PermissionDefaultPolicy = {
68
+ tools: "allow",
69
+ bash: "deny",
70
+ mcp: "ask",
71
+ skills: "allow",
72
+ special: "deny",
73
+ };
74
+ const rules = synthesizeDefaults(mixed);
75
+ const get = (surface: string) => rules.find((r) => r.surface === surface);
76
+ expect(get("*")?.action).toBe("allow"); // tools default
77
+ expect(get("bash")?.action).toBe("deny");
78
+ expect(get("mcp")?.action).toBe("ask");
79
+ expect(get("skill")?.action).toBe("allow");
80
+ expect(get("special")?.action).toBe("deny");
81
+ });
82
+
83
+ test("default rules catch any surface via the universal '*' entry", () => {
84
+ const rules = synthesizeDefaults(ALL_ASK);
85
+ // A brand-new surface "future_tool" should be caught by the universal rule.
86
+ const result = evaluate("future_tool", "*", rules);
87
+ expect(result.action).toBe("ask");
88
+ expect(result.layer).toBe("default");
89
+ });
90
+
91
+ test("specific surface default beats universal default (last-match-wins)", () => {
92
+ const mixed: PermissionDefaultPolicy = {
93
+ tools: "allow",
94
+ bash: "deny",
95
+ mcp: "ask",
96
+ skills: "ask",
97
+ special: "ask",
98
+ };
99
+ const rules = synthesizeDefaults(mixed);
100
+ // For bash, the specific bash rule (later in array) beats the universal rule.
101
+ const result = evaluate("bash", "git status", rules);
102
+ expect(result.action).toBe("deny");
103
+ expect(result.layer).toBe("default");
104
+ });
105
+ });
106
+
107
+ // ── synthesizeOverrides ────────────────────────────────────────────────────
108
+
109
+ describe("synthesizeOverrides", () => {
110
+ test("returns empty ruleset for empty input", () => {
111
+ expect(synthesizeOverrides([])).toEqual([]);
112
+ });
113
+
114
+ test("returns empty ruleset when no scope has overrides", () => {
115
+ expect(synthesizeOverrides([{}, {}, {}])).toEqual([]);
116
+ });
117
+
118
+ test("emits a bash override rule for each scope that defines tools.bash", () => {
119
+ const rules = synthesizeOverrides([{ bash: "allow" }]);
120
+ expect(rules).toEqual([
121
+ { surface: "bash", pattern: "*", action: "allow", layer: "override" },
122
+ ]);
123
+ });
124
+
125
+ test("emits an mcp override rule for each scope that defines tools.mcp", () => {
126
+ const rules = synthesizeOverrides([{ mcp: "deny" }]);
127
+ expect(rules).toEqual([
128
+ { surface: "mcp", pattern: "*", action: "deny", layer: "override" },
129
+ ]);
130
+ });
131
+
132
+ test("emits both bash and mcp override rules when both are defined in a scope", () => {
133
+ const rules = synthesizeOverrides([{ bash: "allow", mcp: "deny" }]);
134
+ expect(rules).toHaveLength(2);
135
+ const bash = rules.find((r) => r.surface === "bash");
136
+ const mcp = rules.find((r) => r.surface === "mcp");
137
+ expect(bash?.action).toBe("allow");
138
+ expect(mcp?.action).toBe("deny");
139
+ });
140
+
141
+ test("later scopes produce later rules (higher priority via last-match-wins)", () => {
142
+ const rules = synthesizeOverrides([{ bash: "deny" }, { bash: "allow" }]);
143
+ const result = evaluate("bash", "git status", rules);
144
+ expect(result.action).toBe("allow"); // later scope wins
145
+ });
146
+
147
+ test("skips undefined fields and emits nothing for them", () => {
148
+ const rules = synthesizeOverrides([
149
+ { bash: undefined, mcp: "allow" },
150
+ { bash: "deny", mcp: undefined },
151
+ ]);
152
+ const bashRules = rules.filter((r) => r.surface === "bash");
153
+ const mcpRules = rules.filter((r) => r.surface === "mcp");
154
+ expect(bashRules).toHaveLength(1);
155
+ expect(mcpRules).toHaveLength(1);
156
+ expect(bashRules[0].action).toBe("deny");
157
+ expect(mcpRules[0].action).toBe("allow");
158
+ });
159
+
160
+ test("override rules all have layer 'override'", () => {
161
+ const rules = synthesizeOverrides([
162
+ { bash: "allow", mcp: "deny" },
163
+ { bash: "deny" },
164
+ ]);
165
+ for (const rule of rules) {
166
+ expect(rule.layer).toBe("override");
167
+ }
168
+ });
169
+ });
170
+
171
+ // ── synthesizeBaseline ─────────────────────────────────────────────────────
172
+
173
+ describe("synthesizeBaseline", () => {
174
+ test("returns empty ruleset when config has no mcp allow rules", () => {
175
+ const configRules = [
176
+ {
177
+ surface: "mcp",
178
+ pattern: "*",
179
+ action: "deny" as const,
180
+ layer: "config" as const,
181
+ },
182
+ ];
183
+ expect(synthesizeBaseline(configRules)).toEqual([]);
184
+ });
185
+
186
+ test("returns empty ruleset for empty config rules", () => {
187
+ expect(synthesizeBaseline([])).toEqual([]);
188
+ });
189
+
190
+ test("synthesizes 5 baseline rules when at least one mcp allow config rule exists", () => {
191
+ const configRules = [
192
+ {
193
+ surface: "mcp",
194
+ pattern: "exa:*",
195
+ action: "allow" as const,
196
+ layer: "config" as const,
197
+ },
198
+ ];
199
+ const rules = synthesizeBaseline(configRules);
200
+ expect(rules).toHaveLength(5);
201
+ });
202
+
203
+ test("baseline rules all have layer 'baseline' and action 'allow'", () => {
204
+ const configRules = [
205
+ {
206
+ surface: "mcp",
207
+ pattern: "exa:*",
208
+ action: "allow" as const,
209
+ layer: "config" as const,
210
+ },
211
+ ];
212
+ const rules = synthesizeBaseline(configRules);
213
+ for (const rule of rules) {
214
+ expect(rule.layer).toBe("baseline");
215
+ expect(rule.action).toBe("allow");
216
+ expect(rule.surface).toBe("mcp");
217
+ }
218
+ });
219
+
220
+ test("baseline rules cover the 5 MCP metadata targets", () => {
221
+ const configRules = [
222
+ {
223
+ surface: "mcp",
224
+ pattern: "exa:*",
225
+ action: "allow" as const,
226
+ layer: "config" as const,
227
+ },
228
+ ];
229
+ const rules = synthesizeBaseline(configRules);
230
+ const patterns = rules.map((r) => r.pattern);
231
+ expect(patterns).toContain("mcp_status");
232
+ expect(patterns).toContain("mcp_list");
233
+ expect(patterns).toContain("mcp_search");
234
+ expect(patterns).toContain("mcp_describe");
235
+ expect(patterns).toContain("mcp_connect");
236
+ });
237
+
238
+ test("baseline is NOT synthesized when allow rule is on a non-mcp surface", () => {
239
+ const configRules = [
240
+ {
241
+ surface: "bash",
242
+ pattern: "git *",
243
+ action: "allow" as const,
244
+ layer: "config" as const,
245
+ },
246
+ ];
247
+ expect(synthesizeBaseline(configRules)).toEqual([]);
248
+ });
249
+
250
+ test("baseline is NOT synthesized when defaults.mcp === 'allow' but no config allow rules", () => {
251
+ // defaults.mcp === 'allow' is handled by the synthesized default catch-all, not baseline.
252
+ const configRules = [
253
+ {
254
+ surface: "mcp",
255
+ pattern: "*",
256
+ action: "deny" as const,
257
+ layer: "config" as const,
258
+ },
259
+ ];
260
+ expect(synthesizeBaseline(configRules)).toEqual([]);
261
+ });
262
+
263
+ test("baseline auto-allows mcp_status when an mcp allow rule exists", () => {
264
+ const configRules = [
265
+ {
266
+ surface: "mcp",
267
+ pattern: "exa:*",
268
+ action: "allow" as const,
269
+ layer: "config" as const,
270
+ },
271
+ ];
272
+ const rules = synthesizeBaseline(configRules);
273
+ const result = evaluate("mcp", "mcp_status", rules);
274
+ expect(result.action).toBe("allow");
275
+ expect(result.layer).toBe("baseline");
276
+ });
277
+ });
278
+
279
+ // ── composeRuleset ─────────────────────────────────────────────────────────
280
+
281
+ describe("composeRuleset", () => {
282
+ test("returns concatenation of all layers in order", () => {
283
+ const defaults = synthesizeDefaults(ALL_ASK);
284
+ const baseline = synthesizeBaseline([
285
+ { surface: "mcp", pattern: "exa:*", action: "allow", layer: "config" },
286
+ ]);
287
+ const overrides = synthesizeOverrides([{ bash: "allow" }]);
288
+ const config = [
289
+ { surface: "bash", pattern: "rm -rf *", action: "deny" as const },
290
+ ];
291
+ const composed = composeRuleset(defaults, baseline, overrides, config);
292
+ expect(composed.length).toBe(
293
+ defaults.length + baseline.length + overrides.length + config.length,
294
+ );
295
+ });
296
+
297
+ test("defaults come first (lowest priority), config comes last (highest priority)", () => {
298
+ const defaults = [
299
+ {
300
+ surface: "bash",
301
+ pattern: "*",
302
+ action: "ask" as const,
303
+ layer: "default" as const,
304
+ },
305
+ ];
306
+ const baseline: never[] = [];
307
+ const overrides = [
308
+ {
309
+ surface: "bash",
310
+ pattern: "*",
311
+ action: "allow" as const,
312
+ layer: "override" as const,
313
+ },
314
+ ];
315
+ const config = [
316
+ {
317
+ surface: "bash",
318
+ pattern: "*",
319
+ action: "deny" as const,
320
+ layer: "config" as const,
321
+ },
322
+ ];
323
+ const composed = composeRuleset(defaults, baseline, overrides, config);
324
+ // Last-match-wins: config is last → deny wins for any bash command.
325
+ const result = evaluate("bash", "echo hello", composed);
326
+ expect(result.action).toBe("deny");
327
+ expect(result.layer).toBe("config");
328
+ });
329
+
330
+ test("override beats default when no config rule exists", () => {
331
+ const defaults = [
332
+ {
333
+ surface: "bash",
334
+ pattern: "*",
335
+ action: "ask" as const,
336
+ layer: "default" as const,
337
+ },
338
+ ];
339
+ const overrides = [
340
+ {
341
+ surface: "bash",
342
+ pattern: "*",
343
+ action: "allow" as const,
344
+ layer: "override" as const,
345
+ },
346
+ ];
347
+ const composed = composeRuleset(defaults, [], overrides, []);
348
+ const result = evaluate("bash", "echo hello", composed);
349
+ expect(result.action).toBe("allow");
350
+ expect(result.layer).toBe("override");
351
+ });
352
+
353
+ test("baseline beats default but override beats baseline", () => {
354
+ const defaults = [
355
+ {
356
+ surface: "mcp",
357
+ pattern: "*",
358
+ action: "ask" as const,
359
+ layer: "default" as const,
360
+ },
361
+ ];
362
+ const baseline = [
363
+ {
364
+ surface: "mcp",
365
+ pattern: "mcp_status",
366
+ action: "allow" as const,
367
+ layer: "baseline" as const,
368
+ },
369
+ ];
370
+ const overrides = [
371
+ {
372
+ surface: "mcp",
373
+ pattern: "*",
374
+ action: "deny" as const,
375
+ layer: "override" as const,
376
+ },
377
+ ];
378
+ const composed = composeRuleset(defaults, baseline, overrides, []);
379
+ // override beats baseline for mcp_status
380
+ const result = evaluate("mcp", "mcp_status", composed);
381
+ expect(result.action).toBe("deny");
382
+ expect(result.layer).toBe("override");
383
+ });
384
+
385
+ test("config beats override for specific patterns", () => {
386
+ const overrides = [
387
+ {
388
+ surface: "mcp",
389
+ pattern: "*",
390
+ action: "deny" as const,
391
+ layer: "override" as const,
392
+ },
393
+ ];
394
+ const config = [
395
+ {
396
+ surface: "mcp",
397
+ pattern: "exa_web_search",
398
+ action: "allow" as const,
399
+ layer: "config" as const,
400
+ },
401
+ ];
402
+ const composed = composeRuleset([], [], overrides, config);
403
+ const result = evaluate("mcp", "exa_web_search", composed);
404
+ expect(result.action).toBe("allow");
405
+ expect(result.layer).toBe("config");
406
+ });
407
+
408
+ test("handles empty layers gracefully", () => {
409
+ const defaults = synthesizeDefaults(ALL_ASK);
410
+ const composed = composeRuleset(defaults, [], [], []);
411
+ expect(composed).toEqual(defaults);
412
+ });
413
+ });