@gotgenes/pi-permission-system 16.0.2 → 16.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 +7 -0
- package/package.json +1 -1
- 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-external-directory.ts +16 -30
- package/src/handlers/gates/bash-path-extractor.ts +9 -3
- package/src/handlers/gates/external-directory-policy.ts +65 -0
- package/src/handlers/gates/external-directory.ts +8 -9
- package/src/path-utils.ts +0 -20
- package/test/access-intent/access-path.test.ts +107 -0
- package/test/access-intent/bash/program.test.ts +30 -12
- package/test/handlers/gates/external-directory-policy.test.ts +112 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
- package/test/path-utils.test.ts +0 -34
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.2...pi-permission-system-v16.1.0) (2026-06-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
8
15
|
## [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
16
|
|
|
10
17
|
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
* Matches against the typed and symlink-resolved aliases
|
|
23
|
+
* ({@link AccessPath.matchValues}) so a config pattern on either form applies
|
|
24
|
+
* (#418). This is the single source for the alias-derivation plus
|
|
25
|
+
* surface-tagged 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.resolvePathPolicy(
|
|
34
|
+
path.matchValues(),
|
|
35
|
+
agentName,
|
|
36
|
+
"external_directory",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a set of external paths and select those not already allowed.
|
|
42
|
+
*
|
|
43
|
+
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
|
|
44
|
+
* whose state is not "allow" are collected (filtering on state, not source, so
|
|
45
|
+
* config-level allow rules suppress the prompt just as session-level allow
|
|
46
|
+
* rules do), and the most restrictive uncovered check is returned so a config
|
|
47
|
+
* "deny" is not downgraded to the catch-all "ask".
|
|
48
|
+
*/
|
|
49
|
+
export function selectUncoveredExternalPaths(
|
|
50
|
+
paths: readonly AccessPath[],
|
|
51
|
+
resolver: ScopedPermissionResolver,
|
|
52
|
+
agentName: string | undefined,
|
|
53
|
+
): UncoveredExternalPaths {
|
|
54
|
+
const uncovered: UncoveredExternalPath[] = [];
|
|
55
|
+
for (const path of paths) {
|
|
56
|
+
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
|
|
57
|
+
if (check.state !== "allow") {
|
|
58
|
+
uncovered.push({ path, check });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
uncovered,
|
|
63
|
+
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -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),
|
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,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
13
|
+
// Default implementation is identity — lexical tests are unaffected.
|
|
14
|
+
const realpathSync = vi.hoisted(() =>
|
|
15
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
16
|
+
);
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
realpathSync,
|
|
19
|
+
default: { realpathSync },
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
23
|
+
|
|
24
|
+
describe("AccessPath.forExternalDirectory", () => {
|
|
25
|
+
const cwd = "/projects/my-app";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
realpathSync.mockReset();
|
|
29
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("matchValues()", () => {
|
|
33
|
+
test("adds the symlink-resolved alias alongside the typed path", () => {
|
|
34
|
+
// /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
|
|
35
|
+
realpathSync.mockImplementation((p: string) =>
|
|
36
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
37
|
+
);
|
|
38
|
+
expect(
|
|
39
|
+
AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
|
|
40
|
+
).toEqual(["/tmp/x", "/private/tmp/x"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("deduplicates when the canonical form equals the lexical form", () => {
|
|
44
|
+
expect(
|
|
45
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
|
|
46
|
+
).toEqual(["/etc/hosts"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
|
50
|
+
expect(
|
|
51
|
+
AccessPath.forExternalDirectory("src/foo.ts", cwd).matchValues(),
|
|
52
|
+
).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("includes only the lexical aliases when canonical is empty", () => {
|
|
56
|
+
// Force canonicalizePath to return the original (no-op symlink resolution
|
|
57
|
+
// effectively means canonical === lexical, handled by dedup).
|
|
58
|
+
expect(
|
|
59
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
|
|
60
|
+
).not.toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("boundaryValue()", () => {
|
|
65
|
+
test("returns the canonical (symlink-resolved) form", () => {
|
|
66
|
+
realpathSync.mockImplementation((p: string) =>
|
|
67
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
68
|
+
);
|
|
69
|
+
expect(
|
|
70
|
+
AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
|
|
71
|
+
).toBe("/private/tmp/x");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns the lexical form when path has no symlinks", () => {
|
|
75
|
+
expect(
|
|
76
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
|
|
77
|
+
).toBe("/etc/hosts");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns empty string for empty input", () => {
|
|
81
|
+
expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("value()", () => {
|
|
86
|
+
test("returns the lexical (as-typed, normalized) form", () => {
|
|
87
|
+
realpathSync.mockImplementation((p: string) =>
|
|
88
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
89
|
+
);
|
|
90
|
+
// Even when the path resolves to a different canonical, value() stays lexical.
|
|
91
|
+
expect(AccessPath.forExternalDirectory("/tmp/x", cwd).value()).toBe(
|
|
92
|
+
"/tmp/x",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("normalizes the path against cwd", () => {
|
|
97
|
+
// A relative path becomes an absolute lexical value.
|
|
98
|
+
expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
|
|
99
|
+
"/projects/my-app/src/foo.ts",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("returns empty string for empty input", () => {
|
|
104
|
+
expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -70,7 +70,9 @@ describe("BashProgram", () => {
|
|
|
70
70
|
it("returns absolute paths resolving outside cwd", async () => {
|
|
71
71
|
const program = await BashProgram.parse("cat /etc/hosts", cwd);
|
|
72
72
|
// Subset matcher: the path is normalized before comparison.
|
|
73
|
-
expect(program.externalPaths()).toContain(
|
|
73
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
74
|
+
"/etc/hosts",
|
|
75
|
+
);
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
it("excludes paths within cwd", async () => {
|
|
@@ -95,7 +97,9 @@ describe("BashProgram", () => {
|
|
|
95
97
|
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
96
98
|
cwd,
|
|
97
99
|
);
|
|
98
|
-
expect(program.externalPaths()).toContain(
|
|
100
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
101
|
+
"/projects/etc/passwd",
|
|
102
|
+
);
|
|
99
103
|
});
|
|
100
104
|
|
|
101
105
|
it("folds a cd that is not the first command", async () => {
|
|
@@ -112,13 +116,17 @@ describe("BashProgram", () => {
|
|
|
112
116
|
// `cd a &` runs in a subshell, so it must not update the running
|
|
113
117
|
// directory; ../b resolves against cwd and escapes.
|
|
114
118
|
const program = await BashProgram.parse("cd a & cat ../b", cwd);
|
|
115
|
-
expect(program.externalPaths()).toContain(
|
|
119
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
120
|
+
"/projects/b",
|
|
121
|
+
);
|
|
116
122
|
});
|
|
117
123
|
|
|
118
124
|
it("does not fold a cd inside a pipeline", async () => {
|
|
119
125
|
// Pipeline members run in subshells; the cd must not leak.
|
|
120
126
|
const program = await BashProgram.parse("cd nested | cat ../b", cwd);
|
|
121
|
-
expect(program.externalPaths()).toContain(
|
|
127
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
128
|
+
"/projects/b",
|
|
129
|
+
);
|
|
122
130
|
});
|
|
123
131
|
|
|
124
132
|
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
@@ -130,7 +138,9 @@ describe("BashProgram", () => {
|
|
|
130
138
|
it("does not leak a subshell cd to following commands", async () => {
|
|
131
139
|
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
132
140
|
const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
|
|
133
|
-
expect(program.externalPaths()).toContain(
|
|
141
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
142
|
+
"/projects/y",
|
|
143
|
+
);
|
|
134
144
|
});
|
|
135
145
|
|
|
136
146
|
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
@@ -152,14 +162,18 @@ describe("BashProgram", () => {
|
|
|
152
162
|
"echo $(cd q && cat ../r)",
|
|
153
163
|
cwd,
|
|
154
164
|
);
|
|
155
|
-
expect(program.externalPaths()).toContain(
|
|
165
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
166
|
+
"/projects/r",
|
|
167
|
+
);
|
|
156
168
|
});
|
|
157
169
|
|
|
158
170
|
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
159
171
|
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
160
172
|
// so it is flagged (least-privilege).
|
|
161
173
|
const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
|
|
162
|
-
expect(program.externalPaths()).toContain(
|
|
174
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
175
|
+
"/projects/x",
|
|
176
|
+
);
|
|
163
177
|
});
|
|
164
178
|
|
|
165
179
|
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
@@ -169,7 +183,7 @@ describe("BashProgram", () => {
|
|
|
169
183
|
'cd "$DIR" && cat src/../within.txt',
|
|
170
184
|
cwd,
|
|
171
185
|
);
|
|
172
|
-
expect(program.externalPaths()).toContain(
|
|
186
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
173
187
|
"/projects/my-app/within.txt",
|
|
174
188
|
);
|
|
175
189
|
});
|
|
@@ -186,7 +200,9 @@ describe("BashProgram", () => {
|
|
|
186
200
|
|
|
187
201
|
it("treats `cd -` as an unknown effective directory", async () => {
|
|
188
202
|
const program = await BashProgram.parse("cd - && cat ../x", cwd);
|
|
189
|
-
expect(program.externalPaths()).toContain(
|
|
203
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
204
|
+
"/projects/x",
|
|
205
|
+
);
|
|
190
206
|
});
|
|
191
207
|
|
|
192
208
|
it("recovers a known base when a later cd is absolute", async () => {
|
|
@@ -233,7 +249,9 @@ describe("BashProgram", () => {
|
|
|
233
249
|
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
234
250
|
cwd,
|
|
235
251
|
);
|
|
236
|
-
expect(program.externalPaths()).toContain(
|
|
252
|
+
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
253
|
+
"/projects/x",
|
|
254
|
+
);
|
|
237
255
|
});
|
|
238
256
|
|
|
239
257
|
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
@@ -262,7 +280,7 @@ describe("BashProgram", () => {
|
|
|
262
280
|
"cat /projects/my-app/link/hosts",
|
|
263
281
|
cwd,
|
|
264
282
|
);
|
|
265
|
-
const external = program.externalPaths();
|
|
283
|
+
const external = program.externalPaths().map((p) => p.value());
|
|
266
284
|
expect(external).toContain("/projects/my-app/link/hosts");
|
|
267
285
|
expect(external).not.toContain("/etc/hosts");
|
|
268
286
|
});
|
|
@@ -426,7 +444,7 @@ describe("BashProgram", () => {
|
|
|
426
444
|
".env",
|
|
427
445
|
"/etc/hosts",
|
|
428
446
|
]);
|
|
429
|
-
const external = program.externalPaths();
|
|
447
|
+
const external = program.externalPaths().map((p) => p.value());
|
|
430
448
|
expect(external).toContain("/etc/hosts");
|
|
431
449
|
expect(external).not.toContain(".env");
|
|
432
450
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
3
|
+
import {
|
|
4
|
+
resolveExternalDirectoryPolicy,
|
|
5
|
+
selectUncoveredExternalPaths,
|
|
6
|
+
} from "#src/handlers/gates/external-directory-policy";
|
|
7
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
8
|
+
|
|
9
|
+
import { makeResolver } from "#test/helpers/gate-fixtures";
|
|
10
|
+
|
|
11
|
+
const cwd = "/test/project";
|
|
12
|
+
|
|
13
|
+
function makeCheckResult(
|
|
14
|
+
state: "allow" | "deny" | "ask",
|
|
15
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
16
|
+
): PermissionCheckResult {
|
|
17
|
+
return {
|
|
18
|
+
state,
|
|
19
|
+
toolName: "external_directory",
|
|
20
|
+
source: "special",
|
|
21
|
+
origin: "builtin",
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("resolveExternalDirectoryPolicy", () => {
|
|
27
|
+
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
|
|
28
|
+
const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
|
|
29
|
+
const resolver = makeResolver(makeCheckResult("ask"));
|
|
30
|
+
|
|
31
|
+
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
|
|
32
|
+
|
|
33
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
34
|
+
path.matchValues(),
|
|
35
|
+
undefined,
|
|
36
|
+
"external_directory",
|
|
37
|
+
);
|
|
38
|
+
expect(result).toEqual(makeCheckResult("ask"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("threads the agent name through to the resolver", () => {
|
|
42
|
+
const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
|
|
43
|
+
const resolver = makeResolver(makeCheckResult("allow"));
|
|
44
|
+
|
|
45
|
+
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
|
|
46
|
+
|
|
47
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
48
|
+
path.matchValues(),
|
|
49
|
+
"reviewer",
|
|
50
|
+
"external_directory",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("selectUncoveredExternalPaths", () => {
|
|
56
|
+
it("returns no uncovered paths when every path resolves to allow", () => {
|
|
57
|
+
const paths = [
|
|
58
|
+
AccessPath.forExternalDirectory("/outside/a.ts", cwd),
|
|
59
|
+
AccessPath.forExternalDirectory("/outside/b.ts", cwd),
|
|
60
|
+
];
|
|
61
|
+
const resolver = makeResolver(makeCheckResult("allow"));
|
|
62
|
+
|
|
63
|
+
const { uncovered, worstCheck } = selectUncoveredExternalPaths(
|
|
64
|
+
paths,
|
|
65
|
+
resolver,
|
|
66
|
+
undefined,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(uncovered).toEqual([]);
|
|
70
|
+
expect(worstCheck).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("collects only paths whose resolved state is not allow", () => {
|
|
74
|
+
const allowed = AccessPath.forExternalDirectory("/outside/ok.ts", cwd);
|
|
75
|
+
const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
|
|
76
|
+
const resolver = makeResolver();
|
|
77
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
78
|
+
(values: readonly string[]) =>
|
|
79
|
+
values.includes("/outside/ok.ts")
|
|
80
|
+
? makeCheckResult("allow")
|
|
81
|
+
: makeCheckResult("ask"),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const { uncovered } = selectUncoveredExternalPaths(
|
|
85
|
+
[allowed, asked],
|
|
86
|
+
resolver,
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
|
|
94
|
+
const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
|
|
95
|
+
const denied = AccessPath.forExternalDirectory("/outside/deny.ts", cwd);
|
|
96
|
+
const resolver = makeResolver();
|
|
97
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
98
|
+
(values: readonly string[]) =>
|
|
99
|
+
values.includes("/outside/deny.ts")
|
|
100
|
+
? makeCheckResult("deny")
|
|
101
|
+
: makeCheckResult("ask"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const { worstCheck } = selectUncoveredExternalPaths(
|
|
105
|
+
[asked, denied],
|
|
106
|
+
resolver,
|
|
107
|
+
undefined,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(worstCheck?.state).toBe("deny");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
3
4
|
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
4
5
|
|
|
5
6
|
import {
|
|
@@ -24,7 +25,7 @@ function makeMockBashProgram() {
|
|
|
24
25
|
return {
|
|
25
26
|
commands: vi.fn<() => []>(() => []),
|
|
26
27
|
pathRuleCandidates: vi.fn<() => []>(() => []),
|
|
27
|
-
externalPaths: vi.fn<() => []>(() => []),
|
|
28
|
+
externalPaths: vi.fn<() => AccessPath[]>(() => []),
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
|
package/test/path-utils.test.ts
CHANGED
|
@@ -22,7 +22,6 @@ vi.mock("node:fs", () => ({
|
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalNormalizePathForComparison,
|
|
25
|
-
getExternalDirectoryPolicyValues,
|
|
26
25
|
getPathBearingToolPath,
|
|
27
26
|
getPathPolicyValues,
|
|
28
27
|
getToolInputPath,
|
|
@@ -615,36 +614,3 @@ describe("getPathPolicyValues", () => {
|
|
|
615
614
|
expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
|
|
616
615
|
});
|
|
617
616
|
});
|
|
618
|
-
|
|
619
|
-
describe("getExternalDirectoryPolicyValues", () => {
|
|
620
|
-
const cwd = "/projects/my-app";
|
|
621
|
-
|
|
622
|
-
beforeEach(() => {
|
|
623
|
-
realpathSync.mockReset();
|
|
624
|
-
realpathSync.mockImplementation((p: string) => p);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
test("adds the symlink-resolved alias alongside the typed path", () => {
|
|
628
|
-
// /tmp -> /private/tmp (the macOS symlink from the bug report).
|
|
629
|
-
realpathSync.mockImplementation((p: string) =>
|
|
630
|
-
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
631
|
-
);
|
|
632
|
-
expect(getExternalDirectoryPolicyValues("/tmp/x", cwd)).toEqual([
|
|
633
|
-
"/tmp/x",
|
|
634
|
-
"/private/tmp/x",
|
|
635
|
-
]);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
test("dedups when the canonical form equals the lexical form", () => {
|
|
639
|
-
expect(getExternalDirectoryPolicyValues("/etc/hosts", cwd)).toEqual([
|
|
640
|
-
"/etc/hosts",
|
|
641
|
-
]);
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
|
645
|
-
expect(getExternalDirectoryPolicyValues("src/foo.ts", cwd)).toEqual([
|
|
646
|
-
"/projects/my-app/src/foo.ts",
|
|
647
|
-
"src/foo.ts",
|
|
648
|
-
]);
|
|
649
|
-
});
|
|
650
|
-
});
|