@gotgenes/pi-permission-system 17.1.0 → 18.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 +33 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +6 -3
- package/src/access-intent/bash/bash-path-resolver.ts +16 -12
- package/src/access-intent/bash/program.ts +0 -4
- package/src/access-intent/bash/token-classification.ts +23 -0
- package/src/handlers/before-agent-start.ts +1 -2
- package/src/handlers/gates/external-directory.ts +2 -12
- package/src/handlers/gates/skill-read.ts +4 -8
- package/src/handlers/gates/tool-call-gate-pipeline.ts +62 -24
- package/src/handlers/gates/tool.ts +10 -14
- package/src/index.ts +8 -6
- package/src/input-normalizer.ts +54 -56
- package/src/path-normalizer.ts +30 -0
- package/src/permission-event-rpc.ts +21 -15
- package/src/permission-manager.ts +3 -6
- package/src/permission-session.ts +0 -5
- package/src/permissions-service.ts +33 -12
- package/src/skill-prompt-sanitizer.ts +8 -21
- package/test/access-intent/bash/program.test.ts +1 -1
- package/test/access-intent/bash/token-classification.test.ts +75 -0
- package/test/bash-external-directory.test.ts +38 -0
- package/test/composition-root.test.ts +22 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -3
- package/test/handlers/gates/external-directory.test.ts +0 -1
- package/test/handlers/gates/skill-read.test.ts +16 -12
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
- package/test/handlers/gates/tool.test.ts +25 -16
- package/test/helpers/gate-fixtures.ts +0 -3
- package/test/input-normalizer.test.ts +163 -270
- package/test/path-normalizer.test.ts +43 -0
- package/test/path-utils.test.ts +1 -1
- package/test/permission-event-rpc.test.ts +80 -65
- package/test/permission-manager-unified.test.ts +134 -145
- package/test/permissions-service.test.ts +84 -72
- package/test/service.test.ts +56 -103
- package/test/skill-prompt-sanitizer.test.ts +31 -65
package/src/input-normalizer.ts
CHANGED
|
@@ -1,25 +1,60 @@
|
|
|
1
|
+
import type { AccessIntent } from "./access-intent/access-intent";
|
|
1
2
|
import { stripBashCommentLines } from "./bash-arity";
|
|
2
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
|
-
import {
|
|
4
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
5
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
4
6
|
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
9
|
+
* Build the {@link AccessIntent} an external policy query (the `Symbol.for()`
|
|
10
|
+
* service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
|
|
11
|
+
* pair.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing
|
|
14
|
+
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an
|
|
15
|
+
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
|
|
16
|
+
* (symlink-resolved) set — at parity with the gates (#486, #502). Every other
|
|
17
|
+
* surface, and any value-less surface-level query, keeps the `tool` intent so
|
|
18
|
+
* the manager's `normalizeInput` `["*"]` fallback is preserved.
|
|
19
|
+
*/
|
|
20
|
+
export function buildAccessIntentForSurface(
|
|
21
|
+
surface: string,
|
|
22
|
+
value: string | undefined,
|
|
23
|
+
normalizer: PathNormalizer,
|
|
24
|
+
agentName: string | undefined,
|
|
25
|
+
): AccessIntent {
|
|
26
|
+
const pathValue = getNonEmptyString(value);
|
|
27
|
+
if (pathValue !== null && PATH_SURFACES.has(surface)) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "access-path",
|
|
30
|
+
surface,
|
|
31
|
+
path: normalizer.forPath(pathValue),
|
|
32
|
+
agentName,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: "tool",
|
|
37
|
+
surface,
|
|
38
|
+
input: buildInputForSurface(surface, value),
|
|
39
|
+
agentName,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Construct a surface-appropriate input object from a raw value string for the
|
|
45
|
+
* `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path
|
|
46
|
+
* surfaces and value-less path queries).
|
|
12
47
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
48
|
+
* This is the inverse of `normalizeInput()` — it builds the minimal input
|
|
49
|
+
* object that the manager expects for a given surface, from a single string
|
|
50
|
+
* value.
|
|
16
51
|
*
|
|
17
52
|
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
|
18
53
|
* providing an MCP surface receive a best-effort policy evaluation using the
|
|
19
54
|
* value as a pre-qualified target string. Pass the fully-qualified target
|
|
20
55
|
* (e.g. "exa:search" or "exa") directly.
|
|
21
56
|
*/
|
|
22
|
-
|
|
57
|
+
function buildInputForSurface(
|
|
23
58
|
surface: string,
|
|
24
59
|
value: string | undefined,
|
|
25
60
|
): unknown {
|
|
@@ -51,12 +86,16 @@ export interface NormalizedInput {
|
|
|
51
86
|
resultExtras: Record<string, unknown>;
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
55
|
-
|
|
56
89
|
/**
|
|
57
90
|
* Map a raw tool invocation to the surface/values/extras triple needed by
|
|
58
91
|
* `checkPermission()`.
|
|
59
92
|
*
|
|
93
|
+
* Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
|
|
94
|
+
* (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`,
|
|
95
|
+
* `ls`) now route through the access-path gate (#502) and service/RPC builder
|
|
96
|
+
* (#503) before reaching the manager, so they never arrive here with a real
|
|
97
|
+
* path value — all fall through to the extension catch-all `["*"]`.
|
|
98
|
+
*
|
|
60
99
|
* @param toolName - Normalized (trimmed) tool name from the tool-call event.
|
|
61
100
|
* @param input - Raw input payload from the tool-call event.
|
|
62
101
|
* @param configuredMcpServerNames - Ordered list of MCP server names from the
|
|
@@ -66,18 +105,7 @@ export function normalizeInput(
|
|
|
66
105
|
toolName: string,
|
|
67
106
|
input: unknown,
|
|
68
107
|
configuredMcpServerNames: readonly string[],
|
|
69
|
-
platform: NodeJS.Platform,
|
|
70
|
-
cwd?: string,
|
|
71
108
|
): NormalizedInput {
|
|
72
|
-
// --- Special surfaces (path, external_directory) ---
|
|
73
|
-
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
74
|
-
return {
|
|
75
|
-
surface: toolName,
|
|
76
|
-
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
77
|
-
resultExtras: {},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
109
|
// --- Skill ---
|
|
82
110
|
if (toolName === "skill") {
|
|
83
111
|
const record = toRecord(input);
|
|
@@ -120,43 +148,13 @@ export function normalizeInput(
|
|
|
120
148
|
};
|
|
121
149
|
}
|
|
122
150
|
|
|
123
|
-
// ---
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
128
|
-
resultExtras: {},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// --- Extension tools (non-path-bearing) ---
|
|
151
|
+
// --- All other surfaces (path-bearing tools and extension tools) ---
|
|
152
|
+
// Path-bearing tools with a present path never reach here — the gate emits
|
|
153
|
+
// an access-path intent (#502). Missing-path and extension-tool cases both
|
|
154
|
+
// collapse to the surface catch-all.
|
|
133
155
|
return {
|
|
134
156
|
surface: toolName,
|
|
135
157
|
values: ["*"],
|
|
136
158
|
resultExtras: {},
|
|
137
159
|
};
|
|
138
160
|
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Extract and normalize the path lookup values shared by every path surface
|
|
142
|
-
* (`path`, `external_directory`, and the path-bearing tools).
|
|
143
|
-
*
|
|
144
|
-
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
145
|
-
* `"*"`. When CWD is known, a relative path also produces a normalized
|
|
146
|
-
* absolute policy value and a project-relative alias while keeping its legacy
|
|
147
|
-
* relative value, so values match home- and cwd-anchored patterns
|
|
148
|
-
* symmetrically with how the patterns themselves are expanded (#350).
|
|
149
|
-
*
|
|
150
|
-
* Only `input.path` is read — policy values are never sourced from any other
|
|
151
|
-
* (potentially attacker-controlled) field on the raw tool input.
|
|
152
|
-
*/
|
|
153
|
-
function normalizePathSurfaceValues(
|
|
154
|
-
input: unknown,
|
|
155
|
-
platform: NodeJS.Platform,
|
|
156
|
-
cwd?: string,
|
|
157
|
-
): string[] {
|
|
158
|
-
const path = getNonEmptyString(toRecord(input).path);
|
|
159
|
-
if (path === null) return ["*"];
|
|
160
|
-
const values = getPathPolicyValues(path, cwd ? { cwd } : {}, platform);
|
|
161
|
-
return values.length > 0 ? values : ["*"];
|
|
162
|
-
}
|
package/src/path-normalizer.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { AccessPath } from "./access-intent/access-path";
|
|
|
4
4
|
import {
|
|
5
5
|
isPathOutsideWorkingDirectory,
|
|
6
6
|
isPathWithinDirectory,
|
|
7
|
+
isPiInfrastructureRead,
|
|
8
|
+
normalizePathForComparison,
|
|
7
9
|
} from "./path-utils";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -67,4 +69,32 @@ export class PathNormalizer {
|
|
|
67
69
|
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
68
70
|
return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
|
|
69
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lexical (not symlink-resolved) comparison value, resolved against the baked
|
|
75
|
+
* cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
|
|
76
|
+
* touches no filesystem, unlike {@link forPath}'s canonical alias.
|
|
77
|
+
*/
|
|
78
|
+
comparableValue(pathValue: string): string {
|
|
79
|
+
return normalizePathForComparison(pathValue, this.cwd, this.platform);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pi infrastructure-read containment for a read-only tool, decided against
|
|
84
|
+
* the canonical (symlink-resolved) path and the baked cwd/platform. Takes the
|
|
85
|
+
* already-built {@link AccessPath} so the caller does not re-resolve it.
|
|
86
|
+
*/
|
|
87
|
+
isInfrastructureRead(
|
|
88
|
+
toolName: string,
|
|
89
|
+
accessPath: AccessPath,
|
|
90
|
+
infraDirs: readonly string[],
|
|
91
|
+
): boolean {
|
|
92
|
+
return isPiInfrastructureRead(
|
|
93
|
+
toolName,
|
|
94
|
+
accessPath.boundaryValue(),
|
|
95
|
+
infraDirs,
|
|
96
|
+
this.cwd,
|
|
97
|
+
this.platform,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
70
100
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* permission prompts without importing this package.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
10
|
+
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
11
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
11
12
|
import type {
|
|
12
13
|
PermissionPromptDecision,
|
|
13
14
|
RequestPermissionOptions,
|
|
@@ -26,22 +27,26 @@ import {
|
|
|
26
27
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
27
28
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
29
|
} from "./permission-events";
|
|
29
|
-
import type {
|
|
30
|
+
import type { ScopedPermissionResolver } from "./permission-resolver";
|
|
30
31
|
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
31
32
|
import type { ReviewLogger } from "./session-logger";
|
|
32
|
-
import type { SessionRules } from "./session-rules";
|
|
33
33
|
|
|
34
34
|
/** Dependencies injected into the RPC handler registry. */
|
|
35
35
|
export interface PermissionRpcDeps {
|
|
36
|
-
/** The shared PermissionManager instance. */
|
|
37
|
-
permissionManager: Pick<ScopedPermissionManager, "check">;
|
|
38
|
-
/** The shared SessionRules instance. */
|
|
39
|
-
sessionRules: Pick<SessionRules, "getRuleset">;
|
|
40
36
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
37
|
+
* The shared resolver: answers an access intent, composing the session
|
|
38
|
+
* ruleset and unwrapping `access-path` → `path-values` internally so the
|
|
39
|
+
* RPC check matches the same lexical ∪ canonical set the gates do (#503).
|
|
43
40
|
*/
|
|
44
|
-
|
|
41
|
+
resolver: Pick<ScopedPermissionResolver, "resolve">;
|
|
42
|
+
/**
|
|
43
|
+
* Narrow session view: runtime context for the prompt handler (hasUI / UI
|
|
44
|
+
* dialog) and the cwd-bound path normalizer for path-surface check queries.
|
|
45
|
+
*/
|
|
46
|
+
session: {
|
|
47
|
+
getRuntimeContext(): ExtensionContext | null;
|
|
48
|
+
getPathNormalizer(): PathNormalizer;
|
|
49
|
+
};
|
|
45
50
|
/** Show the interactive permission dialog in the parent session UI. */
|
|
46
51
|
requestPermissionDecisionFromUi(
|
|
47
52
|
ui: ExtensionContext["ui"],
|
|
@@ -107,12 +112,13 @@ function handleCheckRpc(
|
|
|
107
112
|
return;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
const intent = buildAccessIntentForSurface(
|
|
116
|
+
surface,
|
|
117
|
+
value,
|
|
118
|
+
deps.session.getPathNormalizer(),
|
|
119
|
+
agentName ?? undefined,
|
|
115
120
|
);
|
|
121
|
+
const result = deps.resolver.resolve(intent);
|
|
116
122
|
|
|
117
123
|
const data: PermissionsCheckReplyData = {
|
|
118
124
|
result: result.state,
|
|
@@ -96,7 +96,6 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
96
96
|
export class PermissionManager implements ScopedPermissionManager {
|
|
97
97
|
private readonly agentDir: string | undefined;
|
|
98
98
|
private readonly platform: NodeJS.Platform;
|
|
99
|
-
private currentCwd: string | undefined;
|
|
100
99
|
private loader: PolicyLoader;
|
|
101
100
|
private readonly resolvedPermissionsCache = new Map<
|
|
102
101
|
string,
|
|
@@ -123,8 +122,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
123
122
|
* built with explicit paths), only the cache is cleared.
|
|
124
123
|
*/
|
|
125
124
|
configureForCwd(cwd: string | undefined | null): void {
|
|
126
|
-
this.currentCwd =
|
|
127
|
-
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
128
125
|
if (this.agentDir !== undefined) {
|
|
129
126
|
this.loader = new FilePolicyLoader(
|
|
130
127
|
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
@@ -248,7 +245,9 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
248
245
|
/**
|
|
249
246
|
* Unified resolution entry point — dispatches on intent kind.
|
|
250
247
|
*
|
|
251
|
-
* `"tool"` → normalizes raw input through `normalizeInput
|
|
248
|
+
* `"tool"` → normalizes raw input through `normalizeInput` (bash, skill, mcp,
|
|
249
|
+
* extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
|
|
250
|
+
* the access-path gate (#502) or service/RPC builder (#503).
|
|
252
251
|
* `"path-values"` → evaluates the precomputed values directly.
|
|
253
252
|
*/
|
|
254
253
|
check(
|
|
@@ -280,8 +279,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
280
279
|
toolName,
|
|
281
280
|
intent.input,
|
|
282
281
|
this.loader.getConfiguredMcpServerNames(),
|
|
283
|
-
this.platform,
|
|
284
|
-
this.currentCwd,
|
|
285
282
|
);
|
|
286
283
|
return buildCheckResult(
|
|
287
284
|
surface,
|
|
@@ -85,11 +85,6 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
85
85
|
return this.context;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** The host platform injected at the composition root. */
|
|
89
|
-
getPlatform(): NodeJS.Platform {
|
|
90
|
-
return this.platform;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
88
|
// ── UI notifications ────────────────────────────────────────────────────
|
|
94
89
|
|
|
95
90
|
/** Surface a warning message to the user via the active UI context, if any. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
3
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
3
4
|
import type { PermissionsService } from "./service";
|
|
4
|
-
import type { SessionRules } from "./session-rules";
|
|
5
5
|
import type {
|
|
6
6
|
ToolAccessExtractor,
|
|
7
7
|
ToolAccessExtractorRegistrar,
|
|
@@ -10,18 +10,37 @@ import type {
|
|
|
10
10
|
ToolInputFormatter,
|
|
11
11
|
ToolInputFormatterRegistrar,
|
|
12
12
|
} from "./tool-input-formatter-registry";
|
|
13
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolution surface the service needs: answer a gate-style {@link AccessIntent}
|
|
17
|
+
* (composing the session ruleset internally) and report a tool-level state.
|
|
18
|
+
* `PermissionResolver` satisfies it.
|
|
19
|
+
*/
|
|
20
|
+
interface ResolverForService {
|
|
21
|
+
resolve(intent: AccessIntent): PermissionCheckResult;
|
|
22
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Narrow session view: hands out the cwd-bound path normalizer. */
|
|
26
|
+
interface PathNormalizerProvider {
|
|
27
|
+
getPathNormalizer(): PathNormalizer;
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
/**
|
|
15
31
|
* In-process implementation of the cross-extension {@link PermissionsService}.
|
|
16
32
|
*
|
|
17
33
|
* Constructed once in the composition root and backed by the single shared
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
34
|
+
* `PermissionResolver` and `PermissionSession` that the gates also use — so
|
|
35
|
+
* service queries and gate-path decisions see the same state. Path-shaped
|
|
36
|
+
* surface queries route through the resolver as an `access-path` intent, so
|
|
37
|
+
* they match the lexical aliases ∪ canonical (symlink-resolved) set the gates
|
|
38
|
+
* do (#503); non-path surfaces stay on the `tool` intent.
|
|
20
39
|
*/
|
|
21
40
|
export class LocalPermissionsService implements PermissionsService {
|
|
22
41
|
constructor(
|
|
23
|
-
private readonly
|
|
24
|
-
private readonly
|
|
42
|
+
private readonly resolver: ResolverForService,
|
|
43
|
+
private readonly session: PathNormalizerProvider,
|
|
25
44
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
26
45
|
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
27
46
|
) {}
|
|
@@ -31,18 +50,20 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
31
50
|
value?: string,
|
|
32
51
|
agentName?: string,
|
|
33
52
|
): ReturnType<PermissionsService["checkPermission"]> {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
53
|
+
const intent = buildAccessIntentForSurface(
|
|
54
|
+
surface,
|
|
55
|
+
value,
|
|
56
|
+
this.session.getPathNormalizer(),
|
|
57
|
+
agentName,
|
|
38
58
|
);
|
|
59
|
+
return this.resolver.resolve(intent);
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
getToolPermission(
|
|
42
63
|
toolName: string,
|
|
43
64
|
agentName?: string,
|
|
44
65
|
): ReturnType<PermissionsService["getToolPermission"]> {
|
|
45
|
-
return this.
|
|
66
|
+
return this.resolver.getToolPermission(toolName, agentName);
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
registerToolInputFormatter(
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { dirname } from "node:path";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
isPathWithinDirectory,
|
|
5
|
-
normalizePathForComparison,
|
|
6
|
-
} from "./path-utils";
|
|
3
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
7
4
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
8
5
|
|
|
9
6
|
/**
|
|
@@ -151,24 +148,15 @@ function resolvePermissionState(
|
|
|
151
148
|
function createResolvedSkillEntry(
|
|
152
149
|
entry: ParsedSkillPromptEntry,
|
|
153
150
|
state: PermissionState,
|
|
154
|
-
|
|
155
|
-
platform: NodeJS.Platform,
|
|
151
|
+
normalizer: PathNormalizer,
|
|
156
152
|
): SkillPromptEntry {
|
|
157
153
|
return {
|
|
158
154
|
name: entry.name,
|
|
159
155
|
description: entry.description,
|
|
160
156
|
location: entry.location,
|
|
161
157
|
state,
|
|
162
|
-
normalizedLocation:
|
|
163
|
-
|
|
164
|
-
cwd,
|
|
165
|
-
platform,
|
|
166
|
-
),
|
|
167
|
-
normalizedBaseDir: normalizePathForComparison(
|
|
168
|
-
dirname(entry.location),
|
|
169
|
-
cwd,
|
|
170
|
-
platform,
|
|
171
|
-
),
|
|
158
|
+
normalizedLocation: normalizer.comparableValue(entry.location),
|
|
159
|
+
normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
|
|
172
160
|
};
|
|
173
161
|
}
|
|
174
162
|
|
|
@@ -198,8 +186,7 @@ export function resolveSkillPromptEntries(
|
|
|
198
186
|
prompt: string,
|
|
199
187
|
permissionManager: SkillPermissionChecker,
|
|
200
188
|
agentName: string | null,
|
|
201
|
-
|
|
202
|
-
platform: NodeJS.Platform,
|
|
189
|
+
normalizer: PathNormalizer,
|
|
203
190
|
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
204
191
|
const sections = parseAllSkillPromptSections(prompt);
|
|
205
192
|
if (sections.length === 0) {
|
|
@@ -219,7 +206,7 @@ export function resolveSkillPromptEntries(
|
|
|
219
206
|
agentName,
|
|
220
207
|
permissionCache,
|
|
221
208
|
);
|
|
222
|
-
return createResolvedSkillEntry(entry, state,
|
|
209
|
+
return createResolvedSkillEntry(entry, state, normalizer);
|
|
223
210
|
});
|
|
224
211
|
|
|
225
212
|
const visibleSectionEntries = resolvedEntries.filter(
|
|
@@ -267,7 +254,7 @@ export function resolveSkillPromptEntries(
|
|
|
267
254
|
export function findSkillPathMatch(
|
|
268
255
|
normalizedPath: string,
|
|
269
256
|
entries: readonly SkillPromptEntry[],
|
|
270
|
-
|
|
257
|
+
normalizer: PathNormalizer,
|
|
271
258
|
): SkillPromptEntry | null {
|
|
272
259
|
if (!normalizedPath || entries.length === 0) {
|
|
273
260
|
return null;
|
|
@@ -286,7 +273,7 @@ export function findSkillPathMatch(
|
|
|
286
273
|
for (const entry of entries) {
|
|
287
274
|
if (
|
|
288
275
|
!entry.normalizedBaseDir ||
|
|
289
|
-
!
|
|
276
|
+
!normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)
|
|
290
277
|
) {
|
|
291
278
|
continue;
|
|
292
279
|
}
|
|
@@ -375,7 +375,7 @@ describe("BashProgram", () => {
|
|
|
375
375
|
const symlinkCwd = "/private/tmp";
|
|
376
376
|
realpathSync.mockImplementation((p: string) => {
|
|
377
377
|
if (p === "/tmp") return "/private/tmp";
|
|
378
|
-
if (p.startsWith("/tmp/")) return
|
|
378
|
+
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
|
379
379
|
return p;
|
|
380
380
|
});
|
|
381
381
|
const program = await BashProgram.parse(
|
|
@@ -114,6 +114,40 @@ describe("classifyTokenAsPathCandidate", () => {
|
|
|
114
114
|
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("Windows drive-letter acceptance gate", () => {
|
|
119
|
+
test("forward-slash drive path → returned as-is", () => {
|
|
120
|
+
expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
|
|
121
|
+
"C:/Windows/win.ini",
|
|
122
|
+
);
|
|
123
|
+
expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
|
|
124
|
+
"D:/secrets/password.txt",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("backslash drive path → returned as-is", () => {
|
|
129
|
+
expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
|
|
130
|
+
"C:\\Windows\\win.ini",
|
|
131
|
+
);
|
|
132
|
+
expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
|
|
133
|
+
"D:\\secrets\\password.txt",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("lowercase drive letter → returned as-is", () => {
|
|
138
|
+
expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
|
|
142
|
+
// c:// matches URL_PATTERN before the drive-letter check runs.
|
|
143
|
+
expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("drive-relative path without separator (C:foo) → null", () => {
|
|
147
|
+
// No / or \ after the colon — not an absolute drive path per node:path.
|
|
148
|
+
expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
117
151
|
});
|
|
118
152
|
|
|
119
153
|
describe("classifyTokenAsRuleCandidate", () => {
|
|
@@ -204,6 +238,35 @@ describe("classifyTokenAsRuleCandidate", () => {
|
|
|
204
238
|
});
|
|
205
239
|
});
|
|
206
240
|
|
|
241
|
+
describe("Windows drive-letter acceptance gate", () => {
|
|
242
|
+
test("forward-slash drive path → returned as-is", () => {
|
|
243
|
+
// Forward-slash form was already accepted via token.includes("/").
|
|
244
|
+
// The explicit branch makes it first-class and order-independent.
|
|
245
|
+
expect(classifyTokenAsRuleCandidate("C:/Windows/win.ini")).toBe(
|
|
246
|
+
"C:/Windows/win.ini",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("backslash drive path → returned as-is (new: no forward slash)", () => {
|
|
251
|
+
// Previously dropped by both classifiers; the backslash form has no /
|
|
252
|
+
// so the includes("/") branch could not catch it.
|
|
253
|
+
expect(classifyTokenAsRuleCandidate("D:\\secrets\\password.txt")).toBe(
|
|
254
|
+
"D:\\secrets\\password.txt",
|
|
255
|
+
);
|
|
256
|
+
expect(classifyTokenAsRuleCandidate("C:\\Windows\\win.ini")).toBe(
|
|
257
|
+
"C:\\Windows\\win.ini",
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("lowercase drive letter (backslash) → returned as-is", () => {
|
|
262
|
+
expect(classifyTokenAsRuleCandidate("c:\\foo")).toBe("c:\\foo");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("drive-relative path without separator (C:foo) → null", () => {
|
|
266
|
+
expect(classifyTokenAsRuleCandidate("C:foo")).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
207
270
|
describe("rule-vs-path divergence", () => {
|
|
208
271
|
const dotFiles = [".env", ".gitignore", ".eslintrc"];
|
|
209
272
|
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
|
|
@@ -230,6 +293,18 @@ describe("classifyTokenAsRuleCandidate", () => {
|
|
|
230
293
|
});
|
|
231
294
|
}
|
|
232
295
|
|
|
296
|
+
const winDrivePaths = [
|
|
297
|
+
"C:/Windows/win.ini",
|
|
298
|
+
"D:\\secrets\\password.txt",
|
|
299
|
+
"c:/foo",
|
|
300
|
+
];
|
|
301
|
+
for (const tok of winDrivePaths) {
|
|
302
|
+
test(`Windows drive path "${tok}": both classifiers accept`, () => {
|
|
303
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
304
|
+
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
233
308
|
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
|
|
234
309
|
for (const tok of sharedRejected) {
|
|
235
310
|
test(`"${tok}": both classifiers reject`, () => {
|
|
@@ -953,6 +953,44 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
953
953
|
});
|
|
954
954
|
});
|
|
955
955
|
|
|
956
|
+
describe("Windows drive-letter paths (win32 semantics)", () => {
|
|
957
|
+
const windowsCwd = "C:/projects/app";
|
|
958
|
+
|
|
959
|
+
async function extractWin32(command: string): Promise<string[]> {
|
|
960
|
+
return extractWithNormalizer(
|
|
961
|
+
command,
|
|
962
|
+
new PathNormalizer("win32", windowsCwd),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
test("forward-slash drive path outside CWD is flagged", async () => {
|
|
967
|
+
const result = await extractWin32("cat C:/Windows/win.ini");
|
|
968
|
+
expect(result).not.toHaveLength(0);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("different drive letter outside CWD is flagged", async () => {
|
|
972
|
+
const result = await extractWin32("cat D:/secrets/password.txt");
|
|
973
|
+
expect(result).not.toHaveLength(0);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test("drive path inside CWD is not flagged (known base)", async () => {
|
|
977
|
+
const result = await extractWin32("cat C:/projects/app/inside.txt");
|
|
978
|
+
expect(result).toHaveLength(0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
|
|
982
|
+
// Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
|
|
983
|
+
// check treats C:/ as relative on win32, so the unknown-base conservative
|
|
984
|
+
// branch fires and over-flags an inside-CWD drive path.
|
|
985
|
+
// After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
|
|
986
|
+
// and routes to the resolved branch with its inside-CWD check.
|
|
987
|
+
const result = await extractWin32(
|
|
988
|
+
'cd "$D" && cat C:/projects/app/inside.txt',
|
|
989
|
+
);
|
|
990
|
+
expect(result).toHaveLength(0);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
956
994
|
describe("bash external-directory denial messages (centralized)", () => {
|
|
957
995
|
test("denial message includes command, paths, and extension tag", () => {
|
|
958
996
|
const result = formatDenyReason({
|
|
@@ -479,6 +479,28 @@ describe("single source of truth for session state", () => {
|
|
|
479
479
|
});
|
|
480
480
|
});
|
|
481
481
|
|
|
482
|
+
describe("service path queries evaluate the supplied path (#503)", () => {
|
|
483
|
+
// Before #503 the service path query dropped the value (buildInputForSurface
|
|
484
|
+
// returned {} for the `path` surface), so the query collapsed to ["*"] and a
|
|
485
|
+
// path-specific rule never fired. The query now builds an AccessPath, so the
|
|
486
|
+
// supplied path flows through the resolver → manager and matches `path` rules
|
|
487
|
+
// end-to-end.
|
|
488
|
+
it("resolves a path-surface query against a deny rule on the supplied path", async () => {
|
|
489
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-svc-path-cwd-"));
|
|
490
|
+
const target = join(cwd, "secrets.env");
|
|
491
|
+
writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
|
|
492
|
+
|
|
493
|
+
const pi = makeFakePi({ events: createEventBus() });
|
|
494
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
495
|
+
await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
|
|
496
|
+
|
|
497
|
+
const result = getPermissionsService()!.checkPermission("path", target);
|
|
498
|
+
expect(result.state).toBe("deny");
|
|
499
|
+
|
|
500
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
482
504
|
describe("multi-instance global service interplay", () => {
|
|
483
505
|
// The fix (#302) scopes the process-global service slot to the publishing
|
|
484
506
|
// instance. The parent publishes at its session_start; an in-process child
|
|
@@ -89,7 +89,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
89
89
|
[],
|
|
90
90
|
resolver,
|
|
91
91
|
new PathNormalizer(process.platform, cwd),
|
|
92
|
-
"linux",
|
|
93
92
|
);
|
|
94
93
|
expect(isGateDescriptor(result)).toBe(true);
|
|
95
94
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
@@ -113,7 +112,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
113
112
|
[],
|
|
114
113
|
resolver,
|
|
115
114
|
new PathNormalizer(process.platform, cwd),
|
|
116
|
-
"linux",
|
|
117
115
|
);
|
|
118
116
|
expect(isGateDescriptor(result)).toBe(true);
|
|
119
117
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
@@ -132,7 +130,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
132
130
|
[],
|
|
133
131
|
resolver,
|
|
134
132
|
new PathNormalizer(process.platform, cwd),
|
|
135
|
-
"linux",
|
|
136
133
|
);
|
|
137
134
|
expect(isGateDescriptor(result)).toBe(true);
|
|
138
135
|
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
|