@gotgenes/pi-permission-system 3.8.0 → 3.9.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.
@@ -2,7 +2,6 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
5
- import { BashFilter } from "./bash-filter";
6
5
  import {
7
6
  extractFrontmatter,
8
7
  getNonEmptyString,
@@ -12,22 +11,16 @@ import {
12
11
  } from "./common";
13
12
  import { loadUnifiedConfig, stripJsonComments } from "./config-loader";
14
13
  import { getGlobalConfigPath } from "./config-paths";
15
- import type { Rule, Ruleset } from "./rule";
14
+ import { mergeDefaults } from "./defaults";
15
+ import { normalizeConfig } from "./normalize";
16
+ import type { Ruleset } from "./rule";
16
17
  import { evaluate } from "./rule";
17
18
  import type {
18
- AgentPermissions,
19
- BashPermissions,
20
- GlobalPermissionConfig,
21
19
  PermissionCheckResult,
22
20
  PermissionDefaultPolicy,
23
21
  PermissionState,
22
+ ScopeConfig,
24
23
  } from "./types";
25
- import {
26
- type CompiledWildcardPattern,
27
- compileWildcardPatternEntries,
28
- findCompiledWildcardMatch,
29
- findCompiledWildcardMatchForNames,
30
- } from "./wildcard-matcher";
31
24
 
32
25
  function defaultGlobalConfigPath(): string {
33
26
  return getGlobalConfigPath(getAgentDir());
@@ -163,7 +156,7 @@ const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
163
156
  ]);
164
157
 
165
158
  export interface NormalizeResult {
166
- permissions: AgentPermissions;
159
+ permissions: ScopeConfig;
167
160
  configIssues: string[];
168
161
  }
169
162
 
