@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 +36 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/defaults.ts +6 -0
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/tool-call.ts +20 -19
- package/src/permission-manager.ts +110 -128
- package/src/rule.ts +5 -0
- package/src/runtime.ts +3 -3
- package/src/session-rules.ts +54 -0
- package/src/synthesize.ts +152 -0
- package/src/types.ts +1 -1
- package/tests/handlers/before-agent-start.test.ts +3 -4
- package/tests/handlers/input.test.ts +3 -4
- package/tests/handlers/lifecycle.test.ts +7 -8
- package/tests/handlers/tool-call.test.ts +27 -19
- package/tests/permission-system.test.ts +195 -1
- package/tests/rule.test.ts +31 -0
- package/tests/runtime.test.ts +5 -4
- package/tests/session-rules.test.ts +226 -0
- package/tests/synthesize.test.ts +413 -0
- package/src/session-approval-cache.ts +0 -81
- package/tests/session-approval-cache.test.ts +0 -131
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-
|
|
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
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.
|
|
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 {
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
249
|
-
deps.runtime.
|
|
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
|
-
|
|
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
|
|
343
|
-
deps.runtime.
|
|
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
|
-
|
|
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/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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|