@gotgenes/pi-permission-system 20.2.0 → 20.4.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 +28 -0
- package/docs/cross-extension-api.md +4 -0
- package/docs/subagent-integration.md +4 -2
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +7 -5
- package/src/access-intent/bash/bash-path-resolver.ts +1 -2
- package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
- package/src/access-intent/bash/parser.ts +39 -0
- package/src/access-intent/bash/sync-commands.ts +31 -0
- package/src/access-intent/bash/token-classification.ts +18 -32
- package/src/access-intent/path-normalization.ts +24 -43
- package/src/access-intent/tool-kind.ts +57 -0
- package/src/authority/approval-escalator.ts +18 -8
- package/src/authority/authorizer-selection.ts +1 -1
- package/src/authority/authorizer.ts +2 -2
- package/src/authority/denying-authorizer.ts +1 -1
- package/src/authority/forwarded-request-server.ts +52 -4
- package/src/authority/forwarder-context.ts +1 -1
- package/src/authority/forwarding-io.ts +30 -3
- package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
- package/src/authority/local-user-authorizer.ts +27 -2
- package/src/{permission-dialog.ts → authority/permission-dialog.ts} +26 -0
- package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +22 -2
- package/src/authority/permission-prompter.ts +9 -1
- package/src/authority/subagent-context.ts +11 -14
- package/src/authority/subagent-detection.ts +5 -4
- package/src/bash-advisory-check.ts +38 -0
- package/src/denial-messages.ts +6 -9
- package/src/handlers/before-agent-start.ts +8 -0
- package/src/handlers/gates/helpers.ts +12 -4
- package/src/handlers/gates/runner.ts +4 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
- package/src/handlers/gates/tool.ts +9 -4
- package/src/index.ts +27 -12
- package/src/input-normalizer.ts +53 -48
- package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
- package/src/path/path-containment.ts +24 -0
- package/src/path/path-flavor.ts +114 -0
- package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
- package/src/path-normalizer.ts +35 -59
- package/src/pattern-suggest.ts +29 -0
- package/src/permission-gate.ts +1 -1
- package/src/permission-manager.ts +36 -56
- package/src/permission-prompts.ts +4 -3
- package/src/permission-session.ts +6 -5
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +19 -21
- package/src/session-approval.ts +11 -0
- package/src/tool-input-path.ts +17 -19
- package/src/tool-preview-formatter.ts +2 -5
- package/src/path-containment.ts +0 -56
- /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
- /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
package/src/pattern-suggest.ts
CHANGED
|
@@ -62,6 +62,35 @@ export function suggestMcpPattern(target: string): string {
|
|
|
62
62
|
return "*";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Scope labels for the forwarded-approval two-step scope select. */
|
|
66
|
+
export interface ForwardedScopeLabels {
|
|
67
|
+
/** Least-privilege default: record on the requesting subagent only. */
|
|
68
|
+
subagentLabel: string;
|
|
69
|
+
/** Record on the serving node — covers the parent and all subagents. */
|
|
70
|
+
servingSessionLabel: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the two scope labels shown when a human grants a forwarded request
|
|
75
|
+
* "for this session."
|
|
76
|
+
*
|
|
77
|
+
* The subagent option names the requester (least privilege); the whole-session
|
|
78
|
+
* option restates the surface + pattern being granted session-wide.
|
|
79
|
+
*/
|
|
80
|
+
export function buildForwardedScopeLabels(
|
|
81
|
+
agentName: string | null,
|
|
82
|
+
surface: string,
|
|
83
|
+
pattern: string,
|
|
84
|
+
): ForwardedScopeLabels {
|
|
85
|
+
const subagentLabel = agentName
|
|
86
|
+
? `This subagent ('${agentName}') only`
|
|
87
|
+
: "This subagent only";
|
|
88
|
+
return {
|
|
89
|
+
subagentLabel,
|
|
90
|
+
servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
65
94
|
/** Surface-aware human-readable labels for the session-approval option. */
|
|
66
95
|
function buildLabel(pattern: string, surface: string): string {
|
|
67
96
|
switch (surface) {
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
|
3
|
+
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
3
4
|
import {
|
|
4
5
|
getGlobalConfigPath,
|
|
5
6
|
getProjectAgentsDir,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
} from "./config-paths";
|
|
8
9
|
import { normalizeInput } from "./input-normalizer";
|
|
9
10
|
import { normalizeFlatConfig } from "./normalize";
|
|
11
|
+
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
|
|
10
12
|
import { PATH_SURFACES } from "./path-surfaces";
|
|
11
13
|
import {
|
|
12
14
|
FilePolicyLoader,
|
|
@@ -37,15 +39,6 @@ import type {
|
|
|
37
39
|
import { isPermissionState } from "./types";
|
|
38
40
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
39
41
|
|
|
40
|
-
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
41
|
-
"bash",
|
|
42
|
-
"read",
|
|
43
|
-
"write",
|
|
44
|
-
"edit",
|
|
45
|
-
"grep",
|
|
46
|
-
"find",
|
|
47
|
-
"ls",
|
|
48
|
-
]);
|
|
49
42
|
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
50
43
|
|
|
51
44
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
@@ -109,11 +102,12 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
109
102
|
*/
|
|
110
103
|
agentDir?: string;
|
|
111
104
|
/**
|
|
112
|
-
*
|
|
113
|
-
* path-surface rule matching folds case (and separators) on
|
|
114
|
-
* Defaults to
|
|
105
|
+
* Resolved path-language flavor, injected from the composition root, that
|
|
106
|
+
* decides whether path-surface rule matching folds case (and separators) on
|
|
107
|
+
* Windows. Defaults to the POSIX flavor; production always supplies the real
|
|
108
|
+
* platform's flavor.
|
|
115
109
|
*/
|
|
116
|
-
|
|
110
|
+
flavor?: PathFlavor;
|
|
117
111
|
/**
|
|
118
112
|
* yolo-mode reader, injected from the composition root. When it reports
|
|
119
113
|
* true, {@link PermissionManager.check} rewrites every matched `ask` to a
|
|
@@ -126,7 +120,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
126
120
|
|
|
127
121
|
export class PermissionManager implements ScopedPermissionManager {
|
|
128
122
|
private readonly agentDir: string | undefined;
|
|
129
|
-
private readonly
|
|
123
|
+
private readonly flavor: PathFlavor;
|
|
130
124
|
private readonly isYoloEnabled: () => boolean;
|
|
131
125
|
private loader: PolicyLoader;
|
|
132
126
|
private readonly resolvedPermissionsCache = new Map<
|
|
@@ -136,7 +130,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
136
130
|
|
|
137
131
|
constructor(options: PermissionManagerOptions = {}) {
|
|
138
132
|
this.agentDir = options.agentDir;
|
|
139
|
-
this.
|
|
133
|
+
this.flavor = options.flavor ?? posixPathFlavor;
|
|
140
134
|
this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
|
|
141
135
|
this.loader =
|
|
142
136
|
options.policyLoader ??
|
|
@@ -270,7 +264,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
270
264
|
.map((r) => r.pattern);
|
|
271
265
|
if (patterns.length === 0) return NO_PROMOTION;
|
|
272
266
|
|
|
273
|
-
const matchOptions = pathMatchOptions("path", this.
|
|
267
|
+
const matchOptions = pathMatchOptions("path", this.flavor);
|
|
274
268
|
return (token) =>
|
|
275
269
|
patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
|
|
276
270
|
}
|
|
@@ -281,29 +275,10 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
281
275
|
*/
|
|
282
276
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
283
277
|
const { composedRules } = this.resolvePermissions(agentName);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
return evaluate(normalizedToolName, "*", composedRules, this.platform)
|
|
289
|
-
.action;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
|
|
293
|
-
// (or universal default) handles this correctly.
|
|
294
|
-
if (normalizedToolName === "bash") {
|
|
295
|
-
return evaluate("bash", "*", composedRules, this.platform).action;
|
|
296
|
-
}
|
|
297
|
-
if (normalizedToolName === "mcp") {
|
|
298
|
-
return evaluate("mcp", "*", composedRules, this.platform).action;
|
|
299
|
-
}
|
|
300
|
-
if (normalizedToolName === "skill") {
|
|
301
|
-
return evaluate("skill", "*", composedRules, this.platform).action;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Tool-name surfaces (read, write, etc. and extension tools).
|
|
305
|
-
return evaluate(normalizedToolName, "*", composedRules, this.platform)
|
|
306
|
-
.action;
|
|
278
|
+
// Every surface (special, bash, mcp, skill, path-bearing, and extension
|
|
279
|
+
// tools) resolves its tool-level state identically: evaluate the surface
|
|
280
|
+
// name against the "*" catch-all value. There is no per-kind branch.
|
|
281
|
+
return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
|
|
307
282
|
}
|
|
308
283
|
|
|
309
284
|
/**
|
|
@@ -345,7 +320,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
345
320
|
intent.surface,
|
|
346
321
|
intent.surface,
|
|
347
322
|
fullRules,
|
|
348
|
-
this.
|
|
323
|
+
this.flavor,
|
|
349
324
|
);
|
|
350
325
|
}
|
|
351
326
|
|
|
@@ -363,7 +338,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
363
338
|
toolName,
|
|
364
339
|
intent.surface,
|
|
365
340
|
fullRules,
|
|
366
|
-
this.
|
|
341
|
+
this.flavor,
|
|
367
342
|
);
|
|
368
343
|
}
|
|
369
344
|
}
|
|
@@ -382,16 +357,18 @@ function buildCheckResult(
|
|
|
382
357
|
normalizedToolName: string,
|
|
383
358
|
toolName: string,
|
|
384
359
|
fullRules: Ruleset,
|
|
385
|
-
|
|
360
|
+
flavor: PathFlavor,
|
|
386
361
|
): PermissionCheckResult {
|
|
387
362
|
const { rule, value } = PATH_SURFACES.has(surface)
|
|
388
|
-
? evaluateAnyValue(surface, values, fullRules,
|
|
389
|
-
: evaluateFirst(surface, values, fullRules,
|
|
363
|
+
? evaluateAnyValue(surface, values, fullRules, flavor)
|
|
364
|
+
: evaluateFirst(surface, values, fullRules, flavor);
|
|
390
365
|
|
|
391
366
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
392
367
|
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
393
368
|
const extras =
|
|
394
|
-
surface === "mcp"
|
|
369
|
+
classifyToolKind(surface) === "mcp"
|
|
370
|
+
? { ...resultExtras, target: value }
|
|
371
|
+
: resultExtras;
|
|
395
372
|
|
|
396
373
|
return {
|
|
397
374
|
toolName,
|
|
@@ -444,19 +421,22 @@ function deriveSource(
|
|
|
444
421
|
toolName: string,
|
|
445
422
|
): PermissionCheckResult["source"] {
|
|
446
423
|
if (rule.layer === "session") return "session";
|
|
447
|
-
|
|
448
|
-
if (toolName === "mcp") {
|
|
449
|
-
if (rule.layer === "default") return "default";
|
|
450
|
-
return "mcp";
|
|
451
|
-
}
|
|
452
|
-
|
|
453
424
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
|
|
454
|
-
if (toolName === "skill") return "skill";
|
|
455
|
-
if (toolName === "bash") return "bash";
|
|
456
425
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
426
|
+
switch (classifyToolKind(toolName)) {
|
|
427
|
+
case "mcp":
|
|
428
|
+
return rule.layer === "default" ? "default" : "mcp";
|
|
429
|
+
case "skill":
|
|
430
|
+
return "skill";
|
|
431
|
+
case "bash":
|
|
432
|
+
return "bash";
|
|
433
|
+
case "path":
|
|
434
|
+
// Built-in path-bearing tools (read/write/edit/grep/find/ls).
|
|
435
|
+
return "tool";
|
|
436
|
+
case "extension":
|
|
437
|
+
// Extension tools distinguish a synthesized-default match from a rule.
|
|
438
|
+
return rule.layer === "default" ? "default" : "tool";
|
|
439
|
+
}
|
|
460
440
|
}
|
|
461
441
|
|
|
462
442
|
// Re-export types that external modules import from this file.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
1
2
|
import { matchQualifier } from "./denial-messages";
|
|
2
3
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
3
4
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
@@ -22,7 +23,7 @@ export function formatUnknownToolReason(
|
|
|
22
23
|
preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
|
|
23
24
|
|
|
24
25
|
const mcpHint =
|
|
25
|
-
toolName === "mcp"
|
|
26
|
+
classifyToolKind(toolName) === "mcp"
|
|
26
27
|
? ""
|
|
27
28
|
: ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
|
|
28
29
|
|
|
@@ -37,7 +38,7 @@ export function formatAskPrompt(
|
|
|
37
38
|
): string {
|
|
38
39
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
39
40
|
|
|
40
|
-
if (result.toolName === "bash") {
|
|
41
|
+
if (classifyToolKind(result.toolName) === "bash") {
|
|
41
42
|
const subCommand = result.command ?? "";
|
|
42
43
|
const qualifier = matchQualifier(
|
|
43
44
|
result.matchedPattern,
|
|
@@ -52,7 +53,7 @@ export function formatAskPrompt(
|
|
|
52
53
|
return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
if ((result
|
|
56
|
+
if (isMcpCheck(result) && result.target) {
|
|
56
57
|
const patternInfo = result.matchedPattern
|
|
57
58
|
? ` (matched '${result.matchedPattern}')`
|
|
58
59
|
: "";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ForwardingController } from "#src/authority/forwarding-manager";
|
|
2
3
|
import {
|
|
3
4
|
getActiveAgentName,
|
|
4
5
|
getActiveAgentNameFromSystemPrompt,
|
|
@@ -7,8 +8,8 @@ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-select
|
|
|
7
8
|
import type { SessionConfigStore } from "./config-store";
|
|
8
9
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
9
10
|
import type { ExtensionPaths } from "./extension-paths";
|
|
10
|
-
import type { ForwardingController } from "./forwarding-manager";
|
|
11
11
|
import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
|
|
12
|
+
import type { PathFlavor } from "./path/path-flavor";
|
|
12
13
|
import { PathNormalizer } from "./path-normalizer";
|
|
13
14
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
14
15
|
|
|
@@ -47,12 +48,12 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
47
48
|
private readonly sessionRules: SessionRules,
|
|
48
49
|
private readonly configStore: SessionConfigStore,
|
|
49
50
|
private readonly authorizerSelection: AuthorizerSelectionLifecycle,
|
|
50
|
-
private readonly
|
|
51
|
+
private readonly flavor: PathFlavor,
|
|
51
52
|
) {
|
|
52
53
|
// Placeholder until the first activate(ctx) binds the real cwd; every gate
|
|
53
54
|
// evaluate runs after activate (handleToolCall activates first), so this
|
|
54
55
|
// empty-cwd value is never read.
|
|
55
|
-
this.pathNormalizer = new PathNormalizer(
|
|
56
|
+
this.pathNormalizer = new PathNormalizer(flavor, "");
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
@@ -68,7 +69,7 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
68
69
|
*/
|
|
69
70
|
activate(ctx: ExtensionContext): void {
|
|
70
71
|
this.context = ctx;
|
|
71
|
-
this.pathNormalizer = new PathNormalizer(this.
|
|
72
|
+
this.pathNormalizer = new PathNormalizer(this.flavor, ctx.cwd);
|
|
72
73
|
this.forwarding.start(ctx);
|
|
73
74
|
this.authorizerSelection.activate(ctx);
|
|
74
75
|
}
|
|
@@ -207,7 +208,7 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
207
208
|
// ── Path normalization ────────────────────────────────────────────────
|
|
208
209
|
|
|
209
210
|
/**
|
|
210
|
-
* The session's {@link PathNormalizer}, carrying the host
|
|
211
|
+
* The session's {@link PathNormalizer}, carrying the host path flavor and the
|
|
211
212
|
* session cwd. Rebuilt on every `resetForNewSession` so a session switch
|
|
212
213
|
* rebinds the cwd.
|
|
213
214
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
|
|
2
3
|
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
3
4
|
import type { PathNormalizer } from "./path-normalizer";
|
|
4
5
|
import type { PermissionsService } from "./service";
|
|
@@ -50,6 +51,13 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
50
51
|
value?: string,
|
|
51
52
|
agentName?: string,
|
|
52
53
|
): ReturnType<PermissionsService["checkPermission"]> {
|
|
54
|
+
// Bash decomposes at gate parity: a chained/nested command is split into
|
|
55
|
+
// its command-pattern units and resolved most-restrictive, matching what
|
|
56
|
+
// the enforcement gate enforces (#309). A cold parser falls back to the
|
|
57
|
+
// whole-string match inside resolveBashAdvisoryCheck.
|
|
58
|
+
if (surface === "bash") {
|
|
59
|
+
return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
|
|
60
|
+
}
|
|
53
61
|
const intent = buildAccessIntentForSurface(
|
|
54
62
|
surface,
|
|
55
63
|
value,
|
package/src/rule.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
|
+
|
|
1
3
|
import { PATH_SURFACES } from "./path-surfaces";
|
|
2
4
|
import type { PermissionState } from "./types";
|
|
3
|
-
import { wildcardMatch } from "./wildcard-matcher";
|
|
5
|
+
import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Provenance of a rule — which source contributed it.
|
|
@@ -74,12 +76,10 @@ export function evaluate(
|
|
|
74
76
|
surface: string,
|
|
75
77
|
pattern: string,
|
|
76
78
|
rules: Ruleset,
|
|
77
|
-
|
|
79
|
+
flavor: PathFlavor,
|
|
78
80
|
defaultAction?: PermissionState,
|
|
79
81
|
): Rule {
|
|
80
|
-
const rule = rules.findLast((r) =>
|
|
81
|
-
ruleMatches(r, surface, pattern, platform),
|
|
82
|
-
);
|
|
82
|
+
const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor));
|
|
83
83
|
if (rule !== undefined) return rule;
|
|
84
84
|
return {
|
|
85
85
|
surface,
|
|
@@ -96,20 +96,18 @@ export function evaluate(
|
|
|
96
96
|
*/
|
|
97
97
|
export function pathMatchOptions(
|
|
98
98
|
surface: string,
|
|
99
|
-
|
|
100
|
-
):
|
|
101
|
-
return
|
|
102
|
-
? { caseInsensitive: true, windowsSeparators: true }
|
|
103
|
-
: undefined;
|
|
99
|
+
flavor: PathFlavor,
|
|
100
|
+
): WildcardMatchOptions | undefined {
|
|
101
|
+
return PATH_SURFACES.has(surface) ? flavor.matchOptions : undefined;
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
function ruleMatches(
|
|
107
105
|
rule: Rule,
|
|
108
106
|
surface: string,
|
|
109
107
|
value: string,
|
|
110
|
-
|
|
108
|
+
flavor: PathFlavor,
|
|
111
109
|
): boolean {
|
|
112
|
-
const matchOptions = pathMatchOptions(surface,
|
|
110
|
+
const matchOptions = pathMatchOptions(surface, flavor);
|
|
113
111
|
return (
|
|
114
112
|
wildcardMatch(rule.surface, surface) &&
|
|
115
113
|
wildcardMatch(rule.pattern, value, matchOptions)
|
|
@@ -144,11 +142,11 @@ export function evaluateMostRestrictive(
|
|
|
144
142
|
surface: string,
|
|
145
143
|
values: string[],
|
|
146
144
|
rules: Ruleset,
|
|
147
|
-
|
|
145
|
+
flavor: PathFlavor,
|
|
148
146
|
): { rule: Rule; value: string } | null {
|
|
149
147
|
let worst: { rule: Rule; value: string } | null = null;
|
|
150
148
|
for (const value of values) {
|
|
151
|
-
const rule = evaluate(surface, value, rules,
|
|
149
|
+
const rule = evaluate(surface, value, rules, flavor);
|
|
152
150
|
if (rule.action === "deny") return { rule, value };
|
|
153
151
|
if (rule.action === "ask" && worst?.rule.action !== "ask") {
|
|
154
152
|
worst = { rule, value };
|
|
@@ -161,10 +159,10 @@ export function evaluateFirst(
|
|
|
161
159
|
surface: string,
|
|
162
160
|
values: string[],
|
|
163
161
|
rules: Ruleset,
|
|
164
|
-
|
|
162
|
+
flavor: PathFlavor,
|
|
165
163
|
): { rule: Rule; value: string } {
|
|
166
164
|
for (const value of values) {
|
|
167
|
-
const rule = evaluate(surface, value, rules,
|
|
165
|
+
const rule = evaluate(surface, value, rules, flavor);
|
|
168
166
|
if (rule.layer !== "default") {
|
|
169
167
|
return { rule, value };
|
|
170
168
|
}
|
|
@@ -172,7 +170,7 @@ export function evaluateFirst(
|
|
|
172
170
|
// All candidates matched only the synthesized default — use the first.
|
|
173
171
|
const fallbackValue = values[0] ?? "*";
|
|
174
172
|
return {
|
|
175
|
-
rule: evaluate(surface, fallbackValue, rules,
|
|
173
|
+
rule: evaluate(surface, fallbackValue, rules, flavor),
|
|
176
174
|
value: fallbackValue,
|
|
177
175
|
};
|
|
178
176
|
}
|
|
@@ -189,22 +187,22 @@ export function evaluateAnyValue(
|
|
|
189
187
|
surface: string,
|
|
190
188
|
values: string[],
|
|
191
189
|
rules: Ruleset,
|
|
192
|
-
|
|
190
|
+
flavor: PathFlavor,
|
|
193
191
|
): { rule: Rule; value: string } {
|
|
194
192
|
const fallbackValue = values[0] ?? "*";
|
|
195
193
|
const rule = rules.findLast((r) =>
|
|
196
|
-
values.some((value) => ruleMatches(r, surface, value,
|
|
194
|
+
values.some((value) => ruleMatches(r, surface, value, flavor)),
|
|
197
195
|
);
|
|
198
196
|
if (rule !== undefined) {
|
|
199
197
|
return {
|
|
200
198
|
rule,
|
|
201
199
|
value:
|
|
202
|
-
values.find((value) => ruleMatches(rule, surface, value,
|
|
200
|
+
values.find((value) => ruleMatches(rule, surface, value, flavor)) ??
|
|
203
201
|
fallbackValue,
|
|
204
202
|
};
|
|
205
203
|
}
|
|
206
204
|
return {
|
|
207
|
-
rule: evaluate(surface, fallbackValue, rules,
|
|
205
|
+
rule: evaluate(surface, fallbackValue, rules, flavor),
|
|
208
206
|
value: fallbackValue,
|
|
209
207
|
};
|
|
210
208
|
}
|
package/src/session-approval.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Value object for a session-scoped approval: one surface, one-or-more patterns.
|
|
3
5
|
*
|
|
@@ -40,4 +42,13 @@ export class SessionApproval {
|
|
|
40
42
|
if (pattern === undefined) return undefined;
|
|
41
43
|
return { surface: this.surface, pattern };
|
|
42
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Plain data shape for relaying this approval on a forwarded request, so the
|
|
48
|
+
* serving node can record the same pattern(s) as a whole-session grant.
|
|
49
|
+
* Returns a defensive copy of the patterns.
|
|
50
|
+
*/
|
|
51
|
+
toForwardedData(): ForwardedSessionApproval {
|
|
52
|
+
return { surface: this.surface, patterns: [...this.patterns] };
|
|
53
|
+
}
|
|
43
54
|
}
|
package/src/tool-input-path.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
2
2
|
import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
|
|
3
3
|
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ export function getPathBearingToolPath(
|
|
|
6
6
|
toolName: string,
|
|
7
7
|
input: unknown,
|
|
8
8
|
): string | null {
|
|
9
|
-
if (
|
|
9
|
+
if (classifyToolKind(toolName) !== "path") {
|
|
10
10
|
return null;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -31,24 +31,22 @@ export function getToolInputPath(
|
|
|
31
31
|
input: unknown,
|
|
32
32
|
extractors?: ToolAccessExtractorLookup,
|
|
33
33
|
): string | null {
|
|
34
|
-
if (toolName === "bash") {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
34
|
const record = toRecord(input);
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
switch (classifyToolKind(toolName)) {
|
|
37
|
+
case "bash":
|
|
38
|
+
return null;
|
|
39
|
+
case "path":
|
|
40
|
+
return getNonEmptyString(record.path);
|
|
41
|
+
case "mcp":
|
|
42
|
+
return getNonEmptyString(toRecord(record.arguments).path);
|
|
43
|
+
case "skill":
|
|
44
|
+
case "extension": {
|
|
45
|
+
const custom = extractors?.get(toolName);
|
|
46
|
+
if (custom) {
|
|
47
|
+
return getNonEmptyString(custom(record));
|
|
48
|
+
}
|
|
49
|
+
return getNonEmptyString(record.path);
|
|
50
|
+
}
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
return getNonEmptyString(record.path);
|
|
54
52
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
1
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
2
3
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
3
4
|
import {
|
|
@@ -158,11 +159,7 @@ export class ToolPreviewFormatter {
|
|
|
158
159
|
input: unknown,
|
|
159
160
|
pathBearingTools: ReadonlySet<string>,
|
|
160
161
|
): string | undefined {
|
|
161
|
-
if (
|
|
162
|
-
result.toolName === "bash" ||
|
|
163
|
-
result.toolName === "mcp" ||
|
|
164
|
-
result.source === "mcp"
|
|
165
|
-
) {
|
|
162
|
+
if (classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)) {
|
|
166
163
|
return undefined;
|
|
167
164
|
}
|
|
168
165
|
|
package/src/path-containment.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
|
-
|
|
3
|
-
import { isSafeSystemPath } from "./safe-system-paths";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Returns true when `pathValue` is `directory` itself or nested inside it.
|
|
7
|
-
*
|
|
8
|
-
* Containment is decided with Node's platform-native `path.relative` rather
|
|
9
|
-
* than a hand-rolled prefix check: on `win32` the comparison folds case (and
|
|
10
|
-
* tolerates either separator), matching the case-insensitive filesystem.
|
|
11
|
-
* `platform` is injected from the composition root so Windows behavior is
|
|
12
|
-
* testable on a POSIX CI.
|
|
13
|
-
*/
|
|
14
|
-
export function isPathWithinDirectory(
|
|
15
|
-
pathValue: string,
|
|
16
|
-
directory: string,
|
|
17
|
-
platform: NodeJS.Platform,
|
|
18
|
-
): boolean {
|
|
19
|
-
if (!pathValue || !directory) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (pathValue === directory) {
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const impl = platform === "win32" ? winPath : posixPath;
|
|
28
|
-
const rel = impl.relative(directory, pathValue);
|
|
29
|
-
return (
|
|
30
|
-
rel !== "" &&
|
|
31
|
-
rel !== ".." &&
|
|
32
|
-
!rel.startsWith(`..${impl.sep}`) &&
|
|
33
|
-
!impl.isAbsolute(rel)
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
|
39
|
-
*
|
|
40
|
-
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
|
41
|
-
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
|
|
42
|
-
* This predicate touches no filesystem and does no derivation.
|
|
43
|
-
*/
|
|
44
|
-
export function isPathOutsideWorkingDirectory(
|
|
45
|
-
canonicalPath: string,
|
|
46
|
-
canonicalCwd: string,
|
|
47
|
-
platform: NodeJS.Platform,
|
|
48
|
-
): boolean {
|
|
49
|
-
if (!canonicalCwd || !canonicalPath) {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
if (isSafeSystemPath(canonicalPath)) {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
return !isPathWithinDirectory(canonicalPath, canonicalCwd, platform);
|
|
56
|
-
}
|
|
File without changes
|
|
File without changes
|