@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,157 @@
|
|
|
1
|
+
import { stripBashCommentLines } from "./bash-arity";
|
|
2
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
3
|
+
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
|
+
import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Construct a surface-appropriate input object from a raw value string.
|
|
8
|
+
*
|
|
9
|
+
* This is the inverse of `normalizeInput()` — it builds the minimal input
|
|
10
|
+
* object that `PermissionManager.checkPermission()` expects for a given
|
|
11
|
+
* surface, from a single string value.
|
|
12
|
+
*
|
|
13
|
+
* Used by the event-bus RPC handler and the `Symbol.for()` service accessor
|
|
14
|
+
* so external callers can query policy with `(surface, value)` instead of
|
|
15
|
+
* constructing a full tool-call input payload.
|
|
16
|
+
*
|
|
17
|
+
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
|
18
|
+
* providing an MCP surface receive a best-effort policy evaluation using the
|
|
19
|
+
* value as a pre-qualified target string. Pass the fully-qualified target
|
|
20
|
+
* (e.g. "exa:search" or "exa") directly.
|
|
21
|
+
*/
|
|
22
|
+
export function buildInputForSurface(
|
|
23
|
+
surface: string,
|
|
24
|
+
value: string | undefined,
|
|
25
|
+
): unknown {
|
|
26
|
+
const v = value ?? "";
|
|
27
|
+
if (surface === "bash") return { command: v };
|
|
28
|
+
if (surface === "skill") return { name: v };
|
|
29
|
+
if (surface === "external_directory") return { path: v };
|
|
30
|
+
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Surface-normalized representation of a tool invocation used by
|
|
36
|
+
* `checkPermission()` to feed a single `evaluateFirst()` call.
|
|
37
|
+
*/
|
|
38
|
+
export interface NormalizedInput {
|
|
39
|
+
/** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
|
|
40
|
+
surface: string;
|
|
41
|
+
/**
|
|
42
|
+
* Candidate lookup values in priority order (most-specific first).
|
|
43
|
+
* Most surfaces produce a single-element array; MCP produces a
|
|
44
|
+
* multi-candidate list derived from the invocation input.
|
|
45
|
+
*/
|
|
46
|
+
values: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Surface-specific fields forwarded verbatim into `PermissionCheckResult`
|
|
49
|
+
* (e.g. `{ command }` for bash, `{ target }` for mcp).
|
|
50
|
+
*/
|
|
51
|
+
resultExtras: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map a raw tool invocation to the surface/values/extras triple needed by
|
|
58
|
+
* `checkPermission()`.
|
|
59
|
+
*
|
|
60
|
+
* @param toolName - Normalized (trimmed) tool name from the tool-call event.
|
|
61
|
+
* @param input - Raw input payload from the tool-call event.
|
|
62
|
+
* @param configuredMcpServerNames - Ordered list of MCP server names from the
|
|
63
|
+
* global MCP config, used to derive server-qualified MCP targets.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeInput(
|
|
66
|
+
toolName: string,
|
|
67
|
+
input: unknown,
|
|
68
|
+
configuredMcpServerNames: readonly string[],
|
|
69
|
+
cwd?: string,
|
|
70
|
+
): NormalizedInput {
|
|
71
|
+
// --- Special surfaces (path, external_directory) ---
|
|
72
|
+
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
73
|
+
return {
|
|
74
|
+
surface: toolName,
|
|
75
|
+
values: normalizePathSurfaceValues(input, cwd),
|
|
76
|
+
resultExtras: {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Skill ---
|
|
81
|
+
if (toolName === "skill") {
|
|
82
|
+
const record = toRecord(input);
|
|
83
|
+
const skillName = record.name;
|
|
84
|
+
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
|
85
|
+
return {
|
|
86
|
+
surface: "skill",
|
|
87
|
+
values: [lookupValue],
|
|
88
|
+
resultExtras: {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Bash ---
|
|
93
|
+
if (toolName === "bash") {
|
|
94
|
+
const record = toRecord(input);
|
|
95
|
+
const command = typeof record.command === "string" ? record.command : "";
|
|
96
|
+
// Strip leading shell comment lines so pattern matching operates on the
|
|
97
|
+
// actual command, not a `# description` prefix agents often prepend.
|
|
98
|
+
// Fall back to the raw command when stripping leaves nothing, so an
|
|
99
|
+
// all-comment command still evaluates against its literal text.
|
|
100
|
+
const matchValue = stripBashCommentLines(command) || command;
|
|
101
|
+
return {
|
|
102
|
+
surface: "bash",
|
|
103
|
+
values: [matchValue],
|
|
104
|
+
resultExtras: { command },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- MCP ---
|
|
109
|
+
if (toolName === "mcp") {
|
|
110
|
+
const mcpTargets = [
|
|
111
|
+
...createMcpPermissionTargets(input, configuredMcpServerNames),
|
|
112
|
+
"mcp",
|
|
113
|
+
];
|
|
114
|
+
const fallbackTarget = mcpTargets[0] ?? "mcp";
|
|
115
|
+
return {
|
|
116
|
+
surface: "mcp",
|
|
117
|
+
values: mcpTargets,
|
|
118
|
+
resultExtras: { target: fallbackTarget },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Path-bearing tools (read, write, edit, grep, find, ls) ---
|
|
123
|
+
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
124
|
+
return {
|
|
125
|
+
surface: toolName,
|
|
126
|
+
values: normalizePathSurfaceValues(input, cwd),
|
|
127
|
+
resultExtras: {},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Extension tools (non-path-bearing) ---
|
|
132
|
+
return {
|
|
133
|
+
surface: toolName,
|
|
134
|
+
values: ["*"],
|
|
135
|
+
resultExtras: {},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract and normalize the path lookup values shared by every path surface
|
|
141
|
+
* (`path`, `external_directory`, and the path-bearing tools).
|
|
142
|
+
*
|
|
143
|
+
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
144
|
+
* `"*"`. When CWD is known, a relative path also produces a normalized
|
|
145
|
+
* absolute policy value and a project-relative alias while keeping its legacy
|
|
146
|
+
* relative value, so values match home- and cwd-anchored patterns
|
|
147
|
+
* symmetrically with how the patterns themselves are expanded (#350).
|
|
148
|
+
*
|
|
149
|
+
* Only `input.path` is read — policy values are never sourced from any other
|
|
150
|
+
* (potentially attacker-controlled) field on the raw tool input.
|
|
151
|
+
*/
|
|
152
|
+
function normalizePathSurfaceValues(input: unknown, cwd?: string): string[] {
|
|
153
|
+
const path = getNonEmptyString(toRecord(input).path);
|
|
154
|
+
if (path === null) return ["*"];
|
|
155
|
+
const values = getPathPolicyValues(path, cwd ? { cwd } : {});
|
|
156
|
+
return values.length > 0 ? values : ["*"];
|
|
157
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EXTENSION_ID,
|
|
5
|
+
type PermissionSystemExtensionConfig,
|
|
6
|
+
} from "./extension-config";
|
|
7
|
+
|
|
8
|
+
export function safeJsonStringify(value: unknown): string | undefined {
|
|
9
|
+
const seen = new WeakSet<object>();
|
|
10
|
+
return JSON.stringify(value, (_key, currentValue) => {
|
|
11
|
+
if (currentValue instanceof Error) {
|
|
12
|
+
return {
|
|
13
|
+
name: currentValue.name,
|
|
14
|
+
message: currentValue.message,
|
|
15
|
+
stack: currentValue.stack,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof currentValue === "bigint") {
|
|
20
|
+
return currentValue.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof currentValue === "object" && currentValue !== null) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
|
|
25
|
+
if (seen.has(currentValue)) {
|
|
26
|
+
return "[Circular]";
|
|
27
|
+
}
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
|
|
29
|
+
seen.add(currentValue);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
|
|
33
|
+
return currentValue;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PermissionSystemLogger {
|
|
38
|
+
debug: (
|
|
39
|
+
event: string,
|
|
40
|
+
details?: Record<string, unknown>,
|
|
41
|
+
) => string | undefined;
|
|
42
|
+
review: (
|
|
43
|
+
event: string,
|
|
44
|
+
details?: Record<string, unknown>,
|
|
45
|
+
) => string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PermissionSystemLoggerOptions {
|
|
49
|
+
getConfig: () => PermissionSystemExtensionConfig;
|
|
50
|
+
debugLogPath: string;
|
|
51
|
+
reviewLogPath: string;
|
|
52
|
+
ensureLogsDirectory: () => string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPermissionSystemLogger(
|
|
56
|
+
options: PermissionSystemLoggerOptions,
|
|
57
|
+
): PermissionSystemLogger {
|
|
58
|
+
const { debugLogPath, reviewLogPath, ensureLogsDirectory } = options;
|
|
59
|
+
|
|
60
|
+
const writeLine = (
|
|
61
|
+
stream: "debug" | "review",
|
|
62
|
+
path: string,
|
|
63
|
+
event: string,
|
|
64
|
+
details: Record<string, unknown>,
|
|
65
|
+
): string | undefined => {
|
|
66
|
+
const directoryError = ensureLogsDirectory();
|
|
67
|
+
if (directoryError) {
|
|
68
|
+
return directoryError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const line = safeJsonStringify({
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
extension: EXTENSION_ID,
|
|
75
|
+
stream,
|
|
76
|
+
event,
|
|
77
|
+
...details,
|
|
78
|
+
});
|
|
79
|
+
if (!line) {
|
|
80
|
+
return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
|
|
81
|
+
}
|
|
82
|
+
appendFileSync(path, `${line}\n`, "utf-8");
|
|
83
|
+
return undefined;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
return `Failed to write permission-system ${stream} log '${path}': ${message}`;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const debug = (
|
|
91
|
+
event: string,
|
|
92
|
+
details: Record<string, unknown> = {},
|
|
93
|
+
): string | undefined => {
|
|
94
|
+
if (!options.getConfig().debugLog) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return writeLine("debug", debugLogPath, event, details);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const review = (
|
|
102
|
+
event: string,
|
|
103
|
+
details: Record<string, unknown> = {},
|
|
104
|
+
): string | undefined => {
|
|
105
|
+
if (!options.getConfig().permissionReviewLog) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return writeLine("review", reviewLogPath, event, details);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { debug, review };
|
|
113
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An ordered accumulator that owns the uniqueness invariant.
|
|
5
|
+
*
|
|
6
|
+
* `add` ignores null/empty values and silently skips duplicates (first-insertion
|
|
7
|
+
* wins). `toArray` returns the ordered result as an independent copy.
|
|
8
|
+
*/
|
|
9
|
+
export class McpTargetList {
|
|
10
|
+
private readonly targets: string[] = [];
|
|
11
|
+
|
|
12
|
+
add(value: string | null): void {
|
|
13
|
+
if (!value) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!this.targets.includes(value)) {
|
|
17
|
+
this.targets.push(value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toArray(): string[] {
|
|
22
|
+
return [...this.targets];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a qualified MCP tool name of the form `server:tool`.
|
|
28
|
+
*
|
|
29
|
+
* Returns `{ server, tool }` when the string contains exactly one colon with
|
|
30
|
+
* non-empty text on both sides; otherwise returns `null`.
|
|
31
|
+
*/
|
|
32
|
+
export function parseQualifiedMcpToolName(
|
|
33
|
+
value: string,
|
|
34
|
+
): { server: string; tool: string } | null {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (!trimmed) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const colonIndex = trimmed.indexOf(":");
|
|
41
|
+
if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const server = trimmed.slice(0, colonIndex).trim();
|
|
46
|
+
const tool = trimmed.slice(colonIndex + 1).trim();
|
|
47
|
+
if (!server || !tool) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { server, tool };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function addDerivedMcpServerTargets(
|
|
55
|
+
toolName: string,
|
|
56
|
+
configuredServerNames: readonly string[],
|
|
57
|
+
targets: McpTargetList,
|
|
58
|
+
): void {
|
|
59
|
+
const trimmedToolName = toolName.trim();
|
|
60
|
+
if (!trimmedToolName) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const serverName of configuredServerNames) {
|
|
65
|
+
const trimmedServerName = serverName.trim();
|
|
66
|
+
if (!trimmedServerName) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
targets.add(`${trimmedServerName}_${trimmedToolName}`);
|
|
79
|
+
targets.add(`${trimmedServerName}:${trimmedToolName}`);
|
|
80
|
+
targets.add(trimmedServerName);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pushMcpToolPermissionTargets(
|
|
85
|
+
rawReference: string,
|
|
86
|
+
serverHint: string | null,
|
|
87
|
+
configuredServerNames: readonly string[],
|
|
88
|
+
targets: McpTargetList,
|
|
89
|
+
): void {
|
|
90
|
+
const qualified = parseQualifiedMcpToolName(rawReference);
|
|
91
|
+
const resolvedServer = serverHint ?? qualified?.server ?? null;
|
|
92
|
+
const resolvedTool = qualified?.tool ?? rawReference;
|
|
93
|
+
|
|
94
|
+
if (resolvedServer) {
|
|
95
|
+
targets.add(`${resolvedServer}_${resolvedTool}`);
|
|
96
|
+
targets.add(`${resolvedServer}:${resolvedTool}`);
|
|
97
|
+
targets.add(resolvedServer);
|
|
98
|
+
} else {
|
|
99
|
+
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
targets.add(resolvedTool);
|
|
103
|
+
targets.add(rawReference);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Derive the ordered list of MCP permission-lookup candidates from a raw MCP
|
|
108
|
+
* tool invocation input.
|
|
109
|
+
*
|
|
110
|
+
* Candidates are ordered from most-specific to least-specific so that
|
|
111
|
+
* `evaluateFirst()` stops at the first non-default match.
|
|
112
|
+
*/
|
|
113
|
+
export function createMcpPermissionTargets(
|
|
114
|
+
input: unknown,
|
|
115
|
+
configuredServerNames: readonly string[] = [],
|
|
116
|
+
): string[] {
|
|
117
|
+
const record = toRecord(input);
|
|
118
|
+
const tool = getNonEmptyString(record.tool);
|
|
119
|
+
const server = getNonEmptyString(record.server);
|
|
120
|
+
const connect = getNonEmptyString(record.connect);
|
|
121
|
+
const describe = getNonEmptyString(record.describe);
|
|
122
|
+
const search = getNonEmptyString(record.search);
|
|
123
|
+
|
|
124
|
+
const targets = new McpTargetList();
|
|
125
|
+
|
|
126
|
+
if (tool) {
|
|
127
|
+
pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
|
|
128
|
+
targets.add("mcp_call");
|
|
129
|
+
return targets.toArray();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (connect) {
|
|
133
|
+
targets.add(`mcp_connect_${connect}`);
|
|
134
|
+
targets.add(connect);
|
|
135
|
+
targets.add("mcp_connect");
|
|
136
|
+
return targets.toArray();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (describe) {
|
|
140
|
+
pushMcpToolPermissionTargets(
|
|
141
|
+
describe,
|
|
142
|
+
server,
|
|
143
|
+
configuredServerNames,
|
|
144
|
+
targets,
|
|
145
|
+
);
|
|
146
|
+
targets.add("mcp_describe");
|
|
147
|
+
return targets.toArray();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (search) {
|
|
151
|
+
if (server) {
|
|
152
|
+
targets.add(`mcp_server_${server}`);
|
|
153
|
+
targets.add(server);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
targets.add(search);
|
|
157
|
+
targets.add("mcp_search");
|
|
158
|
+
return targets.toArray();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (server) {
|
|
162
|
+
targets.add(`mcp_server_${server}`);
|
|
163
|
+
targets.add(server);
|
|
164
|
+
targets.add("mcp_list");
|
|
165
|
+
return targets.toArray();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
targets.add("mcp_status");
|
|
169
|
+
return targets.toArray();
|
|
170
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk up the directory tree from the given file URL until a directory
|
|
8
|
+
* literally named `node_modules` is found.
|
|
9
|
+
*
|
|
10
|
+
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
|
11
|
+
* no `node_modules` ancestor exists.
|
|
12
|
+
*/
|
|
13
|
+
function walkUpToNodeModules(fromUrl: string): string | null {
|
|
14
|
+
try {
|
|
15
|
+
const thisFile = fileURLToPath(fromUrl);
|
|
16
|
+
let dir = dirname(thisFile);
|
|
17
|
+
while (dir !== dirname(dir)) {
|
|
18
|
+
if (basename(dir) === "node_modules") {
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
dir = dirname(dir);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
|
31
|
+
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
|
32
|
+
*
|
|
33
|
+
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
|
34
|
+
* running from a local development checkout, not a global install).
|
|
35
|
+
*/
|
|
36
|
+
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
|
37
|
+
try {
|
|
38
|
+
const result = spawnSync("npm", ["root", "-g"], {
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
timeout: 5000,
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
});
|
|
43
|
+
const root = result.stdout.trim();
|
|
44
|
+
if (result.status === 0 && root && existsSync(root)) {
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Discover the global node_modules root.
|
|
55
|
+
*
|
|
56
|
+
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
|
57
|
+
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
|
58
|
+
* directory named `node_modules`. This works whenever the extension is
|
|
59
|
+
* installed inside a `node_modules` tree.
|
|
60
|
+
*
|
|
61
|
+
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
|
62
|
+
* because the extension is running from a local development checkout with no
|
|
63
|
+
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
|
64
|
+
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
|
65
|
+
* returns the correct root regardless of the user's own project package
|
|
66
|
+
* manager.
|
|
67
|
+
*
|
|
68
|
+
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
|
69
|
+
*/
|
|
70
|
+
export function discoverGlobalNodeModulesRoot(
|
|
71
|
+
fromUrl = import.meta.url,
|
|
72
|
+
): string | null {
|
|
73
|
+
const fromSelf = walkUpToNodeModules(fromUrl);
|
|
74
|
+
if (fromSelf) return fromSelf;
|
|
75
|
+
return discoverGlobalNodeModulesViaSubprocess();
|
|
76
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isDenyWithReason, isPermissionState } from "./common";
|
|
2
|
+
import type { Rule, Ruleset } from "./rule";
|
|
3
|
+
import type { FlatPermissionConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a flat permission config into a Ruleset.
|
|
7
|
+
*
|
|
8
|
+
* Each key is a surface name. A string value is shorthand for
|
|
9
|
+
* `{ "*": action }`. An object value maps patterns to actions.
|
|
10
|
+
* A pattern value may be a PermissionState string or a `DenyWithReason`
|
|
11
|
+
* object (`{ action: "deny", reason?: string }`).
|
|
12
|
+
* Invalid action values are silently skipped.
|
|
13
|
+
*
|
|
14
|
+
* The universal fallback key `"*"` is included if present — callers
|
|
15
|
+
* that use `"*"` only for `synthesizeDefaults()` should strip it before
|
|
16
|
+
* calling this function.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
|
19
|
+
const rules: Rule[] = [];
|
|
20
|
+
for (const [surface, value] of Object.entries(permission)) {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
if (isPermissionState(value)) {
|
|
23
|
+
rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
|
|
24
|
+
}
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
|
|
26
|
+
} else if (typeof value === "object" && value !== null) {
|
|
27
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
28
|
+
if (isDenyWithReason(action)) {
|
|
29
|
+
rules.push({
|
|
30
|
+
surface,
|
|
31
|
+
pattern,
|
|
32
|
+
action: "deny",
|
|
33
|
+
reason: action.reason,
|
|
34
|
+
origin: "builtin",
|
|
35
|
+
});
|
|
36
|
+
} else if (isPermissionState(action)) {
|
|
37
|
+
rules.push({ surface, pattern, action, origin: "builtin" });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return rules;
|
|
43
|
+
}
|