@gotgenes/pi-permission-system 20.2.0 → 20.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/docs/cross-extension-api.md +4 -0
- package/docs/subagent-integration.md +4 -2
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +7 -5
- package/src/access-intent/bash/bash-path-resolver.ts +1 -2
- package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
- package/src/access-intent/bash/parser.ts +39 -0
- package/src/access-intent/bash/sync-commands.ts +31 -0
- package/src/access-intent/bash/token-classification.ts +18 -32
- package/src/access-intent/path-normalization.ts +24 -43
- package/src/access-intent/tool-kind.ts +57 -0
- package/src/authority/approval-escalator.ts +18 -8
- package/src/authority/authorizer-selection.ts +1 -1
- package/src/authority/authorizer.ts +2 -2
- package/src/authority/denying-authorizer.ts +1 -1
- package/src/authority/forwarded-request-server.ts +52 -4
- package/src/authority/forwarder-context.ts +1 -1
- package/src/authority/forwarding-io.ts +30 -3
- package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
- package/src/authority/local-user-authorizer.ts +27 -2
- package/src/{permission-dialog.ts → authority/permission-dialog.ts} +26 -0
- package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +22 -2
- package/src/authority/permission-prompter.ts +9 -1
- package/src/authority/subagent-context.ts +11 -14
- package/src/authority/subagent-detection.ts +5 -4
- package/src/bash-advisory-check.ts +38 -0
- package/src/denial-messages.ts +6 -9
- package/src/handlers/before-agent-start.ts +8 -0
- package/src/handlers/gates/helpers.ts +12 -4
- package/src/handlers/gates/runner.ts +4 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
- package/src/handlers/gates/tool.ts +9 -4
- package/src/index.ts +27 -12
- package/src/input-normalizer.ts +53 -48
- package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
- package/src/path/path-containment.ts +24 -0
- package/src/path/path-flavor.ts +114 -0
- package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
- package/src/path-normalizer.ts +35 -59
- package/src/pattern-suggest.ts +29 -0
- package/src/permission-gate.ts +1 -1
- package/src/permission-manager.ts +36 -56
- package/src/permission-prompts.ts +4 -3
- package/src/permission-session.ts +6 -5
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +19 -21
- package/src/session-approval.ts +11 -0
- package/src/tool-input-path.ts +17 -19
- package/src/tool-preview-formatter.ts +2 -5
- package/src/path-containment.ts +0 -56
- /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
- /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
package/src/index.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { warmBashParser } from "./access-intent/bash/parser";
|
|
3
4
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
4
5
|
import {
|
|
5
6
|
ForwardedRequestServer,
|
|
6
7
|
type ServingPolicy,
|
|
7
8
|
} from "./authority/forwarded-request-server";
|
|
9
|
+
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
10
|
+
import { requestPermissionDecisionFromUi } from "./authority/permission-dialog";
|
|
8
11
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
9
12
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
13
|
+
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
14
|
+
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
|
10
15
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
11
16
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
12
17
|
import { getGlobalConfigPath } from "./config-paths";
|
|
@@ -15,7 +20,6 @@ import { DecisionAudit } from "./decision-audit";
|
|
|
15
20
|
import { GateDecisionReporter } from "./decision-reporter";
|
|
16
21
|
import { isYoloModeEnabled } from "./extension-config";
|
|
17
22
|
import { computeExtensionPaths } from "./extension-paths";
|
|
18
|
-
import { ForwardingManager } from "./forwarding-manager";
|
|
19
23
|
import {
|
|
20
24
|
AgentPrepHandler,
|
|
21
25
|
PermissionGateHandler,
|
|
@@ -26,7 +30,7 @@ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeli
|
|
|
26
30
|
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
|
27
31
|
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
|
|
28
32
|
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
29
|
-
import {
|
|
33
|
+
import { pathFlavorForPlatform } from "./path/path-flavor";
|
|
30
34
|
import { PermissionManager } from "./permission-manager";
|
|
31
35
|
import { PermissionResolver } from "./permission-resolver";
|
|
32
36
|
import { PermissionSession } from "./permission-session";
|
|
@@ -34,8 +38,6 @@ import { LocalPermissionsService } from "./permissions-service";
|
|
|
34
38
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
35
39
|
import { PermissionSessionLogger } from "./session-logger";
|
|
36
40
|
import { SessionRules } from "./session-rules";
|
|
37
|
-
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
38
|
-
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
39
41
|
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
|
|
40
42
|
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
41
43
|
|
|
@@ -44,17 +46,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
44
46
|
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
|
|
45
47
|
// so the agent can read Pi's bundled docs/examples regardless of layout.
|
|
46
48
|
const paths = computeExtensionPaths(agentDir, getPackageDir());
|
|
47
|
-
// The single process.platform read for the whole extension
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
49
|
+
// The single process.platform read for the whole extension, resolved once
|
|
50
|
+
// into the path-language flavor that every consumer shares (the session's
|
|
51
|
+
// PathNormalizer, rule evaluation, and subagent detection). Interior modules
|
|
52
|
+
// must not read process.platform (enforced by the eslint guard scoped to
|
|
53
|
+
// src/) and never re-derive the win32 flavor — they receive this product.
|
|
54
|
+
const hostFlavor = pathFlavorForPlatform(process.platform);
|
|
51
55
|
const sessionRules = new SessionRules();
|
|
52
56
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
53
57
|
// Single owner of subagent detection, shared across every consumer instead of
|
|
54
58
|
// threading the (subagentSessionsDir, platform, registry) triple into each.
|
|
55
59
|
const subagentDetection = new SubagentDetection({
|
|
56
60
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
57
|
-
|
|
61
|
+
flavor: hostFlavor,
|
|
58
62
|
registry: subagentRegistry,
|
|
59
63
|
});
|
|
60
64
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
@@ -75,7 +79,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
75
79
|
// assigned below. yolo becomes a composition-stage ask→allow rewrite (#526).
|
|
76
80
|
const permissionManager = new PermissionManager({
|
|
77
81
|
agentDir,
|
|
78
|
-
|
|
82
|
+
flavor: hostFlavor,
|
|
79
83
|
isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
|
|
80
84
|
});
|
|
81
85
|
|
|
@@ -132,6 +136,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
132
136
|
logger,
|
|
133
137
|
policy: servingPolicy,
|
|
134
138
|
escalator: authorizerSelection,
|
|
139
|
+
// Records a whole-session grant into the same SessionRules the resolver and
|
|
140
|
+
// gate runner read, so a serving-scope grant governs the parent and future
|
|
141
|
+
// forwarded resolutions.
|
|
142
|
+
recorder: sessionRules,
|
|
135
143
|
registry: subagentRegistry,
|
|
136
144
|
});
|
|
137
145
|
|
|
@@ -142,7 +150,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
142
150
|
sessionRules,
|
|
143
151
|
configStore,
|
|
144
152
|
authorizerSelection,
|
|
145
|
-
|
|
153
|
+
hostFlavor,
|
|
146
154
|
);
|
|
147
155
|
|
|
148
156
|
// refresh() must run after `session` is assigned: a debug-write IO failure
|
|
@@ -200,7 +208,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
200
208
|
logger,
|
|
201
209
|
audit,
|
|
202
210
|
);
|
|
203
|
-
const agentPrep = new AgentPrepHandler(
|
|
211
|
+
const agentPrep = new AgentPrepHandler(
|
|
212
|
+
session,
|
|
213
|
+
resolver,
|
|
214
|
+
toolRegistry,
|
|
215
|
+
() => {
|
|
216
|
+
void warmBashParser();
|
|
217
|
+
},
|
|
218
|
+
);
|
|
204
219
|
|
|
205
220
|
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
206
221
|
const gateRunner = new GateRunner(
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
2
3
|
import { stripBashCommentLines } from "./bash-arity";
|
|
3
4
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
5
|
import type { PathNormalizer } from "./path-normalizer";
|
|
@@ -106,55 +107,59 @@ export function normalizeInput(
|
|
|
106
107
|
input: unknown,
|
|
107
108
|
configuredMcpServerNames: readonly string[],
|
|
108
109
|
): NormalizedInput {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
switch (classifyToolKind(toolName)) {
|
|
111
|
+
// --- Skill ---
|
|
112
|
+
case "skill": {
|
|
113
|
+
const record = toRecord(input);
|
|
114
|
+
const skillName = record.name;
|
|
115
|
+
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
|
116
|
+
return {
|
|
117
|
+
surface: "skill",
|
|
118
|
+
values: [lookupValue],
|
|
119
|
+
resultExtras: {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
123
|
+
// --- Bash ---
|
|
124
|
+
case "bash": {
|
|
125
|
+
const record = toRecord(input);
|
|
126
|
+
const command = typeof record.command === "string" ? record.command : "";
|
|
127
|
+
// Strip leading shell comment lines so pattern matching operates on the
|
|
128
|
+
// actual command, not a `# description` prefix agents often prepend.
|
|
129
|
+
// Fall back to the raw command when stripping leaves nothing, so an
|
|
130
|
+
// all-comment command still evaluates against its literal text.
|
|
131
|
+
const matchValue = stripBashCommentLines(command) || command;
|
|
132
|
+
return {
|
|
133
|
+
surface: "bash",
|
|
134
|
+
values: [matchValue],
|
|
135
|
+
resultExtras: { command },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
// --- MCP ---
|
|
140
|
+
case "mcp": {
|
|
141
|
+
const mcpTargets = [
|
|
142
|
+
...createMcpPermissionTargets(input, configuredMcpServerNames),
|
|
143
|
+
"mcp",
|
|
144
|
+
];
|
|
145
|
+
const fallbackTarget = mcpTargets[0] ?? "mcp";
|
|
146
|
+
return {
|
|
147
|
+
surface: "mcp",
|
|
148
|
+
values: mcpTargets,
|
|
149
|
+
resultExtras: { target: fallbackTarget },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
// --- All other surfaces (path-bearing tools and extension tools) ---
|
|
154
|
+
// Path-bearing tools with a present path never reach here — the gate emits
|
|
155
|
+
// an access-path intent (#502). Missing-path and extension-tool cases both
|
|
156
|
+
// collapse to the surface catch-all.
|
|
157
|
+
case "path":
|
|
158
|
+
case "extension":
|
|
159
|
+
return {
|
|
160
|
+
surface: toolName,
|
|
161
|
+
values: ["*"],
|
|
162
|
+
resultExtras: {},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
160
165
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Resolve symlinks in an absolute path, best-effort.
|
|
@@ -13,11 +14,11 @@ import { posix as posixPath, win32 as winPath } from "node:path";
|
|
|
13
14
|
*/
|
|
14
15
|
export function canonicalizePath(
|
|
15
16
|
absolutePath: string,
|
|
16
|
-
|
|
17
|
+
flavor: PathFlavor,
|
|
17
18
|
): string {
|
|
18
19
|
if (!absolutePath) return absolutePath;
|
|
19
20
|
|
|
20
|
-
const impl =
|
|
21
|
+
const { impl } = flavor;
|
|
21
22
|
const root = impl.parse(absolutePath).root;
|
|
22
23
|
const rest = absolutePath.slice(root.length);
|
|
23
24
|
const parts = rest.split(impl.sep).filter(Boolean);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
|
+
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
|
6
|
+
*
|
|
7
|
+
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
|
8
|
+
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
|
|
9
|
+
* This predicate touches no filesystem and does no derivation; the containment
|
|
10
|
+
* geometry lives on {@link PathFlavor.isWithin}.
|
|
11
|
+
*/
|
|
12
|
+
export function isPathOutsideWorkingDirectory(
|
|
13
|
+
canonicalPath: string,
|
|
14
|
+
canonicalCwd: string,
|
|
15
|
+
flavor: PathFlavor,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!canonicalCwd || !canonicalPath) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (isSafeSystemPath(canonicalPath)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return !flavor.isWithin(canonicalPath, canonicalCwd);
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { PlatformPath } from "node:path";
|
|
2
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type BashTokenShape,
|
|
6
|
+
classifyWin32BashToken,
|
|
7
|
+
} from "#src/access-intent/bash/msys-bash-tokens";
|
|
8
|
+
import type { WildcardMatchOptions } from "#src/wildcard-matcher";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The resolved product of the single win32-vs-POSIX platform decision: the
|
|
12
|
+
* platform's path *language* as one immutable collaborator.
|
|
13
|
+
*
|
|
14
|
+
* The win32-vs-POSIX difference is not variant growth (the set is closed) but
|
|
15
|
+
* **connascence of algorithm** — every path leaf must re-derive the same
|
|
16
|
+
* mapping identically, and in a permission system a leaf that misses the case
|
|
17
|
+
* fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
|
|
18
|
+
* captures that mapping once so the leaves consume the resolved capability
|
|
19
|
+
* instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
|
|
20
|
+
* **semantics** — syntax ({@link hasPathSeparator}), token shape
|
|
21
|
+
* ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
|
|
22
|
+
* {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
|
|
23
|
+
* (lexical cleanup, alias generation, safe-system-path exclusions, rule
|
|
24
|
+
* dispatch) stays in the functions that consume it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PathFlavor {
|
|
27
|
+
/**
|
|
28
|
+
* Node's own platform path strategy (`path.win32` | `path.posix`). Exposed
|
|
29
|
+
* directly — its post-migration consumers are all path-domain primitives and
|
|
30
|
+
* `PlatformPath` is itself a maintained strategy object, so wrapping it would
|
|
31
|
+
* be pure forwarding.
|
|
32
|
+
*/
|
|
33
|
+
readonly impl: PlatformPath;
|
|
34
|
+
/**
|
|
35
|
+
* Wildcard match options for path-surface rule matching: the win32
|
|
36
|
+
* case-and-separator fold, or `undefined` on POSIX.
|
|
37
|
+
*/
|
|
38
|
+
readonly matchOptions: WildcardMatchOptions | undefined;
|
|
39
|
+
/** Comparison case fold: win32 lowercases, POSIX returns the value unchanged. */
|
|
40
|
+
fold(value: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve `pathValue` against `base`, normalize, and fold — the single home
|
|
43
|
+
* of the #382 case-fold invariant for absolute comparison values.
|
|
44
|
+
*/
|
|
45
|
+
comparable(pathValue: string, base: string): string;
|
|
46
|
+
/** `path.relative`-based containment: is `pathValue` `directory` itself or nested inside it? */
|
|
47
|
+
isWithin(pathValue: string, directory: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True when `token` contains a path separator under this platform: `/` on
|
|
50
|
+
* POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
|
|
51
|
+
*/
|
|
52
|
+
hasPathSeparator(token: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
|
|
55
|
+
* carries device / drive-mount / posix-absolute / plain semantics; on POSIX
|
|
56
|
+
* every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
|
|
57
|
+
*/
|
|
58
|
+
bashTokenShape(token: string): BashTokenShape;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class PlatformPathFlavor implements PathFlavor {
|
|
62
|
+
readonly matchOptions: WildcardMatchOptions | undefined;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
readonly impl: PlatformPath,
|
|
66
|
+
private readonly windows: boolean,
|
|
67
|
+
) {
|
|
68
|
+
this.matchOptions = windows
|
|
69
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fold(value: string): string {
|
|
74
|
+
return this.windows ? value.toLowerCase() : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
comparable(pathValue: string, base: string): string {
|
|
78
|
+
return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isWithin(pathValue: string, directory: string): boolean {
|
|
82
|
+
if (!pathValue || !directory) return false;
|
|
83
|
+
if (pathValue === directory) return true;
|
|
84
|
+
const rel = this.impl.relative(directory, pathValue);
|
|
85
|
+
return (
|
|
86
|
+
rel !== "" &&
|
|
87
|
+
rel !== ".." &&
|
|
88
|
+
!rel.startsWith(`..${this.impl.sep}`) &&
|
|
89
|
+
!this.impl.isAbsolute(rel)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hasPathSeparator(token: string): boolean {
|
|
94
|
+
return token.includes("/") || (this.windows && token.includes("\\"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
bashTokenShape(token: string): BashTokenShape {
|
|
98
|
+
return this.windows ? classifyWin32BashToken(token) : { kind: "plain" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const posixPathFlavor: PathFlavor = new PlatformPathFlavor(
|
|
103
|
+
posixPath,
|
|
104
|
+
false,
|
|
105
|
+
);
|
|
106
|
+
export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
|
|
107
|
+
winPath,
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** The one win32-vs-POSIX platform decision in the package. */
|
|
112
|
+
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
|
|
113
|
+
return platform === "win32" ? win32PathFlavor : posixPathFlavor;
|
|
114
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { expandHomePath } from "
|
|
4
|
-
import {
|
|
5
|
-
import { READ_ONLY_PATH_BEARING_TOOLS } from "
|
|
6
|
-
import { wildcardMatch } from "
|
|
3
|
+
import { expandHomePath } from "#src/expand-home";
|
|
4
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
5
|
+
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/path-surfaces";
|
|
6
|
+
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
7
7
|
|
|
8
8
|
function containsGlobChars(value: string): boolean {
|
|
9
9
|
return value.includes("*") || value.includes("?");
|
|
@@ -29,35 +29,30 @@ export function isPiInfrastructureRead(
|
|
|
29
29
|
normalizedPath: string,
|
|
30
30
|
infrastructureDirs: readonly string[],
|
|
31
31
|
cwd: string,
|
|
32
|
-
|
|
32
|
+
flavor: PathFlavor,
|
|
33
33
|
): boolean {
|
|
34
34
|
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// On Windows the path value is canonicalized + lowercased;
|
|
39
|
-
// separators) so mixed-case infra dirs and glob
|
|
40
|
-
|
|
41
|
-
platform === "win32"
|
|
42
|
-
? { caseInsensitive: true, windowsSeparators: true }
|
|
43
|
-
: undefined;
|
|
44
|
-
|
|
38
|
+
// On Windows the path value is canonicalized + lowercased; the flavor's match
|
|
39
|
+
// options fold case (and separators) so mixed-case infra dirs and glob
|
|
40
|
+
// patterns still match.
|
|
45
41
|
for (const dir of infrastructureDirs) {
|
|
46
42
|
if (containsGlobChars(dir)) {
|
|
47
|
-
if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
|
|
43
|
+
if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
|
|
48
44
|
} else {
|
|
49
|
-
if (
|
|
50
|
-
return true;
|
|
45
|
+
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
|
|
51
46
|
}
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
55
50
|
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
56
51
|
const projectGitDir = join(cwd, ".pi", "git");
|
|
57
|
-
if (
|
|
52
|
+
if (flavor.isWithin(normalizedPath, projectNpmDir)) {
|
|
58
53
|
return true;
|
|
59
54
|
}
|
|
60
|
-
if (
|
|
55
|
+
if (flavor.isWithin(normalizedPath, projectGitDir)) {
|
|
61
56
|
return true;
|
|
62
57
|
}
|
|
63
58
|
|
package/src/path-normalizer.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
2
|
|
|
3
3
|
import { AccessPath } from "./access-intent/access-path";
|
|
4
|
-
import { classifyWin32BashToken } from "./access-intent/bash/msys-bash-tokens";
|
|
5
4
|
import {
|
|
6
5
|
canonicalNormalizePathForComparison,
|
|
7
6
|
normalizePathForComparison,
|
|
8
7
|
normalizePathPolicyLiteral,
|
|
9
8
|
} from "./access-intent/path-normalization";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
isPathWithinDirectory,
|
|
13
|
-
} from "./path-containment";
|
|
14
|
-
import { isPiInfrastructureRead } from "./pi-infrastructure-read";
|
|
9
|
+
import { isPathOutsideWorkingDirectory } from "./path/path-containment";
|
|
10
|
+
import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
|
|
15
11
|
|
|
16
12
|
/**
|
|
17
13
|
* The interpreted effect of a literal `cd` target on the effective base, under
|
|
@@ -31,28 +27,27 @@ export type BashCdTarget =
|
|
|
31
27
|
|
|
32
28
|
/**
|
|
33
29
|
* Path-interpretation collaborator, constructed once at the session edge with
|
|
34
|
-
* the two ambient inputs — the
|
|
35
|
-
* in, and handed raw path tokens thereafter.
|
|
30
|
+
* the two ambient inputs — the resolved {@link PathFlavor} and the session
|
|
31
|
+
* `cwd` — baked in, and handed raw path tokens thereafter.
|
|
36
32
|
*
|
|
37
33
|
* The bash path pipeline and the per-tool/external-directory gates ask this
|
|
38
34
|
* object the platform-dependent questions ("is this path absolute *under our
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
35
|
+
* flavor*?", "resolve this `cd` offset *against our cwd*") and receive prepared
|
|
36
|
+
* {@link AccessPath} values, instead of reading `process.platform` ambiently or
|
|
37
|
+
* threading `cwd` through every call. All platform semantics live on the
|
|
38
|
+
* injected `flavor`; this class holds no platform discriminator and no
|
|
39
|
+
* `win32`/`posix` branch — it delegates to `flavor` and the flavor-parameterized
|
|
43
40
|
* `path-containment` / `path-normalization` / `AccessPath` primitives.
|
|
44
41
|
*/
|
|
45
42
|
export class PathNormalizer {
|
|
46
|
-
private readonly impl: typeof posixPath;
|
|
47
43
|
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
|
|
48
44
|
private readonly canonicalCwd: string;
|
|
49
45
|
|
|
50
46
|
constructor(
|
|
51
|
-
|
|
47
|
+
readonly flavor: PathFlavor,
|
|
52
48
|
private readonly cwd: string,
|
|
53
49
|
) {
|
|
54
|
-
this.
|
|
55
|
-
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
|
|
50
|
+
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor);
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
|
|
@@ -60,7 +55,7 @@ export class PathNormalizer {
|
|
|
60
55
|
return AccessPath.forPath(pathValue, {
|
|
61
56
|
cwd: this.cwd,
|
|
62
57
|
resolveBase: options?.resolveBase,
|
|
63
|
-
|
|
58
|
+
flavor: this.flavor,
|
|
64
59
|
});
|
|
65
60
|
}
|
|
66
61
|
|
|
@@ -74,18 +69,16 @@ export class PathNormalizer {
|
|
|
74
69
|
* semantics on a win32 host.
|
|
75
70
|
*
|
|
76
71
|
* Pi core always executes bash through Git Bash on Windows, so a POSIX-shaped
|
|
77
|
-
* absolute token carries MSYS semantics, not `node:path.win32` semantics.
|
|
78
|
-
* win32 the recognized safe device
|
|
79
|
-
* are preserved verbatim as
|
|
80
|
-
* `c:\dev\null`, and MSYS drive mounts
|
|
81
|
-
* Windows equivalent (`C:\…`) before
|
|
82
|
-
* to {@link forPath}. On POSIX
|
|
83
|
-
* {@link forPath}.
|
|
72
|
+
* absolute token carries MSYS semantics, not `node:path.win32` semantics. The
|
|
73
|
+
* flavor classifies the token's shape: on win32 the recognized safe device
|
|
74
|
+
* paths (`/dev/null`, `/dev/std{in,out,err}`) are preserved verbatim as
|
|
75
|
+
* devices instead of being resolved into `c:\dev\null`, and MSYS drive mounts
|
|
76
|
+
* (`/c/…`) are translated to their Windows equivalent (`C:\…`) before
|
|
77
|
+
* resolution; every other token delegates to {@link forPath}. On POSIX every
|
|
78
|
+
* token is `plain`, so this is a straight delegation to {@link forPath}.
|
|
84
79
|
*/
|
|
85
80
|
forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const shape = classifyWin32BashToken(token);
|
|
81
|
+
const shape = this.flavor.bashTokenShape(token);
|
|
89
82
|
switch (shape.kind) {
|
|
90
83
|
case "device":
|
|
91
84
|
return AccessPath.forDevice(token);
|
|
@@ -109,19 +102,7 @@ export class PathNormalizer {
|
|
|
109
102
|
|
|
110
103
|
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
|
|
111
104
|
isAbsolute(pathValue: string): boolean {
|
|
112
|
-
return this.impl.isAbsolute(pathValue);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* True when the host platform treats a backslash as a path separator (win32).
|
|
117
|
-
*
|
|
118
|
-
* The bash rule-candidate classifier reads this to decide whether a
|
|
119
|
-
* backslash-relative token (`dir\file`) is path-shaped: on win32 `\` is a
|
|
120
|
-
* separator, but on POSIX it is a legal filename character (#520). Keeping the
|
|
121
|
-
* decision here means no bash-layer module re-reads `process.platform`.
|
|
122
|
-
*/
|
|
123
|
-
usesWindowsSeparators(): boolean {
|
|
124
|
-
return this.platform === "win32";
|
|
105
|
+
return this.flavor.impl.isAbsolute(pathValue);
|
|
125
106
|
}
|
|
126
107
|
|
|
127
108
|
/**
|
|
@@ -131,16 +112,11 @@ export class PathNormalizer {
|
|
|
131
112
|
* (`cd /c/x`) resolves to a translated Windows base (`C:\x`), a non-mount
|
|
132
113
|
* POSIX absolute (`cd /tmp`) is not deterministically resolvable and yields an
|
|
133
114
|
* `unknown` base, and a native/relative target is handled as usual. On POSIX
|
|
134
|
-
* an absolute target is absolute and everything
|
|
115
|
+
* every token is `plain`, so an absolute target is absolute and everything
|
|
116
|
+
* else is relative.
|
|
135
117
|
*/
|
|
136
118
|
interpretBashCdTarget(target: string): BashCdTarget {
|
|
137
|
-
|
|
138
|
-
return this.impl.isAbsolute(target)
|
|
139
|
-
? { kind: "absolute", value: target }
|
|
140
|
-
: { kind: "relative" };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const shape = classifyWin32BashToken(target);
|
|
119
|
+
const shape = this.flavor.bashTokenShape(target);
|
|
144
120
|
switch (shape.kind) {
|
|
145
121
|
case "drive-mount":
|
|
146
122
|
return { kind: "absolute", value: shape.windowsPath };
|
|
@@ -148,7 +124,7 @@ export class PathNormalizer {
|
|
|
148
124
|
case "posix-absolute":
|
|
149
125
|
return { kind: "unknown" };
|
|
150
126
|
case "plain":
|
|
151
|
-
return this.impl.isAbsolute(target)
|
|
127
|
+
return this.flavor.impl.isAbsolute(target)
|
|
152
128
|
? { kind: "absolute", value: target }
|
|
153
129
|
: { kind: "relative" };
|
|
154
130
|
}
|
|
@@ -156,17 +132,17 @@ export class PathNormalizer {
|
|
|
156
132
|
|
|
157
133
|
/** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
|
|
158
134
|
resolveBase(offset: string): string {
|
|
159
|
-
return this.impl.resolve(this.cwd, offset);
|
|
135
|
+
return this.flavor.impl.resolve(this.cwd, offset);
|
|
160
136
|
}
|
|
161
137
|
|
|
162
138
|
/** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
|
|
163
139
|
joinBase(offset: string, target: string): string {
|
|
164
|
-
return this.impl.join(offset, target);
|
|
140
|
+
return this.flavor.impl.join(offset, target);
|
|
165
141
|
}
|
|
166
142
|
|
|
167
143
|
/** Containment of `pathValue` within `directory` (platform-aware). */
|
|
168
144
|
isWithinDirectory(pathValue: string, directory: string): boolean {
|
|
169
|
-
return
|
|
145
|
+
return this.flavor.isWithin(pathValue, directory);
|
|
170
146
|
}
|
|
171
147
|
|
|
172
148
|
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
|
@@ -174,12 +150,12 @@ export class PathNormalizer {
|
|
|
174
150
|
const canonicalPath = canonicalNormalizePathForComparison(
|
|
175
151
|
pathValue,
|
|
176
152
|
this.cwd,
|
|
177
|
-
this.
|
|
153
|
+
this.flavor,
|
|
178
154
|
);
|
|
179
155
|
return isPathOutsideWorkingDirectory(
|
|
180
156
|
canonicalPath,
|
|
181
157
|
this.canonicalCwd,
|
|
182
|
-
this.
|
|
158
|
+
this.flavor,
|
|
183
159
|
);
|
|
184
160
|
}
|
|
185
161
|
|
|
@@ -196,7 +172,7 @@ export class PathNormalizer {
|
|
|
196
172
|
return isPathOutsideWorkingDirectory(
|
|
197
173
|
canonicalPath,
|
|
198
174
|
this.canonicalCwd,
|
|
199
|
-
this.
|
|
175
|
+
this.flavor,
|
|
200
176
|
);
|
|
201
177
|
}
|
|
202
178
|
|
|
@@ -206,12 +182,12 @@ export class PathNormalizer {
|
|
|
206
182
|
* touches no filesystem, unlike {@link forPath}'s canonical alias.
|
|
207
183
|
*/
|
|
208
184
|
comparableValue(pathValue: string): string {
|
|
209
|
-
return normalizePathForComparison(pathValue, this.cwd, this.
|
|
185
|
+
return normalizePathForComparison(pathValue, this.cwd, this.flavor);
|
|
210
186
|
}
|
|
211
187
|
|
|
212
188
|
/**
|
|
213
189
|
* Pi infrastructure-read containment for a read-only tool, decided against
|
|
214
|
-
* the canonical (symlink-resolved) path and the baked cwd/
|
|
190
|
+
* the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the
|
|
215
191
|
* already-built {@link AccessPath} so the caller does not re-resolve it.
|
|
216
192
|
*/
|
|
217
193
|
isInfrastructureRead(
|
|
@@ -224,7 +200,7 @@ export class PathNormalizer {
|
|
|
224
200
|
accessPath.boundaryValue(),
|
|
225
201
|
infraDirs,
|
|
226
202
|
this.cwd,
|
|
227
|
-
this.
|
|
203
|
+
this.flavor,
|
|
228
204
|
);
|
|
229
205
|
}
|
|
230
206
|
}
|