@gotgenes/pi-permission-system 5.4.0 → 5.5.1
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 +28 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +7 -7
- package/src/handlers/gates/bash-external-directory.ts +22 -24
- package/src/handlers/gates/external-directory.ts +32 -41
- package/src/handlers/gates/skill-read.ts +10 -12
- package/src/handlers/gates/tool.ts +20 -27
- package/src/handlers/gates/types.ts +75 -0
- package/src/handlers/input.ts +3 -3
- package/src/handlers/lifecycle.ts +21 -21
- package/src/handlers/tool-call.ts +77 -7
- package/src/handlers/types.ts +20 -7
- package/src/index.ts +6 -1
- package/src/permission-manager.ts +28 -279
- package/src/policy-loader.ts +350 -0
- package/src/runtime.ts +17 -9
- package/tests/handlers/before-agent-start.test.ts +17 -27
- package/tests/handlers/gates/bash-external-directory.test.ts +48 -105
- package/tests/handlers/gates/external-directory.test.ts +65 -140
- package/tests/handlers/gates/skill-read.test.ts +50 -65
- package/tests/handlers/gates/tool.test.ts +90 -334
- package/tests/handlers/input-events.test.ts +10 -21
- package/tests/handlers/input.test.ts +26 -43
- package/tests/handlers/lifecycle.test.ts +47 -66
- package/tests/handlers/tool-call-events.test.ts +29 -40
- package/tests/handlers/tool-call.test.ts +19 -30
- package/tests/permission-manager-unified.test.ts +319 -0
- package/tests/policy-loader.test.ts +561 -0
|
@@ -19,25 +19,25 @@ export async function handleSessionStart(
|
|
|
19
19
|
event: SessionStartPayload,
|
|
20
20
|
ctx: ExtensionContext,
|
|
21
21
|
): Promise<void> {
|
|
22
|
-
deps.
|
|
22
|
+
deps.session.runtimeContext = ctx;
|
|
23
23
|
deps.refreshExtensionConfig(ctx);
|
|
24
|
-
deps.
|
|
25
|
-
deps.
|
|
26
|
-
deps.
|
|
27
|
-
deps.
|
|
28
|
-
deps.
|
|
24
|
+
deps.session.permissionManager = deps.createPermissionManagerForCwd(ctx.cwd);
|
|
25
|
+
deps.session.activeSkillEntries = [];
|
|
26
|
+
deps.session.lastActiveToolsCacheKey = null;
|
|
27
|
+
deps.session.lastPromptStateCacheKey = null;
|
|
28
|
+
deps.session.lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
29
29
|
deps.startForwardedPermissionPolling(ctx);
|
|
30
30
|
deps.logResolvedConfigPaths();
|
|
31
31
|
|
|
32
|
-
const agentName = deps.
|
|
32
|
+
const agentName = deps.session.lastKnownActiveAgentName;
|
|
33
33
|
const policyIssues =
|
|
34
|
-
deps.
|
|
34
|
+
deps.session.permissionManager.getConfigIssues(agentName);
|
|
35
35
|
for (const issue of policyIssues) {
|
|
36
36
|
deps.notifyWarning(issue);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
if (event.reason === "reload") {
|
|
40
|
-
deps.
|
|
40
|
+
deps.writeDebugLog("lifecycle.reload", {
|
|
41
41
|
triggeredBy: "session_start",
|
|
42
42
|
reason: event.reason,
|
|
43
43
|
cwd: ctx.cwd,
|
|
@@ -53,14 +53,14 @@ export async function handleResourcesDiscover(
|
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const { runtimeContext } = deps.
|
|
57
|
-
deps.
|
|
56
|
+
const { runtimeContext } = deps.session;
|
|
57
|
+
deps.session.permissionManager = deps.createPermissionManagerForCwd(
|
|
58
58
|
runtimeContext?.cwd,
|
|
59
59
|
);
|
|
60
|
-
deps.
|
|
61
|
-
deps.
|
|
62
|
-
deps.
|
|
63
|
-
deps.
|
|
60
|
+
deps.session.activeSkillEntries = [];
|
|
61
|
+
deps.session.lastActiveToolsCacheKey = null;
|
|
62
|
+
deps.session.lastPromptStateCacheKey = null;
|
|
63
|
+
deps.writeDebugLog("lifecycle.reload", {
|
|
64
64
|
triggeredBy: "resources_discover",
|
|
65
65
|
reason: event.reason,
|
|
66
66
|
cwd: runtimeContext?.cwd ?? null,
|
|
@@ -68,15 +68,15 @@ export async function handleResourcesDiscover(
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
|
|
71
|
-
const { runtimeContext } = deps.
|
|
71
|
+
const { runtimeContext } = deps.session;
|
|
72
72
|
if (runtimeContext) {
|
|
73
73
|
runtimeContext.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
|
74
74
|
}
|
|
75
|
-
deps.
|
|
76
|
-
deps.
|
|
77
|
-
deps.
|
|
78
|
-
deps.
|
|
79
|
-
deps.
|
|
75
|
+
deps.session.runtimeContext = null;
|
|
76
|
+
deps.session.activeSkillEntries = [];
|
|
77
|
+
deps.session.lastActiveToolsCacheKey = null;
|
|
78
|
+
deps.session.lastPromptStateCacheKey = null;
|
|
79
|
+
deps.session.sessionRules.clear();
|
|
80
80
|
deps.stopForwardedPermissionPolling();
|
|
81
81
|
deps.stopPermissionRpcHandlers();
|
|
82
82
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { toRecord } from "../common";
|
|
4
|
+
import { emitDecisionEvent } from "../permission-events";
|
|
4
5
|
import {
|
|
5
6
|
formatMissingToolNameReason,
|
|
6
7
|
formatUnknownToolReason,
|
|
@@ -13,8 +14,14 @@ import { evaluateBashExternalDirectoryGate } from "./gates/bash-external-directo
|
|
|
13
14
|
import { evaluateExternalDirectoryGate } from "./gates/external-directory";
|
|
14
15
|
import { evaluateSkillReadGate } from "./gates/skill-read";
|
|
15
16
|
import { evaluateToolGate } from "./gates/tool";
|
|
16
|
-
import type {
|
|
17
|
-
|
|
17
|
+
import type {
|
|
18
|
+
BashExternalDirectoryGateDeps,
|
|
19
|
+
ExternalDirectoryGateDeps,
|
|
20
|
+
SkillReadGateDeps,
|
|
21
|
+
ToolCallContext,
|
|
22
|
+
ToolGateDeps,
|
|
23
|
+
} from "./gates/types";
|
|
24
|
+
import type { HandlerDeps, PromptPermissionDetails } from "./types";
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
@@ -39,7 +46,7 @@ export async function handleToolCall(
|
|
|
39
46
|
event: unknown,
|
|
40
47
|
ctx: ExtensionContext,
|
|
41
48
|
): Promise<{ block?: true; reason?: string }> {
|
|
42
|
-
deps.
|
|
49
|
+
deps.session.runtimeContext = ctx;
|
|
43
50
|
deps.startForwardedPermissionPolling(ctx);
|
|
44
51
|
|
|
45
52
|
const agentName = deps.resolveAgentName(ctx);
|
|
@@ -81,26 +88,89 @@ export async function handleToolCall(
|
|
|
81
88
|
cwd: ctx.cwd,
|
|
82
89
|
};
|
|
83
90
|
|
|
91
|
+
// ── Shared gate adapter closures ───────────────────────────────────────
|
|
92
|
+
const canConfirm = () => deps.canRequestPermissionConfirmation(ctx);
|
|
93
|
+
const promptPermission = (details: PromptPermissionDetails) =>
|
|
94
|
+
deps.promptPermission(ctx, details);
|
|
95
|
+
const emitDecision = (e: Parameters<ToolGateDeps["emitDecision"]>[0]) =>
|
|
96
|
+
emitDecisionEvent(deps.events, e);
|
|
97
|
+
const { writeReviewLog } = deps;
|
|
98
|
+
const checkPermission: ToolGateDeps["checkPermission"] = (
|
|
99
|
+
surface,
|
|
100
|
+
input,
|
|
101
|
+
agent,
|
|
102
|
+
sessionRules,
|
|
103
|
+
) =>
|
|
104
|
+
deps.session.permissionManager.checkPermission(
|
|
105
|
+
surface,
|
|
106
|
+
input,
|
|
107
|
+
agent,
|
|
108
|
+
sessionRules,
|
|
109
|
+
);
|
|
110
|
+
const getSessionRuleset = () => deps.session.sessionRules.getRuleset();
|
|
111
|
+
const approveSessionRule = (surface: string, pattern: string) =>
|
|
112
|
+
deps.session.sessionRules.approve(surface, pattern);
|
|
113
|
+
|
|
84
114
|
// ── Skill-read gate ──────────────────────────────────────────────────────
|
|
85
|
-
const
|
|
115
|
+
const skillReadGateDeps: SkillReadGateDeps = {
|
|
116
|
+
getActiveSkillEntries: () => deps.session.activeSkillEntries,
|
|
117
|
+
writeReviewLog,
|
|
118
|
+
emitDecision,
|
|
119
|
+
canConfirm,
|
|
120
|
+
promptPermission,
|
|
121
|
+
};
|
|
122
|
+
const skillResult = await evaluateSkillReadGate(tcc, skillReadGateDeps);
|
|
86
123
|
if (skillResult?.action === "block") {
|
|
87
124
|
return { block: true, reason: skillResult.reason };
|
|
88
125
|
}
|
|
89
126
|
|
|
90
127
|
// ── External-directory gate (file tools) ─────────────────────────────────
|
|
91
|
-
const
|
|
128
|
+
const extDirGateDeps: ExternalDirectoryGateDeps = {
|
|
129
|
+
checkPermission,
|
|
130
|
+
getSessionRuleset,
|
|
131
|
+
approveSessionRule,
|
|
132
|
+
writeReviewLog,
|
|
133
|
+
emitDecision,
|
|
134
|
+
canConfirm,
|
|
135
|
+
promptPermission,
|
|
136
|
+
getInfrastructureDirs: () => [
|
|
137
|
+
...deps.piInfrastructureDirs,
|
|
138
|
+
...deps.getPiInfrastructureReadPaths(),
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const extDirResult = await evaluateExternalDirectoryGate(tcc, extDirGateDeps);
|
|
92
142
|
if (extDirResult?.action === "block") {
|
|
93
143
|
return { block: true, reason: extDirResult.reason };
|
|
94
144
|
}
|
|
95
145
|
|
|
96
146
|
// ── Bash external-directory gate ─────────────────────────────────────────
|
|
97
|
-
const
|
|
147
|
+
const bashExtGateDeps: BashExternalDirectoryGateDeps = {
|
|
148
|
+
checkPermission,
|
|
149
|
+
getSessionRuleset,
|
|
150
|
+
approveSessionRule,
|
|
151
|
+
writeReviewLog,
|
|
152
|
+
canConfirm,
|
|
153
|
+
promptPermission,
|
|
154
|
+
};
|
|
155
|
+
const bashExtResult = await evaluateBashExternalDirectoryGate(
|
|
156
|
+
tcc,
|
|
157
|
+
bashExtGateDeps,
|
|
158
|
+
);
|
|
98
159
|
if (bashExtResult?.action === "block") {
|
|
99
160
|
return { block: true, reason: bashExtResult.reason };
|
|
100
161
|
}
|
|
101
162
|
|
|
102
163
|
// ── Normal tool permission gate ──────────────────────────────────────────
|
|
103
|
-
const
|
|
164
|
+
const toolGateDeps: ToolGateDeps = {
|
|
165
|
+
checkPermission,
|
|
166
|
+
getSessionRuleset,
|
|
167
|
+
approveSessionRule,
|
|
168
|
+
writeReviewLog,
|
|
169
|
+
emitDecision,
|
|
170
|
+
canConfirm,
|
|
171
|
+
promptPermission,
|
|
172
|
+
};
|
|
173
|
+
const toolResult = await evaluateToolGate(tcc, toolGateDeps);
|
|
104
174
|
if (toolResult.action === "block") {
|
|
105
175
|
return { block: true, reason: toolResult.reason };
|
|
106
176
|
}
|
package/src/handlers/types.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
|
3
3
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
4
4
|
import type { PermissionEventBus } from "../permission-events";
|
|
5
5
|
import type { PermissionManager } from "../permission-manager";
|
|
6
|
-
import type {
|
|
6
|
+
import type { SessionState } from "../runtime";
|
|
7
7
|
|
|
8
8
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
9
9
|
|
|
@@ -27,13 +27,26 @@ export interface PromptPermissionDetails {
|
|
|
27
27
|
/**
|
|
28
28
|
* Explicit dependency bag passed to each extracted event handler.
|
|
29
29
|
*
|
|
30
|
-
* Mutable state lives in `
|
|
31
|
-
* directly
|
|
30
|
+
* Mutable session state lives in `session`; handlers read and write
|
|
31
|
+
* `deps.session.*` directly. Logging, infrastructure paths, and the
|
|
32
|
+
* event bus are promoted to top-level fields so handlers and gate
|
|
33
|
+
* adapters never reach through nested objects for leaf operations.
|
|
32
34
|
*/
|
|
33
35
|
export interface HandlerDeps {
|
|
34
|
-
// ──
|
|
35
|
-
/**
|
|
36
|
-
readonly
|
|
36
|
+
// ── Session state ─────────────────────────────────────────────────────
|
|
37
|
+
/** Mutable session state: permissionManager, sessionRules, cache keys. */
|
|
38
|
+
readonly session: SessionState;
|
|
39
|
+
|
|
40
|
+
// ── Logging (promoted from runtime) ───────────────────────────────────
|
|
41
|
+
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
42
|
+
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
43
|
+
|
|
44
|
+
// ── Immutable infrastructure paths ───────────────────────────────────
|
|
45
|
+
readonly piInfrastructureDirs: string[];
|
|
46
|
+
/** Returns config-derived infrastructure read paths (current at call time). */
|
|
47
|
+
getPiInfrastructureReadPaths(): string[];
|
|
48
|
+
|
|
49
|
+
// ── Event bus ────────────────────────────────────────────────────────
|
|
37
50
|
/** Event bus for emitting permissions:decision broadcast events. */
|
|
38
51
|
readonly events: PermissionEventBus;
|
|
39
52
|
|
|
@@ -54,7 +67,7 @@ export interface HandlerDeps {
|
|
|
54
67
|
// ── Permission helpers ─────────────────────────────────────────────────
|
|
55
68
|
/**
|
|
56
69
|
* Resolve the active agent name from the session context or system prompt.
|
|
57
|
-
* Updates
|
|
70
|
+
* Updates session.lastKnownActiveAgentName as a side effect.
|
|
58
71
|
*/
|
|
59
72
|
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
|
|
60
73
|
/** Whether the current context can show an interactive permission prompt. */
|
package/src/index.ts
CHANGED
|
@@ -78,7 +78,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
const deps: HandlerDeps = {
|
|
81
|
-
runtime,
|
|
81
|
+
session: runtime,
|
|
82
|
+
writeDebugLog: (event, details) => runtime.writeDebugLog(event, details),
|
|
83
|
+
writeReviewLog: (event, details) => runtime.writeReviewLog(event, details),
|
|
84
|
+
piInfrastructureDirs: runtime.piInfrastructureDirs,
|
|
85
|
+
getPiInfrastructureReadPaths: () =>
|
|
86
|
+
runtime.config.piInfrastructureReadPaths ?? [],
|
|
82
87
|
events: pi.events,
|
|
83
88
|
createPermissionManagerForCwd: (cwd) =>
|
|
84
89
|
createPermissionManagerForCwd(runtime.agentDir, cwd),
|
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
extractFrontmatter,
|
|
7
|
-
isPermissionState,
|
|
8
|
-
parseSimpleYamlMap,
|
|
9
|
-
toRecord,
|
|
10
|
-
} from "./common";
|
|
11
|
-
import {
|
|
12
|
-
loadUnifiedConfig,
|
|
13
|
-
normalizeUnifiedConfig,
|
|
14
|
-
stripJsonComments,
|
|
15
|
-
} from "./config-loader";
|
|
16
|
-
import { getGlobalConfigPath } from "./config-paths";
|
|
1
|
+
import { isPermissionState } from "./common";
|
|
17
2
|
import { normalizeInput } from "./input-normalizer";
|
|
18
3
|
import { normalizeFlatConfig } from "./normalize";
|
|
4
|
+
import {
|
|
5
|
+
FilePolicyLoader,
|
|
6
|
+
type PolicyLoader,
|
|
7
|
+
type PolicyLoaderOptions,
|
|
8
|
+
type ResolvedPolicyPaths,
|
|
9
|
+
} from "./policy-loader";
|
|
19
10
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
20
11
|
import { evaluate, evaluateFirst } from "./rule";
|
|
21
12
|
import {
|
|
@@ -27,19 +18,8 @@ import type {
|
|
|
27
18
|
FlatPermissionConfig,
|
|
28
19
|
PermissionCheckResult,
|
|
29
20
|
PermissionState,
|
|
30
|
-
ScopeConfig,
|
|
31
21
|
} from "./types";
|
|
32
22
|
|
|
33
|
-
function defaultGlobalConfigPath(): string {
|
|
34
|
-
return getGlobalConfigPath(getAgentDir());
|
|
35
|
-
}
|
|
36
|
-
function defaultAgentsDir(): string {
|
|
37
|
-
return join(getAgentDir(), "agents");
|
|
38
|
-
}
|
|
39
|
-
function defaultGlobalMcpConfigPath(): string {
|
|
40
|
-
return join(getAgentDir(), "mcp.json");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
23
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
44
24
|
"bash",
|
|
45
25
|
"read",
|
|
@@ -83,49 +63,10 @@ function mergeFlatPermissions(
|
|
|
83
63
|
return merged;
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
91
|
-
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
92
|
-
const root = toRecord(parsed);
|
|
93
|
-
const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
|
|
94
|
-
|
|
95
|
-
return Object.keys(serverRecord)
|
|
96
|
-
.map((name) => name.trim())
|
|
97
|
-
.filter((name) => name.length > 0);
|
|
98
|
-
} catch {
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function getConfiguredMcpServerNamesFromPaths(
|
|
104
|
-
paths: readonly string[],
|
|
105
|
-
): string[] {
|
|
106
|
-
const seen = new Set<string>();
|
|
107
|
-
|
|
108
|
-
for (const path of paths) {
|
|
109
|
-
for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
|
|
110
|
-
seen.add(name);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return [...seen].sort(
|
|
115
|
-
(left, right) => right.length - left.length || left.localeCompare(right),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface ResolvedPolicyPaths {
|
|
120
|
-
globalConfigPath: string;
|
|
121
|
-
globalConfigExists: boolean;
|
|
122
|
-
projectConfigPath: string | null;
|
|
123
|
-
projectConfigExists: boolean;
|
|
124
|
-
agentsDir: string;
|
|
125
|
-
agentsDirExists: boolean;
|
|
126
|
-
projectAgentsDir: string | null;
|
|
127
|
-
projectAgentsDirExists: boolean;
|
|
128
|
-
}
|
|
66
|
+
type FileCacheEntry<TValue> = {
|
|
67
|
+
stamp: string;
|
|
68
|
+
value: TValue;
|
|
69
|
+
};
|
|
129
70
|
|
|
130
71
|
type ResolvedPermissions = {
|
|
131
72
|
/**
|
|
@@ -135,223 +76,47 @@ type ResolvedPermissions = {
|
|
|
135
76
|
composedRules: Ruleset;
|
|
136
77
|
};
|
|
137
78
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
value: TValue;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
function getFileStamp(path: string): string {
|
|
144
|
-
try {
|
|
145
|
-
return String(statSync(path).mtimeMs);
|
|
146
|
-
} catch {
|
|
147
|
-
return "missing";
|
|
148
|
-
}
|
|
79
|
+
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
80
|
+
policyLoader?: PolicyLoader;
|
|
149
81
|
}
|
|
150
82
|
|
|
151
83
|
export class PermissionManager {
|
|
152
|
-
private readonly
|
|
153
|
-
private readonly agentsDir: string;
|
|
154
|
-
private readonly projectGlobalConfigPath: string | null;
|
|
155
|
-
private readonly projectAgentsDir: string | null;
|
|
156
|
-
private readonly globalMcpConfigPath: string;
|
|
157
|
-
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
158
|
-
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
159
|
-
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
160
|
-
private readonly agentConfigCache = new Map<
|
|
161
|
-
string,
|
|
162
|
-
FileCacheEntry<ScopeConfig>
|
|
163
|
-
>();
|
|
164
|
-
private readonly projectAgentConfigCache = new Map<
|
|
165
|
-
string,
|
|
166
|
-
FileCacheEntry<ScopeConfig>
|
|
167
|
-
>();
|
|
84
|
+
private readonly loader: PolicyLoader;
|
|
168
85
|
private readonly resolvedPermissionsCache = new Map<
|
|
169
86
|
string,
|
|
170
87
|
FileCacheEntry<ResolvedPermissions>
|
|
171
88
|
>();
|
|
172
|
-
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
173
|
-
readonly string[]
|
|
174
|
-
> | null = null;
|
|
175
|
-
private accumulatedConfigIssues: string[] = [];
|
|
176
|
-
|
|
177
|
-
constructor(
|
|
178
|
-
options: {
|
|
179
|
-
globalConfigPath?: string;
|
|
180
|
-
agentsDir?: string;
|
|
181
|
-
projectGlobalConfigPath?: string;
|
|
182
|
-
projectAgentsDir?: string;
|
|
183
|
-
globalMcpConfigPath?: string;
|
|
184
|
-
mcpServerNames?: readonly string[];
|
|
185
|
-
} = {},
|
|
186
|
-
) {
|
|
187
|
-
this.globalConfigPath =
|
|
188
|
-
options.globalConfigPath || defaultGlobalConfigPath();
|
|
189
|
-
this.agentsDir = options.agentsDir || defaultAgentsDir();
|
|
190
|
-
this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
|
|
191
|
-
this.projectAgentsDir = options.projectAgentsDir || null;
|
|
192
|
-
this.globalMcpConfigPath =
|
|
193
|
-
options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
|
|
194
|
-
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
195
|
-
? [
|
|
196
|
-
...new Set(
|
|
197
|
-
options.mcpServerNames
|
|
198
|
-
.map((name) => name.trim())
|
|
199
|
-
.filter((name) => name.length > 0),
|
|
200
|
-
),
|
|
201
|
-
]
|
|
202
|
-
: null;
|
|
203
|
-
}
|
|
204
89
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
208
|
-
this.accumulatedConfigIssues.push(issue);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
90
|
+
constructor(options: PermissionManagerOptions = {}) {
|
|
91
|
+
this.loader = options.policyLoader ?? new FilePolicyLoader(options);
|
|
211
92
|
}
|
|
212
93
|
|
|
213
94
|
getConfigIssues(agentName?: string): string[] {
|
|
214
95
|
// Trigger a load/resolve to ensure issues are collected.
|
|
215
96
|
this.resolvePermissions(agentName);
|
|
216
|
-
return [...this.
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private loadGlobalConfig(): ScopeConfig {
|
|
220
|
-
const stamp = getFileStamp(this.globalConfigPath);
|
|
221
|
-
if (this.globalConfigCache?.stamp === stamp) {
|
|
222
|
-
return this.globalConfigCache.value;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
|
|
226
|
-
this.accumulateConfigIssues(issues);
|
|
227
|
-
|
|
228
|
-
const value: ScopeConfig = {
|
|
229
|
-
permission: config.permission,
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
this.globalConfigCache = { stamp, value };
|
|
233
|
-
return value;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private loadProjectGlobalConfig(): ScopeConfig {
|
|
237
|
-
if (!this.projectGlobalConfigPath) {
|
|
238
|
-
return {};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const stamp = getFileStamp(this.projectGlobalConfigPath);
|
|
242
|
-
if (this.projectGlobalConfigCache?.stamp === stamp) {
|
|
243
|
-
return this.projectGlobalConfigCache.value;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
|
|
247
|
-
this.accumulateConfigIssues(issues);
|
|
248
|
-
|
|
249
|
-
const value: ScopeConfig = {
|
|
250
|
-
permission: config.permission,
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
this.projectGlobalConfigCache = { stamp, value };
|
|
254
|
-
return value;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private loadScopeConfigFrom(
|
|
258
|
-
dir: string | null,
|
|
259
|
-
cache: Map<string, FileCacheEntry<ScopeConfig>>,
|
|
260
|
-
agentName?: string,
|
|
261
|
-
): ScopeConfig {
|
|
262
|
-
if (!dir || !agentName) {
|
|
263
|
-
return {};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const filePath = join(dir, `${agentName}.md`);
|
|
267
|
-
const stamp = getFileStamp(filePath);
|
|
268
|
-
const cached = cache.get(agentName);
|
|
269
|
-
if (cached?.stamp === stamp) {
|
|
270
|
-
return cached.value;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
let value: ScopeConfig;
|
|
274
|
-
try {
|
|
275
|
-
const markdown = readFileSync(filePath, "utf-8");
|
|
276
|
-
const frontmatter = extractFrontmatter(markdown);
|
|
277
|
-
if (!frontmatter) {
|
|
278
|
-
value = {};
|
|
279
|
-
} else {
|
|
280
|
-
const parsed = parseSimpleYamlMap(frontmatter);
|
|
281
|
-
// Re-use the config-loader normalizer so the flat permission shape
|
|
282
|
-
// is validated the same way as on-disk config files.
|
|
283
|
-
const { config, issues } = normalizeUnifiedConfig(parsed);
|
|
284
|
-
this.accumulateConfigIssues(issues);
|
|
285
|
-
value = { permission: config.permission };
|
|
286
|
-
}
|
|
287
|
-
} catch {
|
|
288
|
-
value = {};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
cache.set(agentName, { stamp, value });
|
|
292
|
-
return value;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private loadScopeConfig(agentName?: string): ScopeConfig {
|
|
296
|
-
return this.loadScopeConfigFrom(
|
|
297
|
-
this.agentsDir,
|
|
298
|
-
this.agentConfigCache,
|
|
299
|
-
agentName,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private loadProjectScopeConfig(agentName?: string): ScopeConfig {
|
|
304
|
-
return this.loadScopeConfigFrom(
|
|
305
|
-
this.projectAgentsDir,
|
|
306
|
-
this.projectAgentConfigCache,
|
|
307
|
-
agentName,
|
|
308
|
-
);
|
|
97
|
+
return [...this.loader.getConfigIssues()];
|
|
309
98
|
}
|
|
310
99
|
|
|
311
100
|
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
312
|
-
return
|
|
313
|
-
globalConfigPath: this.globalConfigPath,
|
|
314
|
-
globalConfigExists: existsSync(this.globalConfigPath),
|
|
315
|
-
projectConfigPath: this.projectGlobalConfigPath,
|
|
316
|
-
projectConfigExists: this.projectGlobalConfigPath
|
|
317
|
-
? existsSync(this.projectGlobalConfigPath)
|
|
318
|
-
: false,
|
|
319
|
-
agentsDir: this.agentsDir,
|
|
320
|
-
agentsDirExists: existsSync(this.agentsDir),
|
|
321
|
-
projectAgentsDir: this.projectAgentsDir,
|
|
322
|
-
projectAgentsDirExists: this.projectAgentsDir
|
|
323
|
-
? existsSync(this.projectAgentsDir)
|
|
324
|
-
: false,
|
|
325
|
-
};
|
|
101
|
+
return this.loader.getResolvedPolicyPaths();
|
|
326
102
|
}
|
|
327
103
|
|
|
328
104
|
getPolicyCacheStamp(agentName?: string): string {
|
|
329
|
-
|
|
330
|
-
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
|
|
331
|
-
: "missing";
|
|
332
|
-
const projectStamp = this.projectGlobalConfigPath
|
|
333
|
-
? getFileStamp(this.projectGlobalConfigPath)
|
|
334
|
-
: "none";
|
|
335
|
-
const projectAgentStamp =
|
|
336
|
-
this.projectAgentsDir && agentName
|
|
337
|
-
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
|
|
338
|
-
: "none";
|
|
339
|
-
|
|
340
|
-
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
|
|
105
|
+
return this.loader.getCacheStamp(agentName);
|
|
341
106
|
}
|
|
342
107
|
|
|
343
108
|
private resolvePermissions(agentName?: string): ResolvedPermissions {
|
|
344
109
|
const cacheKey = agentName || "__global__";
|
|
345
|
-
const stamp = this.
|
|
110
|
+
const stamp = this.loader.getCacheStamp(agentName);
|
|
346
111
|
const cached = this.resolvedPermissionsCache.get(cacheKey);
|
|
347
112
|
if (cached?.stamp === stamp) {
|
|
348
113
|
return cached.value;
|
|
349
114
|
}
|
|
350
115
|
|
|
351
|
-
const globalConfig = this.loadGlobalConfig();
|
|
352
|
-
const projectConfig = this.
|
|
353
|
-
const agentConfig = this.
|
|
354
|
-
const projectAgentConfig = this.
|
|
116
|
+
const globalConfig = this.loader.loadGlobalConfig();
|
|
117
|
+
const projectConfig = this.loader.loadProjectConfig();
|
|
118
|
+
const agentConfig = this.loader.loadAgentConfig(agentName);
|
|
119
|
+
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
|
355
120
|
|
|
356
121
|
// Merge permission objects across scopes (lowest → highest precedence).
|
|
357
122
|
// Build a parallel origin map that tracks which scope contributed each
|
|
@@ -442,24 +207,6 @@ export class PermissionManager {
|
|
|
442
207
|
return value;
|
|
443
208
|
}
|
|
444
209
|
|
|
445
|
-
private getConfiguredMcpServerNames(): readonly string[] {
|
|
446
|
-
if (this.configuredMcpServerNamesOverride) {
|
|
447
|
-
return this.configuredMcpServerNamesOverride;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const paths = [this.globalMcpConfigPath];
|
|
451
|
-
const stamp = paths
|
|
452
|
-
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
453
|
-
.join("|");
|
|
454
|
-
if (this.configuredMcpServerNamesCache?.stamp === stamp) {
|
|
455
|
-
return this.configuredMcpServerNamesCache.value;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const value = getConfiguredMcpServerNamesFromPaths(paths);
|
|
459
|
-
this.configuredMcpServerNamesCache = { stamp, value };
|
|
460
|
-
return value;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
210
|
/**
|
|
464
211
|
* Return the composed config-layer rules for the given agent scope.
|
|
465
212
|
* Used by the `/permission-system show` command to display effective rules
|
|
@@ -518,7 +265,7 @@ export class PermissionManager {
|
|
|
518
265
|
const { surface, values, resultExtras } = normalizeInput(
|
|
519
266
|
normalizedToolName,
|
|
520
267
|
input,
|
|
521
|
-
this.getConfiguredMcpServerNames(),
|
|
268
|
+
this.loader.getConfiguredMcpServerNames(),
|
|
522
269
|
);
|
|
523
270
|
|
|
524
271
|
const { rule, value } = evaluateFirst(surface, values, fullRules);
|
|
@@ -579,4 +326,6 @@ function deriveSource(
|
|
|
579
326
|
|
|
580
327
|
// Keep isPermissionState and toRecord available for convenience — they are
|
|
581
328
|
// used directly in some handler files that import from permission-manager.
|
|
582
|
-
export { isPermissionState, toRecord };
|
|
329
|
+
export { isPermissionState, toRecord } from "./common";
|
|
330
|
+
// Re-export types that external modules import from this file.
|
|
331
|
+
export type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
|