@gotgenes/pi-permission-system 17.0.0 → 17.1.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 +7 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +5 -5
- package/src/access-intent/bash/bash-path-resolver.ts +531 -0
- package/src/access-intent/bash/program.ts +21 -14
- package/src/access-intent/bash/token-classification.ts +1 -1
- package/src/canonicalize-path.ts +11 -5
- package/src/forwarded-permissions/permission-forwarder.ts +11 -1
- package/src/forwarding-manager.ts +7 -1
- package/src/handlers/before-agent-start.ts +1 -0
- package/src/handlers/gates/bash-path-extractor.ts +7 -5
- package/src/handlers/gates/external-directory.ts +14 -15
- package/src/handlers/gates/path.ts +3 -2
- package/src/handlers/gates/skill-read.ts +7 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
- package/src/handlers/gates/tool.ts +4 -2
- package/src/index.ts +12 -1
- package/src/input-normalizer.ts +9 -4
- package/src/path-normalizer.ts +70 -0
- package/src/path-utils.ts +39 -28
- package/src/permission-manager.ts +21 -7
- package/src/permission-session.ts +35 -2
- package/src/prompting-gateway.ts +3 -0
- package/src/rule.ts +8 -6
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/src/subagent-context.ts +17 -9
- package/test/access-intent/access-path.test.ts +73 -24
- package/test/access-intent/bash/program.test.ts +131 -65
- package/test/bash-external-directory.test.ts +15 -1
- package/test/canonicalize-path.test.ts +34 -8
- package/test/forwarding-manager.test.ts +7 -1
- package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
- package/test/handlers/gates/bash-external-directory.test.ts +5 -1
- package/test/handlers/gates/bash-path.test.ts +6 -1
- package/test/handlers/gates/external-directory-policy.test.ts +26 -8
- package/test/handlers/gates/external-directory.test.ts +9 -1
- package/test/handlers/gates/path.test.ts +53 -14
- package/test/handlers/gates/skill-read.test.ts +22 -13
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
- package/test/handlers/gates/tool.test.ts +13 -1
- package/test/helpers/gate-fixtures.ts +10 -0
- package/test/helpers/session-fixtures.ts +3 -0
- package/test/input-normalizer.test.ts +104 -39
- package/test/path-normalizer.test.ts +123 -0
- package/test/path-utils.test.ts +130 -51
- package/test/permission-forwarder.test.ts +1 -0
- package/test/permission-manager-unified.test.ts +40 -0
- package/test/permission-resolver.test.ts +12 -3
- package/test/permission-session.test.ts +41 -0
- package/test/pi-infrastructure-read.test.ts +27 -4
- package/test/prompting-gateway.test.ts +1 -0
- package/test/rule.test.ts +88 -42
- package/test/session-rules.test.ts +21 -4
- package/test/skill-prompt-sanitizer.test.ts +69 -14
- package/test/subagent-context.test.ts +77 -31
- package/test/synthesize.test.ts +13 -11
- package/src/access-intent/bash/cwd-projection.ts +0 -500
package/src/canonicalize-path.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolve symlinks in an absolute path, best-effort.
|
|
@@ -11,16 +11,22 @@ import { join } from "node:path";
|
|
|
11
11
|
* encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
|
|
12
12
|
* containment for paths that cannot be resolved.
|
|
13
13
|
*/
|
|
14
|
-
export function canonicalizePath(
|
|
14
|
+
export function canonicalizePath(
|
|
15
|
+
absolutePath: string,
|
|
16
|
+
platform: NodeJS.Platform,
|
|
17
|
+
): string {
|
|
15
18
|
if (!absolutePath) return absolutePath;
|
|
16
19
|
|
|
17
|
-
const
|
|
20
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
21
|
+
const root = impl.parse(absolutePath).root;
|
|
22
|
+
const rest = absolutePath.slice(root.length);
|
|
23
|
+
const parts = rest.split(impl.sep).filter(Boolean);
|
|
18
24
|
for (let i = parts.length; i >= 0; i--) {
|
|
19
|
-
const candidate =
|
|
25
|
+
const candidate = root + parts.slice(0, i).join(impl.sep);
|
|
20
26
|
try {
|
|
21
27
|
const real = realpathSync(candidate);
|
|
22
28
|
const tail = parts.slice(i);
|
|
23
|
-
return tail.length === 0 ? real : join(real, ...tail);
|
|
29
|
+
return tail.length === 0 ? real : impl.join(real, ...tail);
|
|
24
30
|
} catch (error) {
|
|
25
31
|
const code = (error as NodeJS.ErrnoException).code;
|
|
26
32
|
if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
|
|
@@ -77,6 +77,8 @@ export interface ForwarderContext {
|
|
|
77
77
|
export interface PermissionForwarderDeps {
|
|
78
78
|
forwardingDir: string;
|
|
79
79
|
subagentSessionsDir: string;
|
|
80
|
+
/** Host platform, injected from the composition root, for subagent-context path detection. */
|
|
81
|
+
platform: NodeJS.Platform;
|
|
80
82
|
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
81
83
|
registry?: SubagentSessionRegistry;
|
|
82
84
|
/** Event bus used for UI prompt broadcasts. */
|
|
@@ -185,6 +187,7 @@ export interface InboxProcessor {
|
|
|
185
187
|
export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
186
188
|
private readonly forwardingDir: string;
|
|
187
189
|
private readonly subagentSessionsDir: string;
|
|
190
|
+
private readonly platform: NodeJS.Platform;
|
|
188
191
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
189
192
|
private readonly events: PermissionEventBus | undefined;
|
|
190
193
|
private readonly logger: DebugReviewLogger;
|
|
@@ -199,6 +202,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
199
202
|
constructor(deps: PermissionForwarderDeps) {
|
|
200
203
|
this.forwardingDir = deps.forwardingDir;
|
|
201
204
|
this.subagentSessionsDir = deps.subagentSessionsDir;
|
|
205
|
+
this.platform = deps.platform;
|
|
202
206
|
this.registry = deps.registry;
|
|
203
207
|
this.events = deps.events;
|
|
204
208
|
this.logger = deps.logger;
|
|
@@ -228,7 +232,12 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
if (
|
|
231
|
-
!isSubagentExecutionContext(
|
|
235
|
+
!isSubagentExecutionContext(
|
|
236
|
+
ctx,
|
|
237
|
+
this.subagentSessionsDir,
|
|
238
|
+
this.platform,
|
|
239
|
+
this.registry,
|
|
240
|
+
)
|
|
232
241
|
) {
|
|
233
242
|
return Promise.resolve({ approved: false, state: "denied" });
|
|
234
243
|
}
|
|
@@ -307,6 +316,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
307
316
|
isSubagent: isSubagentExecutionContext(
|
|
308
317
|
ctx,
|
|
309
318
|
this.subagentSessionsDir,
|
|
319
|
+
this.platform,
|
|
310
320
|
this.registry,
|
|
311
321
|
),
|
|
312
322
|
currentSessionId: requesterSessionId,
|
|
@@ -30,6 +30,7 @@ export class ForwardingManager {
|
|
|
30
30
|
constructor(
|
|
31
31
|
private readonly subagentSessionsDir: string,
|
|
32
32
|
private readonly forwarder: InboxProcessor,
|
|
33
|
+
private readonly platform: NodeJS.Platform,
|
|
33
34
|
private readonly registry?: SubagentSessionRegistry,
|
|
34
35
|
) {}
|
|
35
36
|
|
|
@@ -42,7 +43,12 @@ export class ForwardingManager {
|
|
|
42
43
|
start(ctx: ExtensionContext): void {
|
|
43
44
|
if (
|
|
44
45
|
!ctx.hasUI ||
|
|
45
|
-
isSubagentExecutionContext(
|
|
46
|
+
isSubagentExecutionContext(
|
|
47
|
+
ctx,
|
|
48
|
+
this.subagentSessionsDir,
|
|
49
|
+
this.platform,
|
|
50
|
+
this.registry,
|
|
51
|
+
)
|
|
46
52
|
) {
|
|
47
53
|
this.stop();
|
|
48
54
|
return;
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Extract paths from a bash command
|
|
5
|
+
* Extract paths from a bash command that resolve outside CWD.
|
|
5
6
|
*
|
|
6
|
-
* Thin facade over {@link BashProgram.externalPaths}; parses the command
|
|
7
|
+
* Thin facade over {@link BashProgram.externalPaths}; parses the command
|
|
8
|
+
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
|
|
7
9
|
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
|
8
10
|
* See `BashProgram` for the parsing and resolution semantics.
|
|
9
11
|
*
|
|
10
12
|
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
|
11
13
|
* test suite in `bash-external-directory.test.ts` can assert path values
|
|
12
|
-
* without migrating
|
|
14
|
+
* without migrating to the `AccessPath` accessors.
|
|
13
15
|
*/
|
|
14
16
|
export async function extractExternalPathsFromBashCommand(
|
|
15
17
|
command: string,
|
|
16
|
-
|
|
18
|
+
normalizer: PathNormalizer,
|
|
17
19
|
): Promise<string[]> {
|
|
18
|
-
return (await BashProgram.parse(command,
|
|
20
|
+
return (await BashProgram.parse(command, normalizer))
|
|
19
21
|
.externalPaths()
|
|
20
22
|
.map((p) => p.value());
|
|
21
23
|
}
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
getToolInputPath,
|
|
4
|
-
isPathOutsideWorkingDirectory,
|
|
5
|
-
isPiInfrastructureRead,
|
|
6
|
-
normalizePathForComparison,
|
|
7
|
-
} from "#src/path-utils";
|
|
1
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
|
+
import { getToolInputPath, isPiInfrastructureRead } from "#src/path-utils";
|
|
8
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
9
4
|
import { SessionApproval } from "#src/session-approval";
|
|
10
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -26,6 +21,8 @@ export function describeExternalDirectoryGate(
|
|
|
26
21
|
tcc: ToolCallContext,
|
|
27
22
|
infraDirs: string[],
|
|
28
23
|
resolver: ScopedPermissionResolver,
|
|
24
|
+
normalizer: PathNormalizer,
|
|
25
|
+
platform: NodeJS.Platform,
|
|
29
26
|
extractors?: ToolAccessExtractorLookup,
|
|
30
27
|
): GateResult {
|
|
31
28
|
const externalDirectoryPath = getToolInputPath(
|
|
@@ -35,21 +32,25 @@ export function describeExternalDirectoryGate(
|
|
|
35
32
|
);
|
|
36
33
|
if (!externalDirectoryPath) return null;
|
|
37
34
|
|
|
38
|
-
if (!
|
|
35
|
+
if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
|
|
39
36
|
return null;
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
// The boundary decision (above) and the infrastructure-read containment
|
|
43
40
|
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
44
41
|
// uses the typed and resolved aliases (#418).
|
|
45
|
-
const accessPath =
|
|
46
|
-
cwd: tcc.cwd,
|
|
47
|
-
});
|
|
42
|
+
const accessPath = normalizer.forPath(externalDirectoryPath);
|
|
48
43
|
const canonicalExtPath = accessPath.boundaryValue();
|
|
49
44
|
|
|
50
45
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
51
46
|
if (
|
|
52
|
-
isPiInfrastructureRead(
|
|
47
|
+
isPiInfrastructureRead(
|
|
48
|
+
tcc.toolName,
|
|
49
|
+
canonicalExtPath,
|
|
50
|
+
infraDirs,
|
|
51
|
+
tcc.cwd,
|
|
52
|
+
platform,
|
|
53
|
+
)
|
|
53
54
|
) {
|
|
54
55
|
return {
|
|
55
56
|
action: "allow",
|
|
@@ -89,9 +90,7 @@ export function describeExternalDirectoryGate(
|
|
|
89
90
|
resolver,
|
|
90
91
|
tcc.agentName ?? undefined,
|
|
91
92
|
);
|
|
92
|
-
const pattern = deriveApprovalPattern(
|
|
93
|
-
normalizePathForComparison(externalDirectoryPath, tcc.cwd),
|
|
94
|
-
);
|
|
93
|
+
const pattern = deriveApprovalPattern(accessPath.value());
|
|
95
94
|
|
|
96
95
|
return {
|
|
97
96
|
surface: "external_directory",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
2
|
import { getToolInputPath } from "#src/path-utils";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import { SessionApproval } from "#src/session-approval";
|
|
@@ -18,6 +18,7 @@ import type { ToolCallContext } from "./types";
|
|
|
18
18
|
export function describePathGate(
|
|
19
19
|
tcc: ToolCallContext,
|
|
20
20
|
resolver: ScopedPermissionResolver,
|
|
21
|
+
normalizer: PathNormalizer,
|
|
21
22
|
extractors?: ToolAccessExtractorLookup,
|
|
22
23
|
): GateResult {
|
|
23
24
|
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
|
@@ -26,7 +27,7 @@ export function describePathGate(
|
|
|
26
27
|
// Emit an access-path intent so the resolver matches the lexical aliases
|
|
27
28
|
// *and* the canonical (symlink-resolved) form, the same set
|
|
28
29
|
// `external_directory` matches (#418, #486).
|
|
29
|
-
const accessPath =
|
|
30
|
+
const accessPath = normalizer.forPath(filePath);
|
|
30
31
|
const check = resolver.resolve({
|
|
31
32
|
kind: "access-path",
|
|
32
33
|
surface: "path",
|
|
@@ -15,6 +15,7 @@ import type { ToolCallContext } from "./types";
|
|
|
15
15
|
*/
|
|
16
16
|
export function describeSkillReadGate(
|
|
17
17
|
tcc: ToolCallContext,
|
|
18
|
+
platform: NodeJS.Platform,
|
|
18
19
|
getActiveSkillEntries: () => SkillPromptEntry[],
|
|
19
20
|
): GateDescriptor | null {
|
|
20
21
|
const activeSkillEntries = getActiveSkillEntries();
|
|
@@ -29,10 +30,15 @@ export function describeSkillReadGate(
|
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const normalizedReadPath = normalizePathForComparison(
|
|
33
|
+
const normalizedReadPath = normalizePathForComparison(
|
|
34
|
+
path,
|
|
35
|
+
tcc.cwd,
|
|
36
|
+
platform,
|
|
37
|
+
);
|
|
33
38
|
const matchedSkill = findSkillPathMatch(
|
|
34
39
|
normalizedReadPath,
|
|
35
40
|
activeSkillEntries,
|
|
41
|
+
platform,
|
|
36
42
|
);
|
|
37
43
|
|
|
38
44
|
if (!matchedSkill) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
4
5
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
@@ -36,6 +37,10 @@ export interface ToolCallGateInputs {
|
|
|
36
37
|
getInfrastructureReadDirs(): string[];
|
|
37
38
|
/** Resolved tool-preview formatter options from the current config. */
|
|
38
39
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
40
|
+
/** The session's path normalizer (platform + cwd baked in). */
|
|
41
|
+
getPathNormalizer(): PathNormalizer;
|
|
42
|
+
/** The host platform injected at the composition root. */
|
|
43
|
+
getPlatform(): NodeJS.Platform;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -64,9 +69,10 @@ export class ToolCallGatePipeline {
|
|
|
64
69
|
// Parse the bash command exactly once per evaluate; the three bash gates
|
|
65
70
|
// share this single BashProgram instead of each re-parsing (#308).
|
|
66
71
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
72
|
+
const normalizer = this.inputs.getPathNormalizer();
|
|
67
73
|
const bashProgram =
|
|
68
74
|
tcc.toolName === "bash" && command
|
|
69
|
-
? await BashProgram.parse(command,
|
|
75
|
+
? await BashProgram.parse(command, normalizer)
|
|
70
76
|
: null;
|
|
71
77
|
|
|
72
78
|
const formatter = new ToolPreviewFormatter(
|
|
@@ -75,16 +81,22 @@ export class ToolCallGatePipeline {
|
|
|
75
81
|
);
|
|
76
82
|
|
|
77
83
|
const infraDirs = this.inputs.getInfrastructureReadDirs();
|
|
84
|
+
const platform = this.inputs.getPlatform();
|
|
78
85
|
|
|
79
86
|
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
80
87
|
() =>
|
|
81
|
-
describeSkillReadGate(tcc, () =>
|
|
82
|
-
|
|
88
|
+
describeSkillReadGate(tcc, platform, () =>
|
|
89
|
+
this.inputs.getActiveSkillEntries(),
|
|
90
|
+
),
|
|
91
|
+
() =>
|
|
92
|
+
describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
|
|
83
93
|
() =>
|
|
84
94
|
describeExternalDirectoryGate(
|
|
85
95
|
tcc,
|
|
86
96
|
infraDirs,
|
|
87
97
|
this.resolver,
|
|
98
|
+
normalizer,
|
|
99
|
+
platform,
|
|
88
100
|
this.customExtractors,
|
|
89
101
|
),
|
|
90
102
|
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
|
@@ -108,7 +120,12 @@ export class ToolCallGatePipeline {
|
|
|
108
120
|
input: tcc.input,
|
|
109
121
|
agentName: tcc.agentName ?? undefined,
|
|
110
122
|
});
|
|
111
|
-
const toolDescriptor = describeToolGate(
|
|
123
|
+
const toolDescriptor = describeToolGate(
|
|
124
|
+
tcc,
|
|
125
|
+
toolCheck,
|
|
126
|
+
formatter,
|
|
127
|
+
platform,
|
|
128
|
+
);
|
|
112
129
|
toolDescriptor.preCheck = toolCheck;
|
|
113
130
|
return toolDescriptor;
|
|
114
131
|
},
|
|
@@ -23,12 +23,13 @@ import type { ToolCallContext } from "./types";
|
|
|
23
23
|
function deriveSuggestionValue(
|
|
24
24
|
tcc: ToolCallContext,
|
|
25
25
|
check: PermissionCheckResult,
|
|
26
|
+
platform: NodeJS.Platform,
|
|
26
27
|
): string {
|
|
27
28
|
if (tcc.toolName === "bash") return check.command ?? "";
|
|
28
29
|
if (tcc.toolName === "mcp") return check.target ?? "mcp";
|
|
29
30
|
const path = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
30
31
|
if (path === null) return "*";
|
|
31
|
-
return normalizePathForComparison(path, tcc.cwd);
|
|
32
|
+
return normalizePathForComparison(path, tcc.cwd, platform);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -41,6 +42,7 @@ export function describeToolGate(
|
|
|
41
42
|
tcc: ToolCallContext,
|
|
42
43
|
check: PermissionCheckResult,
|
|
43
44
|
formatter: ToolPreviewFormatter,
|
|
45
|
+
platform: NodeJS.Platform,
|
|
44
46
|
): GateDescriptor {
|
|
45
47
|
const permissionLogContext = formatter.getPermissionLogContext(
|
|
46
48
|
check,
|
|
@@ -51,7 +53,7 @@ export function describeToolGate(
|
|
|
51
53
|
// Compute session approval suggestion for the "for this session" option.
|
|
52
54
|
const suggestion = suggestSessionPattern(
|
|
53
55
|
tcc.toolName,
|
|
54
|
-
deriveSuggestionValue(tcc, check),
|
|
56
|
+
deriveSuggestionValue(tcc, check, platform),
|
|
55
57
|
);
|
|
56
58
|
|
|
57
59
|
const askMessage = formatAskPrompt(
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
42
42
|
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
|
|
43
43
|
// so the agent can read Pi's bundled docs/examples regardless of layout.
|
|
44
44
|
const paths = computeExtensionPaths(agentDir, getPackageDir());
|
|
45
|
-
|
|
45
|
+
// The single process.platform read for the whole extension; injected into the
|
|
46
|
+
// session (PathNormalizer) and, later, rule evaluation. Interior modules must
|
|
47
|
+
// not read process.platform (enforced by the eslint guard scoped to src/).
|
|
48
|
+
const hostPlatform = process.platform;
|
|
49
|
+
const permissionManager = new PermissionManager({
|
|
50
|
+
agentDir,
|
|
51
|
+
platform: hostPlatform,
|
|
52
|
+
});
|
|
46
53
|
const sessionRules = new SessionRules();
|
|
47
54
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
48
55
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
@@ -73,6 +80,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
73
80
|
const forwardingDeps: PermissionForwarderDeps = {
|
|
74
81
|
forwardingDir: paths.forwardingDir,
|
|
75
82
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
83
|
+
platform: hostPlatform,
|
|
76
84
|
registry: subagentRegistry,
|
|
77
85
|
events: pi.events,
|
|
78
86
|
logger,
|
|
@@ -91,6 +99,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
91
99
|
const gateway = new PromptingGateway({
|
|
92
100
|
config: configStore,
|
|
93
101
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
102
|
+
platform: hostPlatform,
|
|
94
103
|
registry: subagentRegistry,
|
|
95
104
|
prompter,
|
|
96
105
|
});
|
|
@@ -100,12 +109,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
100
109
|
new ForwardingManager(
|
|
101
110
|
paths.subagentSessionsDir,
|
|
102
111
|
forwarder,
|
|
112
|
+
hostPlatform,
|
|
103
113
|
subagentRegistry,
|
|
104
114
|
),
|
|
105
115
|
permissionManager,
|
|
106
116
|
sessionRules,
|
|
107
117
|
configStore,
|
|
108
118
|
gateway,
|
|
119
|
+
hostPlatform,
|
|
109
120
|
);
|
|
110
121
|
|
|
111
122
|
// refresh() must run after `session` is assigned: a debug-write IO failure
|
package/src/input-normalizer.ts
CHANGED
|
@@ -66,13 +66,14 @@ export function normalizeInput(
|
|
|
66
66
|
toolName: string,
|
|
67
67
|
input: unknown,
|
|
68
68
|
configuredMcpServerNames: readonly string[],
|
|
69
|
+
platform: NodeJS.Platform,
|
|
69
70
|
cwd?: string,
|
|
70
71
|
): NormalizedInput {
|
|
71
72
|
// --- Special surfaces (path, external_directory) ---
|
|
72
73
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
73
74
|
return {
|
|
74
75
|
surface: toolName,
|
|
75
|
-
values: normalizePathSurfaceValues(input, cwd),
|
|
76
|
+
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
76
77
|
resultExtras: {},
|
|
77
78
|
};
|
|
78
79
|
}
|
|
@@ -123,7 +124,7 @@ export function normalizeInput(
|
|
|
123
124
|
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
124
125
|
return {
|
|
125
126
|
surface: toolName,
|
|
126
|
-
values: normalizePathSurfaceValues(input, cwd),
|
|
127
|
+
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
127
128
|
resultExtras: {},
|
|
128
129
|
};
|
|
129
130
|
}
|
|
@@ -149,9 +150,13 @@ export function normalizeInput(
|
|
|
149
150
|
* Only `input.path` is read — policy values are never sourced from any other
|
|
150
151
|
* (potentially attacker-controlled) field on the raw tool input.
|
|
151
152
|
*/
|
|
152
|
-
function normalizePathSurfaceValues(
|
|
153
|
+
function normalizePathSurfaceValues(
|
|
154
|
+
input: unknown,
|
|
155
|
+
platform: NodeJS.Platform,
|
|
156
|
+
cwd?: string,
|
|
157
|
+
): string[] {
|
|
153
158
|
const path = getNonEmptyString(toRecord(input).path);
|
|
154
159
|
if (path === null) return ["*"];
|
|
155
|
-
const values = getPathPolicyValues(path, cwd ? { cwd } : {});
|
|
160
|
+
const values = getPathPolicyValues(path, cwd ? { cwd } : {}, platform);
|
|
156
161
|
return values.length > 0 ? values : ["*"];
|
|
157
162
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccessPath } from "./access-intent/access-path";
|
|
4
|
+
import {
|
|
5
|
+
isPathOutsideWorkingDirectory,
|
|
6
|
+
isPathWithinDirectory,
|
|
7
|
+
} from "./path-utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Path-interpretation collaborator, constructed once at the session edge with
|
|
11
|
+
* the two ambient inputs — the host `platform` and the session `cwd` — baked
|
|
12
|
+
* in, and handed raw path tokens thereafter.
|
|
13
|
+
*
|
|
14
|
+
* The bash path pipeline and the per-tool/external-directory gates ask this
|
|
15
|
+
* object the platform-dependent questions ("is this path absolute *under our
|
|
16
|
+
* platform*?", "resolve this `cd` offset *against our cwd*") and receive
|
|
17
|
+
* prepared {@link AccessPath} values, instead of reading `process.platform`
|
|
18
|
+
* ambiently or threading `cwd` through every call. Internally it selects the
|
|
19
|
+
* `win32`/`posix` path flavor once and delegates to the platform-parameterized
|
|
20
|
+
* `path-utils` / `AccessPath` primitives.
|
|
21
|
+
*/
|
|
22
|
+
export class PathNormalizer {
|
|
23
|
+
private readonly impl: typeof posixPath;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly platform: NodeJS.Platform,
|
|
27
|
+
private readonly cwd: string,
|
|
28
|
+
) {
|
|
29
|
+
this.impl = platform === "win32" ? winPath : posixPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
|
|
33
|
+
forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath {
|
|
34
|
+
return AccessPath.forPath(pathValue, {
|
|
35
|
+
cwd: this.cwd,
|
|
36
|
+
resolveBase: options?.resolveBase,
|
|
37
|
+
platform: this.platform,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
|
|
42
|
+
forLiteral(literal: string): AccessPath {
|
|
43
|
+
return AccessPath.forLiteral(literal);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
|
|
47
|
+
isAbsolute(pathValue: string): boolean {
|
|
48
|
+
return this.impl.isAbsolute(pathValue);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
|
|
52
|
+
resolveBase(offset: string): string {
|
|
53
|
+
return this.impl.resolve(this.cwd, offset);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
|
|
57
|
+
joinBase(offset: string, target: string): string {
|
|
58
|
+
return this.impl.join(offset, target);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Containment of `pathValue` within `directory` (platform-aware). */
|
|
62
|
+
isWithinDirectory(pathValue: string, directory: string): boolean {
|
|
63
|
+
return isPathWithinDirectory(pathValue, directory, this.platform);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
|
67
|
+
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
68
|
+
return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/path-utils.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
join,
|
|
3
|
-
normalize,
|
|
4
|
-
posix as posixPath,
|
|
5
|
-
relative,
|
|
6
|
-
resolve,
|
|
7
|
-
win32 as winPath,
|
|
8
|
-
} from "node:path";
|
|
1
|
+
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
9
2
|
|
|
10
3
|
import { canonicalizePath } from "./canonicalize-path";
|
|
11
4
|
import { expandHomePath } from "./expand-home";
|
|
@@ -16,6 +9,7 @@ import { wildcardMatch } from "./wildcard-matcher";
|
|
|
16
9
|
export function normalizePathForComparison(
|
|
17
10
|
pathValue: string,
|
|
18
11
|
cwd: string,
|
|
12
|
+
platform: NodeJS.Platform,
|
|
19
13
|
): string {
|
|
20
14
|
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
21
15
|
if (!trimmed) {
|
|
@@ -25,9 +19,10 @@ export function normalizePathForComparison(
|
|
|
25
19
|
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
26
20
|
normalizedPath = expandHomePath(normalizedPath);
|
|
27
21
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
22
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
23
|
+
const absolutePath = impl.resolve(cwd, normalizedPath);
|
|
24
|
+
const normalizedAbsolutePath = impl.normalize(absolutePath);
|
|
25
|
+
return platform === "win32"
|
|
31
26
|
? normalizedAbsolutePath.toLowerCase()
|
|
32
27
|
: normalizedAbsolutePath;
|
|
33
28
|
}
|
|
@@ -38,13 +33,13 @@ export function normalizePathForComparison(
|
|
|
38
33
|
* Containment is decided with Node's platform-native `path.relative` rather
|
|
39
34
|
* than a hand-rolled prefix check: on `win32` the comparison folds case (and
|
|
40
35
|
* tolerates either separator), matching the case-insensitive filesystem.
|
|
41
|
-
* `platform`
|
|
42
|
-
*
|
|
36
|
+
* `platform` is injected from the composition root so Windows behavior is
|
|
37
|
+
* testable on a POSIX CI.
|
|
43
38
|
*/
|
|
44
39
|
export function isPathWithinDirectory(
|
|
45
40
|
pathValue: string,
|
|
46
41
|
directory: string,
|
|
47
|
-
platform: NodeJS.Platform
|
|
42
|
+
platform: NodeJS.Platform,
|
|
48
43
|
): boolean {
|
|
49
44
|
if (!pathValue || !directory) {
|
|
50
45
|
return false;
|
|
@@ -101,46 +96,56 @@ export function normalizePathPolicyLiteral(pathValue: string): string {
|
|
|
101
96
|
*/
|
|
102
97
|
export function getPathPolicyValues(
|
|
103
98
|
pathValue: string,
|
|
104
|
-
options: PathPolicyValueOptions
|
|
99
|
+
options: PathPolicyValueOptions,
|
|
100
|
+
platform: NodeJS.Platform,
|
|
105
101
|
): string[] {
|
|
106
102
|
const literal = normalizePathPolicyLiteral(pathValue);
|
|
107
103
|
if (!literal) return [];
|
|
108
104
|
if (literal === "*") return ["*"];
|
|
109
105
|
|
|
110
106
|
return [
|
|
111
|
-
...new Set([
|
|
107
|
+
...new Set([
|
|
108
|
+
...getAbsolutePathPolicyValues(pathValue, options, platform),
|
|
109
|
+
literal,
|
|
110
|
+
]),
|
|
112
111
|
];
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
function getAbsolutePathPolicyValues(
|
|
116
115
|
pathValue: string,
|
|
117
116
|
options: PathPolicyValueOptions,
|
|
117
|
+
platform: NodeJS.Platform,
|
|
118
118
|
): string[] {
|
|
119
119
|
const resolveBase = options.resolveBase ?? options.cwd;
|
|
120
120
|
if (!resolveBase) return [];
|
|
121
121
|
|
|
122
|
-
const absolute = normalizePathForComparison(pathValue, resolveBase);
|
|
122
|
+
const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
|
|
123
123
|
if (!absolute) return [];
|
|
124
124
|
|
|
125
|
-
return [
|
|
125
|
+
return [
|
|
126
|
+
absolute,
|
|
127
|
+
...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
|
|
128
|
+
];
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
function getCwdRelativePathPolicyValues(
|
|
129
132
|
absolute: string,
|
|
130
133
|
cwd: string | undefined,
|
|
134
|
+
platform: NodeJS.Platform,
|
|
131
135
|
): string[] {
|
|
132
136
|
if (!cwd) return [];
|
|
133
137
|
|
|
134
|
-
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
138
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
|
|
135
139
|
if (!normalizedCwd) return [];
|
|
136
140
|
if (
|
|
137
141
|
absolute !== normalizedCwd &&
|
|
138
|
-
!isPathWithinDirectory(absolute, normalizedCwd)
|
|
142
|
+
!isPathWithinDirectory(absolute, normalizedCwd, platform)
|
|
139
143
|
) {
|
|
140
144
|
return [];
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
const
|
|
147
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
148
|
+
const relativeValue = impl.relative(normalizedCwd, absolute);
|
|
144
149
|
return relativeValue ? [relativeValue] : [];
|
|
145
150
|
}
|
|
146
151
|
|
|
@@ -253,26 +258,32 @@ export function getToolInputPath(
|
|
|
253
258
|
export function canonicalNormalizePathForComparison(
|
|
254
259
|
pathValue: string,
|
|
255
260
|
cwd: string,
|
|
261
|
+
platform: NodeJS.Platform,
|
|
256
262
|
): string {
|
|
257
|
-
const lexical = normalizePathForComparison(pathValue, cwd);
|
|
263
|
+
const lexical = normalizePathForComparison(pathValue, cwd, platform);
|
|
258
264
|
if (!lexical) return "";
|
|
259
|
-
const canonical = canonicalizePath(lexical);
|
|
260
|
-
return
|
|
265
|
+
const canonical = canonicalizePath(lexical, platform);
|
|
266
|
+
return platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
export function isPathOutsideWorkingDirectory(
|
|
264
270
|
pathValue: string,
|
|
265
271
|
cwd: string,
|
|
272
|
+
platform: NodeJS.Platform,
|
|
266
273
|
): boolean {
|
|
267
|
-
const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
|
|
268
|
-
const normalizedPath = canonicalNormalizePathForComparison(
|
|
274
|
+
const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
|
|
275
|
+
const normalizedPath = canonicalNormalizePathForComparison(
|
|
276
|
+
pathValue,
|
|
277
|
+
cwd,
|
|
278
|
+
platform,
|
|
279
|
+
);
|
|
269
280
|
if (!normalizedCwd || !normalizedPath) {
|
|
270
281
|
return false;
|
|
271
282
|
}
|
|
272
283
|
if (isSafeSystemPath(normalizedPath)) {
|
|
273
284
|
return false;
|
|
274
285
|
}
|
|
275
|
-
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
286
|
+
return !isPathWithinDirectory(normalizedPath, normalizedCwd, platform);
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
function containsGlobChars(value: string): boolean {
|
|
@@ -299,7 +310,7 @@ export function isPiInfrastructureRead(
|
|
|
299
310
|
normalizedPath: string,
|
|
300
311
|
infrastructureDirs: readonly string[],
|
|
301
312
|
cwd: string,
|
|
302
|
-
platform: NodeJS.Platform
|
|
313
|
+
platform: NodeJS.Platform,
|
|
303
314
|
): boolean {
|
|
304
315
|
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
305
316
|
return false;
|