@gotgenes/pi-permission-system 20.3.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 +15 -0
- package/docs/cross-extension-api.md +4 -0
- package/docs/subagent-integration.md +2 -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 +3 -3
- 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 +3 -3
- package/src/authority/forwarder-context.ts +1 -1
- package/src/authority/forwarding-io.ts +3 -3
- package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
- package/src/authority/local-user-authorizer.ts +2 -2
- package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +1 -2
- package/src/authority/permission-prompter.ts +2 -2
- 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 +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
- package/src/handlers/gates/tool.ts +9 -4
- package/src/index.ts +23 -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/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 +1 -1
- 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/{permission-dialog.ts → authority/permission-dialog.ts} +0 -0
- /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
- /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
|
@@ -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
|
}
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
|
3
|
+
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
3
4
|
import {
|
|
4
5
|
getGlobalConfigPath,
|
|
5
6
|
getProjectAgentsDir,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
} from "./config-paths";
|
|
8
9
|
import { normalizeInput } from "./input-normalizer";
|
|
9
10
|
import { normalizeFlatConfig } from "./normalize";
|
|
11
|
+
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
|
|
10
12
|
import { PATH_SURFACES } from "./path-surfaces";
|
|
11
13
|
import {
|
|
12
14
|
FilePolicyLoader,
|
|
@@ -37,15 +39,6 @@ import type {
|
|
|
37
39
|
import { isPermissionState } from "./types";
|
|
38
40
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
39
41
|
|
|
40
|
-
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
41
|
-
"bash",
|
|
42
|
-
"read",
|
|
43
|
-
"write",
|
|
44
|
-
"edit",
|
|
45
|
-
"grep",
|
|
46
|
-
"find",
|
|
47
|
-
"ls",
|
|
48
|
-
]);
|
|
49
42
|
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
50
43
|
|
|
51
44
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
@@ -109,11 +102,12 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
109
102
|
*/
|
|
110
103
|
agentDir?: string;
|
|
111
104
|
/**
|
|
112
|
-
*
|
|
113
|
-
* path-surface rule matching folds case (and separators) on
|
|
114
|
-
* Defaults to
|
|
105
|
+
* Resolved path-language flavor, injected from the composition root, that
|
|
106
|
+
* decides whether path-surface rule matching folds case (and separators) on
|
|
107
|
+
* Windows. Defaults to the POSIX flavor; production always supplies the real
|
|
108
|
+
* platform's flavor.
|
|
115
109
|
*/
|
|
116
|
-
|
|
110
|
+
flavor?: PathFlavor;
|
|
117
111
|
/**
|
|
118
112
|
* yolo-mode reader, injected from the composition root. When it reports
|
|
119
113
|
* true, {@link PermissionManager.check} rewrites every matched `ask` to a
|
|
@@ -126,7 +120,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
126
120
|
|
|
127
121
|
export class PermissionManager implements ScopedPermissionManager {
|
|
128
122
|
private readonly agentDir: string | undefined;
|
|
129
|
-
private readonly
|
|
123
|
+
private readonly flavor: PathFlavor;
|
|
130
124
|
private readonly isYoloEnabled: () => boolean;
|
|
131
125
|
private loader: PolicyLoader;
|
|
132
126
|
private readonly resolvedPermissionsCache = new Map<
|
|
@@ -136,7 +130,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
136
130
|
|
|
137
131
|
constructor(options: PermissionManagerOptions = {}) {
|
|
138
132
|
this.agentDir = options.agentDir;
|
|
139
|
-
this.
|
|
133
|
+
this.flavor = options.flavor ?? posixPathFlavor;
|
|
140
134
|
this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
|
|
141
135
|
this.loader =
|
|
142
136
|
options.policyLoader ??
|
|
@@ -270,7 +264,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
270
264
|
.map((r) => r.pattern);
|
|
271
265
|
if (patterns.length === 0) return NO_PROMOTION;
|
|
272
266
|
|
|
273
|
-
const matchOptions = pathMatchOptions("path", this.
|
|
267
|
+
const matchOptions = pathMatchOptions("path", this.flavor);
|
|
274
268
|
return (token) =>
|
|
275
269
|
patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
|
|
276
270
|
}
|
|
@@ -281,29 +275,10 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
281
275
|
*/
|
|
282
276
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
283
277
|
const { composedRules } = this.resolvePermissions(agentName);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
return evaluate(normalizedToolName, "*", composedRules, this.platform)
|
|
289
|
-
.action;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
|
|
293
|
-
// (or universal default) handles this correctly.
|
|
294
|
-
if (normalizedToolName === "bash") {
|
|
295
|
-
return evaluate("bash", "*", composedRules, this.platform).action;
|
|
296
|
-
}
|
|
297
|
-
if (normalizedToolName === "mcp") {
|
|
298
|
-
return evaluate("mcp", "*", composedRules, this.platform).action;
|
|
299
|
-
}
|
|
300
|
-
if (normalizedToolName === "skill") {
|
|
301
|
-
return evaluate("skill", "*", composedRules, this.platform).action;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Tool-name surfaces (read, write, etc. and extension tools).
|
|
305
|
-
return evaluate(normalizedToolName, "*", composedRules, this.platform)
|
|
306
|
-
.action;
|
|
278
|
+
// Every surface (special, bash, mcp, skill, path-bearing, and extension
|
|
279
|
+
// tools) resolves its tool-level state identically: evaluate the surface
|
|
280
|
+
// name against the "*" catch-all value. There is no per-kind branch.
|
|
281
|
+
return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
|
|
307
282
|
}
|
|
308
283
|
|
|
309
284
|
/**
|
|
@@ -345,7 +320,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
345
320
|
intent.surface,
|
|
346
321
|
intent.surface,
|
|
347
322
|
fullRules,
|
|
348
|
-
this.
|
|
323
|
+
this.flavor,
|
|
349
324
|
);
|
|
350
325
|
}
|
|
351
326
|
|
|
@@ -363,7 +338,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
363
338
|
toolName,
|
|
364
339
|
intent.surface,
|
|
365
340
|
fullRules,
|
|
366
|
-
this.
|
|
341
|
+
this.flavor,
|
|
367
342
|
);
|
|
368
343
|
}
|
|
369
344
|
}
|
|
@@ -382,16 +357,18 @@ function buildCheckResult(
|
|
|
382
357
|
normalizedToolName: string,
|
|
383
358
|
toolName: string,
|
|
384
359
|
fullRules: Ruleset,
|
|
385
|
-
|
|
360
|
+
flavor: PathFlavor,
|
|
386
361
|
): PermissionCheckResult {
|
|
387
362
|
const { rule, value } = PATH_SURFACES.has(surface)
|
|
388
|
-
? evaluateAnyValue(surface, values, fullRules,
|
|
389
|
-
: evaluateFirst(surface, values, fullRules,
|
|
363
|
+
? evaluateAnyValue(surface, values, fullRules, flavor)
|
|
364
|
+
: evaluateFirst(surface, values, fullRules, flavor);
|
|
390
365
|
|
|
391
366
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
392
367
|
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
393
368
|
const extras =
|
|
394
|
-
surface === "mcp"
|
|
369
|
+
classifyToolKind(surface) === "mcp"
|
|
370
|
+
? { ...resultExtras, target: value }
|
|
371
|
+
: resultExtras;
|
|
395
372
|
|
|
396
373
|
return {
|
|
397
374
|
toolName,
|
|
@@ -444,19 +421,22 @@ function deriveSource(
|
|
|
444
421
|
toolName: string,
|
|
445
422
|
): PermissionCheckResult["source"] {
|
|
446
423
|
if (rule.layer === "session") return "session";
|
|
447
|
-
|
|
448
|
-
if (toolName === "mcp") {
|
|
449
|
-
if (rule.layer === "default") return "default";
|
|
450
|
-
return "mcp";
|
|
451
|
-
}
|
|
452
|
-
|
|
453
424
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
|
|
454
|
-
if (toolName === "skill") return "skill";
|
|
455
|
-
if (toolName === "bash") return "bash";
|
|
456
425
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
426
|
+
switch (classifyToolKind(toolName)) {
|
|
427
|
+
case "mcp":
|
|
428
|
+
return rule.layer === "default" ? "default" : "mcp";
|
|
429
|
+
case "skill":
|
|
430
|
+
return "skill";
|
|
431
|
+
case "bash":
|
|
432
|
+
return "bash";
|
|
433
|
+
case "path":
|
|
434
|
+
// Built-in path-bearing tools (read/write/edit/grep/find/ls).
|
|
435
|
+
return "tool";
|
|
436
|
+
case "extension":
|
|
437
|
+
// Extension tools distinguish a synthesized-default match from a rule.
|
|
438
|
+
return rule.layer === "default" ? "default" : "tool";
|
|
439
|
+
}
|
|
460
440
|
}
|
|
461
441
|
|
|
462
442
|
// Re-export types that external modules import from this file.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
1
2
|
import { matchQualifier } from "./denial-messages";
|
|
2
3
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
3
4
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
@@ -22,7 +23,7 @@ export function formatUnknownToolReason(
|
|
|
22
23
|
preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
|
|
23
24
|
|
|
24
25
|
const mcpHint =
|
|
25
|
-
toolName === "mcp"
|
|
26
|
+
classifyToolKind(toolName) === "mcp"
|
|
26
27
|
? ""
|
|
27
28
|
: ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
|
|
28
29
|
|
|
@@ -37,7 +38,7 @@ export function formatAskPrompt(
|
|
|
37
38
|
): string {
|
|
38
39
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
39
40
|
|
|
40
|
-
if (result.toolName === "bash") {
|
|
41
|
+
if (classifyToolKind(result.toolName) === "bash") {
|
|
41
42
|
const subCommand = result.command ?? "";
|
|
42
43
|
const qualifier = matchQualifier(
|
|
43
44
|
result.matchedPattern,
|
|
@@ -52,7 +53,7 @@ export function formatAskPrompt(
|
|
|
52
53
|
return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
if ((result
|
|
56
|
+
if (isMcpCheck(result) && result.target) {
|
|
56
57
|
const patternInfo = result.matchedPattern
|
|
57
58
|
? ` (matched '${result.matchedPattern}')`
|
|
58
59
|
: "";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ForwardingController } from "#src/authority/forwarding-manager";
|
|
2
3
|
import {
|
|
3
4
|
getActiveAgentName,
|
|
4
5
|
getActiveAgentNameFromSystemPrompt,
|
|
@@ -7,8 +8,8 @@ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-select
|
|
|
7
8
|
import type { SessionConfigStore } from "./config-store";
|
|
8
9
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
9
10
|
import type { ExtensionPaths } from "./extension-paths";
|
|
10
|
-
import type { ForwardingController } from "./forwarding-manager";
|
|
11
11
|
import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
|
|
12
|
+
import type { PathFlavor } from "./path/path-flavor";
|
|
12
13
|
import { PathNormalizer } from "./path-normalizer";
|
|
13
14
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
14
15
|
|
|
@@ -47,12 +48,12 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
47
48
|
private readonly sessionRules: SessionRules,
|
|
48
49
|
private readonly configStore: SessionConfigStore,
|
|
49
50
|
private readonly authorizerSelection: AuthorizerSelectionLifecycle,
|
|
50
|
-
private readonly
|
|
51
|
+
private readonly flavor: PathFlavor,
|
|
51
52
|
) {
|
|
52
53
|
// Placeholder until the first activate(ctx) binds the real cwd; every gate
|
|
53
54
|
// evaluate runs after activate (handleToolCall activates first), so this
|
|
54
55
|
// empty-cwd value is never read.
|
|
55
|
-
this.pathNormalizer = new PathNormalizer(
|
|
56
|
+
this.pathNormalizer = new PathNormalizer(flavor, "");
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// ── Context lifecycle ──────────────────────────────────────────────────
|
|
@@ -68,7 +69,7 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
68
69
|
*/
|
|
69
70
|
activate(ctx: ExtensionContext): void {
|
|
70
71
|
this.context = ctx;
|
|
71
|
-
this.pathNormalizer = new PathNormalizer(this.
|
|
72
|
+
this.pathNormalizer = new PathNormalizer(this.flavor, ctx.cwd);
|
|
72
73
|
this.forwarding.start(ctx);
|
|
73
74
|
this.authorizerSelection.activate(ctx);
|
|
74
75
|
}
|
|
@@ -207,7 +208,7 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
207
208
|
// ── Path normalization ────────────────────────────────────────────────
|
|
208
209
|
|
|
209
210
|
/**
|
|
210
|
-
* The session's {@link PathNormalizer}, carrying the host
|
|
211
|
+
* The session's {@link PathNormalizer}, carrying the host path flavor and the
|
|
211
212
|
* session cwd. Rebuilt on every `resetForNewSession` so a session switch
|
|
212
213
|
* rebinds the cwd.
|
|
213
214
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
|
|
2
3
|
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
3
4
|
import type { PathNormalizer } from "./path-normalizer";
|
|
4
5
|
import type { PermissionsService } from "./service";
|
|
@@ -50,6 +51,13 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
50
51
|
value?: string,
|
|
51
52
|
agentName?: string,
|
|
52
53
|
): ReturnType<PermissionsService["checkPermission"]> {
|
|
54
|
+
// Bash decomposes at gate parity: a chained/nested command is split into
|
|
55
|
+
// its command-pattern units and resolved most-restrictive, matching what
|
|
56
|
+
// the enforcement gate enforces (#309). A cold parser falls back to the
|
|
57
|
+
// whole-string match inside resolveBashAdvisoryCheck.
|
|
58
|
+
if (surface === "bash") {
|
|
59
|
+
return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
|
|
60
|
+
}
|
|
53
61
|
const intent = buildAccessIntentForSurface(
|
|
54
62
|
surface,
|
|
55
63
|
value,
|