@komarspn/pi-permission-system 16.0.2
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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
|
|
3
|
+
/** Result of applying the permission gate. */
|
|
4
|
+
export type PermissionGateResult =
|
|
5
|
+
| {
|
|
6
|
+
action: "allow";
|
|
7
|
+
sessionApproval?: { surface: string; pattern: string };
|
|
8
|
+
persistentApprovalScope?: "project" | "global";
|
|
9
|
+
}
|
|
10
|
+
| { action: "block"; reason: string };
|
|
11
|
+
|
|
12
|
+
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
|
13
|
+
export interface PermissionGateParams {
|
|
14
|
+
/** The resolved permission state from checkPermission(). */
|
|
15
|
+
state: "allow" | "deny" | "ask";
|
|
16
|
+
|
|
17
|
+
/** Whether the current context supports interactive prompts. */
|
|
18
|
+
canConfirm: boolean;
|
|
19
|
+
|
|
20
|
+
/** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
|
|
21
|
+
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Session approval suggestion to record when the user selects
|
|
25
|
+
* "for this session". When present and the decision is `approved_for_session`,
|
|
26
|
+
* the result carries the suggestion back to the caller for recording.
|
|
27
|
+
*/
|
|
28
|
+
sessionApproval?: { surface: string; pattern: string };
|
|
29
|
+
|
|
30
|
+
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
|
31
|
+
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
|
32
|
+
|
|
33
|
+
/** Log context fields shared across all log calls for this gate. */
|
|
34
|
+
logContext: Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
/** Message strings/factories for each outcome. */
|
|
37
|
+
messages: {
|
|
38
|
+
denyReason: string;
|
|
39
|
+
unavailableReason: string;
|
|
40
|
+
userDeniedReason: (decision: PermissionPromptDecision) => string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply the deny/ask/allow permission gate.
|
|
46
|
+
*
|
|
47
|
+
* This is a pure decision function: all IO is injected via callbacks.
|
|
48
|
+
*/
|
|
49
|
+
export async function applyPermissionGate(
|
|
50
|
+
params: PermissionGateParams,
|
|
51
|
+
): Promise<PermissionGateResult> {
|
|
52
|
+
const {
|
|
53
|
+
state,
|
|
54
|
+
canConfirm,
|
|
55
|
+
promptForApproval,
|
|
56
|
+
writeLog,
|
|
57
|
+
logContext,
|
|
58
|
+
messages,
|
|
59
|
+
} = params;
|
|
60
|
+
|
|
61
|
+
if (state === "deny") {
|
|
62
|
+
writeLog("permission_request.blocked", {
|
|
63
|
+
...logContext,
|
|
64
|
+
resolution: "policy_denied",
|
|
65
|
+
});
|
|
66
|
+
return { action: "block", reason: messages.denyReason };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (state === "ask") {
|
|
70
|
+
if (!canConfirm) {
|
|
71
|
+
writeLog("permission_request.blocked", {
|
|
72
|
+
...logContext,
|
|
73
|
+
resolution: "confirmation_unavailable",
|
|
74
|
+
});
|
|
75
|
+
return { action: "block", reason: messages.unavailableReason };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const decision = await promptForApproval();
|
|
79
|
+
if (!decision.approved) {
|
|
80
|
+
return { action: "block", reason: messages.userDeniedReason(decision) };
|
|
81
|
+
}
|
|
82
|
+
if (decision.state === "approved_for_session" && params.sessionApproval) {
|
|
83
|
+
return { action: "allow", sessionApproval: params.sessionApproval };
|
|
84
|
+
}
|
|
85
|
+
if (decision.state === "approved_for_project") {
|
|
86
|
+
return { action: "allow", persistentApprovalScope: "project" };
|
|
87
|
+
}
|
|
88
|
+
if (decision.state === "approved_globally") {
|
|
89
|
+
return { action: "allow", persistentApprovalScope: "global" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { action: "allow" };
|
|
94
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isPermissionState } from "./common";
|
|
3
|
+
import {
|
|
4
|
+
getGlobalConfigPath,
|
|
5
|
+
getProjectAgentsDir,
|
|
6
|
+
getProjectConfigPath,
|
|
7
|
+
} from "./config-paths";
|
|
8
|
+
import { normalizeInput } from "./input-normalizer";
|
|
9
|
+
import { normalizeFlatConfig } from "./normalize";
|
|
10
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
11
|
+
import {
|
|
12
|
+
FilePolicyLoader,
|
|
13
|
+
type PolicyLoader,
|
|
14
|
+
type PolicyLoaderOptions,
|
|
15
|
+
type ResolvedPolicyPaths,
|
|
16
|
+
} from "./policy-loader";
|
|
17
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
18
|
+
import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
|
|
19
|
+
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
20
|
+
import {
|
|
21
|
+
composeRuleset,
|
|
22
|
+
synthesizeBaseline,
|
|
23
|
+
synthesizeDefaults,
|
|
24
|
+
} from "./synthesize";
|
|
25
|
+
import type {
|
|
26
|
+
FlatPermissionConfig,
|
|
27
|
+
PermissionCheckResult,
|
|
28
|
+
PermissionState,
|
|
29
|
+
} from "./types";
|
|
30
|
+
|
|
31
|
+
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
32
|
+
"bash",
|
|
33
|
+
"read",
|
|
34
|
+
"write",
|
|
35
|
+
"edit",
|
|
36
|
+
"grep",
|
|
37
|
+
"find",
|
|
38
|
+
"ls",
|
|
39
|
+
]);
|
|
40
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
41
|
+
|
|
42
|
+
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
43
|
+
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
|
44
|
+
|
|
45
|
+
type FileCacheEntry<TValue> = {
|
|
46
|
+
stamp: string;
|
|
47
|
+
value: TValue;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ResolvedPermissions = {
|
|
51
|
+
/**
|
|
52
|
+
* Fully composed ruleset: synthesized defaults → baseline → config.
|
|
53
|
+
* Session rules are appended at call-time inside checkPermission().
|
|
54
|
+
*/
|
|
55
|
+
composedRules: Ruleset;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Narrow interface for session-scoped permission checking.
|
|
60
|
+
* `PermissionSession` depends on this — not the full concrete class — so
|
|
61
|
+
* test mocks can satisfy it without an `as unknown as PermissionManager` cast.
|
|
62
|
+
*/
|
|
63
|
+
export interface ScopedPermissionManager {
|
|
64
|
+
configureForCwd(cwd: string | undefined | null): void;
|
|
65
|
+
checkPermission(
|
|
66
|
+
toolName: string,
|
|
67
|
+
input: unknown,
|
|
68
|
+
agentName?: string,
|
|
69
|
+
sessionRules?: Ruleset,
|
|
70
|
+
): PermissionCheckResult;
|
|
71
|
+
/**
|
|
72
|
+
* Evaluate a path-shaped surface (`path` or `external_directory`) against a
|
|
73
|
+
* caller-supplied set of equivalent policy values (e.g. bash tokens already
|
|
74
|
+
* resolved against a preceding literal `cd`, or a path's typed and
|
|
75
|
+
* symlink-resolved aliases). The values are trusted because they are computed
|
|
76
|
+
* internally, never read from a field on raw tool input. `surface` defaults
|
|
77
|
+
* to `path`.
|
|
78
|
+
*/
|
|
79
|
+
checkPathPolicy(
|
|
80
|
+
values: readonly string[],
|
|
81
|
+
agentName?: string,
|
|
82
|
+
sessionRules?: Ruleset,
|
|
83
|
+
surface?: string,
|
|
84
|
+
): PermissionCheckResult;
|
|
85
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
86
|
+
getConfigIssues(agentName?: string): string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
90
|
+
policyLoader?: PolicyLoader;
|
|
91
|
+
/**
|
|
92
|
+
* Pi agent directory. When provided, the manager derives all loader paths
|
|
93
|
+
* from this value and supports {@link PermissionManager.configureForCwd}.
|
|
94
|
+
*/
|
|
95
|
+
agentDir?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class PermissionManager implements ScopedPermissionManager {
|
|
99
|
+
private readonly agentDir: string | undefined;
|
|
100
|
+
private currentCwd: string | undefined;
|
|
101
|
+
private loader: PolicyLoader;
|
|
102
|
+
private readonly resolvedPermissionsCache = new Map<
|
|
103
|
+
string,
|
|
104
|
+
FileCacheEntry<ResolvedPermissions>
|
|
105
|
+
>();
|
|
106
|
+
|
|
107
|
+
constructor(options: PermissionManagerOptions = {}) {
|
|
108
|
+
this.agentDir = options.agentDir;
|
|
109
|
+
this.loader =
|
|
110
|
+
options.policyLoader ??
|
|
111
|
+
new FilePolicyLoader(
|
|
112
|
+
options.agentDir !== undefined
|
|
113
|
+
? derivePolicyLoaderOptions(options.agentDir, undefined)
|
|
114
|
+
: options,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rebuild the policy loader for a new working directory and clear the
|
|
120
|
+
* resolved-permissions cache.
|
|
121
|
+
*
|
|
122
|
+
* When `agentDir` was not provided at construction (e.g. test managers
|
|
123
|
+
* built with explicit paths), only the cache is cleared.
|
|
124
|
+
*/
|
|
125
|
+
configureForCwd(cwd: string | undefined | null): void {
|
|
126
|
+
this.currentCwd =
|
|
127
|
+
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
128
|
+
if (this.agentDir !== undefined) {
|
|
129
|
+
this.loader = new FilePolicyLoader(
|
|
130
|
+
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
this.resolvedPermissionsCache.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getConfigIssues(agentName?: string): string[] {
|
|
137
|
+
// Trigger a load/resolve to ensure issues are collected.
|
|
138
|
+
this.resolvePermissions(agentName);
|
|
139
|
+
return [...this.loader.getConfigIssues()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
143
|
+
return this.loader.getResolvedPolicyPaths();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private resolvePermissions(agentName?: string): ResolvedPermissions {
|
|
147
|
+
const cacheKey = agentName ?? "__global__";
|
|
148
|
+
const stamp = this.loader.getCacheStamp(agentName);
|
|
149
|
+
const cached = this.resolvedPermissionsCache.get(cacheKey);
|
|
150
|
+
if (cached?.stamp === stamp) {
|
|
151
|
+
return cached.value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const globalConfig = this.loader.loadGlobalConfig();
|
|
155
|
+
const projectConfig = this.loader.loadProjectConfig();
|
|
156
|
+
const agentConfig = this.loader.loadAgentConfig(agentName);
|
|
157
|
+
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
|
158
|
+
|
|
159
|
+
// Merge permission objects across scopes (lowest → highest precedence),
|
|
160
|
+
// building a parallel origin map that tracks which scope contributed each
|
|
161
|
+
// (surface, pattern) entry.
|
|
162
|
+
const { mergedPermission, origins } = mergeScopesWithOrigins([
|
|
163
|
+
["global", globalConfig],
|
|
164
|
+
["project", projectConfig],
|
|
165
|
+
["agent", agentConfig],
|
|
166
|
+
["project-agent", projectAgentConfig],
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
// Extract the universal fallback from permission["*"].
|
|
170
|
+
// The "*" key feeds synthesizeDefaults() only — it is NOT included as a
|
|
171
|
+
// config rule so that extension tools fall through to source:"default".
|
|
172
|
+
const universalFallback = isPermissionState(mergedPermission["*"])
|
|
173
|
+
? mergedPermission["*"]
|
|
174
|
+
: DEFAULT_UNIVERSAL_FALLBACK;
|
|
175
|
+
// Track which scope contributed the universal fallback.
|
|
176
|
+
const universalFallbackOrigin: RuleOrigin =
|
|
177
|
+
origins.get("*")?.get("*") ?? "builtin";
|
|
178
|
+
|
|
179
|
+
// Build config rules from everything except the universal "*" key.
|
|
180
|
+
const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
|
|
181
|
+
Object.entries(mergedPermission).filter(([k]) => k !== "*"),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Normalize to config rules, tagged with "config" layer and their origin.
|
|
185
|
+
const configRules: Ruleset = normalizeFlatConfig(
|
|
186
|
+
permissionWithoutUniversal,
|
|
187
|
+
).map(
|
|
188
|
+
(r): Rule => ({
|
|
189
|
+
...r,
|
|
190
|
+
layer: "config",
|
|
191
|
+
origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const composedRules = composeRuleset(
|
|
196
|
+
synthesizeDefaults(universalFallback, universalFallbackOrigin),
|
|
197
|
+
synthesizeBaseline(configRules),
|
|
198
|
+
configRules,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const value: ResolvedPermissions = { composedRules };
|
|
202
|
+
this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Return the composed config-layer rules for the given agent scope.
|
|
208
|
+
* Used by the `/permission-system show` command to display effective rules
|
|
209
|
+
* with their origin annotations.
|
|
210
|
+
* Session rules are not included — they are runtime-only.
|
|
211
|
+
*/
|
|
212
|
+
getComposedConfigRules(agentName?: string): Ruleset {
|
|
213
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
214
|
+
return composedRules.filter((r) => r.layer === "config");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get the tool-level permission state for a tool, without considering
|
|
219
|
+
* command-level rules. Used for tool injection decisions.
|
|
220
|
+
*/
|
|
221
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
222
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
223
|
+
const normalizedToolName = toolName.trim();
|
|
224
|
+
|
|
225
|
+
// Special surfaces (external_directory): evaluate directly by surface name.
|
|
226
|
+
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
227
|
+
return evaluate(normalizedToolName, "*", composedRules).action;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
|
|
231
|
+
// (or universal default) handles this correctly.
|
|
232
|
+
if (normalizedToolName === "bash") {
|
|
233
|
+
return evaluate("bash", "*", composedRules).action;
|
|
234
|
+
}
|
|
235
|
+
if (normalizedToolName === "mcp") {
|
|
236
|
+
return evaluate("mcp", "*", composedRules).action;
|
|
237
|
+
}
|
|
238
|
+
if (normalizedToolName === "skill") {
|
|
239
|
+
return evaluate("skill", "*", composedRules).action;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Tool-name surfaces (read, write, etc. and extension tools).
|
|
243
|
+
return evaluate(normalizedToolName, "*", composedRules).action;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
checkPermission(
|
|
247
|
+
toolName: string,
|
|
248
|
+
input: unknown,
|
|
249
|
+
agentName?: string,
|
|
250
|
+
sessionRules?: Ruleset,
|
|
251
|
+
): PermissionCheckResult {
|
|
252
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
253
|
+
const normalizedToolName = toolName.trim();
|
|
254
|
+
|
|
255
|
+
// Append session rules at the end (highest priority) so evaluate() handles
|
|
256
|
+
// them via last-match-wins — no separate per-branch pre-check needed.
|
|
257
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
258
|
+
? [...composedRules, ...sessionRules]
|
|
259
|
+
: composedRules;
|
|
260
|
+
|
|
261
|
+
const { surface, values, resultExtras } = normalizeInput(
|
|
262
|
+
normalizedToolName,
|
|
263
|
+
input,
|
|
264
|
+
this.loader.getConfiguredMcpServerNames(),
|
|
265
|
+
this.currentCwd,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return buildCheckResult(
|
|
269
|
+
surface,
|
|
270
|
+
values,
|
|
271
|
+
resultExtras,
|
|
272
|
+
normalizedToolName,
|
|
273
|
+
toolName,
|
|
274
|
+
fullRules,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
checkPathPolicy(
|
|
279
|
+
values: readonly string[],
|
|
280
|
+
agentName?: string,
|
|
281
|
+
sessionRules?: Ruleset,
|
|
282
|
+
surface = "path",
|
|
283
|
+
): PermissionCheckResult {
|
|
284
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
285
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
286
|
+
? [...composedRules, ...sessionRules]
|
|
287
|
+
: composedRules;
|
|
288
|
+
|
|
289
|
+
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
290
|
+
return buildCheckResult(
|
|
291
|
+
surface,
|
|
292
|
+
lookupValues,
|
|
293
|
+
{},
|
|
294
|
+
surface,
|
|
295
|
+
surface,
|
|
296
|
+
fullRules,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Evaluate a normalized surface/values triple and shape the result.
|
|
303
|
+
*
|
|
304
|
+
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
|
305
|
+
* aliases); every other surface keeps {@link evaluateFirst}. Shared by
|
|
306
|
+
* `checkPermission` and `checkPathPolicy`.
|
|
307
|
+
*/
|
|
308
|
+
function buildCheckResult(
|
|
309
|
+
surface: string,
|
|
310
|
+
values: string[],
|
|
311
|
+
resultExtras: Record<string, unknown>,
|
|
312
|
+
normalizedToolName: string,
|
|
313
|
+
toolName: string,
|
|
314
|
+
fullRules: Ruleset,
|
|
315
|
+
): PermissionCheckResult {
|
|
316
|
+
const { rule, value } = PATH_SURFACES.has(surface)
|
|
317
|
+
? evaluateAnyValue(surface, values, fullRules)
|
|
318
|
+
: evaluateFirst(surface, values, fullRules);
|
|
319
|
+
|
|
320
|
+
// For MCP, replace the normalizer's fallback target with the actual
|
|
321
|
+
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
322
|
+
const extras =
|
|
323
|
+
surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
toolName,
|
|
327
|
+
state: rule.action,
|
|
328
|
+
reason: rule.reason,
|
|
329
|
+
matchedPattern:
|
|
330
|
+
rule.layer === "config" || rule.layer === "session"
|
|
331
|
+
? rule.pattern
|
|
332
|
+
: undefined,
|
|
333
|
+
source: deriveSource(rule, normalizedToolName),
|
|
334
|
+
origin: rule.origin,
|
|
335
|
+
...extras,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
|
341
|
+
* Setting agentsDir explicitly from agentDir removes the hidden
|
|
342
|
+
* `getAgentDir()` env-read that FilePolicyLoader's default would perform.
|
|
343
|
+
*/
|
|
344
|
+
function derivePolicyLoaderOptions(
|
|
345
|
+
agentDir: string,
|
|
346
|
+
cwd: string | undefined | null,
|
|
347
|
+
): PolicyLoaderOptions {
|
|
348
|
+
return {
|
|
349
|
+
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
350
|
+
agentsDir: join(agentDir, "agents"),
|
|
351
|
+
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
|
|
352
|
+
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Map a matched rule + tool name to the correct PermissionCheckResult.source.
|
|
358
|
+
*
|
|
359
|
+
* Mirrors the source-derivation logic from the former per-branch
|
|
360
|
+
* checkPermission() implementation:
|
|
361
|
+
*
|
|
362
|
+
* - session → "session" (always, all surfaces)
|
|
363
|
+
* - mcp + default → "default"
|
|
364
|
+
* - mcp + other → "mcp"
|
|
365
|
+
* - special → "special" (always)
|
|
366
|
+
* - skill → "skill" (always)
|
|
367
|
+
* - bash → "bash" (always)
|
|
368
|
+
* - built-in tool → "tool" (always)
|
|
369
|
+
* - extension tool → "default" when default layer, "tool" otherwise
|
|
370
|
+
*/
|
|
371
|
+
function deriveSource(
|
|
372
|
+
rule: Rule,
|
|
373
|
+
toolName: string,
|
|
374
|
+
): PermissionCheckResult["source"] {
|
|
375
|
+
if (rule.layer === "session") return "session";
|
|
376
|
+
|
|
377
|
+
if (toolName === "mcp") {
|
|
378
|
+
if (rule.layer === "default") return "default";
|
|
379
|
+
return "mcp";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
|
|
383
|
+
if (toolName === "skill") return "skill";
|
|
384
|
+
if (toolName === "bash") return "bash";
|
|
385
|
+
|
|
386
|
+
// Built-in tools always report "tool"; extension tools distinguish default.
|
|
387
|
+
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
|
|
388
|
+
return rule.layer === "default" ? "default" : "tool";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Re-export types that external modules import from this file.
|
|
392
|
+
export type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FlatPermissionConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deep-shallow merge two flat permission configs.
|
|
5
|
+
* Both objects → shallow-merge the pattern maps.
|
|
6
|
+
* Otherwise → override replaces base.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeFlatPermissions(
|
|
9
|
+
base: FlatPermissionConfig,
|
|
10
|
+
override: FlatPermissionConfig,
|
|
11
|
+
): FlatPermissionConfig {
|
|
12
|
+
const merged: FlatPermissionConfig = { ...base };
|
|
13
|
+
for (const [key, value] of Object.entries(override)) {
|
|
14
|
+
const baseVal = merged[key];
|
|
15
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
16
|
+
if (
|
|
17
|
+
typeof baseVal === "object" &&
|
|
18
|
+
baseVal !== null &&
|
|
19
|
+
typeof value === "object" &&
|
|
20
|
+
value !== null
|
|
21
|
+
) {
|
|
22
|
+
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
23
|
+
merged[key] = {
|
|
24
|
+
...baseVal,
|
|
25
|
+
...value,
|
|
26
|
+
};
|
|
27
|
+
} else {
|
|
28
|
+
merged[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return merged;
|
|
32
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ConfigReader } from "./config-store";
|
|
3
|
+
import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
|
|
4
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
5
|
+
import {
|
|
6
|
+
emitUiPromptEvent,
|
|
7
|
+
type PermissionEventBus,
|
|
8
|
+
} from "./permission-events";
|
|
9
|
+
import { buildDirectUiPrompt } from "./permission-ui-prompt";
|
|
10
|
+
import type { ReviewLogger } from "./session-logger";
|
|
11
|
+
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
12
|
+
|
|
13
|
+
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
14
|
+
|
|
15
|
+
/** Details passed when prompting the user for a permission decision. */
|
|
16
|
+
export interface PromptPermissionDetails {
|
|
17
|
+
requestId: string;
|
|
18
|
+
source: PermissionReviewSource;
|
|
19
|
+
agentName: string | null;
|
|
20
|
+
message: string;
|
|
21
|
+
toolCallId?: string;
|
|
22
|
+
toolName?: string;
|
|
23
|
+
skillName?: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
command?: string;
|
|
26
|
+
target?: string;
|
|
27
|
+
toolInputPreview?: string;
|
|
28
|
+
/** Override label for the "for this session" dialog option. */
|
|
29
|
+
sessionLabel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mockable contract for permission prompting. */
|
|
33
|
+
export interface PermissionPrompterApi {
|
|
34
|
+
prompt(
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
details: PromptPermissionDetails,
|
|
37
|
+
): Promise<PermissionPromptDecision>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dependencies required by PermissionPrompter.
|
|
42
|
+
*
|
|
43
|
+
* Keeps the prompter's external surface narrow: callers provide config
|
|
44
|
+
* access, a review logger, the UI-prompt event bus, and the forwarder
|
|
45
|
+
* that owns the UI/subagent-forwarding branching logic.
|
|
46
|
+
*/
|
|
47
|
+
export interface PermissionPrompterDeps {
|
|
48
|
+
/** Read current config for yolo-mode check (called at prompt time). */
|
|
49
|
+
config: ConfigReader;
|
|
50
|
+
/** Write structured entries to the permission review log. */
|
|
51
|
+
logger: ReviewLogger;
|
|
52
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
53
|
+
events: PermissionEventBus;
|
|
54
|
+
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
55
|
+
forwarder: ApprovalRequester;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Encapsulates the full permission-prompt flow:
|
|
60
|
+
* 1. Yolo-mode auto-approval check.
|
|
61
|
+
* 2. Review-log "waiting" entry.
|
|
62
|
+
* 3. UI-present vs. subagent-forwarding branching (via confirmPermission).
|
|
63
|
+
* 4. Review-log "approved" / "denied" entry.
|
|
64
|
+
*
|
|
65
|
+
* Injecting a single PermissionPrompter instance means adding a new prompt
|
|
66
|
+
* parameter (e.g. a future sessionLabel variant) only requires changing
|
|
67
|
+
* PromptPermissionDetails and this class — not the full threading chain.
|
|
68
|
+
*/
|
|
69
|
+
export class PermissionPrompter implements PermissionPrompterApi {
|
|
70
|
+
constructor(private readonly deps: PermissionPrompterDeps) {}
|
|
71
|
+
|
|
72
|
+
async prompt(
|
|
73
|
+
ctx: ExtensionContext,
|
|
74
|
+
details: PromptPermissionDetails,
|
|
75
|
+
): Promise<PermissionPromptDecision> {
|
|
76
|
+
if (shouldAutoApprovePermissionState("ask", this.deps.config.current())) {
|
|
77
|
+
this.writeReviewEntry("permission_request.auto_approved", details);
|
|
78
|
+
return { approved: true, state: "approved", autoApproved: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.writeReviewEntry("permission_request.waiting", details);
|
|
82
|
+
|
|
83
|
+
// Build the event once. When this session has UI it broadcasts directly;
|
|
84
|
+
// when it does not (a forwarding subagent), the display fields ride along
|
|
85
|
+
// to the parent so the parent emits a non-degraded event from the
|
|
86
|
+
// forwarded path instead of here.
|
|
87
|
+
const uiPrompt = buildDirectUiPrompt(details);
|
|
88
|
+
if (ctx.hasUI) {
|
|
89
|
+
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const decision = await this.deps.forwarder.requestApproval(
|
|
93
|
+
ctx,
|
|
94
|
+
details.message,
|
|
95
|
+
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
96
|
+
{
|
|
97
|
+
source: uiPrompt.source,
|
|
98
|
+
surface: uiPrompt.surface,
|
|
99
|
+
value: uiPrompt.value,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
this.writeReviewEntry(
|
|
104
|
+
decision.approved
|
|
105
|
+
? "permission_request.approved"
|
|
106
|
+
: "permission_request.denied",
|
|
107
|
+
{
|
|
108
|
+
...details,
|
|
109
|
+
resolution: decision.state,
|
|
110
|
+
denialReason: decision.denialReason,
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return decision;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
private writeReviewEntry(
|
|
120
|
+
event: string,
|
|
121
|
+
details: PromptPermissionDetails & {
|
|
122
|
+
resolution?: string;
|
|
123
|
+
denialReason?: string;
|
|
124
|
+
},
|
|
125
|
+
): void {
|
|
126
|
+
this.deps.logger.review(event, {
|
|
127
|
+
requestId: details.requestId,
|
|
128
|
+
source: details.source,
|
|
129
|
+
agentName: details.agentName,
|
|
130
|
+
message: details.message,
|
|
131
|
+
toolCallId: details.toolCallId ?? null,
|
|
132
|
+
toolName: details.toolName ?? null,
|
|
133
|
+
skillName: details.skillName ?? null,
|
|
134
|
+
path: details.path ?? null,
|
|
135
|
+
command: details.command ?? null,
|
|
136
|
+
target: details.target ?? null,
|
|
137
|
+
toolInputPreview: details.toolInputPreview ?? null,
|
|
138
|
+
resolution: details.resolution ?? null,
|
|
139
|
+
denialReason: details.denialReason ?? null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|