@gotgenes/pi-permission-system 16.0.2 → 16.2.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 +20 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +57 -0
- package/src/access-intent/access-path.ts +88 -0
- package/src/access-intent/bash/cwd-projection.ts +9 -4
- package/src/access-intent/bash/program.ts +8 -8
- package/src/handlers/gates/bash-command.ts +18 -3
- package/src/handlers/gates/bash-external-directory.ts +16 -30
- package/src/handlers/gates/bash-path-extractor.ts +9 -3
- package/src/handlers/gates/bash-path.ts +6 -4
- package/src/handlers/gates/external-directory-policy.ts +66 -0
- package/src/handlers/gates/external-directory.ts +8 -9
- package/src/handlers/gates/path.ts +6 -5
- package/src/handlers/gates/runner.ts +6 -5
- package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
- package/src/path-utils.ts +0 -20
- package/src/permission-event-rpc.ts +4 -6
- package/src/permission-manager.ts +39 -55
- package/src/permission-resolver.ts +45 -57
- package/src/permissions-service.ts +2 -4
- package/src/skill-prompt-sanitizer.ts +2 -2
- package/test/access-intent/access-path.test.ts +107 -0
- package/test/access-intent/bash/program.test.ts +30 -12
- package/test/handlers/before-agent-start.test.ts +6 -9
- package/test/handlers/external-directory-session-dedup.test.ts +16 -40
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
- package/test/handlers/gates/bash-command.test.ts +26 -23
- package/test/handlers/gates/bash-external-directory.test.ts +36 -32
- package/test/handlers/gates/bash-path.test.ts +12 -8
- package/test/handlers/gates/external-directory-policy.test.ts +124 -0
- package/test/handlers/gates/external-directory.test.ts +11 -5
- package/test/handlers/gates/path.test.ts +30 -25
- package/test/handlers/gates/runner.test.ts +6 -1
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +4 -3
- package/test/handlers/input.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +12 -24
- package/test/helpers/handler-fixtures.ts +21 -15
- package/test/helpers/session-fixtures.ts +3 -18
- package/test/path-utils.test.ts +0 -34
- package/test/permission-event-rpc.test.ts +24 -20
- package/test/permission-manager-unified.test.ts +445 -209
- package/test/permission-resolver.test.ts +112 -89
- package/test/permissions-service.test.ts +10 -7
- package/test/skill-prompt-sanitizer.test.ts +30 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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
|
+
## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.1.0...pi-permission-system-v16.2.0) (2026-06-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add ScopedPermissionManager.check(intent) ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([7cb600a](https://github.com/gotgenes/pi-packages/commit/7cb600a11efd4bc60bd31c70d85550c7e1b28058))
|
|
14
|
+
* **pi-permission-system:** narrow ScopedPermissionResolver to resolve(intent) ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([908176f](https://github.com/gotgenes/pi-packages/commit/908176f8341e05d9181d5cf22ede10a6d5a470d0))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** record resolve(intent) narrowing ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([fb8f986](https://github.com/gotgenes/pi-packages/commit/fb8f986e15e5db20d84030d0f77b2aa4aad39aab))
|
|
20
|
+
|
|
21
|
+
## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.2...pi-permission-system-v16.1.0) (2026-06-26)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** introduce AccessPath value object ([c00d5c5](https://github.com/gotgenes/pi-packages/commit/c00d5c580a828234fafd7946be4e81313665d25d)), closes [#476](https://github.com/gotgenes/pi-packages/issues/476)
|
|
27
|
+
|
|
8
28
|
## [16.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.1...pi-permission-system-v16.0.2) (2026-06-25)
|
|
9
29
|
|
|
10
30
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Raw tool input the manager must normalize (path / bash / MCP / extension tools).
|
|
5
|
+
*
|
|
6
|
+
* The `surface` is the tool name fed to `normalizeInput` (e.g. `"read"`, `"bash"`,
|
|
7
|
+
* an MCP server name).
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolAccessIntent {
|
|
10
|
+
kind: "tool";
|
|
11
|
+
/** Tool name fed to input normalization. */
|
|
12
|
+
surface: string;
|
|
13
|
+
input: unknown;
|
|
14
|
+
agentName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Precomputed equivalent policy values for a path-shaped surface.
|
|
19
|
+
*
|
|
20
|
+
* Used by the bash-path gate (`path` surface), whose cd-resolved lexical
|
|
21
|
+
* `string[]` carry no canonical-boundary notion.
|
|
22
|
+
*/
|
|
23
|
+
export interface PathValuesAccessIntent {
|
|
24
|
+
kind: "path-values";
|
|
25
|
+
/** `"path"` or `"external_directory"`. */
|
|
26
|
+
surface: string;
|
|
27
|
+
values: readonly string[];
|
|
28
|
+
agentName?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An `AccessPath` value object for a path-shaped surface.
|
|
33
|
+
*
|
|
34
|
+
* Used by the external-directory gates; lets `AccessPath` flow into the
|
|
35
|
+
* resolver as a first-class variant so the resolver — not the gate — asks it
|
|
36
|
+
* for `matchValues()` (Tell-Don't-Ask).
|
|
37
|
+
*/
|
|
38
|
+
export interface AccessPathAccessIntent {
|
|
39
|
+
kind: "access-path";
|
|
40
|
+
surface: string;
|
|
41
|
+
path: AccessPath;
|
|
42
|
+
agentName?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a gate emits — the full three-variant union. */
|
|
46
|
+
export type AccessIntent =
|
|
47
|
+
| ToolAccessIntent
|
|
48
|
+
| PathValuesAccessIntent
|
|
49
|
+
| AccessPathAccessIntent;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the manager consumes — the `access-path` variant has already been
|
|
53
|
+
* unwrapped to `path-values` by the resolver via `path.matchValues()`.
|
|
54
|
+
*
|
|
55
|
+
* The manager stays string-based and never imports `AccessPath`.
|
|
56
|
+
*/
|
|
57
|
+
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalNormalizePathForComparison,
|
|
3
|
+
getPathPolicyValues,
|
|
4
|
+
normalizePathForComparison,
|
|
5
|
+
} from "#src/path-utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A path's two representations held behind type-distinct accessors.
|
|
9
|
+
*
|
|
10
|
+
* A single `string` carrying both meanings was the root cause of [#418]:
|
|
11
|
+
* both external-directory gates matched config patterns against the
|
|
12
|
+
* symlink-resolved (canonical) path instead of the typed (lexical) path,
|
|
13
|
+
* defeating a configured `/tmp/*` allow.
|
|
14
|
+
*
|
|
15
|
+
* `AccessPath` makes the misuse a compile error:
|
|
16
|
+
* - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical,
|
|
17
|
+
* for `external_directory` pattern matching.
|
|
18
|
+
* - {@link boundaryValue} returns `string` — the canonical form, for
|
|
19
|
+
* outside-CWD containment and infra-read checks.
|
|
20
|
+
* - {@link value} returns `string` — the lexical absolute form, for display,
|
|
21
|
+
* approval patterns, decision values, and logs.
|
|
22
|
+
*
|
|
23
|
+
* Construct via {@link forExternalDirectory}; the constructor is private.
|
|
24
|
+
*/
|
|
25
|
+
export class AccessPath {
|
|
26
|
+
private constructor(
|
|
27
|
+
private readonly lexical: string,
|
|
28
|
+
private readonly matchAliases: readonly string[],
|
|
29
|
+
private readonly canonical: string,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pattern-match values for the `external_directory` surface: the lexical
|
|
34
|
+
* alias union plus the canonical alias, so a config pattern on either the
|
|
35
|
+
* typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`)
|
|
36
|
+
* matches (#418).
|
|
37
|
+
*
|
|
38
|
+
* Collapses to the lexical aliases when the canonical equals one of them
|
|
39
|
+
* (e.g. when the path is not a symlink).
|
|
40
|
+
*/
|
|
41
|
+
matchValues(): string[] {
|
|
42
|
+
return this.canonical
|
|
43
|
+
? [...new Set([...this.matchAliases, this.canonical])]
|
|
44
|
+
: [...this.matchAliases];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Canonical (symlink-resolved, win32-lowercased) form, for the outside-CWD
|
|
49
|
+
* boundary decision and Pi infrastructure-read containment checks.
|
|
50
|
+
*
|
|
51
|
+
* Returns `""` when the path could not be resolved (empty input).
|
|
52
|
+
*/
|
|
53
|
+
boundaryValue(): string {
|
|
54
|
+
return this.canonical;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Lexical (as-typed, normalized but not symlink-resolved) form, for display,
|
|
59
|
+
* approval patterns, decision values, and log messages.
|
|
60
|
+
*
|
|
61
|
+
* Returns `""` for empty input.
|
|
62
|
+
*/
|
|
63
|
+
value(): string {
|
|
64
|
+
return this.lexical;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build an `AccessPath` for a tool-input or bash-token path resolved against
|
|
69
|
+
* `cwd`.
|
|
70
|
+
*
|
|
71
|
+
* - `matchValues()` returns the same set as the former
|
|
72
|
+
* `getExternalDirectoryPolicyValues(pathValue, cwd)` — the lexical alias
|
|
73
|
+
* union from `getPathPolicyValues` plus the canonical alias from
|
|
74
|
+
* `canonicalNormalizePathForComparison` (#418).
|
|
75
|
+
* - `boundaryValue()` returns `canonicalNormalizePathForComparison(pathValue, cwd)`,
|
|
76
|
+
* which is win32-lowercased (#382) — do not substitute a raw
|
|
77
|
+
* `canonicalizePath` output here.
|
|
78
|
+
* - `value()` returns `normalizePathForComparison(pathValue, cwd)`, the
|
|
79
|
+
* absolute lexical form.
|
|
80
|
+
*/
|
|
81
|
+
static forExternalDirectory(pathValue: string, cwd: string): AccessPath {
|
|
82
|
+
return new AccessPath(
|
|
83
|
+
normalizePathForComparison(pathValue, cwd),
|
|
84
|
+
getPathPolicyValues(pathValue, { cwd }),
|
|
85
|
+
canonicalNormalizePathForComparison(pathValue, cwd),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
2
3
|
import {
|
|
3
4
|
ARG_NODE_TYPES,
|
|
4
5
|
SKIP_SUBTREE_TYPES,
|
|
@@ -409,11 +410,11 @@ function getPolicyValuesForRuleCandidate(
|
|
|
409
410
|
export function projectExternalPaths(
|
|
410
411
|
candidates: readonly PathCandidate[],
|
|
411
412
|
cwd: string,
|
|
412
|
-
):
|
|
413
|
+
): AccessPath[] {
|
|
413
414
|
const normalizedCwd = canonicalizePath(normalizePathForComparison(cwd, cwd));
|
|
414
415
|
|
|
415
416
|
const seen = new Set<string>();
|
|
416
|
-
const externalPaths:
|
|
417
|
+
const externalPaths: AccessPath[] = [];
|
|
417
418
|
|
|
418
419
|
for (const { token, base } of candidates) {
|
|
419
420
|
const candidate = classifyTokenAsPathCandidate(token);
|
|
@@ -432,7 +433,9 @@ export function projectExternalPaths(
|
|
|
432
433
|
!seen.has(canonical)
|
|
433
434
|
) {
|
|
434
435
|
seen.add(canonical);
|
|
435
|
-
|
|
436
|
+
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
437
|
+
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
438
|
+
externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
|
|
436
439
|
}
|
|
437
440
|
continue;
|
|
438
441
|
}
|
|
@@ -452,7 +455,9 @@ export function projectExternalPaths(
|
|
|
452
455
|
!seen.has(canonical)
|
|
453
456
|
) {
|
|
454
457
|
seen.add(canonical);
|
|
455
|
-
|
|
458
|
+
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
459
|
+
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
460
|
+
externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
|
|
456
461
|
}
|
|
457
462
|
}
|
|
458
463
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import {
|
|
2
3
|
type BashCommand,
|
|
3
4
|
collectCommands,
|
|
@@ -25,7 +26,7 @@ export type { BashCommand, BashPathRuleCandidate };
|
|
|
25
26
|
export class BashProgram {
|
|
26
27
|
private constructor(
|
|
27
28
|
private readonly commandUnits: readonly BashCommand[],
|
|
28
|
-
private readonly resolvedExternalPaths: readonly
|
|
29
|
+
private readonly resolvedExternalPaths: readonly AccessPath[],
|
|
29
30
|
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
|
|
30
31
|
) {}
|
|
31
32
|
|
|
@@ -73,16 +74,15 @@ export class BashProgram {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* Deduplicated paths that resolve outside `cwd`,
|
|
77
|
-
*
|
|
77
|
+
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
|
|
78
|
+
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
|
|
79
|
+
* forms behind distinct accessors.
|
|
78
80
|
*
|
|
79
81
|
* Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
|
|
80
|
-
*
|
|
81
|
-
* (
|
|
82
|
-
* `external_directory` config patterns match the path as the user typed it
|
|
83
|
-
* (#418); the gate re-derives the canonical alias for matching.
|
|
82
|
+
* Use `.matchValues()` for `external_directory` pattern matching and
|
|
83
|
+
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
|
|
84
84
|
*/
|
|
85
|
-
externalPaths():
|
|
85
|
+
externalPaths(): AccessPath[] {
|
|
86
86
|
return [...this.resolvedExternalPaths];
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -38,7 +38,12 @@ export function resolveBashCommandCheck(
|
|
|
38
38
|
): PermissionCheckResult {
|
|
39
39
|
if (commands.length === 0) {
|
|
40
40
|
if (isTriviallyEmptyCommand(command)) {
|
|
41
|
-
return resolver.resolve(
|
|
41
|
+
return resolver.resolve({
|
|
42
|
+
kind: "tool",
|
|
43
|
+
surface: "bash",
|
|
44
|
+
input: { command },
|
|
45
|
+
agentName,
|
|
46
|
+
});
|
|
42
47
|
}
|
|
43
48
|
return {
|
|
44
49
|
state: "ask",
|
|
@@ -51,12 +56,22 @@ export function resolveBashCommandCheck(
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
const results = commands.map((cmd) => {
|
|
54
|
-
const result = resolver.resolve(
|
|
59
|
+
const result = resolver.resolve({
|
|
60
|
+
kind: "tool",
|
|
61
|
+
surface: "bash",
|
|
62
|
+
input: { command: cmd.text },
|
|
63
|
+
agentName,
|
|
64
|
+
});
|
|
55
65
|
return cmd.context ? { ...result, commandContext: cmd.context } : result;
|
|
56
66
|
});
|
|
57
67
|
return (
|
|
58
68
|
pickMostRestrictive(results) ??
|
|
59
|
-
resolver.resolve(
|
|
69
|
+
resolver.resolve({
|
|
70
|
+
kind: "tool",
|
|
71
|
+
surface: "bash",
|
|
72
|
+
input: { command },
|
|
73
|
+
agentName,
|
|
74
|
+
})
|
|
60
75
|
);
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
2
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
3
|
-
import { getExternalDirectoryPolicyValues } from "#src/path-utils";
|
|
4
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
5
4
|
import { SessionApproval } from "#src/session-approval";
|
|
6
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
7
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
8
|
-
import { pickMostRestrictive } from "./candidate-check";
|
|
9
6
|
import type { GateResult } from "./descriptor";
|
|
10
7
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
8
|
+
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
11
9
|
import type { ToolCallContext } from "./types";
|
|
12
10
|
|
|
13
11
|
/**
|
|
@@ -34,27 +32,17 @@ export function describeBashExternalDirectoryGate(
|
|
|
34
32
|
const externalPaths = bashProgram.externalPaths();
|
|
35
33
|
if (externalPaths.length === 0) return null;
|
|
36
34
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Match each path against both its typed and symlink-resolved aliases on
|
|
46
|
-
// the external_directory surface, so a config pattern on either form
|
|
47
|
-
// applies (#418).
|
|
48
|
-
const check = resolver.resolvePathPolicy(
|
|
49
|
-
getExternalDirectoryPolicyValues(p, tcc.cwd),
|
|
35
|
+
// Resolve every external path on the external_directory surface and keep the
|
|
36
|
+
// ones not already allowed (config-level allows suppress the prompt just as
|
|
37
|
+
// session-level allows do); the shared helper single-sources the #418 alias
|
|
38
|
+
// matching and the worst-uncovered selection.
|
|
39
|
+
const { uncovered: uncoveredEntries, worstCheck } =
|
|
40
|
+
selectUncoveredExternalPaths(
|
|
41
|
+
externalPaths,
|
|
42
|
+
resolver,
|
|
50
43
|
tcc.agentName ?? undefined,
|
|
51
|
-
"external_directory",
|
|
52
44
|
);
|
|
53
|
-
|
|
54
|
-
uncoveredEntries.push({ path: p, check });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
|
|
45
|
+
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
|
|
58
46
|
|
|
59
47
|
if (uncoveredPaths.length === 0) {
|
|
60
48
|
return {
|
|
@@ -67,19 +55,17 @@ export function describeBashExternalDirectoryGate(
|
|
|
67
55
|
toolName: tcc.toolName,
|
|
68
56
|
agentName: tcc.agentName,
|
|
69
57
|
command,
|
|
70
|
-
externalPaths,
|
|
58
|
+
externalPaths: externalPaths.map((p) => p.value()),
|
|
71
59
|
resolution: "session_approved",
|
|
72
60
|
},
|
|
73
61
|
},
|
|
74
62
|
};
|
|
75
63
|
}
|
|
76
64
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const worstCheck
|
|
81
|
-
pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
|
|
82
|
-
uncoveredEntries[0].check;
|
|
65
|
+
// After the early bypass, at least one path is uncovered, so worstCheck is
|
|
66
|
+
// defined; the fallback keeps TypeScript happy across the early return. A
|
|
67
|
+
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
|
68
|
+
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
|
83
69
|
|
|
84
70
|
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
85
71
|
command,
|
|
@@ -122,6 +108,6 @@ export function describeBashExternalDirectoryGate(
|
|
|
122
108
|
surface: "external_directory",
|
|
123
109
|
value: command,
|
|
124
110
|
},
|
|
125
|
-
preCheck
|
|
111
|
+
preCheck,
|
|
126
112
|
};
|
|
127
113
|
}
|
|
@@ -4,12 +4,18 @@ import { BashProgram } from "#src/access-intent/bash/program";
|
|
|
4
4
|
* Extract paths from a bash command string that resolve outside CWD.
|
|
5
5
|
*
|
|
6
6
|
* Thin facade over {@link BashProgram.externalPaths}; parses the command and
|
|
7
|
-
* returns the cd-aware external paths
|
|
8
|
-
* resolution semantics.
|
|
7
|
+
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
|
8
|
+
* See `BashProgram` for the parsing and resolution semantics.
|
|
9
|
+
*
|
|
10
|
+
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
|
11
|
+
* test suite in `bash-external-directory.test.ts` can assert path values
|
|
12
|
+
* without migrating every call site.
|
|
9
13
|
*/
|
|
10
14
|
export async function extractExternalPathsFromBashCommand(
|
|
11
15
|
command: string,
|
|
12
16
|
cwd: string,
|
|
13
17
|
): Promise<string[]> {
|
|
14
|
-
return (await BashProgram.parse(command, cwd))
|
|
18
|
+
return (await BashProgram.parse(command, cwd))
|
|
19
|
+
.externalPaths()
|
|
20
|
+
.map((p) => p.value());
|
|
15
21
|
}
|
|
@@ -51,10 +51,12 @@ export function describeBashPathGate(
|
|
|
51
51
|
let allSessionCovered = true;
|
|
52
52
|
|
|
53
53
|
for (const { token, policyValues } of candidates) {
|
|
54
|
-
const check = resolver.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const check = resolver.resolve({
|
|
55
|
+
kind: "path-values",
|
|
56
|
+
surface: "path",
|
|
57
|
+
values: policyValues,
|
|
58
|
+
agentName: tcc.agentName ?? undefined,
|
|
59
|
+
});
|
|
58
60
|
|
|
59
61
|
// No explicit path rule matched — only the universal default fired.
|
|
60
62
|
// Treat this token as unrestricted to preserve backward compatibility
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
4
|
+
import { pickMostRestrictive } from "./candidate-check";
|
|
5
|
+
|
|
6
|
+
/** An external path whose resolved `external_directory` state is not "allow". */
|
|
7
|
+
export interface UncoveredExternalPath {
|
|
8
|
+
path: AccessPath;
|
|
9
|
+
check: PermissionCheckResult;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The uncovered external paths plus the most restrictive check among them. */
|
|
13
|
+
export interface UncoveredExternalPaths {
|
|
14
|
+
uncovered: UncoveredExternalPath[];
|
|
15
|
+
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
|
|
16
|
+
worstCheck: PermissionCheckResult | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve one external path's policy on the `external_directory` surface.
|
|
21
|
+
*
|
|
22
|
+
* Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
|
|
23
|
+
* {@link AccessPath.matchValues} so a config pattern on either the typed or
|
|
24
|
+
* symlink-resolved alias applies (#418). This is the single source for the
|
|
25
|
+
* external-directory resolve that the two external-directory gates previously
|
|
26
|
+
* duplicated.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveExternalDirectoryPolicy(
|
|
29
|
+
path: AccessPath,
|
|
30
|
+
resolver: ScopedPermissionResolver,
|
|
31
|
+
agentName: string | undefined,
|
|
32
|
+
): PermissionCheckResult {
|
|
33
|
+
return resolver.resolve({
|
|
34
|
+
kind: "access-path",
|
|
35
|
+
surface: "external_directory",
|
|
36
|
+
path,
|
|
37
|
+
agentName,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a set of external paths and select those not already allowed.
|
|
43
|
+
*
|
|
44
|
+
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
|
|
45
|
+
* whose state is not "allow" are collected (filtering on state, not source, so
|
|
46
|
+
* config-level allow rules suppress the prompt just as session-level allow
|
|
47
|
+
* rules do), and the most restrictive uncovered check is returned so a config
|
|
48
|
+
* "deny" is not downgraded to the catch-all "ask".
|
|
49
|
+
*/
|
|
50
|
+
export function selectUncoveredExternalPaths(
|
|
51
|
+
paths: readonly AccessPath[],
|
|
52
|
+
resolver: ScopedPermissionResolver,
|
|
53
|
+
agentName: string | undefined,
|
|
54
|
+
): UncoveredExternalPaths {
|
|
55
|
+
const uncovered: UncoveredExternalPath[] = [];
|
|
56
|
+
for (const path of paths) {
|
|
57
|
+
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
|
|
58
|
+
if (check.state !== "allow") {
|
|
59
|
+
uncovered.push({ path, check });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
uncovered,
|
|
64
|
+
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import {
|
|
2
|
-
canonicalNormalizePathForComparison,
|
|
3
|
-
getExternalDirectoryPolicyValues,
|
|
4
3
|
getToolInputPath,
|
|
5
4
|
isPathOutsideWorkingDirectory,
|
|
6
5
|
isPiInfrastructureRead,
|
|
@@ -12,6 +11,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
12
11
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
13
12
|
import type { GateResult } from "./descriptor";
|
|
14
13
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
14
|
+
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
15
15
|
import type { ToolCallContext } from "./types";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -42,10 +42,11 @@ export function describeExternalDirectoryGate(
|
|
|
42
42
|
// The boundary decision (above) and the infrastructure-read containment
|
|
43
43
|
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
44
44
|
// uses the typed and resolved aliases (#418).
|
|
45
|
-
const
|
|
45
|
+
const accessPath = AccessPath.forExternalDirectory(
|
|
46
46
|
externalDirectoryPath,
|
|
47
47
|
tcc.cwd,
|
|
48
48
|
);
|
|
49
|
+
const canonicalExtPath = accessPath.boundaryValue();
|
|
49
50
|
|
|
50
51
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
51
52
|
if (
|
|
@@ -83,13 +84,11 @@ export function describeExternalDirectoryGate(
|
|
|
83
84
|
tcc.agentName ?? undefined,
|
|
84
85
|
);
|
|
85
86
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
|
|
87
|
+
// The runner consumes this preCheck and skips its own resolve.
|
|
88
|
+
const preCheck = resolveExternalDirectoryPolicy(
|
|
89
|
+
accessPath,
|
|
90
|
+
resolver,
|
|
91
91
|
tcc.agentName ?? undefined,
|
|
92
|
-
"external_directory",
|
|
93
92
|
);
|
|
94
93
|
const pattern = deriveApprovalPattern(
|
|
95
94
|
normalizePathForComparison(externalDirectoryPath, tcc.cwd),
|
|
@@ -22,11 +22,12 @@ export function describePathGate(
|
|
|
22
22
|
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
|
23
23
|
if (!filePath) return null;
|
|
24
24
|
|
|
25
|
-
const check = resolver.resolve(
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
const check = resolver.resolve({
|
|
26
|
+
kind: "tool",
|
|
27
|
+
surface: "path",
|
|
28
|
+
input: { path: filePath },
|
|
29
|
+
agentName: tcc.agentName ?? undefined,
|
|
30
|
+
});
|
|
30
31
|
|
|
31
32
|
if (check.state === "allow") return null;
|
|
32
33
|
|
|
@@ -77,11 +77,12 @@ export class GateRunner {
|
|
|
77
77
|
origin: "builtin",
|
|
78
78
|
};
|
|
79
79
|
} else {
|
|
80
|
-
check = this.resolver.resolve(
|
|
81
|
-
|
|
82
|
-
descriptor.
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
check = this.resolver.resolve({
|
|
81
|
+
kind: "tool",
|
|
82
|
+
surface: descriptor.surface,
|
|
83
|
+
input: descriptor.input,
|
|
84
|
+
agentName: agentName ?? undefined,
|
|
85
|
+
});
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// 2. Session-hit fast path
|
|
@@ -102,11 +102,12 @@ export class ToolCallGatePipeline {
|
|
|
102
102
|
tcc.agentName ?? undefined,
|
|
103
103
|
this.resolver,
|
|
104
104
|
)
|
|
105
|
-
: this.resolver.resolve(
|
|
106
|
-
|
|
107
|
-
tcc.
|
|
108
|
-
tcc.
|
|
109
|
-
|
|
105
|
+
: this.resolver.resolve({
|
|
106
|
+
kind: "tool",
|
|
107
|
+
surface: tcc.toolName,
|
|
108
|
+
input: tcc.input,
|
|
109
|
+
agentName: tcc.agentName ?? undefined,
|
|
110
|
+
});
|
|
110
111
|
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
|
|
111
112
|
toolDescriptor.preCheck = toolCheck;
|
|
112
113
|
return toolDescriptor;
|
package/src/path-utils.ts
CHANGED
|
@@ -112,26 +112,6 @@ export function getPathPolicyValues(
|
|
|
112
112
|
];
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* Equivalent `external_directory` policy-match values for a path: the lexical
|
|
117
|
-
* (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
|
|
118
|
-
*
|
|
119
|
-
* The outside-CWD boundary decision uses the canonical form separately; this
|
|
120
|
-
* helper exists only for pattern matching, so a user's pattern on the typed
|
|
121
|
-
* path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
|
|
122
|
-
* the last-match-wins alias evaluation. On systems where the path is not a
|
|
123
|
-
* symlink the canonical form equals the lexical absolute alias and the `Set`
|
|
124
|
-
* collapses it, leaving today's behavior unchanged.
|
|
125
|
-
*/
|
|
126
|
-
export function getExternalDirectoryPolicyValues(
|
|
127
|
-
pathValue: string,
|
|
128
|
-
cwd: string,
|
|
129
|
-
): string[] {
|
|
130
|
-
const lexical = getPathPolicyValues(pathValue, { cwd });
|
|
131
|
-
const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
|
|
132
|
-
return canonical ? [...new Set([...lexical, canonical])] : lexical;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
115
|
function getAbsolutePathPolicyValues(
|
|
136
116
|
pathValue: string,
|
|
137
117
|
options: PathPolicyValueOptions,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
27
27
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
28
|
} from "./permission-events";
|
|
29
|
-
import type {
|
|
29
|
+
import type { ScopedPermissionManager } from "./permission-manager";
|
|
30
30
|
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
31
31
|
import type { ReviewLogger } from "./session-logger";
|
|
32
32
|
import type { SessionRules } from "./session-rules";
|
|
@@ -34,7 +34,7 @@ import type { SessionRules } from "./session-rules";
|
|
|
34
34
|
/** Dependencies injected into the RPC handler registry. */
|
|
35
35
|
export interface PermissionRpcDeps {
|
|
36
36
|
/** The shared PermissionManager instance. */
|
|
37
|
-
permissionManager: Pick<
|
|
37
|
+
permissionManager: Pick<ScopedPermissionManager, "check">;
|
|
38
38
|
/** The shared SessionRules instance. */
|
|
39
39
|
sessionRules: Pick<SessionRules, "getRuleset">;
|
|
40
40
|
/**
|
|
@@ -109,10 +109,8 @@ function handleCheckRpc(
|
|
|
109
109
|
|
|
110
110
|
const input = buildInputForSurface(surface, value);
|
|
111
111
|
const sessionRules = deps.sessionRules.getRuleset();
|
|
112
|
-
const result = deps.permissionManager.
|
|
113
|
-
surface,
|
|
114
|
-
input,
|
|
115
|
-
agentName ?? undefined,
|
|
112
|
+
const result = deps.permissionManager.check(
|
|
113
|
+
{ kind: "tool", surface, input, agentName: agentName ?? undefined },
|
|
116
114
|
sessionRules,
|
|
117
115
|
);
|
|
118
116
|
|