@gotgenes/pi-permission-system 3.9.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,42 @@ 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
+
28
+ ## [3.10.0](https://github.com/gotgenes/pi-permission-system/compare/v3.9.0...v3.10.0) (2026-05-04)
29
+
30
+
31
+ ### Features
32
+
33
+ * migrate tool_call external_directory to SessionRules ([42c2bd9](https://github.com/gotgenes/pi-permission-system/commit/42c2bd91dbc35c6e4343133fb907f43a6a2550bf))
34
+ * remove SessionApprovalCache ([9d5a5be](https://github.com/gotgenes/pi-permission-system/commit/9d5a5be8251491a66b2826183ca22cbd5a232374))
35
+ * replace SessionApprovalCache with SessionRules in runtime ([4cec9c5](https://github.com/gotgenes/pi-permission-system/commit/4cec9c553779afa8a5fb62bf2ffbd35a43af3e23))
36
+
37
+
38
+ ### Documentation
39
+
40
+ * plan replace SessionApprovalCache with session Ruleset ([#57](https://github.com/gotgenes/pi-permission-system/issues/57)) ([ed1cefe](https://github.com/gotgenes/pi-permission-system/commit/ed1cefec2fd81542084460eb02cd3706b7093c07))
41
+ * **retro:** add retro notes for issue [#56](https://github.com/gotgenes/pi-permission-system/issues/56) ([f97f65c](https://github.com/gotgenes/pi-permission-system/commit/f97f65c448bd907866042bf9804378f441ae7c36))
42
+ * update session approval references ([#57](https://github.com/gotgenes/pi-permission-system/issues/57)) ([40e5e89](https://github.com/gotgenes/pi-permission-system/commit/40e5e89bf29b404b36fedaa48c896391d30574f6))
43
+
8
44
  ## [3.9.0](https://github.com/gotgenes/pi-permission-system/compare/v3.8.0...v3.9.0) (2026-05-03)
9
45
 
10
46
 
package/README.md CHANGED
@@ -529,7 +529,7 @@ This makes it easy to verify which files the extension actually loaded:
529
529
  index.ts → Root Pi entrypoint shim
530
530
  src/
531
531
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
532
- ├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
532
+ ├── session-rules.ts → Ephemeral session-scoped approval rules (Ruleset-based, external-directory access)
533
533
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
534
534
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
535
535
  ├── config-reporter.ts → Resolved config path reporting for diagnostic logs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.9.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,
@@ -76,6 +76,6 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
76
76
  deps.runtime.activeSkillEntries = [];
77
77
  deps.runtime.lastActiveToolsCacheKey = null;
78
78
  deps.runtime.lastPromptStateCacheKey = null;
79
- deps.runtime.sessionApprovalCache.clear();
79
+ deps.runtime.sessionRules.clear();
80
80
  deps.stopForwardedPermissionPolling();
81
81
  }
@@ -29,7 +29,7 @@ import {
29
29
  formatUnknownToolReason,
30
30
  formatUserDeniedReason,
31
31
  } from "../permission-prompts";
32
- import { deriveApprovalPrefix } from "../session-approval-cache";
32
+ import { deriveApprovalPattern } from "../session-rules";
33
33
  import { findSkillPathMatch } from "../skill-prompt-sanitizer";
34
34
  import { getPermissionLogContext } from "../tool-input-preview";
35
35
  import {
@@ -169,12 +169,14 @@ export async function handleToolCall(
169
169
  externalDirectoryPath,
170
170
  ctx.cwd,
171
171
  );
172
- const sessionPrefix = deps.runtime.sessionApprovalCache.findMatchingPrefix(
172
+ const extCheck = deps.runtime.permissionManager.checkPermission(
173
173
  "external_directory",
174
- normalizedExtPath,
174
+ { path: normalizedExtPath },
175
+ agentName ?? undefined,
176
+ deps.runtime.sessionRules.getRuleset(),
175
177
  );
176
178
 
177
- if (sessionPrefix) {
179
+ if (extCheck.source === "session") {
178
180
  deps.runtime.writeReviewLog("permission_request.session_approved", {
179
181
  source: "tool_call",
180
182
  toolCallId: (event as { toolCallId: string }).toolCallId,
@@ -182,16 +184,10 @@ export async function handleToolCall(
182
184
  agentName,
183
185
  path: externalDirectoryPath,
184
186
  resolution: "session_approved",
185
- sessionApprovalPrefix: sessionPrefix,
187
+ sessionApprovalPattern: extCheck.matchedPattern,
186
188
  });
187
189
  // Fall through to normal permission check
188
190
  } else {
189
- const extCheck = deps.runtime.permissionManager.checkPermission(
190
- "external_directory",
191
- {},
192
- agentName ?? undefined,
193
- );
194
-
195
191
  let extDirDecision: PermissionPromptDecision | null = null;
196
192
  const extDirMessage = formatExternalDirectoryAskPrompt(
197
193
  toolName,
@@ -245,8 +241,8 @@ export async function handleToolCall(
245
241
  }
246
242
 
247
243
  if (extDirDecision?.state === "approved_for_session") {
248
- const prefix = deriveApprovalPrefix(normalizedExtPath);
249
- deps.runtime.sessionApprovalCache.approve("external_directory", prefix);
244
+ const pattern = deriveApprovalPattern(normalizedExtPath);
245
+ deps.runtime.sessionRules.approve("external_directory", pattern);
250
246
  }
251
247
  }
252
248
  // Fall through to normal permission check
@@ -261,9 +257,15 @@ export async function handleToolCall(
261
257
  ctx.cwd,
262
258
  );
263
259
  if (externalPaths.length > 0) {
260
+ const bashSessionRules = deps.runtime.sessionRules.getRuleset();
264
261
  const uncoveredPaths = externalPaths.filter(
265
262
  (p) =>
266
- !deps.runtime.sessionApprovalCache.has("external_directory", p),
263
+ deps.runtime.permissionManager.checkPermission(
264
+ "external_directory",
265
+ { path: p },
266
+ agentName ?? undefined,
267
+ bashSessionRules,
268
+ ).source !== "session",
267
269
  );
268
270
 
269
271
  if (uncoveredPaths.length === 0) {
@@ -278,6 +280,7 @@ export async function handleToolCall(
278
280
  });
279
281
  // Fall through to normal bash permission check
280
282
  } else {
283
+ // Get the config-level policy (no path → no session check).
281
284
  const extCheck = deps.runtime.permissionManager.checkPermission(
282
285
  "external_directory",
283
286
  {},
@@ -339,11 +342,8 @@ export async function handleToolCall(
339
342
 
340
343
  if (bashExtDecision?.state === "approved_for_session") {
341
344
  for (const extPath of uncoveredPaths) {
342
- const prefix = deriveApprovalPrefix(extPath);
343
- deps.runtime.sessionApprovalCache.approve(
344
- "external_directory",
345
- prefix,
346
- );
345
+ const pattern = deriveApprovalPattern(extPath);
346
+ deps.runtime.sessionRules.approve("external_directory", pattern);
347
347
  }
348
348
  }
349
349
  }
@@ -357,6 +357,7 @@ export async function handleToolCall(
357
357
  toolName,
358
358
  input,
359
359
  agentName ?? undefined,
360
+ deps.runtime.sessionRules.getRuleset(),
360
361
  );
361
362
  const permissionLogContext = getPermissionLogContext(
362
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). */
package/src/runtime.ts CHANGED
@@ -44,7 +44,7 @@ import { createPermissionSystemLogger } from "./logging";
44
44
  import type { PermissionPromptDecision } from "./permission-dialog";
45
45
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
46
46
  import { PermissionManager } from "./permission-manager";
47
- import { SessionApprovalCache } from "./session-approval-cache";
47
+ import { SessionRules } from "./session-rules";
48
48
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
49
49
  import { syncPermissionSystemStatus } from "./status";
50
50
  import { isSubagentExecutionContext } from "./subagent-context";
@@ -78,7 +78,7 @@ export interface ExtensionRuntime {
78
78
  lastActiveToolsCacheKey: string | null;
79
79
  lastPromptStateCacheKey: string | null;
80
80
  lastConfigWarning: string | null;
81
- readonly sessionApprovalCache: SessionApprovalCache;
81
+ readonly sessionRules: SessionRules;
82
82
 
83
83
  // ── Forwarding polling state ───────────────────────────────────────────
84
84
  permissionForwardingContext: ExtensionContext | null;
@@ -432,7 +432,7 @@ export function createExtensionRuntime(options?: {
432
432
  lastActiveToolsCacheKey: null,
433
433
  lastPromptStateCacheKey: null,
434
434
  lastConfigWarning: null,
435
- sessionApprovalCache: new SessionApprovalCache(),
435
+ sessionRules: new SessionRules(),
436
436
  permissionForwardingContext: null,
437
437
  permissionForwardingTimer: null,
438
438
  isProcessingForwardedRequests: false,
@@ -0,0 +1,54 @@
1
+ import { dirname, sep } from "node:path";
2
+
3
+ import type { Ruleset } from "./rule";
4
+
5
+ /**
6
+ * Ephemeral in-memory store of session-scoped permission approvals.
7
+ *
8
+ * Each approval is stored as a `Rule` with `action: "allow"`, making the
9
+ * ruleset directly usable with `evaluate()` — no custom matching engine needed.
10
+ *
11
+ * Cleared on session_shutdown — never persisted to disk.
12
+ */
13
+ export class SessionRules {
14
+ private rules: Ruleset = [];
15
+
16
+ /** Record a wildcard pattern as approved for the given surface. */
17
+ approve(surface: string, pattern: string): void {
18
+ this.rules.push({ surface, pattern, action: "allow", layer: "session" });
19
+ }
20
+
21
+ /** Return a defensive copy of the current session ruleset. */
22
+ getRuleset(): Ruleset {
23
+ return [...this.rules];
24
+ }
25
+
26
+ /** Remove all session approvals. */
27
+ clear(): void {
28
+ this.rules = [];
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Derive the wildcard glob pattern to approve from a normalized path.
34
+ *
35
+ * Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
36
+ * all paths under the approved directory — identical semantics to the former
37
+ * `SessionApprovalCache` prefix matching, using the unified wildcard engine.
38
+ *
39
+ * For paths that already end with a separator (directories), the separator
40
+ * is treated as the directory boundary and `*` is appended directly.
41
+ */
42
+ export function deriveApprovalPattern(normalizedPath: string): string {
43
+ // If the path already ends with a separator, it's a directory — glob its contents.
44
+ if (normalizedPath.endsWith(sep)) {
45
+ return `${normalizedPath}*`;
46
+ }
47
+ const dir = dirname(normalizedPath);
48
+ if (dir === normalizedPath) {
49
+ // Root path — dirname('/') === '/'
50
+ return `${dir}*`;
51
+ }
52
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
53
+ return `${prefix}*`;
54
+ }