@gotgenes/pi-permission-system 18.0.1 → 18.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +16 -0
- package/src/access-intent/bash/bash-path-resolver.ts +30 -10
- package/src/access-intent/bash/program.ts +9 -0
- package/src/access-intent/bash/token-classification.ts +39 -9
- 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 +3 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +13 -2
- package/src/permission-manager.ts +51 -1
- package/src/permission-session.ts +12 -0
- package/src/rule.ts +1 -1
- package/src/types.ts +11 -0
- package/test/access-intent/access-path.test.ts +61 -0
- package/test/access-intent/bash/program.test.ts +57 -0
- package/test/access-intent/bash/token-classification.test.ts +47 -0
- package/test/bash-external-directory.test.ts +18 -4
- package/test/composition-root.test.ts +73 -0
- 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/handlers/gates/tool-call-gate-pipeline.test.ts +26 -0
- package/test/helpers/gate-fixtures.ts +7 -1
- package/test/helpers/session-fixtures.ts +8 -1
- package/test/permission-manager-unified.test.ts +81 -0
- package/test/permission-resolver.test.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.2...pi-permission-system-v18.1.0) (2026-07-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add rule-driven bare-token classifier ([0d693a7](https://github.com/gotgenes/pi-packages/commit/0d693a79478b3e7d9d23c573ec5a085ce8afccfd)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
|
|
14
|
+
* **pi-permission-system:** derive promotable path-token matcher from config ([4a29882](https://github.com/gotgenes/pi-packages/commit/4a298827768fe8730b6b51898740d084676d46cd)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
|
|
15
|
+
* **pi-permission-system:** gate bash bare filenames via path rules ([4ee201a](https://github.com/gotgenes/pi-packages/commit/4ee201a0f771c732e9ac62562a09d54204b425aa)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
|
|
16
|
+
* **pi-permission-system:** promote bare tokens in bash path projection ([f887b7c](https://github.com/gotgenes/pi-packages/commit/f887b7cd3820f085d97105bfa6dc470b35500835)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** document bash bare-filename path promotion ([db7159b](https://github.com/gotgenes/pi-packages/commit/db7159b00af083dc9a7af13c41589ba95376f9ac)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
|
|
22
|
+
|
|
23
|
+
## [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)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **pi-permission-system:** add AccessPath.resolvedAlias() for symlink-target disclosure ([cf6e25c](https://github.com/gotgenes/pi-packages/commit/cf6e25c8a5b672fbab4b41a21240af8ca818971b))
|
|
29
|
+
* **pi-permission-system:** disclose resolved symlink target in tool external-directory messages ([1225e2b](https://github.com/gotgenes/pi-packages/commit/1225e2bcface907a0a4407aadcd744c2398edc19))
|
|
30
|
+
* **pi-permission-system:** disclose resolved symlink targets in bash external-directory messages ([f9fab42](https://github.com/gotgenes/pi-packages/commit/f9fab42b758dd106e67b83a0e3c95fe6fc216687))
|
|
31
|
+
|
|
8
32
|
## [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)
|
|
9
33
|
|
|
10
34
|
|
package/package.json
CHANGED
|
@@ -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`).
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/access-intent/bash/node-text";
|
|
6
6
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
7
|
import {
|
|
8
|
+
classifyPromotedRuleCandidate,
|
|
8
9
|
classifyTokenAsPathCandidate,
|
|
9
10
|
classifyTokenAsRuleCandidate,
|
|
10
11
|
} from "#src/access-intent/bash/token-classification";
|
|
@@ -17,6 +18,10 @@ import {
|
|
|
17
18
|
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
18
19
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
19
20
|
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
21
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
22
|
+
|
|
23
|
+
/** Default promotion predicate: promotes nothing (#509). */
|
|
24
|
+
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
20
25
|
|
|
21
26
|
// ── Internal types ───────────────────────────────────────────────────────────
|
|
22
27
|
|
|
@@ -74,20 +79,29 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
|
74
79
|
/**
|
|
75
80
|
* Resolves the filesystem paths a parsed bash program references.
|
|
76
81
|
*
|
|
77
|
-
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its
|
|
78
|
-
* and answers all platform/cwd-dependent questions through it —
|
|
79
|
-
* folding (`isAbsolute`/`joinBase`), per-candidate resolution
|
|
80
|
-
* `forLiteral`/`resolveBase`), and the outside-cwd boundary
|
|
81
|
-
* walk step re-reads the platform or threads the cwd.
|
|
82
|
+
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its primary
|
|
83
|
+
* collaborator and answers all platform/cwd-dependent questions through it —
|
|
84
|
+
* `cd`-base folding (`isAbsolute`/`joinBase`), per-candidate resolution
|
|
85
|
+
* (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
|
|
86
|
+
* decision — so no walk step re-reads the platform or threads the cwd.
|
|
87
|
+
*
|
|
88
|
+
* Also holds an `isPromotablePathToken` predicate (default: promotes nothing)
|
|
89
|
+
* deciding whether a bare token that fails the broad rule-candidate shape gate
|
|
90
|
+
* should still be promoted because it matches an active, specific `path` rule
|
|
91
|
+
* (#509). The resolver never sees the rules themselves — the predicate is
|
|
92
|
+
* built and owned by `PermissionManager.getPromotablePathTokenMatcher`.
|
|
82
93
|
*
|
|
83
94
|
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
|
84
95
|
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
|
85
96
|
* the `cd`-folding state, and the intermediate path candidates stay private.
|
|
86
97
|
* One instance per parse ({@link BashProgram.parse} constructs it with the
|
|
87
|
-
* session normalizer).
|
|
98
|
+
* session normalizer and promotion predicate).
|
|
88
99
|
*/
|
|
89
100
|
export class BashPathResolver {
|
|
90
|
-
constructor(
|
|
101
|
+
constructor(
|
|
102
|
+
private readonly normalizer: PathNormalizer,
|
|
103
|
+
private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
|
|
104
|
+
) {}
|
|
91
105
|
|
|
92
106
|
/**
|
|
93
107
|
* Resolve a parsed bash program's path references into its external-path and
|
|
@@ -387,8 +401,12 @@ export class BashPathResolver {
|
|
|
387
401
|
* policy lookup values.
|
|
388
402
|
*
|
|
389
403
|
* Filters candidates through the broad path classifier
|
|
390
|
-
* (`classifyTokenAsRuleCandidate`)
|
|
391
|
-
*
|
|
404
|
+
* (`classifyTokenAsRuleCandidate`), falling back to the rule-driven promoted
|
|
405
|
+
* classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
|
|
406
|
+
* broad classifier rejects for shape — promoted only when the injected
|
|
407
|
+
* `isPromotablePathToken` predicate matches an active, specific `path` rule.
|
|
408
|
+
* Pairs each qualifying token with its set of policy values (absolute +
|
|
409
|
+
* project-relative + raw).
|
|
392
410
|
* A token after a non-literal `cd` keeps only its literal value so no
|
|
393
411
|
* spurious absolute rule can match (#393).
|
|
394
412
|
*/
|
|
@@ -399,7 +417,9 @@ export class BashPathResolver {
|
|
|
399
417
|
const result: BashPathRuleCandidate[] = [];
|
|
400
418
|
|
|
401
419
|
for (const { token, base } of candidates) {
|
|
402
|
-
const candidate =
|
|
420
|
+
const candidate =
|
|
421
|
+
classifyTokenAsRuleCandidate(token) ??
|
|
422
|
+
classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
|
|
403
423
|
if (!candidate) continue;
|
|
404
424
|
|
|
405
425
|
const path = this.buildRuleCandidatePath(candidate, base);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "#src/access-intent/bash/command-enumeration";
|
|
10
10
|
import { getParser } from "#src/access-intent/bash/parser";
|
|
11
11
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
12
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
12
13
|
|
|
13
14
|
export type { BashCommand, BashPathRuleCandidate };
|
|
14
15
|
|
|
@@ -37,10 +38,17 @@ export class BashProgram {
|
|
|
37
38
|
* through the injected {@link PathNormalizer} (platform + cwd baked in).
|
|
38
39
|
* Heredoc bodies, comments, and other non-argument content are skipped. An
|
|
39
40
|
* unparseable command yields an empty program.
|
|
41
|
+
*
|
|
42
|
+
* `isPromotablePathToken`, when supplied, promotes a bare filename token
|
|
43
|
+
* (e.g. `id_rsa`) into `pathRuleCandidates()` when it matches an active,
|
|
44
|
+
* specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
|
|
45
|
+
* callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
|
|
46
|
+
* are unaffected.
|
|
40
47
|
*/
|
|
41
48
|
static async parse(
|
|
42
49
|
command: string,
|
|
43
50
|
normalizer: PathNormalizer,
|
|
51
|
+
isPromotablePathToken?: PathRuleTokenMatcher,
|
|
44
52
|
): Promise<BashProgram> {
|
|
45
53
|
const parser = await getParser();
|
|
46
54
|
const tree = parser.parse(command);
|
|
@@ -49,6 +57,7 @@ export class BashProgram {
|
|
|
49
57
|
try {
|
|
50
58
|
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
|
51
59
|
normalizer,
|
|
60
|
+
isPromotablePathToken,
|
|
52
61
|
).resolve(tree.rootNode);
|
|
53
62
|
return new BashProgram(
|
|
54
63
|
collectCommands(tree.rootNode),
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
3
|
*
|
|
4
|
-
* Exports
|
|
4
|
+
* Exports three classifiers consumed by `bash-path-resolver.ts`:
|
|
5
5
|
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
6
|
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
|
+
* - `classifyPromotedRuleCandidate` — rule-driven promotion of a bare filename
|
|
8
|
+
* (e.g. `id_rsa`) that `classifyTokenAsRuleCandidate` rejects for shape, but
|
|
9
|
+
* which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
* the seven rejection cases common to
|
|
10
|
-
* extracted to eliminate).
|
|
11
|
+
* All three classifiers share the private `rejectNonPathToken` predicate that
|
|
12
|
+
* captures the seven rejection cases common to them (the production clone this
|
|
13
|
+
* module was extracted to eliminate).
|
|
11
14
|
*
|
|
12
|
-
* Both
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
|
|
16
|
+
* Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
|
|
17
|
+
* platforms. On POSIX the token resolves as a real in-CWD relative path and is
|
|
18
|
+
* gated by the `path` surface; on Windows the `PathNormalizer` routes it through
|
|
19
|
+
* the absolute-path branch. Shape recognition is platform-independent string
|
|
20
|
+
* matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
|
|
17
21
|
*/
|
|
22
|
+
import type { PathRuleTokenMatcher } from "#src/types";
|
|
18
23
|
|
|
19
24
|
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
20
25
|
|
|
@@ -70,6 +75,31 @@ export function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
|
70
75
|
return null;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Rule-driven promotion classifier for bare filenames (#509).
|
|
80
|
+
*
|
|
81
|
+
* A bare token (`id_rsa`) has none of the shapes `classifyTokenAsRuleCandidate`
|
|
82
|
+
* accepts, so it is dropped before rule evaluation by default — most bash
|
|
83
|
+
* argument tokens are not file paths (subcommands, branch names, search
|
|
84
|
+
* patterns). This classifier promotes a bare token into the rule-candidate
|
|
85
|
+
* surface only when the caller-supplied `isPromotable` predicate says it
|
|
86
|
+
* matches an active, specific `path` deny/ask rule, closing the bypass without
|
|
87
|
+
* treating every bare argument as a path.
|
|
88
|
+
*
|
|
89
|
+
* Still runs the shared `rejectNonPathToken` prelude first, so a flag,
|
|
90
|
+
* env-assignment, URL, `@scope` token, or regex-shaped token is never
|
|
91
|
+
* promoted even if it happens to match a configured pattern.
|
|
92
|
+
*
|
|
93
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
94
|
+
*/
|
|
95
|
+
export function classifyPromotedRuleCandidate(
|
|
96
|
+
token: string,
|
|
97
|
+
isPromotable: PathRuleTokenMatcher,
|
|
98
|
+
): string | null {
|
|
99
|
+
if (rejectNonPathToken(token)) return null;
|
|
100
|
+
return isPromotable(token) ? token : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
// ── Private rejection predicate ────────────────────────────────────────────
|
|
74
104
|
|
|
75
105
|
/**
|
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
|
}
|
|
@@ -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
|
},
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
ToolPreviewFormatter,
|
|
11
11
|
type ToolPreviewFormatterOptions,
|
|
12
12
|
} from "#src/tool-preview-formatter";
|
|
13
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
13
|
+
import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
|
|
14
14
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
15
15
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
16
16
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
@@ -42,6 +42,11 @@ export interface ToolCallGateInputs {
|
|
|
42
42
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
43
43
|
/** The session's path normalizer (platform + cwd baked in). */
|
|
44
44
|
getPathNormalizer(): PathNormalizer;
|
|
45
|
+
/**
|
|
46
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
47
|
+
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
48
|
+
*/
|
|
49
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -73,7 +78,13 @@ export class ToolCallGatePipeline {
|
|
|
73
78
|
const normalizer = this.inputs.getPathNormalizer();
|
|
74
79
|
const bashProgram =
|
|
75
80
|
tcc.toolName === "bash" && command
|
|
76
|
-
? await BashProgram.parse(
|
|
81
|
+
? await BashProgram.parse(
|
|
82
|
+
command,
|
|
83
|
+
normalizer,
|
|
84
|
+
this.inputs.getPromotablePathTokenMatcher(
|
|
85
|
+
tcc.agentName ?? undefined,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
77
88
|
: null;
|
|
78
89
|
|
|
79
90
|
const formatter = new ToolPreviewFormatter(
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
type ResolvedPolicyPaths,
|
|
16
16
|
} from "./policy-loader";
|
|
17
17
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
evaluate,
|
|
20
|
+
evaluateAnyValue,
|
|
21
|
+
evaluateFirst,
|
|
22
|
+
pathMatchOptions,
|
|
23
|
+
} from "./rule";
|
|
19
24
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
20
25
|
import {
|
|
21
26
|
composeRuleset,
|
|
@@ -24,10 +29,12 @@ import {
|
|
|
24
29
|
} from "./synthesize";
|
|
25
30
|
import type {
|
|
26
31
|
FlatPermissionConfig,
|
|
32
|
+
PathRuleTokenMatcher,
|
|
27
33
|
PermissionCheckResult,
|
|
28
34
|
PermissionState,
|
|
29
35
|
} from "./types";
|
|
30
36
|
import { isPermissionState } from "./value-guards";
|
|
37
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
31
38
|
|
|
32
39
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
33
40
|
"bash",
|
|
@@ -43,6 +50,9 @@ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
|
43
50
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
44
51
|
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
|
45
52
|
|
|
53
|
+
/** Promotion predicate matching no token — the no-`path`-rules default (#509). */
|
|
54
|
+
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
55
|
+
|
|
46
56
|
type FileCacheEntry<TValue> = {
|
|
47
57
|
stamp: string;
|
|
48
58
|
value: TValue;
|
|
@@ -76,6 +86,15 @@ export interface ScopedPermissionManager {
|
|
|
76
86
|
): PermissionCheckResult;
|
|
77
87
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
78
88
|
getConfigIssues(agentName?: string): string[];
|
|
89
|
+
/**
|
|
90
|
+
* Build a predicate deciding whether a bare bash token should be promoted
|
|
91
|
+
* into the `path` rule-candidate surface (#509).
|
|
92
|
+
*
|
|
93
|
+
* Matches against specific (non-`*`) `path`-surface config rules whose
|
|
94
|
+
* action is `deny` or `ask` — an allow rule never gates, and `"*"` would
|
|
95
|
+
* promote every bare bash argument.
|
|
96
|
+
*/
|
|
97
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
@@ -211,6 +230,37 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
211
230
|
return composedRules.filter((r) => r.layer === "config");
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Build a predicate deciding whether a bare bash token should be promoted
|
|
235
|
+
* into the `path` rule-candidate surface (#509).
|
|
236
|
+
*
|
|
237
|
+
* Filters the composed config ruleset to specific (non-`*`) `path`-surface
|
|
238
|
+
* deny/ask patterns, then returns a closure matching a token against them
|
|
239
|
+
* with the platform-correct fold (Windows case-and-separator matching, same
|
|
240
|
+
* as {@link pathMatchOptions} applies for evaluation) so promotion agrees
|
|
241
|
+
* with the later `path`-surface decision.
|
|
242
|
+
*
|
|
243
|
+
* Returns a matcher rejecting every token when no such rule exists — the
|
|
244
|
+
* default-config case is unaffected by promotion.
|
|
245
|
+
*/
|
|
246
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
247
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
248
|
+
const patterns = composedRules
|
|
249
|
+
.filter(
|
|
250
|
+
(r) =>
|
|
251
|
+
r.layer === "config" &&
|
|
252
|
+
r.surface === "path" &&
|
|
253
|
+
r.pattern !== "*" &&
|
|
254
|
+
r.action !== "allow",
|
|
255
|
+
)
|
|
256
|
+
.map((r) => r.pattern);
|
|
257
|
+
if (patterns.length === 0) return NO_PROMOTION;
|
|
258
|
+
|
|
259
|
+
const matchOptions = pathMatchOptions("path", this.platform);
|
|
260
|
+
return (token) =>
|
|
261
|
+
patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
|
|
262
|
+
}
|
|
263
|
+
|
|
214
264
|
/**
|
|
215
265
|
* Get the tool-level permission state for a tool, without considering
|
|
216
266
|
* command-level rules. Used for tool injection decisions.
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveToolPreviewLimits,
|
|
20
20
|
type ToolPreviewFormatterOptions,
|
|
21
21
|
} from "./tool-preview-formatter";
|
|
22
|
+
import type { PathRuleTokenMatcher } from "./types";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Encapsulates all mutable session state and exposes operations instead of
|
|
@@ -214,4 +215,15 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
214
215
|
getPathNormalizer(): PathNormalizer {
|
|
215
216
|
return this.pathNormalizer;
|
|
216
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
221
|
+
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
222
|
+
*
|
|
223
|
+
* Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
|
|
224
|
+
* the manager owns the composed ruleset and the platform-correct match.
|
|
225
|
+
*/
|
|
226
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
227
|
+
return this.permissionManager.getPromotablePathTokenMatcher(agentName);
|
|
228
|
+
}
|
|
217
229
|
}
|
package/src/rule.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function evaluate(
|
|
|
74
74
|
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
75
75
|
* overrides still match. The surface→surface match stays exact.
|
|
76
76
|
*/
|
|
77
|
-
function pathMatchOptions(
|
|
77
|
+
export function pathMatchOptions(
|
|
78
78
|
surface: string,
|
|
79
79
|
platform: NodeJS.Platform,
|
|
80
80
|
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface DenyWithReason {
|
|
|
16
16
|
/** A pattern value: a PermissionState string OR a DenyWithReason object. */
|
|
17
17
|
export type PatternValue = PermissionState | DenyWithReason;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
21
|
+
* `path` rule-candidate surface.
|
|
22
|
+
*
|
|
23
|
+
* Built by `PermissionManager.getPromotablePathTokenMatcher` from the
|
|
24
|
+
* composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
|
|
25
|
+
* threaded through to `BashPathResolver` so promotion policy stays in the
|
|
26
|
+
* manager while the bash layer only asks the predicate.
|
|
27
|
+
*/
|
|
28
|
+
export type PathRuleTokenMatcher = (token: string) => boolean;
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* The on-disk permission shape inside the `"permission"` key.
|
|
21
32
|
* A surface value is a PermissionState string (shorthand for `{ "*": action }`)
|
|
@@ -191,6 +191,67 @@ describe("AccessPath.forPath", () => {
|
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("resolvedAlias()", () => {
|
|
195
|
+
const cwd = "/projects/my-app";
|
|
196
|
+
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
realpathSync.mockReset();
|
|
199
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("returns the canonical form when a symlink resolves elsewhere", () => {
|
|
203
|
+
realpathSync.mockImplementation((p: string) =>
|
|
204
|
+
p === "/projects/my-app/demo-symlink-passwd" ? "/etc/passwd" : p,
|
|
205
|
+
);
|
|
206
|
+
expect(
|
|
207
|
+
AccessPath.forPath("demo-symlink-passwd", {
|
|
208
|
+
cwd,
|
|
209
|
+
platform: "linux",
|
|
210
|
+
}).resolvedAlias(),
|
|
211
|
+
).toBe("/etc/passwd");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("returns undefined when the path has no symlinks (canonical equals lexical)", () => {
|
|
215
|
+
expect(
|
|
216
|
+
AccessPath.forPath("/etc/hosts", {
|
|
217
|
+
cwd,
|
|
218
|
+
platform: "linux",
|
|
219
|
+
}).resolvedAlias(),
|
|
220
|
+
).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("returns undefined for a literal-only path (no canonical)", () => {
|
|
224
|
+
expect(AccessPath.forLiteral("foo.ts").resolvedAlias()).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("returns undefined for empty input", () => {
|
|
228
|
+
expect(
|
|
229
|
+
AccessPath.forPath("", { cwd, platform: "linux" }).resolvedAlias(),
|
|
230
|
+
).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("win32: returns the lowercased canonical form for a real symlink target", () => {
|
|
234
|
+
realpathSync.mockImplementation((p: string) =>
|
|
235
|
+
p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
|
|
236
|
+
);
|
|
237
|
+
expect(
|
|
238
|
+
AccessPath.forPath("link", {
|
|
239
|
+
cwd: "C:\\Projects\\App",
|
|
240
|
+
platform: "win32",
|
|
241
|
+
}).resolvedAlias(),
|
|
242
|
+
).toBe("c:\\real\\app");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("win32: returns undefined for a case-only difference (both forms lowercased)", () => {
|
|
246
|
+
expect(
|
|
247
|
+
AccessPath.forPath("src\\foo.ts", {
|
|
248
|
+
cwd: "C:\\Projects\\App",
|
|
249
|
+
platform: "win32",
|
|
250
|
+
}).resolvedAlias(),
|
|
251
|
+
).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
194
255
|
describe("AccessPath.forLiteral", () => {
|
|
195
256
|
beforeEach(() => {
|
|
196
257
|
realpathSync.mockReset();
|