@@ -172,7 +165,7 @@ export function normalizeRawPermission(raw: unknown): NormalizeResult {
172
165
  const configIssues: string[] = [];
173
166
  const normalizedTools = normalizePermissionRecord(record.tools);
174
167
 
175
- const normalized: AgentPermissions = {
168
+ const normalized: ScopeConfig = {
176
169
  defaultPolicy: normalizePartialPolicy(record.defaultPolicy),
177
170
  tools: normalizedTools,
178
171
  bash: normalizePermissionRecord(record.bash),
@@ -359,9 +352,6 @@ function createMcpPermissionTargets(
359
352
  return targets;
360
353
  }
361
354
 
362
- type CompiledPermissionPatterns =
363
- readonly CompiledWildcardPattern<PermissionState>[];
364
-
365
355
  export interface ResolvedPolicyPaths {
366
356
  globalConfigPath: string;
367
357
  globalConfigExists: boolean;
@@ -374,76 +364,15 @@ export interface ResolvedPolicyPaths {
374
364
  }
375
365
 
376
366
  type ResolvedPermissions = {
377
- globalConfig: GlobalPermissionConfig;
378
- agentConfig: AgentPermissions;
379
- merged: GlobalPermissionConfig;
380
- compiledBash: CompiledPermissionPatterns;
367
+ rules: Ruleset;
368
+ defaults: PermissionDefaultPolicy;
369
+ /** tools.bash fallback: tools.bash || defaults.bash */
381
370
  bashDefault: PermissionState;
382
- compiledSpecial: CompiledPermissionPatterns;
383
- compiledSkills: CompiledPermissionPatterns;
384
- compiledMcp: CompiledPermissionPatterns;
385
- bashFilter: BashFilter;
371
+ /** tools.mcp fallback (undefined = no explicit tools.mcp) */
372
+ mcpToolLevel: PermissionState | undefined;
373
+ hasAnyMcpAllowRule: boolean;
386
374
  };
387
375
 
388
- function compilePermissionPatternsFromSources(
389
- ...sources: Array<Record<string, PermissionState> | undefined>
390
- ): CompiledPermissionPatterns {
391
- const entries: Array<readonly [string, PermissionState]> = [];
392
-
393
- for (const source of sources) {
394
- if (!source) {
395
- continue;
396
- }
397
-
398
- for (const entry of Object.entries(source)) {
399
- entries.push(entry);
400
- }
401
- }
402
-
403
- if (entries.length === 0) {
404
- return [];
405
- }
406
-
407
- return compileWildcardPatternEntries(entries);
408
- }
409
-
410
- /**
411
- * Convert compiled wildcard patterns into a Ruleset for use with evaluate().
412
- * The returned Rule objects are the same references as the input; evaluate()
413
- * uses reference equality to distinguish an explicit match from the synthetic
414
- * default it returns when nothing matches.
415
- */
416
- function compiledToRuleset(
417
- surface: string,
418
- patterns: CompiledPermissionPatterns,
419
- ): Ruleset {
420
- return patterns.map(
421
- (p): Rule => ({ surface, pattern: p.pattern, action: p.state }),
422
- );
423
- }
424
-
425
- function findCompiledPermissionMatch(
426
- patterns: CompiledPermissionPatterns,
427
- name: string,
428
- ) {
429
- if (patterns.length === 0) {
430
- return null;
431
- }
432
-
433
- return findCompiledWildcardMatch(patterns, name);
434
- }
435
-
436
- function findCompiledPermissionMatchForNames(
437
- patterns: CompiledPermissionPatterns,
438
- names: readonly string[],
439
- ) {
440
- if (patterns.length === 0) {
441
- return null;
442
- }
443
-
444
- return findCompiledWildcardMatchForNames(patterns, names);
445
- }
446
-
447
376
  type FileCacheEntry<TValue> = {
448
377
  stamp: string;
449
378
  value: TValue;
@@ -464,17 +393,15 @@ export class PermissionManager {
464
393
  private readonly projectAgentsDir: string | null;
465
394
  private readonly globalMcpConfigPath: string;
466
395
  private readonly configuredMcpServerNamesOverride: readonly string[] | null;
467
- private globalConfigCache: FileCacheEntry<GlobalPermissionConfig> | null =
468
- null;
469
- private projectGlobalConfigCache: FileCacheEntry<AgentPermissions> | null =
470
- null;
396
+ private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
397
+ private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
471
398
  private readonly agentConfigCache = new Map<
472
399
  string,
473
- FileCacheEntry<AgentPermissions>
400
+ FileCacheEntry<ScopeConfig>
474
401
  >();
475
402
  private readonly projectAgentConfigCache = new Map<
476
403
  string,
477
- FileCacheEntry<AgentPermissions>
404
+ FileCacheEntry<ScopeConfig>
478
405
  >();
479
406
  private readonly resolvedPermissionsCache = new Map<
480
407
  string,
@@ -527,7 +454,7 @@ export class PermissionManager {
527
454
  return [...this.accumulatedConfigIssues];
528
455
  }
529
456
 
530
- private loadGlobalConfig(): GlobalPermissionConfig {
457
+ private loadGlobalConfig(): ScopeConfig {
531
458
  const stamp = getFileStamp(this.globalConfigPath);
532
459
  if (this.globalConfigCache?.stamp === stamp) {
533
460
  return this.globalConfigCache.value;
@@ -536,7 +463,7 @@ export class PermissionManager {
536
463
  const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
537
464
  this.accumulateConfigIssues(issues);
538
465
 
539
- const value: GlobalPermissionConfig = {
466
+ const value: ScopeConfig = {
540
467
  defaultPolicy: normalizePolicy(config.defaultPolicy),
541
468
  tools: config.tools || {},
542
469
  bash: config.bash || {},
@@ -549,7 +476,7 @@ export class PermissionManager {
549
476
  return value;
550
477
  }
551
478
 
552
- private loadProjectGlobalConfig(): AgentPermissions {
479
+ private loadProjectGlobalConfig(): ScopeConfig {
553
480
  if (!this.projectGlobalConfigPath) {
554
481
  return {};
555
482
  }
@@ -562,7 +489,7 @@ export class PermissionManager {
562
489
  const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
563
490
  this.accumulateConfigIssues(issues);
564
491
 
565
- const value: AgentPermissions = {
492
+ const value: ScopeConfig = {
566
493
  defaultPolicy: config.defaultPolicy,
567
494
  tools: config.tools,
568
495
  bash: config.bash,
@@ -575,11 +502,11 @@ export class PermissionManager {
575
502
  return value;
576
503
  }
577
504
 
578
- private loadAgentPermissionsFrom(
505
+ private loadScopeConfigFrom(
579
506
  dir: string | null,
580
- cache: Map<string, FileCacheEntry<AgentPermissions>>,
507
+ cache: Map<string, FileCacheEntry<ScopeConfig>>,
581
508
  agentName?: string,
582
- ): AgentPermissions {
509
+ ): ScopeConfig {
583
510
  if (!dir || !agentName) {
584
511
  return {};
585
512
  }
@@ -591,7 +518,7 @@ export class PermissionManager {
591
518
  return cached.value;
592
519
  }
593
520
 
594
- let value: AgentPermissions;
521
+ let value: ScopeConfig;
595
522
  try {
596
523
  const markdown = readFileSync(filePath, "utf-8");
597
524
  const frontmatter = extractFrontmatter(markdown);
@@ -611,54 +538,22 @@ export class PermissionManager {
611
538
  return value;
612
539
  }
613
540
 
614
- private loadAgentPermissions(agentName?: string): AgentPermissions {
615
- return this.loadAgentPermissionsFrom(
541
+ private loadScopeConfig(agentName?: string): ScopeConfig {
542
+ return this.loadScopeConfigFrom(
616
543
  this.agentsDir,
617
544
  this.agentConfigCache,
618
545
  agentName,
619
546
  );
620
547
  }
621
548
 
622
- private loadProjectAgentPermissions(agentName?: string): AgentPermissions {
623
- return this.loadAgentPermissionsFrom(
549
+ private loadProjectScopeConfig(agentName?: string): ScopeConfig {
550
+ return this.loadScopeConfigFrom(
624
551
  this.projectAgentsDir,
625
552
  this.projectAgentConfigCache,
626
553
  agentName,
627
554
  );
628
555
  }
629
556
 
630
- private mergePermissions(
631
- globalConfig: GlobalPermissionConfig,
632
- agentConfig: AgentPermissions,
633
- ): GlobalPermissionConfig {
634
- return {
635
- defaultPolicy: {
636
- ...globalConfig.defaultPolicy,
637
- ...(agentConfig.defaultPolicy || {}),
638
- },
639
- tools: {
640
- ...(globalConfig.tools || {}),
641
- ...(agentConfig.tools || {}),
642
- },
643
- bash: {
644
- ...(globalConfig.bash || {}),
645
- ...(agentConfig.bash || {}),
646
- },
647
- mcp: {
648
- ...(globalConfig.mcp || {}),
649
- ...(agentConfig.mcp || {}),
650
- },
651
- skills: {
652
- ...(globalConfig.skills || {}),
653
- ...(agentConfig.skills || {}),
654
- },
655
- special: {
656
- ...(globalConfig.special || {}),
657
- ...(agentConfig.special || {}),
658
- },
659
- };
660
- }
661
-
662
557
  getResolvedPolicyPaths(): ResolvedPolicyPaths {
663
558
  return {
664
559
  globalConfigPath: this.globalConfigPath,
@@ -701,67 +596,57 @@ export class PermissionManager {
701
596
 
702
597
  const globalConfig = this.loadGlobalConfig();
703
598
  const projectConfig = this.loadProjectGlobalConfig();
704
- const agentConfig = this.loadAgentPermissions(agentName);
705
- const projectAgentConfig = this.loadProjectAgentPermissions(agentName);
706
-
707
- const mergedWithProject = this.mergePermissions(
708
- globalConfig,
709
- projectConfig,
710
- );
711
- const mergedWithAgent = this.mergePermissions(
712
- mergedWithProject,
713
- agentConfig,
599
+ const agentConfig = this.loadScopeConfig(agentName);
600
+ const projectAgentConfig = this.loadProjectScopeConfig(agentName);
601
+
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),
609
+ ];
610
+
611
+ // Merge defaults separately (shallow spread, same precedence order).
612
+ const defaults = mergeDefaults(
613
+ globalConfig.defaultPolicy,
614
+ projectConfig.defaultPolicy,
615
+ agentConfig.defaultPolicy,
616
+ projectAgentConfig.defaultPolicy,
714
617
  );
715
- const merged = this.mergePermissions(mergedWithAgent, projectAgentConfig);
716
-
717
- const bashDefault =
718
- projectAgentConfig.tools?.bash ||
719
- agentConfig.tools?.bash ||
720
- projectConfig.tools?.bash ||
721
- merged.tools?.bash ||
722
- merged.defaultPolicy.bash;
723
- const compiledBash = compilePermissionPatternsFromSources(
724
- globalConfig.bash,
725
- projectConfig.bash,
726
- agentConfig.bash,
727
- projectAgentConfig.bash,
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",
728
636
  );
637
+
729
638
  const value: ResolvedPermissions = {
730
- globalConfig,
731
- agentConfig,
732
- merged,
733
- compiledBash,
639
+ rules,
640
+ defaults,
734
641
  bashDefault,
735
- compiledSpecial: compilePermissionPatternsFromSources(
736
- globalConfig.special,
737
- projectConfig.special,
738
- agentConfig.special,
739
- projectAgentConfig.special,
740
- ),
741
- compiledSkills: compilePermissionPatternsFromSources(
742
- globalConfig.skills,
743
- projectConfig.skills,
744
- agentConfig.skills,
745
- projectAgentConfig.skills,
746
- ),
747
- compiledMcp: compilePermissionPatternsFromSources(
748
- globalConfig.mcp,
749
- projectConfig.mcp,
750
- agentConfig.mcp,
751
- projectAgentConfig.mcp,
752
- ),
753
- bashFilter: new BashFilter(compiledBash, bashDefault),
642
+ mcpToolLevel,
643
+ hasAnyMcpAllowRule,
754
644
  };
755
645
 
756
646
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
757
647
  return value;
758
648
  }
759
649
 
760
- getBashPermissions(agentName?: string): BashPermissions {
761
- const { merged } = this.resolvePermissions(agentName);
762
- return merged.bash || {};
763
- }
764
-
765
650
  private getConfiguredMcpServerNames(): readonly string[] {
766
651
  if (this.configuredMcpServerNamesOverride) {
767
652
  return this.configuredMcpServerNamesOverride;
@@ -785,34 +670,37 @@ export class PermissionManager {
785
670
  * This is used for tool injection decisions where we need to know if a tool is allowed/denied
786
671
  * at the tool level before checking specific command permissions.
787
672
  *
788
- * Exact-name entries in `tools` work for arbitrary registered extension tools.
789
- * Canonical Pi tools with dedicated categories still use their specialized fallbacks.
673
+ * With tool-name-as-surface normalization, tools.bash becomes a bash catch-all
674
+ * { surface: "bash", pattern: "*", action } so getToolPermission("bash")
675
+ * naturally picks it up via evaluate("bash", "*", rules).
790
676
  *
791
677
  * @param toolName - The name of the tool (for example "bash", "read", or a third-party tool name)
792
678
  * @param agentName - Optional agent name to check agent-specific permissions
793
679
  * @returns The permission state for the tool at the tool level
794
680
  */
795
681
  getToolPermission(toolName: string, agentName?: string): PermissionState {
796
- const { merged } = this.resolvePermissions(agentName);
682
+ const { rules, defaults, bashDefault, mcpToolLevel } =
683
+ this.resolvePermissions(agentName);
797
684
  const normalizedToolName = toolName.trim();
798
685
 
686
+ // Special keys use the special default.
799
687
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
800
- return merged.defaultPolicy.special;
688
+ const rule = evaluate("special", normalizedToolName, rules);
689
+ if (rules.includes(rule)) return rule.action;
690
+ return defaults.special;
801
691
  }
802
692
 
803
- if (normalizedToolName === "skill") {
804
- return merged.defaultPolicy.skills;
805
- }
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;
806
696
 
807
- if (normalizedToolName === "bash") {
808
- return merged.tools?.bash || merged.defaultPolicy.bash;
809
- }
697
+ // Skills use the skills default.
698
+ if (normalizedToolName === "skill") return defaults.skills;
810
699
 
811
- if (normalizedToolName === "mcp") {
812
- return merged.tools?.mcp || merged.defaultPolicy.mcp;
813
- }
814
-
815
- return merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools;
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;
816
704
  }
817
705
 
818
706
  checkPermission(
@@ -820,56 +708,48 @@ export class PermissionManager {
820
708
  input: unknown,
821
709
  agentName?: string,
822
710
  ): PermissionCheckResult {
823
- const {
824
- agentConfig: _agentConfig,
825
- merged,
826
- compiledBash,
827
- bashDefault,
828
- compiledSpecial,
829
- compiledSkills,
830
- compiledMcp,
831
- bashFilter: _bashFilter,
832
- } = this.resolvePermissions(agentName);
711
+ const { rules, defaults, bashDefault, mcpToolLevel, hasAnyMcpAllowRule } =
712
+ this.resolvePermissions(agentName);
833
713
  const normalizedToolName = toolName.trim();
834
714
 
715
+ // --- Special surfaces (external_directory) ---
835
716
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
836
- const specialRuleset = compiledToRuleset("special", compiledSpecial);
837
- const rule = evaluate("special", normalizedToolName, specialRuleset);
838
- const explicit = specialRuleset.includes(rule);
717
+ const rule = evaluate("special", normalizedToolName, rules);
718
+ const explicit = rules.includes(rule);
839
719
  return {
840
720
  toolName,
841
- state: explicit ? rule.action : merged.defaultPolicy.special,
721
+ state: explicit ? rule.action : defaults.special,
842
722
  matchedPattern: explicit ? rule.pattern : undefined,
843
723
  source: "special",
844
724
  };
845
725
  }
846
726
 
727
+ // --- Skills ---
847
728
  if (normalizedToolName === "skill") {
848
729
  const skillName = toRecord(input).name;
849
730
  if (typeof skillName === "string") {
850
- const skillRuleset = compiledToRuleset("skill", compiledSkills);
851
- const rule = evaluate("skill", skillName, skillRuleset);
852
- const explicit = skillRuleset.includes(rule);
731
+ const rule = evaluate("skill", skillName, rules);
732
+ const explicit = rules.includes(rule);
853
733
  return {
854
734
  toolName,
855
- state: explicit ? rule.action : merged.defaultPolicy.skills,
735
+ state: explicit ? rule.action : defaults.skills,
856
736
  matchedPattern: explicit ? rule.pattern : undefined,
857
- source: "skill",
737
+ source: explicit ? "skill" : "skill",
858
738
  };
859
739
  }
860
740
  return {
861
741
  toolName,
862
- state: merged.defaultPolicy.skills,
742
+ state: defaults.skills,
863
743
  source: "skill",
864
744
  };
865
745
  }
866
746
 
747
+ // --- Bash ---
867
748
  if (normalizedToolName === "bash") {
868
749
  const record = toRecord(input);
869
750
  const command = typeof record.command === "string" ? record.command : "";
870
- const bashRuleset = compiledToRuleset("bash", compiledBash);
871
- const rule = evaluate("bash", command, bashRuleset);
872
- const explicit = bashRuleset.includes(rule);
751
+ const rule = evaluate("bash", command, rules);
752
+ const explicit = rules.includes(rule);
873
753
  return {
874
754
  toolName,
875
755
  state: explicit ? rule.action : bashDefault,
@@ -879,6 +759,7 @@ export class PermissionManager {
879
759
  };
880
760
  }
881
761
 
762
+ // --- MCP ---
882
763
  if (normalizedToolName === "mcp") {
883
764
  const mcpTargets = [
884
765
  ...createMcpPermissionTargets(
@@ -888,44 +769,38 @@ export class PermissionManager {
888
769
  "mcp",
889
770
  ];
890
771
  const fallbackTarget = mcpTargets[0] || "mcp";
891
- const toolLevelMcpState = merged.tools?.mcp;
892
772
 
893
- const mcpRuleset = compiledToRuleset("mcp", compiledMcp);
894
- let mcpExplicitMatch: { target: string; rule: Rule } | null = null;
773
+ // Try each candidate target against the merged rules.
895
774
  for (const target of mcpTargets) {
896
- const rule = evaluate("mcp", target, mcpRuleset);
897
- if (mcpRuleset.includes(rule)) {
898
- mcpExplicitMatch = { target, rule };
899
- break;
775
+ const rule = evaluate("mcp", target, rules);
776
+ if (rules.includes(rule)) {
777
+ return {
778
+ toolName,
779
+ state: rule.action,
780
+ matchedPattern: rule.pattern,
781
+ target,
782
+ source: "mcp",
783
+ };
900
784
  }
901
785
  }
902
- if (mcpExplicitMatch) {
903
- return {
904
- toolName,
905
- state: mcpExplicitMatch.rule.action,
906
- matchedPattern: mcpExplicitMatch.rule.pattern,
907
- target: mcpExplicitMatch.target,
908
- source: "mcp",
909
- };
910
- }
911
786
 
912
- if (toolLevelMcpState) {
787
+ // tools.mcp fallback (e.g. tools: { mcp: "allow" }).
788
+ if (mcpToolLevel) {
913
789
  return {
914
790
  toolName,
915
- state: toolLevelMcpState,
791
+ state: mcpToolLevel,
916
792
  target: fallbackTarget,
917
793
  source: "tool",
918
794
  };
919
795
  }
920
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.
921
799
  const baselineTarget = mcpTargets.find((target) =>
922
800
  MCP_BASELINE_TARGETS.has(target),
923
801
  );
924
802
  if (baselineTarget) {
925
- const hasAnyMcpAllowRule = Object.values(merged.mcp || {}).some(
926
- (state) => state === "allow",
927
- );
928
- if (hasAnyMcpAllowRule || merged.defaultPolicy.mcp === "allow") {
803
+ if (hasAnyMcpAllowRule || defaults.mcp === "allow") {
929
804
  return {
930
805
  toolName,
931
806
  state: "allow",
@@ -937,37 +812,35 @@ export class PermissionManager {
937
812
 
938
813
  return {
939
814
  toolName,
940
- state: merged.defaultPolicy.mcp || "deny",
815
+ state: defaults.mcp,
941
816
  target: fallbackTarget,
942
817
  source: "default",
943
818
  };
944
819
  }
945
820
 
946
- const toolRuleset: Ruleset = Object.entries(merged.tools ?? {}).map(
947
- ([name, action]) => ({ surface: "tool", pattern: name, action }),
948
- );
949
- const toolRule = evaluate("tool", normalizedToolName, toolRuleset);
950
- const explicitTool = toolRuleset.includes(toolRule);
821
+ // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
822
+ const rule = evaluate(normalizedToolName, "*", rules);
823
+ const explicit = rules.includes(rule);
951
824
 
952
825
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
953
826
  return {
954
827
  toolName,
955
- state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
828
+ state: explicit ? rule.action : defaults.tools,
956
829
  source: "tool",
957
830
  };
958
831
  }
959
832
 
960
- if (explicitTool) {
833
+ if (explicit) {
961
834
  return {
962
835
  toolName,
963
- state: toolRule.action,
836
+ state: rule.action,
964
837
  source: "tool",
965
838
  };
966
839
  }
967
840
 
968
841
  return {
969
842
  toolName,
970
- state: merged.defaultPolicy.tools,
843
+ state: defaults.tools,
971
844
  source: "default",
972
845
  };
973
846
  }
package/src/rule.ts CHANGED
@@ -14,37 +14,21 @@ export interface Rule {
14
14
  /** An ordered list of rules. Later rules take priority (last-match-wins). */
15
15
  export type Ruleset = Rule[];
16
16
 
17
- const SURFACE_DEFAULTS: Record<string, PermissionState> = {
18
- tools: "ask",
19
- bash: "ask",
20
- mcp: "ask",
21
- skill: "ask",
22
- special: "ask",
23
- };
24
-
25
- /**
26
- * Returns the default action for a surface when no rules match.
27
- * Defaults to "ask" for unknown surfaces (least privilege).
28
- */
29
- export function getDefaultAction(surface: string): PermissionState {
30
- return SURFACE_DEFAULTS[surface] ?? "ask";
31
- }
32
-
33
17
  /**
34
18
  * Pure permission evaluation.
35
19
  *
36
- * Flattens all provided rulesets and returns the last rule whose surface and
37
- * pattern both wildcard-match the supplied values (last-match-wins, so later
38
- * rulesets / later entries have higher priority).
20
+ * Returns the last rule in `rules` whose surface and pattern both
21
+ * wildcard-match the supplied values (last-match-wins).
39
22
  *
40
- * When no rule matches, returns a synthetic rule using getDefaultAction().
23
+ * When no rule matches, returns a synthetic rule with `defaultAction`
24
+ * (defaults to "ask" — least privilege).
41
25
  */
42
26
  export function evaluate(
43
27
  surface: string,
44
28
  pattern: string,
45
- ...rulesets: Ruleset[]
29
+ rules: Ruleset,
30
+ defaultAction?: PermissionState,
46
31
  ): Rule {
47
- const rules = rulesets.flat();
48
32
  for (let i = rules.length - 1; i >= 0; i -= 1) {
49
33
  const rule = rules[i];
50
34
  if (
@@ -54,5 +38,5 @@ export function evaluate(
54
38
  return rule;
55
39
  }
56
40
  }
57
- return { surface, pattern, action: getDefaultAction(surface) };
41
+ return { surface, pattern, action: defaultAction ?? "ask" };
58
42
  }