@gotgenes/pi-permission-system 11.0.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +9 -11
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/config-modal.ts +3 -7
- package/src/extension-config.ts +11 -25
- package/src/handlers/gates/bash-path-extractor.ts +0 -12
- package/src/handlers/gates/bash-path.ts +12 -10
- package/src/handlers/gates/bash-program.ts +52 -11
- package/src/handlers/gates/bash-token-classification.ts +1 -1
- package/src/handlers/gates/external-directory.ts +8 -2
- package/src/handlers/gates/path.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +5 -2
- package/src/index.ts +8 -2
- package/src/input-normalizer.ts +17 -11
- package/src/path-utils.ts +122 -0
- package/src/permission-manager.ts +81 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/permissions-service.ts +12 -0
- package/src/rule.ts +61 -11
- package/src/service.ts +24 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/test/bash-external-directory.test.ts +1 -81
- package/test/composition-root.test.ts +36 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/extension-config.test.ts +0 -58
- package/test/handlers/gates/bash-path.test.ts +45 -2
- package/test/handlers/gates/bash-program.test.ts +44 -11
- package/test/handlers/gates/external-directory.test.ts +54 -0
- package/test/handlers/gates/path.test.ts +72 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +64 -1
- package/test/helpers/gate-fixtures.ts +23 -3
- package/test/helpers/handler-fixtures.ts +14 -2
- package/test/helpers/session-fixtures.ts +14 -0
- package/test/input-normalizer.test.ts +52 -0
- package/test/path-utils.test.ts +135 -0
- package/test/permission-manager-unified.test.ts +134 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/permissions-service.test.ts +35 -1
- package/test/rule.test.ts +74 -1
- package/test/service-lifecycle.test.ts +1 -0
- package/test/service.test.ts +53 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
package/src/path-utils.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
join,
|
|
3
3
|
normalize,
|
|
4
4
|
posix as posixPath,
|
|
5
|
+
relative,
|
|
5
6
|
resolve,
|
|
6
7
|
win32 as winPath,
|
|
7
8
|
} from "node:path";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
import { canonicalizePath } from "./canonicalize-path";
|
|
10
11
|
import { getNonEmptyString, toRecord } from "./common";
|
|
11
12
|
import { expandHomePath } from "./expand-home";
|
|
13
|
+
import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
|
|
12
14
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
13
15
|
|
|
14
16
|
export function normalizePathForComparison(
|
|
@@ -62,6 +64,86 @@ export function isPathWithinDirectory(
|
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
export interface PathPolicyValueOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Current Pi working directory. When provided, returned values include a
|
|
70
|
+
* project-relative alias for paths that resolve inside this directory.
|
|
71
|
+
*/
|
|
72
|
+
cwd?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Directory used to resolve `pathValue` into an absolute policy value.
|
|
75
|
+
* Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
|
|
76
|
+
*/
|
|
77
|
+
resolveBase?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a single path-like lookup value without resolving it against CWD.
|
|
82
|
+
*
|
|
83
|
+
* Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
|
|
84
|
+
* while applying the same lexical cleanup as
|
|
85
|
+
* {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
|
|
86
|
+
* strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePathPolicyLiteral(pathValue: string): string {
|
|
89
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
90
|
+
if (!trimmed) return "";
|
|
91
|
+
const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
92
|
+
return expandHomePath(unprefixed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Return equivalent lookup values for path-policy matching.
|
|
97
|
+
*
|
|
98
|
+
* The first value is the cwd/effective-base normalized absolute path when a
|
|
99
|
+
* base is available. The later values preserve project-relative and raw
|
|
100
|
+
* relative forms so existing rules like `src/*` and `*.env` continue to match.
|
|
101
|
+
*/
|
|
102
|
+
export function getPathPolicyValues(
|
|
103
|
+
pathValue: string,
|
|
104
|
+
options: PathPolicyValueOptions = {},
|
|
105
|
+
): string[] {
|
|
106
|
+
const literal = normalizePathPolicyLiteral(pathValue);
|
|
107
|
+
if (!literal) return [];
|
|
108
|
+
if (literal === "*") return ["*"];
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getAbsolutePathPolicyValues(
|
|
116
|
+
pathValue: string,
|
|
117
|
+
options: PathPolicyValueOptions,
|
|
118
|
+
): string[] {
|
|
119
|
+
const resolveBase = options.resolveBase ?? options.cwd;
|
|
120
|
+
if (!resolveBase) return [];
|
|
121
|
+
|
|
122
|
+
const absolute = normalizePathForComparison(pathValue, resolveBase);
|
|
123
|
+
if (!absolute) return [];
|
|
124
|
+
|
|
125
|
+
return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCwdRelativePathPolicyValues(
|
|
129
|
+
absolute: string,
|
|
130
|
+
cwd: string | undefined,
|
|
131
|
+
): string[] {
|
|
132
|
+
if (!cwd) return [];
|
|
133
|
+
|
|
134
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
135
|
+
if (!normalizedCwd) return [];
|
|
136
|
+
if (
|
|
137
|
+
absolute !== normalizedCwd &&
|
|
138
|
+
!isPathWithinDirectory(absolute, normalizedCwd)
|
|
139
|
+
) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const relativeValue = relative(normalizedCwd, absolute);
|
|
144
|
+
return relativeValue ? [relativeValue] : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
65
147
|
/**
|
|
66
148
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
67
149
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -123,6 +205,46 @@ export function getPathBearingToolPath(
|
|
|
123
205
|
return getNonEmptyString(toRecord(input).path);
|
|
124
206
|
}
|
|
125
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Extract the filesystem path a tool will access, for the cross-cutting `path`
|
|
210
|
+
* and `external_directory` gates.
|
|
211
|
+
*
|
|
212
|
+
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
|
|
213
|
+
* extension and MCP tools so they are no longer exempt from path gating:
|
|
214
|
+
*
|
|
215
|
+
* - `bash` → `null` (bash has its own token-based path gates).
|
|
216
|
+
* - Built-in path-bearing tools → `input.path`.
|
|
217
|
+
* - `mcp` → `input.arguments.path`.
|
|
218
|
+
* - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
|
|
219
|
+
* default `input.path` convention.
|
|
220
|
+
*/
|
|
221
|
+
export function getToolInputPath(
|
|
222
|
+
toolName: string,
|
|
223
|
+
input: unknown,
|
|
224
|
+
extractors?: ToolAccessExtractorLookup,
|
|
225
|
+
): string | null {
|
|
226
|
+
if (toolName === "bash") {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const record = toRecord(input);
|
|
231
|
+
|
|
232
|
+
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
233
|
+
return getNonEmptyString(record.path);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (toolName === "mcp") {
|
|
237
|
+
return getNonEmptyString(toRecord(record.arguments).path);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const custom = extractors?.get(toolName);
|
|
241
|
+
if (custom) {
|
|
242
|
+
return getNonEmptyString(custom(record));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return getNonEmptyString(record.path);
|
|
246
|
+
}
|
|
247
|
+
|
|
126
248
|
/**
|
|
127
249
|
* Like {@link normalizePathForComparison} but also resolves symlinks via
|
|
128
250
|
* `realpathSync` (best-effort). Use this for containment decisions where the
|
|
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
|
|
|
3
3
|
import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
|
|
4
4
|
import { normalizeInput } from "./input-normalizer";
|
|
5
5
|
import { normalizeFlatConfig } from "./normalize";
|
|
6
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
6
7
|
import {
|
|
7
8
|
FilePolicyLoader,
|
|
8
9
|
type PolicyLoader,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
type ResolvedPolicyPaths,
|
|
11
12
|
} from "./policy-loader";
|
|
12
13
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
13
|
-
import { evaluate, evaluateFirst } from "./rule";
|
|
14
|
+
import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
|
|
14
15
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
15
16
|
import {
|
|
16
17
|
composeRuleset,
|
|
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
|
|
|
63
64
|
agentName?: string,
|
|
64
65
|
sessionRules?: Ruleset,
|
|
65
66
|
): PermissionCheckResult;
|
|
67
|
+
/**
|
|
68
|
+
* Evaluate the cross-cutting `path` surface against a caller-supplied set of
|
|
69
|
+
* equivalent policy values (e.g. bash tokens already resolved against a
|
|
70
|
+
* preceding literal `cd`). The values are trusted because they are computed
|
|
71
|
+
* internally, never read from a field on raw tool input.
|
|
72
|
+
*/
|
|
73
|
+
checkPathPolicy(
|
|
74
|
+
values: readonly string[],
|
|
75
|
+
agentName?: string,
|
|
76
|
+
sessionRules?: Ruleset,
|
|
77
|
+
): PermissionCheckResult;
|
|
66
78
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
67
79
|
getConfigIssues(agentName?: string): string[];
|
|
68
80
|
getPolicyCacheStamp(agentName?: string): string;
|
|
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
79
91
|
|
|
80
92
|
export class PermissionManager implements ScopedPermissionManager {
|
|
81
93
|
private readonly agentDir: string | undefined;
|
|
94
|
+
private currentCwd: string | undefined;
|
|
82
95
|
private loader: PolicyLoader;
|
|
83
96
|
private readonly resolvedPermissionsCache = new Map<
|
|
84
97
|
string,
|
|
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
104
117
|
* built with explicit paths), only the cache is cleared.
|
|
105
118
|
*/
|
|
106
119
|
configureForCwd(cwd: string | undefined | null): void {
|
|
120
|
+
this.currentCwd =
|
|
121
|
+
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
107
122
|
if (this.agentDir !== undefined) {
|
|
108
123
|
this.loader = new FilePolicyLoader(
|
|
109
124
|
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
@@ -245,29 +260,78 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
245
260
|
normalizedToolName,
|
|
246
261
|
input,
|
|
247
262
|
this.loader.getConfiguredMcpServerNames(),
|
|
263
|
+
this.currentCwd,
|
|
248
264
|
);
|
|
249
265
|
|
|
250
|
-
|
|
266
|
+
return buildCheckResult(
|
|
267
|
+
surface,
|
|
268
|
+
values,
|
|
269
|
+
resultExtras,
|
|
270
|
+
normalizedToolName,
|
|
271
|
+
toolName,
|
|
272
|
+
fullRules,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
251
275
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
checkPathPolicy(
|
|
277
|
+
values: readonly string[],
|
|
278
|
+
agentName?: string,
|
|
279
|
+
sessionRules?: Ruleset,
|
|
280
|
+
): PermissionCheckResult {
|
|
281
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
282
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
283
|
+
? [...composedRules, ...sessionRules]
|
|
284
|
+
: composedRules;
|
|
256
285
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
...extras,
|
|
267
|
-
};
|
|
286
|
+
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
287
|
+
return buildCheckResult(
|
|
288
|
+
"path",
|
|
289
|
+
lookupValues,
|
|
290
|
+
{},
|
|
291
|
+
"path",
|
|
292
|
+
"path",
|
|
293
|
+
fullRules,
|
|
294
|
+
);
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Evaluate a normalized surface/values triple and shape the result.
|
|
300
|
+
*
|
|
301
|
+
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
|
302
|
+
* aliases); every other surface keeps {@link evaluateFirst}. Shared by
|
|
303
|
+
* `checkPermission` and `checkPathPolicy`.
|
|
304
|
+
*/
|
|
305
|
+
function buildCheckResult(
|
|
306
|
+
surface: string,
|
|
307
|
+
values: string[],
|
|
308
|
+
resultExtras: Record<string, unknown>,
|
|
309
|
+
normalizedToolName: string,
|
|
310
|
+
toolName: string,
|
|
311
|
+
fullRules: Ruleset,
|
|
312
|
+
): PermissionCheckResult {
|
|
313
|
+
const { rule, value } = PATH_SURFACES.has(surface)
|
|
314
|
+
? evaluateAnyValue(surface, values, fullRules)
|
|
315
|
+
: evaluateFirst(surface, values, fullRules);
|
|
316
|
+
|
|
317
|
+
// For MCP, replace the normalizer's fallback target with the actual
|
|
318
|
+
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
319
|
+
const extras =
|
|
320
|
+
surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
toolName,
|
|
324
|
+
state: rule.action,
|
|
325
|
+
matchedPattern:
|
|
326
|
+
rule.layer === "config" || rule.layer === "session"
|
|
327
|
+
? rule.pattern
|
|
328
|
+
: undefined,
|
|
329
|
+
source: deriveSource(rule, normalizedToolName),
|
|
330
|
+
origin: rule.origin,
|
|
331
|
+
...extras,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
271
335
|
/**
|
|
272
336
|
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
|
273
337
|
* Setting agentsDir explicitly from agentDir removes the hidden
|
|
@@ -17,6 +17,15 @@ export interface ScopedPermissionResolver {
|
|
|
17
17
|
input: unknown,
|
|
18
18
|
agentName?: string,
|
|
19
19
|
): PermissionCheckResult;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the cross-cutting `path` surface against a caller-supplied set of
|
|
22
|
+
* equivalent policy values, applying the current session rules. Used by the
|
|
23
|
+
* bash path gate, which computes cd-aware policy values per token.
|
|
24
|
+
*/
|
|
25
|
+
resolvePathPolicy(
|
|
26
|
+
values: readonly string[],
|
|
27
|
+
agentName?: string,
|
|
28
|
+
): PermissionCheckResult;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -53,6 +62,21 @@ export class PermissionResolver implements ScopedPermissionResolver {
|
|
|
53
62
|
);
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the `path` surface for precomputed policy values, composing the
|
|
67
|
+
* current session ruleset so callers never thread it by hand.
|
|
68
|
+
*/
|
|
69
|
+
resolvePathPolicy(
|
|
70
|
+
values: readonly string[],
|
|
71
|
+
agentName?: string,
|
|
72
|
+
): PermissionCheckResult {
|
|
73
|
+
return this.permissionManager.checkPathPolicy(
|
|
74
|
+
values,
|
|
75
|
+
agentName,
|
|
76
|
+
this.sessionRules.getRuleset(),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
checkPermission(
|
|
57
81
|
surface: string,
|
|
58
82
|
input: unknown,
|
|
@@ -150,10 +150,8 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
150
150
|
return this.knownAgentName;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Read by config-modal
|
|
154
|
-
//
|
|
155
|
-
// wiring, so it reports a false positive here.
|
|
156
|
-
// fallow-ignore-next-line unused-class-member
|
|
153
|
+
// Read by the `index.ts` config-modal adapter closure:
|
|
154
|
+
// `permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
|
|
157
155
|
get lastKnownActiveAgentName(): string | null {
|
|
158
156
|
return this.knownAgentName;
|
|
159
157
|
}
|
|
@@ -2,6 +2,10 @@ import { buildInputForSurface } from "./input-normalizer";
|
|
|
2
2
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
3
3
|
import type { PermissionsService } from "./service";
|
|
4
4
|
import type { SessionRules } from "./session-rules";
|
|
5
|
+
import type {
|
|
6
|
+
ToolAccessExtractor,
|
|
7
|
+
ToolAccessExtractorRegistrar,
|
|
8
|
+
} from "./tool-access-extractor-registry";
|
|
5
9
|
import type {
|
|
6
10
|
ToolInputFormatter,
|
|
7
11
|
ToolInputFormatterRegistrar,
|
|
@@ -19,6 +23,7 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
19
23
|
private readonly permissionManager: ScopedPermissionManager,
|
|
20
24
|
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
|
|
21
25
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
26
|
+
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
22
27
|
) {}
|
|
23
28
|
|
|
24
29
|
checkPermission(
|
|
@@ -48,4 +53,11 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
48
53
|
): ReturnType<PermissionsService["registerToolInputFormatter"]> {
|
|
49
54
|
return this.formatterRegistry.register(toolName, formatter);
|
|
50
55
|
}
|
|
56
|
+
|
|
57
|
+
registerToolAccessExtractor(
|
|
58
|
+
toolName: string,
|
|
59
|
+
extractor: ToolAccessExtractor,
|
|
60
|
+
): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
|
|
61
|
+
return this.accessExtractorRegistry.register(toolName, extractor);
|
|
62
|
+
}
|
|
51
63
|
}
|
package/src/rule.ts
CHANGED
|
@@ -55,17 +55,8 @@ export function evaluate(
|
|
|
55
55
|
defaultAction?: PermissionState,
|
|
56
56
|
platform: NodeJS.Platform = process.platform,
|
|
57
57
|
): Rule {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// overrides still match. The surface→surface match stays exact.
|
|
61
|
-
const matchOptions =
|
|
62
|
-
platform === "win32" && PATH_SURFACES.has(surface)
|
|
63
|
-
? { caseInsensitive: true, windowsSeparators: true }
|
|
64
|
-
: undefined;
|
|
65
|
-
const rule = rules.findLast(
|
|
66
|
-
(r) =>
|
|
67
|
-
wildcardMatch(r.surface, surface) &&
|
|
68
|
-
wildcardMatch(r.pattern, pattern, matchOptions),
|
|
58
|
+
const rule = rules.findLast((r) =>
|
|
59
|
+
ruleMatches(r, surface, pattern, platform),
|
|
69
60
|
);
|
|
70
61
|
if (rule !== undefined) return rule;
|
|
71
62
|
return {
|
|
@@ -76,6 +67,33 @@ export function evaluate(
|
|
|
76
67
|
};
|
|
77
68
|
}
|
|
78
69
|
|
|
70
|
+
/**
|
|
71
|
+
* On Windows, path-surface values are canonicalized + lowercased; fold the
|
|
72
|
+
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
73
|
+
* overrides still match. The surface→surface match stays exact.
|
|
74
|
+
*/
|
|
75
|
+
function pathMatchOptions(
|
|
76
|
+
surface: string,
|
|
77
|
+
platform: NodeJS.Platform,
|
|
78
|
+
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
|
79
|
+
return platform === "win32" && PATH_SURFACES.has(surface)
|
|
80
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ruleMatches(
|
|
85
|
+
rule: Rule,
|
|
86
|
+
surface: string,
|
|
87
|
+
value: string,
|
|
88
|
+
platform: NodeJS.Platform,
|
|
89
|
+
): boolean {
|
|
90
|
+
const matchOptions = pathMatchOptions(surface, platform);
|
|
91
|
+
return (
|
|
92
|
+
wildcardMatch(rule.surface, surface) &&
|
|
93
|
+
wildcardMatch(rule.pattern, value, matchOptions)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
/**
|
|
80
98
|
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
81
99
|
* the first candidate that matches a non-default rule (last-match-wins within
|
|
@@ -134,3 +152,35 @@ export function evaluateFirst(
|
|
|
134
152
|
value: fallbackValue,
|
|
135
153
|
};
|
|
136
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Evaluate equivalent lookup values as aliases of the same path.
|
|
158
|
+
*
|
|
159
|
+
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
160
|
+
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
161
|
+
* legacy relative rules coexist without a catch-all match on the first alias
|
|
162
|
+
* masking a later, more specific rule on another alias.
|
|
163
|
+
*/
|
|
164
|
+
export function evaluateAnyValue(
|
|
165
|
+
surface: string,
|
|
166
|
+
values: string[],
|
|
167
|
+
rules: Ruleset,
|
|
168
|
+
platform: NodeJS.Platform = process.platform,
|
|
169
|
+
): { rule: Rule; value: string } {
|
|
170
|
+
const fallbackValue = values[0] ?? "*";
|
|
171
|
+
const rule = rules.findLast((r) =>
|
|
172
|
+
values.some((value) => ruleMatches(r, surface, value, platform)),
|
|
173
|
+
);
|
|
174
|
+
if (rule !== undefined) {
|
|
175
|
+
return {
|
|
176
|
+
rule,
|
|
177
|
+
value:
|
|
178
|
+
values.find((value) => ruleMatches(rule, surface, value, platform)) ??
|
|
179
|
+
fallbackValue,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
184
|
+
value: fallbackValue,
|
|
185
|
+
};
|
|
186
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
|
|
14
15
|
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
15
16
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
16
17
|
|
|
@@ -80,6 +81,29 @@ export interface PermissionsService {
|
|
|
80
81
|
formatter: ToolInputFormatter,
|
|
81
82
|
): () => void;
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Register a custom access-intent extractor for a specific tool name.
|
|
86
|
+
*
|
|
87
|
+
* The extractor declares the filesystem path a tool will access so the
|
|
88
|
+
* cross-cutting `path` and `external_directory` gates can see it. Use it for
|
|
89
|
+
* tools whose path lives under a non-standard key — built-in file tools and
|
|
90
|
+
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
|
|
91
|
+
* already covered by convention without registration.
|
|
92
|
+
*
|
|
93
|
+
* The extractor receives the raw `input` record and returns the path string,
|
|
94
|
+
* or `undefined` to decline. Only one extractor may be registered per tool
|
|
95
|
+
* name — a second call for the same name throws. The returned disposer
|
|
96
|
+
* unregisters the extractor.
|
|
97
|
+
*
|
|
98
|
+
* @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
|
|
99
|
+
* @param extractor - Receives the raw `input` record; return the path string,
|
|
100
|
+
* or `undefined` to decline.
|
|
101
|
+
*/
|
|
102
|
+
registerToolAccessExtractor(
|
|
103
|
+
toolName: string,
|
|
104
|
+
extractor: ToolAccessExtractor,
|
|
105
|
+
): () => void;
|
|
106
|
+
|
|
83
107
|
/**
|
|
84
108
|
* Query the tool-level permission state for pre-filtering tools before
|
|
85
109
|
* creating a child session.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for custom tool access-intent extractors.
|
|
3
|
+
*
|
|
4
|
+
* Lets sibling extensions declare the filesystem path a tool will access when
|
|
5
|
+
* the tool's input shape is not the default `input.path` convention, so the
|
|
6
|
+
* cross-cutting `path` and `external_directory` gates can see it.
|
|
7
|
+
* One extractor per tool name; duplicate registration throws.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Returns the filesystem path this tool will access, or `undefined` to decline. */
|
|
11
|
+
export type ToolAccessExtractor = (
|
|
12
|
+
input: Record<string, unknown>,
|
|
13
|
+
) => string | undefined;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read-only lookup used by the gate pipeline (ISP — exposes only the read
|
|
17
|
+
* side, not the registration surface).
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolAccessExtractorLookup {
|
|
20
|
+
get(toolName: string): ToolAccessExtractor | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registration side of the extractor registry (ISP — exposes only the write
|
|
25
|
+
* surface, mirroring the read-only {@link ToolAccessExtractorLookup}).
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolAccessExtractorRegistrar {
|
|
28
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Persistent registry mapping tool names to custom access-intent extractors.
|
|
33
|
+
*
|
|
34
|
+
* Owned by the extension factory (`index.ts`) so it survives across the
|
|
35
|
+
* per-tool-call gate evaluation cycle.
|
|
36
|
+
* Exposed to sibling extensions via `PermissionsService.registerToolAccessExtractor`.
|
|
37
|
+
*/
|
|
38
|
+
export class ToolAccessExtractorRegistry
|
|
39
|
+
implements ToolAccessExtractorLookup, ToolAccessExtractorRegistrar
|
|
40
|
+
{
|
|
41
|
+
private readonly extractors = new Map<string, ToolAccessExtractor>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register an extractor for `toolName`.
|
|
45
|
+
*
|
|
46
|
+
* Throws if an extractor is already registered for that name — keeps
|
|
47
|
+
* resolution deterministic (a pi-permission-system package priority).
|
|
48
|
+
* Returns a disposer that removes the extractor; the disposer is
|
|
49
|
+
* identity-guarded so a stale call cannot evict a later registration.
|
|
50
|
+
*/
|
|
51
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void {
|
|
52
|
+
if (this.extractors.has(toolName)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`A tool access extractor is already registered for '${toolName}'.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.extractors.set(toolName, extractor);
|
|
58
|
+
return () => {
|
|
59
|
+
if (this.extractors.get(toolName) === extractor) {
|
|
60
|
+
this.extractors.delete(toolName);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(toolName: string): ToolAccessExtractor | undefined {
|
|
66
|
+
return this.extractors.get(toolName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -18,10 +18,7 @@ vi.mock("node:fs", () => ({
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
import { formatDenyReason } from "#src/denial-messages";
|
|
21
|
-
import {
|
|
22
|
-
extractExternalPathsFromBashCommand,
|
|
23
|
-
extractTokensForPathRules,
|
|
24
|
-
} from "#src/handlers/gates/bash-path-extractor";
|
|
21
|
+
import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
|
|
25
22
|
import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
26
23
|
|
|
27
24
|
afterEach(() => {
|
|
@@ -957,80 +954,3 @@ describe("bash external-directory denial messages (centralized)", () => {
|
|
|
957
954
|
expect(result).not.toContain("Hard stop");
|
|
958
955
|
});
|
|
959
956
|
});
|
|
960
|
-
|
|
961
|
-
describe("extractTokensForPathRules", () => {
|
|
962
|
-
test("extracts dot-files: cat .env", async () => {
|
|
963
|
-
const tokens = await extractTokensForPathRules("cat .env");
|
|
964
|
-
expect(tokens).toContain(".env");
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
test("extracts relative dot-paths: git add src/.env", async () => {
|
|
968
|
-
const tokens = await extractTokensForPathRules("git add src/.env");
|
|
969
|
-
expect(tokens).toContain("src/.env");
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
test("extracts nothing from plain words: echo hello", async () => {
|
|
973
|
-
const tokens = await extractTokensForPathRules("echo hello");
|
|
974
|
-
expect(tokens).toHaveLength(0);
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
test("extracts ./src and skips flags: rm -rf ./src", async () => {
|
|
978
|
-
const tokens = await extractTokensForPathRules("rm -rf ./src");
|
|
979
|
-
expect(tokens).toContain("./src");
|
|
980
|
-
expect(tokens).not.toContain("-rf");
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
test("extracts absolute paths: cat /etc/hosts", async () => {
|
|
984
|
-
const tokens = await extractTokensForPathRules("cat /etc/hosts");
|
|
985
|
-
expect(tokens).toContain("/etc/hosts");
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
test("skips URLs: curl https://example.com", async () => {
|
|
989
|
-
const tokens = await extractTokensForPathRules("curl https://example.com");
|
|
990
|
-
expect(tokens).not.toContain("https://example.com");
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
test("extracts slash-containing tokens: cat src/foo.ts", async () => {
|
|
994
|
-
const tokens = await extractTokensForPathRules("cat src/foo.ts");
|
|
995
|
-
expect(tokens).toContain("src/foo.ts");
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
test("skips heredoc content", async () => {
|
|
999
|
-
const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
|
|
1000
|
-
expect(tokens).not.toContain(".env");
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
test("skips @scope/package patterns", async () => {
|
|
1004
|
-
const tokens = await extractTokensForPathRules(
|
|
1005
|
-
"npm install @scope/package",
|
|
1006
|
-
);
|
|
1007
|
-
expect(tokens).not.toContain("@scope/package");
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
test("skips env assignments", async () => {
|
|
1011
|
-
const tokens = await extractTokensForPathRules("FOO=/bar command");
|
|
1012
|
-
expect(tokens).not.toContain("FOO=/bar");
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
test("skips bare-slash tokens", async () => {
|
|
1016
|
-
const tokens = await extractTokensForPathRules("ls /");
|
|
1017
|
-
expect(tokens).not.toContain("/");
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
test("extracts redirect targets: echo test > .env", async () => {
|
|
1021
|
-
const tokens = await extractTokensForPathRules("echo test > .env");
|
|
1022
|
-
expect(tokens).toContain(".env");
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
test("extracts multiple path tokens: cp .env .env.backup", async () => {
|
|
1026
|
-
const tokens = await extractTokensForPathRules("cp .env .env.backup");
|
|
1027
|
-
expect(tokens).toContain(".env");
|
|
1028
|
-
expect(tokens).toContain(".env.backup");
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
test("deduplicates repeated tokens", async () => {
|
|
1032
|
-
const tokens = await extractTokensForPathRules("cat .env && rm .env");
|
|
1033
|
-
const envCount = tokens.filter((t) => t === ".env").length;
|
|
1034
|
-
expect(envCount).toBe(1);
|
|
1035
|
-
});
|
|
1036
|
-
});
|