@gotgenes/pi-permission-system 18.0.0 → 18.0.2
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 +16 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -1
- package/src/access-intent/access-path.ts +17 -1
- package/src/access-intent/bash/bash-path-resolver.ts +2 -1
- package/src/access-intent/path-normalization.ts +139 -0
- package/src/denial-messages.ts +28 -5
- package/src/handlers/gates/bash-external-directory.ts +7 -2
- package/src/handlers/gates/external-directory-messages.ts +11 -3
- package/src/handlers/gates/external-directory.ts +4 -1
- package/src/handlers/gates/path.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/gates/tool.ts +2 -1
- package/src/input-normalizer.ts +1 -1
- package/src/path-containment.ts +56 -0
- package/src/path-normalizer.ts +20 -5
- package/src/path-surfaces.ts +30 -0
- package/src/pattern-suggest.ts +1 -1
- package/src/permission-manager.ts +7 -1
- package/src/permission-resolver.ts +5 -0
- package/src/pi-infrastructure-read.ts +65 -0
- package/src/rule.ts +1 -1
- package/src/safe-system-paths.ts +18 -0
- package/src/tool-input-path.ts +54 -0
- package/test/access-intent/access-path.test.ts +61 -0
- package/test/bash-external-directory.test.ts +18 -4
- package/test/denial-messages.test.ts +63 -5
- package/test/handlers/external-directory-integration.test.ts +6 -1
- package/test/handlers/gates/external-directory-messages.test.ts +26 -2
- package/test/path-containment.test.ts +161 -0
- package/test/path-normalization.test.ts +233 -0
- package/test/path-normalizer.test.ts +30 -0
- package/test/path-surfaces.test.ts +55 -0
- package/test/permission-manager-unified.test.ts +1 -1
- package/test/pi-infrastructure-read.test.ts +41 -1
- package/test/safe-system-paths.test.ts +46 -0
- package/test/tool-input-path.test.ts +84 -0
- package/src/path-utils.ts +0 -346
- package/test/path-utils.test.ts +0 -695
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [18.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.1...pi-permission-system-v18.0.2) (2026-07-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add AccessPath.resolvedAlias() for symlink-target disclosure ([cf6e25c](https://github.com/gotgenes/pi-packages/commit/cf6e25c8a5b672fbab4b41a21240af8ca818971b))
|
|
14
|
+
* **pi-permission-system:** disclose resolved symlink target in tool external-directory messages ([1225e2b](https://github.com/gotgenes/pi-packages/commit/1225e2bcface907a0a4407aadcd744c2398edc19))
|
|
15
|
+
* **pi-permission-system:** disclose resolved symlink targets in bash external-directory messages ([f9fab42](https://github.com/gotgenes/pi-packages/commit/f9fab42b758dd106e67b83a0e3c95fe6fc216687))
|
|
16
|
+
|
|
17
|
+
## [18.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.0...pi-permission-system-v18.0.1) (2026-06-30)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** name the path-values boundary contract and guard it ([#506](https://github.com/gotgenes/pi-packages/issues/506)) ([a47648c](https://github.com/gotgenes/pi-packages/commit/a47648c1fcff3226b6ea3269392f9d8548654ee1))
|
|
23
|
+
|
|
8
24
|
## [18.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.1...pi-permission-system-v18.0.0) (2026-06-29)
|
|
9
25
|
|
|
10
26
|
|
package/package.json
CHANGED
|
@@ -20,6 +20,10 @@ export interface ToolAccessIntent {
|
|
|
20
20
|
* Not gate-emitted: the resolver produces it internally by unwrapping an
|
|
21
21
|
* `access-path` intent via `matchValues()`, keeping the low-level manager
|
|
22
22
|
* string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
|
|
23
|
+
*
|
|
24
|
+
* This string seam is a deliberate, formalized boundary — not transitional
|
|
25
|
+
* scaffolding to collapse into the manager (ADR-0002,
|
|
26
|
+
* `docs/decisions/0002-path-values-string-boundary.md`).
|
|
23
27
|
*/
|
|
24
28
|
export interface PathValuesAccessIntent {
|
|
25
29
|
kind: "path-values";
|
|
@@ -53,6 +57,9 @@ export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
|
|
|
53
57
|
* What the manager consumes — the `access-path` variant has already been
|
|
54
58
|
* unwrapped to `path-values` by the resolver via `path.matchValues()`.
|
|
55
59
|
*
|
|
56
|
-
* The manager stays string-based and never imports `AccessPath
|
|
60
|
+
* The manager stays string-based and never imports `AccessPath`: this is the
|
|
61
|
+
* deliberate boundary formalized in ADR-0002
|
|
62
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`), guarded by a
|
|
63
|
+
* `no-restricted-imports` lint rule on `permission-manager.ts`.
|
|
57
64
|
*/
|
|
58
65
|
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
canonicalNormalizePathForComparison,
|
|
3
3
|
getPathPolicyValues,
|
|
4
4
|
normalizePathForComparison,
|
|
5
|
-
} from "
|
|
5
|
+
} from "./path-normalization";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* A path's two representations held behind type-distinct accessors.
|
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
* outside-CWD containment and infra-read checks.
|
|
20
20
|
* - {@link value} returns `string` — the lexical absolute form, for display,
|
|
21
21
|
* approval patterns, decision values, and logs.
|
|
22
|
+
* - {@link resolvedAlias} returns `string | undefined` — the canonical form
|
|
23
|
+
* only when it names a location distinct from the lexical form, for
|
|
24
|
+
* disclosing a symlink target in a prompt or denial message.
|
|
22
25
|
*
|
|
23
26
|
* Construct via {@link forPath} (resolved, with optional cd-folded base) or
|
|
24
27
|
* {@link forLiteral} (literal-only, for an unknown base); the constructor is
|
|
@@ -66,6 +69,19 @@ export class AccessPath {
|
|
|
66
69
|
return this.lexical;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The canonical (symlink-resolved) form when it names a location distinct
|
|
74
|
+
* from the lexical form — for disclosing the resolved target in a prompt or
|
|
75
|
+
* denial message. `undefined` when the path is not a symlink (canonical
|
|
76
|
+
* equals lexical) or has no canonical (literal-only / empty input).
|
|
77
|
+
*/
|
|
78
|
+
resolvedAlias(): string | undefined {
|
|
79
|
+
if (!this.canonical || this.canonical === this.lexical) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return this.canonical;
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
/**
|
|
70
86
|
* Build an `AccessPath` for a tool-input or bash-token path, resolved against
|
|
71
87
|
* `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
|
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
collectRedirectTokens,
|
|
15
15
|
extractCommandName,
|
|
16
16
|
} from "#src/access-intent/bash/token-collection";
|
|
17
|
+
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
17
18
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
18
|
-
import { isSafeSystemPath
|
|
19
|
+
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
19
20
|
|
|
20
21
|
// ── Internal types ───────────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { canonicalizePath } from "#src/canonicalize-path";
|
|
4
|
+
import { expandHomePath } from "#src/expand-home";
|
|
5
|
+
import { isPathWithinDirectory } from "#src/path-containment";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Representation derivation backing {@link AccessPath}: turn an accessed path
|
|
9
|
+
* into the lexical / canonical / policy-value forms the resolver matches
|
|
10
|
+
* against rules. Pure (no filesystem access except `canonicalizePath`'s
|
|
11
|
+
* best-effort symlink resolution); the `platform` is injected, never read
|
|
12
|
+
* ambiently.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizePathForComparison(
|
|
15
|
+
pathValue: string,
|
|
16
|
+
cwd: string,
|
|
17
|
+
platform: NodeJS.Platform,
|
|
18
|
+
): string {
|
|
19
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
20
|
+
if (!trimmed) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
25
|
+
normalizedPath = expandHomePath(normalizedPath);
|
|
26
|
+
|
|
27
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
28
|
+
const absolutePath = impl.resolve(cwd, normalizedPath);
|
|
29
|
+
const normalizedAbsolutePath = impl.normalize(absolutePath);
|
|
30
|
+
return platform === "win32"
|
|
31
|
+
? normalizedAbsolutePath.toLowerCase()
|
|
32
|
+
: normalizedAbsolutePath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PathPolicyValueOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Current Pi working directory. When provided, returned values include a
|
|
38
|
+
* project-relative alias for paths that resolve inside this directory.
|
|
39
|
+
*/
|
|
40
|
+
cwd?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Directory used to resolve `pathValue` into an absolute policy value.
|
|
43
|
+
* Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
|
|
44
|
+
*/
|
|
45
|
+
resolveBase?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a single path-like lookup value without resolving it against CWD.
|
|
50
|
+
*
|
|
51
|
+
* Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
|
|
52
|
+
* while applying the same lexical cleanup as
|
|
53
|
+
* {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
|
|
54
|
+
* strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizePathPolicyLiteral(pathValue: string): string {
|
|
57
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
58
|
+
if (!trimmed) return "";
|
|
59
|
+
const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
60
|
+
return expandHomePath(unprefixed);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return equivalent lookup values for path-policy matching.
|
|
65
|
+
*
|
|
66
|
+
* The first value is the cwd/effective-base normalized absolute path when a
|
|
67
|
+
* base is available. The later values preserve project-relative and raw
|
|
68
|
+
* relative forms so existing rules like `src/*` and `*.env` continue to match.
|
|
69
|
+
*/
|
|
70
|
+
export function getPathPolicyValues(
|
|
71
|
+
pathValue: string,
|
|
72
|
+
options: PathPolicyValueOptions,
|
|
73
|
+
platform: NodeJS.Platform,
|
|
74
|
+
): string[] {
|
|
75
|
+
const literal = normalizePathPolicyLiteral(pathValue);
|
|
76
|
+
if (!literal) return [];
|
|
77
|
+
if (literal === "*") return ["*"];
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
...new Set([
|
|
81
|
+
...getAbsolutePathPolicyValues(pathValue, options, platform),
|
|
82
|
+
literal,
|
|
83
|
+
]),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getAbsolutePathPolicyValues(
|
|
88
|
+
pathValue: string,
|
|
89
|
+
options: PathPolicyValueOptions,
|
|
90
|
+
platform: NodeJS.Platform,
|
|
91
|
+
): string[] {
|
|
92
|
+
const resolveBase = options.resolveBase ?? options.cwd;
|
|
93
|
+
if (!resolveBase) return [];
|
|
94
|
+
|
|
95
|
+
const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
|
|
96
|
+
if (!absolute) return [];
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
absolute,
|
|
100
|
+
...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getCwdRelativePathPolicyValues(
|
|
105
|
+
absolute: string,
|
|
106
|
+
cwd: string | undefined,
|
|
107
|
+
platform: NodeJS.Platform,
|
|
108
|
+
): string[] {
|
|
109
|
+
if (!cwd) return [];
|
|
110
|
+
|
|
111
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
|
|
112
|
+
if (!normalizedCwd) return [];
|
|
113
|
+
if (
|
|
114
|
+
absolute !== normalizedCwd &&
|
|
115
|
+
!isPathWithinDirectory(absolute, normalizedCwd, platform)
|
|
116
|
+
) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
121
|
+
const relativeValue = impl.relative(normalizedCwd, absolute);
|
|
122
|
+
return relativeValue ? [relativeValue] : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Like {@link normalizePathForComparison} but also resolves symlinks via
|
|
127
|
+
* `realpathSync` (best-effort). Use this for containment decisions where the
|
|
128
|
+
* OS-followed path matters, not for pattern matching.
|
|
129
|
+
*/
|
|
130
|
+
export function canonicalNormalizePathForComparison(
|
|
131
|
+
pathValue: string,
|
|
132
|
+
cwd: string,
|
|
133
|
+
platform: NodeJS.Platform,
|
|
134
|
+
): string {
|
|
135
|
+
const lexical = normalizePathForComparison(pathValue, cwd, platform);
|
|
136
|
+
if (!lexical) return "";
|
|
137
|
+
const canonical = canonicalizePath(lexical, platform);
|
|
138
|
+
return platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
139
|
+
}
|
package/src/denial-messages.ts
CHANGED
|
@@ -5,6 +5,21 @@ import type { BashCommandContext, PermissionCheckResult } from "./types";
|
|
|
5
5
|
|
|
6
6
|
export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
|
|
7
7
|
|
|
8
|
+
// ── External-path resolved-target disclosure ────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** A displayed external path paired with its resolved target, when distinct. */
|
|
11
|
+
export interface ExternalPathDisclosure {
|
|
12
|
+
/** The path as displayed (typed for tools, lexical-absolute for bash). */
|
|
13
|
+
path: string;
|
|
14
|
+
/** The canonical symlink-resolved target; present only when it differs. */
|
|
15
|
+
resolvedPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** ` (resolves to '<canonical>')` when a distinct target exists, else `""`. */
|
|
19
|
+
export function resolvesToSuffix(resolvedPath?: string): string {
|
|
20
|
+
return resolvedPath ? ` (resolves to '${resolvedPath}')` : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
// ── Denial context discriminated union ─────────────────────────────────────
|
|
9
24
|
|
|
10
25
|
export type DenialContext =
|
|
@@ -24,13 +39,14 @@ export type DenialContext =
|
|
|
24
39
|
kind: "external_directory";
|
|
25
40
|
toolName: string;
|
|
26
41
|
pathValue: string;
|
|
42
|
+
resolvedPath?: string;
|
|
27
43
|
cwd: string;
|
|
28
44
|
agentName?: string;
|
|
29
45
|
}
|
|
30
46
|
| {
|
|
31
47
|
kind: "bash_external_directory";
|
|
32
48
|
command: string;
|
|
33
|
-
externalPaths:
|
|
49
|
+
externalPaths: ExternalPathDisclosure[];
|
|
34
50
|
cwd: string;
|
|
35
51
|
agentName?: string;
|
|
36
52
|
}
|
|
@@ -89,9 +105,9 @@ function buildDenyBody(ctx: DenialContext): string {
|
|
|
89
105
|
case "path":
|
|
90
106
|
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool '${ctx.toolName}'.`;
|
|
91
107
|
case "external_directory":
|
|
92
|
-
return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}' outside working directory '${ctx.cwd}'.`;
|
|
108
|
+
return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside working directory '${ctx.cwd}'.`;
|
|
93
109
|
case "bash_external_directory":
|
|
94
|
-
return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${ctx.externalPaths
|
|
110
|
+
return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${formatExternalPathList(ctx.externalPaths)}.`;
|
|
95
111
|
case "bash_path":
|
|
96
112
|
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool 'bash'.`;
|
|
97
113
|
case "skill_read":
|
|
@@ -185,7 +201,7 @@ function buildUnavailableBody(ctx: DenialContext): string {
|
|
|
185
201
|
case "path":
|
|
186
202
|
return `Accessing '${ctx.pathValue}' requires approval, but no interactive UI is available.`;
|
|
187
203
|
case "external_directory":
|
|
188
|
-
return `Accessing '${ctx.pathValue}' outside the working directory requires approval, but no interactive UI is available.`;
|
|
204
|
+
return `Accessing '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside the working directory requires approval, but no interactive UI is available.`;
|
|
189
205
|
case "bash_external_directory":
|
|
190
206
|
return `Bash command '${ctx.command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`;
|
|
191
207
|
case "bash_path":
|
|
@@ -215,7 +231,7 @@ function buildUserDeniedBody(
|
|
|
215
231
|
case "path":
|
|
216
232
|
return `User denied access to path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
|
|
217
233
|
case "external_directory":
|
|
218
|
-
return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
|
|
234
|
+
return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)}.${reasonSuffix(denialReason)}`;
|
|
219
235
|
case "bash_external_directory":
|
|
220
236
|
return `User denied external directory access for bash command '${ctx.command}'.${reasonSuffix(denialReason)}`;
|
|
221
237
|
case "bash_path":
|
|
@@ -230,3 +246,10 @@ function buildUserDeniedBody(
|
|
|
230
246
|
function isMcpCheck(check: PermissionCheckResult): boolean {
|
|
231
247
|
return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
|
|
232
248
|
}
|
|
249
|
+
|
|
250
|
+
/** Render an external-path disclosure list for the bash deny body's path clause. */
|
|
251
|
+
function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
|
|
252
|
+
return paths
|
|
253
|
+
.map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
|
|
254
|
+
.join(", ");
|
|
255
|
+
}
|
|
@@ -67,9 +67,14 @@ export function describeBashExternalDirectoryGate(
|
|
|
67
67
|
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
|
68
68
|
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
|
69
69
|
|
|
70
|
+
const disclosures = uncoveredEntries.map(({ path }) => ({
|
|
71
|
+
path: path.value(),
|
|
72
|
+
resolvedPath: path.resolvedAlias(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
70
75
|
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
71
76
|
command,
|
|
72
|
-
|
|
77
|
+
disclosures,
|
|
73
78
|
tcc.cwd,
|
|
74
79
|
tcc.agentName ?? undefined,
|
|
75
80
|
);
|
|
@@ -82,7 +87,7 @@ export function describeBashExternalDirectoryGate(
|
|
|
82
87
|
denialContext: {
|
|
83
88
|
kind: "bash_external_directory",
|
|
84
89
|
command,
|
|
85
|
-
externalPaths:
|
|
90
|
+
externalPaths: disclosures,
|
|
86
91
|
cwd: tcc.cwd,
|
|
87
92
|
agentName: tcc.agentName ?? undefined,
|
|
88
93
|
},
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExternalPathDisclosure,
|
|
3
|
+
resolvesToSuffix,
|
|
4
|
+
} from "#src/denial-messages";
|
|
5
|
+
|
|
1
6
|
export function formatExternalDirectoryAskPrompt(
|
|
2
7
|
toolName: string,
|
|
3
8
|
pathValue: string,
|
|
9
|
+
resolvedPath: string | undefined,
|
|
4
10
|
cwd: string,
|
|
5
11
|
agentName?: string,
|
|
6
12
|
): string {
|
|
7
13
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
8
|
-
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
14
|
+
return `${subject} requested tool '${toolName}' for path '${pathValue}'${resolvesToSuffix(resolvedPath)} outside working directory '${cwd}'. Allow this external directory access?`;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export function formatBashExternalDirectoryAskPrompt(
|
|
12
18
|
command: string,
|
|
13
|
-
externalPaths:
|
|
19
|
+
externalPaths: ExternalPathDisclosure[],
|
|
14
20
|
cwd: string,
|
|
15
21
|
agentName?: string,
|
|
16
22
|
): string {
|
|
17
23
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
18
|
-
const pathList = externalPaths
|
|
24
|
+
const pathList = externalPaths
|
|
25
|
+
.map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
|
|
26
|
+
.join(", ");
|
|
19
27
|
return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
|
|
20
28
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
|
-
import { getToolInputPath } from "#src/path-utils";
|
|
3
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
3
|
import { SessionApproval } from "#src/session-approval";
|
|
5
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
5
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
6
|
+
import { getToolInputPath } from "#src/tool-input-path";
|
|
7
7
|
import type { GateResult } from "./descriptor";
|
|
8
8
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
9
9
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
@@ -67,9 +67,11 @@ export function describeExternalDirectoryGate(
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// ── Build descriptor for permission check ───────────────────────────────
|
|
70
|
+
const resolvedAlias = accessPath.resolvedAlias();
|
|
70
71
|
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
71
72
|
tcc.toolName,
|
|
72
73
|
externalDirectoryPath,
|
|
74
|
+
resolvedAlias,
|
|
73
75
|
tcc.cwd,
|
|
74
76
|
tcc.agentName ?? undefined,
|
|
75
77
|
);
|
|
@@ -90,6 +92,7 @@ export function describeExternalDirectoryGate(
|
|
|
90
92
|
kind: "external_directory",
|
|
91
93
|
toolName: tcc.toolName,
|
|
92
94
|
pathValue: externalDirectoryPath,
|
|
95
|
+
resolvedPath: resolvedAlias,
|
|
93
96
|
cwd: tcc.cwd,
|
|
94
97
|
agentName: tcc.agentName ?? undefined,
|
|
95
98
|
},
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
|
-
import { getToolInputPath } from "#src/path-utils";
|
|
3
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
3
|
import { SessionApproval } from "#src/session-approval";
|
|
5
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
5
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
6
|
+
import { getToolInputPath } from "#src/tool-input-path";
|
|
7
7
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
8
8
|
import type { ToolCallContext } from "./types";
|
|
9
9
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
3
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
4
|
-
import { getPathBearingToolPath } from "#src/path-utils";
|
|
5
4
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
6
5
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
7
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
8
7
|
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
|
8
|
+
import { getPathBearingToolPath } from "#src/tool-input-path";
|
|
9
9
|
import {
|
|
10
10
|
ToolPreviewFormatter,
|
|
11
11
|
type ToolPreviewFormatterOptions,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
-
import {
|
|
2
|
+
import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
|
|
3
3
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
4
4
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
5
5
|
import { SessionApproval } from "#src/session-approval";
|
|
6
|
+
import { getPathBearingToolPath } from "#src/tool-input-path";
|
|
6
7
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
7
8
|
import type { PermissionCheckResult } from "#src/types";
|
|
8
9
|
import type { GateDescriptor } from "./descriptor";
|
package/src/input-normalizer.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AccessIntent } from "./access-intent/access-intent";
|
|
|
2
2
|
import { stripBashCommentLines } from "./bash-arity";
|
|
3
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
4
|
import type { PathNormalizer } from "./path-normalizer";
|
|
5
|
-
import { PATH_SURFACES } from "./path-
|
|
5
|
+
import { PATH_SURFACES } from "./path-surfaces";
|
|
6
6
|
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { isSafeSystemPath } from "./safe-system-paths";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true when `pathValue` is `directory` itself or nested inside it.
|
|
7
|
+
*
|
|
8
|
+
* Containment is decided with Node's platform-native `path.relative` rather
|
|
9
|
+
* than a hand-rolled prefix check: on `win32` the comparison folds case (and
|
|
10
|
+
* tolerates either separator), matching the case-insensitive filesystem.
|
|
11
|
+
* `platform` is injected from the composition root so Windows behavior is
|
|
12
|
+
* testable on a POSIX CI.
|
|
13
|
+
*/
|
|
14
|
+
export function isPathWithinDirectory(
|
|
15
|
+
pathValue: string,
|
|
16
|
+
directory: string,
|
|
17
|
+
platform: NodeJS.Platform,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (!pathValue || !directory) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (pathValue === directory) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
28
|
+
const rel = impl.relative(directory, pathValue);
|
|
29
|
+
return (
|
|
30
|
+
rel !== "" &&
|
|
31
|
+
rel !== ".." &&
|
|
32
|
+
!rel.startsWith(`..${impl.sep}`) &&
|
|
33
|
+
!impl.isAbsolute(rel)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
|
39
|
+
*
|
|
40
|
+
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
|
41
|
+
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
|
|
42
|
+
* This predicate touches no filesystem and does no derivation.
|
|
43
|
+
*/
|
|
44
|
+
export function isPathOutsideWorkingDirectory(
|
|
45
|
+
canonicalPath: string,
|
|
46
|
+
canonicalCwd: string,
|
|
47
|
+
platform: NodeJS.Platform,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (!canonicalCwd || !canonicalPath) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (isSafeSystemPath(canonicalPath)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return !isPathWithinDirectory(canonicalPath, canonicalCwd, platform);
|
|
56
|
+
}
|
package/src/path-normalizer.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
2
2
|
|
|
3
3
|
import { AccessPath } from "./access-intent/access-path";
|
|
4
|
+
import {
|
|
5
|
+
canonicalNormalizePathForComparison,
|
|
6
|
+
normalizePathForComparison,
|
|
7
|
+
} from "./access-intent/path-normalization";
|
|
4
8
|
import {
|
|
5
9
|
isPathOutsideWorkingDirectory,
|
|
6
10
|
isPathWithinDirectory,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./path-utils";
|
|
11
|
+
} from "./path-containment";
|
|
12
|
+
import { isPiInfrastructureRead } from "./pi-infrastructure-read";
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Path-interpretation collaborator, constructed once at the session edge with
|
|
@@ -19,16 +22,19 @@ import {
|
|
|
19
22
|
* prepared {@link AccessPath} values, instead of reading `process.platform`
|
|
20
23
|
* ambiently or threading `cwd` through every call. Internally it selects the
|
|
21
24
|
* `win32`/`posix` path flavor once and delegates to the platform-parameterized
|
|
22
|
-
* `path-
|
|
25
|
+
* `path-containment` / `path-normalization` / `AccessPath` primitives.
|
|
23
26
|
*/
|
|
24
27
|
export class PathNormalizer {
|
|
25
28
|
private readonly impl: typeof posixPath;
|
|
29
|
+
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
|
|
30
|
+
private readonly canonicalCwd: string;
|
|
26
31
|
|
|
27
32
|
constructor(
|
|
28
33
|
private readonly platform: NodeJS.Platform,
|
|
29
34
|
private readonly cwd: string,
|
|
30
35
|
) {
|
|
31
36
|
this.impl = platform === "win32" ? winPath : posixPath;
|
|
37
|
+
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
|
|
@@ -67,7 +73,16 @@ export class PathNormalizer {
|
|
|
67
73
|
|
|
68
74
|
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
|
69
75
|
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
70
|
-
|
|
76
|
+
const canonicalPath = canonicalNormalizePathForComparison(
|
|
77
|
+
pathValue,
|
|
78
|
+
this.cwd,
|
|
79
|
+
this.platform,
|
|
80
|
+
);
|
|
81
|
+
return isPathOutsideWorkingDirectory(
|
|
82
|
+
canonicalPath,
|
|
83
|
+
this.canonicalCwd,
|
|
84
|
+
this.platform,
|
|
85
|
+
);
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tools that only read — never write — the filesystem.
|
|
3
|
+
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
4
|
+
*/
|
|
5
|
+
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
6
|
+
"read",
|
|
7
|
+
"find",
|
|
8
|
+
"grep",
|
|
9
|
+
"ls",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const PATH_BEARING_TOOLS = new Set([
|
|
13
|
+
"read",
|
|
14
|
+
"write",
|
|
15
|
+
"edit",
|
|
16
|
+
"find",
|
|
17
|
+
"grep",
|
|
18
|
+
"ls",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Surfaces whose patterns are matched against filesystem paths and therefore
|
|
23
|
+
* fold case (and separators) on Windows: the path-bearing tools plus the
|
|
24
|
+
* cross-cutting `path` gate and the `external_directory` boundary gate.
|
|
25
|
+
*/
|
|
26
|
+
export const PATH_SURFACES: ReadonlySet<string> = new Set([
|
|
27
|
+
...PATH_BEARING_TOOLS,
|
|
28
|
+
"external_directory",
|
|
29
|
+
"path",
|
|
30
|
+
]);
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
2
|
-
import { PATH_BEARING_TOOLS } from "./path-
|
|
2
|
+
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
3
3
|
import { deriveApprovalPattern } from "./session-rules";
|
|
4
4
|
|
|
5
5
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./config-paths";
|
|
8
8
|
import { normalizeInput } from "./input-normalizer";
|
|
9
9
|
import { normalizeFlatConfig } from "./normalize";
|
|
10
|
-
import { PATH_SURFACES } from "./path-
|
|
10
|
+
import { PATH_SURFACES } from "./path-surfaces";
|
|
11
11
|
import {
|
|
12
12
|
FilePolicyLoader,
|
|
13
13
|
type PolicyLoader,
|
|
@@ -249,6 +249,12 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
249
249
|
* extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
|
|
250
250
|
* the access-path gate (#502) or service/RPC builder (#503).
|
|
251
251
|
* `"path-values"` → evaluates the precomputed values directly.
|
|
252
|
+
*
|
|
253
|
+
* The manager stays string-based by design: it consumes `ResolvedAccessIntent`
|
|
254
|
+
* (`tool | path-values`) and never imports `AccessPath`. This deliberate
|
|
255
|
+
* boundary is formalized in ADR-0002
|
|
256
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
|
|
257
|
+
* `no-restricted-imports` lint rule on this file.
|
|
252
258
|
*/
|
|
253
259
|
check(
|
|
254
260
|
intent: ResolvedAccessIntent,
|
|
@@ -26,6 +26,11 @@ export interface ScopedPermissionResolver {
|
|
|
26
26
|
*
|
|
27
27
|
* Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`,
|
|
28
28
|
* so the low-level manager never imports the value object.
|
|
29
|
+
*
|
|
30
|
+
* This is the sole `matchValues()` unwrap site — the single place the lexical ∪
|
|
31
|
+
* canonical alias set (#418) is derived. Keeping it here (not in the manager)
|
|
32
|
+
* is the deliberate boundary formalized in ADR-0002
|
|
33
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
29
34
|
*/
|
|
30
35
|
function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
|
|
31
36
|
if (intent.kind === "access-path") {
|