@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 +20 -0
- package/package.json +1 -1
- package/src/defaults.ts +6 -0
- package/src/handlers/tool-call.ts +15 -18
- package/src/permission-manager.ts +110 -128
- package/src/rule.ts +5 -0
- package/src/session-rules.ts +1 -1
- package/src/synthesize.ts +152 -0
- package/src/types.ts +1 -1
- package/tests/permission-system.test.ts +195 -1
- package/tests/rule.test.ts +31 -0
- package/tests/session-rules.test.ts +1 -0
- package/tests/synthesize.test.ts +413 -0
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
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
|
|
174
|
-
const sessionMatch = evaluate(
|
|
172
|
+
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
175
173
|
"external_directory",
|
|
176
|
-
normalizedExtPath,
|
|
177
|
-
|
|
174
|
+
{ path: normalizedExtPath },
|
|
175
|
+
agentName ?? undefined,
|
|
176
|
+
deps.runtime.sessionRules.getRuleset(),
|
|
178
177
|
);
|
|
179
|
-
const isSessionApproved = sessionRuleset.includes(sessionMatch);
|
|
180
178
|
|
|
181
|
-
if (
|
|
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:
|
|
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
|
|
260
|
+
const bashSessionRules = deps.runtime.sessionRules.getRuleset();
|
|
269
261
|
const uncoveredPaths = externalPaths.filter(
|
|
270
262
|
(p) =>
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
//
|
|
603
|
-
//
|
|
604
|
-
const
|
|
605
|
-
...
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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
|
|
620
|
-
//
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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 {
|
|
683
|
-
this.resolvePermissions(agentName);
|
|
676
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
684
677
|
const normalizedToolName = toolName.trim();
|
|
685
678
|
|
|
686
|
-
// Special
|
|
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
|
-
|
|
689
|
-
if (rules.includes(rule)) return rule.action;
|
|
690
|
-
return defaults.special;
|
|
682
|
+
return evaluate("special", normalizedToolName, composedRules).action;
|
|
691
683
|
}
|
|
692
684
|
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
701
|
-
|
|
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 {
|
|
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
|
-
|
|
718
|
-
const
|
|
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:
|
|
722
|
-
matchedPattern:
|
|
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
|
-
|
|
731
|
-
|
|
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:
|
|
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,
|
|
752
|
-
const explicit = rules.includes(rule);
|
|
760
|
+
const rule = evaluate("bash", command, composedRules);
|
|
753
761
|
return {
|
|
754
762
|
toolName,
|
|
755
|
-
state:
|
|
763
|
+
state: rule.action,
|
|
756
764
|
command,
|
|
757
|
-
matchedPattern:
|
|
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
|
|
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,
|
|
776
|
-
if (
|
|
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:
|
|
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, "*",
|
|
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:
|
|
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/session-rules.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/tests/rule.test.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -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
|
+
});
|