@gotgenes/pi-permission-system 17.1.1 → 18.0.1
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 +14 -4
- package/src/access-intent/access-path.ts +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +2 -1
- package/src/access-intent/bash/program.ts +0 -4
- package/src/access-intent/path-normalization.ts +139 -0
- package/src/handlers/gates/external-directory.ts +1 -1
- package/src/handlers/gates/path.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +61 -22
- package/src/handlers/gates/tool.ts +11 -14
- package/src/index.ts +8 -6
- package/src/input-normalizer.ts +54 -56
- package/src/path-containment.ts +56 -0
- package/src/path-normalizer.ts +20 -5
- package/src/path-surfaces.ts +30 -0
- package/src/pattern-suggest.ts +1 -1
- package/src/permission-event-rpc.ts +21 -15
- package/src/permission-manager.ts +10 -7
- package/src/permission-resolver.ts +5 -0
- package/src/permission-session.ts +0 -5
- package/src/permissions-service.ts +33 -12
- package/src/pi-infrastructure-read.ts +65 -0
- package/src/rule.ts +1 -1
- package/src/safe-system-paths.ts +18 -0
- package/src/tool-input-path.ts +54 -0
- package/test/access-intent/bash/program.test.ts +1 -1
- package/test/composition-root.test.ts +22 -0
- 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-containment.test.ts +161 -0
- package/test/path-normalization.test.ts +233 -0
- package/test/path-normalizer.test.ts +30 -0
- package/test/path-surfaces.test.ts +55 -0
- 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/pi-infrastructure-read.test.ts +41 -1
- package/test/safe-system-paths.test.ts +46 -0
- package/test/service.test.ts +56 -103
- package/test/tool-input-path.test.ts +84 -0
- package/src/path-utils.ts +0 -346
- package/test/path-utils.test.ts +0 -695
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-surfaces";
|
|
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
|
-
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
}
|
package/src/path-normalizer.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
2
|
|
|
3
3
|
import { AccessPath } from "./access-intent/access-path";
|
|
4
|
+
import {
|
|
5
|
+
canonicalNormalizePathForComparison,
|
|
6
|
+
normalizePathForComparison,
|
|
7
|
+
} from "./access-intent/path-normalization";
|
|
4
8
|
import {
|
|
5
9
|
isPathOutsideWorkingDirectory,
|
|
6
10
|
isPathWithinDirectory,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./path-utils";
|
|
11
|
+
} from "./path-containment";
|
|
12
|
+
import { isPiInfrastructureRead } from "./pi-infrastructure-read";
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Path-interpretation collaborator, constructed once at the session edge with
|
|
@@ -19,16 +22,19 @@ import {
|
|
|
19
22
|
* prepared {@link AccessPath} values, instead of reading `process.platform`
|
|
20
23
|
* ambiently or threading `cwd` through every call. Internally it selects the
|
|
21
24
|
* `win32`/`posix` path flavor once and delegates to the platform-parameterized
|
|
22
|
-
* `path-
|
|
25
|
+
* `path-containment` / `path-normalization` / `AccessPath` primitives.
|
|
23
26
|
*/
|
|
24
27
|
export class PathNormalizer {
|
|
25
28
|
private readonly impl: typeof posixPath;
|
|
29
|
+
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
|
|
30
|
+
private readonly canonicalCwd: string;
|
|
26
31
|
|
|
27
32
|
constructor(
|
|
28
33
|
private readonly platform: NodeJS.Platform,
|
|
29
34
|
private readonly cwd: string,
|
|
30
35
|
) {
|
|
31
36
|
this.impl = platform === "win32" ? winPath : posixPath;
|
|
37
|
+
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
|
|
@@ -67,7 +73,16 @@ export class PathNormalizer {
|
|
|
67
73
|
|
|
68
74
|
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
|
69
75
|
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
70
|
-
|
|
76
|
+
const canonicalPath = canonicalNormalizePathForComparison(
|
|
77
|
+
pathValue,
|
|
78
|
+
this.cwd,
|
|
79
|
+
this.platform,
|
|
80
|
+
);
|
|
81
|
+
return isPathOutsideWorkingDirectory(
|
|
82
|
+
canonicalPath,
|
|
83
|
+
this.canonicalCwd,
|
|
84
|
+
this.platform,
|
|
85
|
+
);
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tools that only read — never write — the filesystem.
|
|
3
|
+
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
4
|
+
*/
|
|
5
|
+
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
6
|
+
"read",
|
|
7
|
+
"find",
|
|
8
|
+
"grep",
|
|
9
|
+
"ls",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const PATH_BEARING_TOOLS = new Set([
|
|
13
|
+
"read",
|
|
14
|
+
"write",
|
|
15
|
+
"edit",
|
|
16
|
+
"find",
|
|
17
|
+
"grep",
|
|
18
|
+
"ls",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Surfaces whose patterns are matched against filesystem paths and therefore
|
|
23
|
+
* fold case (and separators) on Windows: the path-bearing tools plus the
|
|
24
|
+
* cross-cutting `path` gate and the `external_directory` boundary gate.
|
|
25
|
+
*/
|
|
26
|
+
export const PATH_SURFACES: ReadonlySet<string> = new Set([
|
|
27
|
+
...PATH_BEARING_TOOLS,
|
|
28
|
+
"external_directory",
|
|
29
|
+
"path",
|
|
30
|
+
]);
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
2
|
-
import { PATH_BEARING_TOOLS } from "./path-
|
|
2
|
+
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
3
3
|
import { deriveApprovalPattern } from "./session-rules";
|
|
4
4
|
|
|
5
5
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -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,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./config-paths";
|
|
8
8
|
import { normalizeInput } from "./input-normalizer";
|
|
9
9
|
import { normalizeFlatConfig } from "./normalize";
|
|
10
|
-
import { PATH_SURFACES } from "./path-
|
|
10
|
+
import { PATH_SURFACES } from "./path-surfaces";
|
|
11
11
|
import {
|
|
12
12
|
FilePolicyLoader,
|
|
13
13
|
type PolicyLoader,
|
|
@@ -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,8 +245,16 @@ 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.
|
|
252
|
+
*
|
|
253
|
+
* The manager stays string-based by design: it consumes `ResolvedAccessIntent`
|
|
254
|
+
* (`tool | path-values`) and never imports `AccessPath`. This deliberate
|
|
255
|
+
* boundary is formalized in ADR-0002
|
|
256
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
|
|
257
|
+
* `no-restricted-imports` lint rule on this file.
|
|
253
258
|
*/
|
|
254
259
|
check(
|
|
255
260
|
intent: ResolvedAccessIntent,
|
|
@@ -280,8 +285,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
280
285
|
toolName,
|
|
281
286
|
intent.input,
|
|
282
287
|
this.loader.getConfiguredMcpServerNames(),
|
|
283
|
-
this.platform,
|
|
284
|
-
this.currentCwd,
|
|
285
288
|
);
|
|
286
289
|
return buildCheckResult(
|
|
287
290
|
surface,
|
|
@@ -26,6 +26,11 @@ export interface ScopedPermissionResolver {
|
|
|
26
26
|
*
|
|
27
27
|
* Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`,
|
|
28
28
|
* so the low-level manager never imports the value object.
|
|
29
|
+
*
|
|
30
|
+
* This is the sole `matchValues()` unwrap site — the single place the lexical ∪
|
|
31
|
+
* canonical alias set (#418) is derived. Keeping it here (not in the manager)
|
|
32
|
+
* is the deliberate boundary formalized in ADR-0002
|
|
33
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
29
34
|
*/
|
|
30
35
|
function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
|
|
31
36
|
if (intent.kind === "access-path") {
|
|
@@ -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(
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { expandHomePath } from "./expand-home";
|
|
4
|
+
import { isPathWithinDirectory } from "./path-containment";
|
|
5
|
+
import { READ_ONLY_PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
6
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
7
|
+
|
|
8
|
+
function containsGlobChars(value: string): boolean {
|
|
9
|
+
return value.includes("*") || value.includes("?");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the given tool + normalized path combination qualifies for
|
|
14
|
+
* automatic allow as a Pi infrastructure read.
|
|
15
|
+
*
|
|
16
|
+
* A path qualifies when:
|
|
17
|
+
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
18
|
+
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
19
|
+
* OR within the project-local Pi package directories
|
|
20
|
+
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
21
|
+
*
|
|
22
|
+
* `infrastructureDirs` entries may be absolute paths or patterns containing
|
|
23
|
+
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
|
|
24
|
+
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
25
|
+
* follow working-directory changes without a runtime rebuild.
|
|
26
|
+
*/
|
|
27
|
+
export function isPiInfrastructureRead(
|
|
28
|
+
toolName: string,
|
|
29
|
+
normalizedPath: string,
|
|
30
|
+
infrastructureDirs: readonly string[],
|
|
31
|
+
cwd: string,
|
|
32
|
+
platform: NodeJS.Platform,
|
|
33
|
+
): boolean {
|
|
34
|
+
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// On Windows the path value is canonicalized + lowercased; fold case (and
|
|
39
|
+
// separators) so mixed-case infra dirs and glob patterns still match.
|
|
40
|
+
const matchOptions =
|
|
41
|
+
platform === "win32"
|
|
42
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
for (const dir of infrastructureDirs) {
|
|
46
|
+
if (containsGlobChars(dir)) {
|
|
47
|
+
if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
|
|
48
|
+
} else {
|
|
49
|
+
if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
55
|
+
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
56
|
+
const projectGitDir = join(cwd, ".pi", "git");
|
|
57
|
+
if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
package/src/rule.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths that are universally safe and should never trigger external-directory checks.
|
|
3
|
+
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
4
|
+
*/
|
|
5
|
+
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
6
|
+
"/dev/null",
|
|
7
|
+
"/dev/stdin",
|
|
8
|
+
"/dev/stdout",
|
|
9
|
+
"/dev/stderr",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the given normalized path is a safe OS device file
|
|
14
|
+
* that should never trigger external-directory checks.
|
|
15
|
+
*/
|
|
16
|
+
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
17
|
+
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
18
|
+
}
|