@gotgenes/pi-permission-system 16.2.1 → 17.0.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 +19 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/cwd-projection.ts +16 -14
- package/src/handlers/gates/bash-path.ts +13 -13
- package/src/handlers/gates/external-directory.ts +3 -4
- package/src/handlers/gates/path.ts +11 -7
- package/test/access-intent/access-path.test.ts +83 -23
- package/test/access-intent/bash/program.test.ts +49 -17
- package/test/handlers/gates/bash-external-directory.test.ts +0 -1
- package/test/handlers/gates/bash-path.test.ts +8 -8
- package/test/handlers/gates/external-directory-policy.test.ts +10 -18
- package/test/handlers/gates/path.test.ts +43 -12
- package/test/helpers/gate-fixtures.ts +1 -2
- package/test/permission-resolver.test.ts +5 -32
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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
|
+
## [17.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.1...pi-permission-system-v17.0.0) (2026-06-27)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of bash path-rule tokens, so a path rule can fire on a symlink alias it previously missed, changing decisions on upgrade with no config edit.
|
|
14
|
+
* **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of a tool's file path. A path rule that previously matched only the as-typed spelling now also matches when the path resolves through a symlink to a target the pattern covers, which can change allow/deny decisions on upgrade with no config edit.
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** add AccessPath.forPath and forLiteral factories ([4323cae](https://github.com/gotgenes/pi-packages/commit/4323cae859907dd62d9cb401c194443717ab752c)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
|
|
19
|
+
* **pi-permission-system:** match the canonical form on the bash-path gate ([6ce0c06](https://github.com/gotgenes/pi-packages/commit/6ce0c06b35c12eca32ffce5d4fe974c2f2dee393))
|
|
20
|
+
* **pi-permission-system:** match the canonical form on the path tool gate ([869ca76](https://github.com/gotgenes/pi-packages/commit/869ca761cf3d7a8367eb932106065a866d996ce4))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** document canonical path-surface matching ([9606dce](https://github.com/gotgenes/pi-packages/commit/9606dce744bc1f95bd8424d2bb115a376d9a1191)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
|
|
26
|
+
|
|
8
27
|
## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.0...pi-permission-system-v16.2.1) (2026-06-27)
|
|
9
28
|
|
|
10
29
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
17
17
|
- **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs
|
|
18
18
|
- **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`)
|
|
19
19
|
- **Gates MCP and skill access** at server, tool, and skill-name granularity
|
|
20
|
-
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
|
|
20
|
+
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
22
|
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
|
|
23
23
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
@@ -69,6 +69,7 @@ See [docs/session-approvals.md](docs/session-approvals.md) for details on sessio
|
|
|
69
69
|
|
|
70
70
|
The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
|
|
71
71
|
Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
|
|
72
|
+
A `path` pattern matches both the path as the agent references it and its canonical (symlink-resolved) form, so a deny still fires when a symlink aliases a sensitive target.
|
|
72
73
|
|
|
73
74
|
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
74
75
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
package/package.json
CHANGED
|
@@ -17,8 +17,9 @@ export interface ToolAccessIntent {
|
|
|
17
17
|
/**
|
|
18
18
|
* Precomputed equivalent policy values for a path-shaped surface.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
20
|
+
* Not gate-emitted: the resolver produces it internally by unwrapping an
|
|
21
|
+
* `access-path` intent via `matchValues()`, keeping the low-level manager
|
|
22
|
+
* string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
|
|
22
23
|
*/
|
|
23
24
|
export interface PathValuesAccessIntent {
|
|
24
25
|
kind: "path-values";
|
|
@@ -31,9 +32,9 @@ export interface PathValuesAccessIntent {
|
|
|
31
32
|
/**
|
|
32
33
|
* An `AccessPath` value object for a path-shaped surface.
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
* resolver as a first-class variant so the
|
|
36
|
-
* for `matchValues()` (Tell-Don't-Ask).
|
|
35
|
+
* Emitted by every path gate (the `path` and `external_directory` surfaces);
|
|
36
|
+
* lets `AccessPath` flow into the resolver as a first-class variant so the
|
|
37
|
+
* resolver — not the gate — asks it for `matchValues()` (Tell-Don't-Ask).
|
|
37
38
|
*/
|
|
38
39
|
export interface AccessPathAccessIntent {
|
|
39
40
|
kind: "access-path";
|
|
@@ -42,11 +43,8 @@ export interface AccessPathAccessIntent {
|
|
|
42
43
|
agentName?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** What a gate emits —
|
|
46
|
-
export type AccessIntent =
|
|
47
|
-
| ToolAccessIntent
|
|
48
|
-
| PathValuesAccessIntent
|
|
49
|
-
| AccessPathAccessIntent;
|
|
46
|
+
/** What a gate emits — a raw tool input or an `AccessPath`. */
|
|
47
|
+
export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
|
|
50
48
|
|
|
51
49
|
/**
|
|
52
50
|
* What the manager consumes — the `access-path` variant has already been
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
* - {@link value} returns `string` — the lexical absolute form, for display,
|
|
21
21
|
* approval patterns, decision values, and logs.
|
|
22
22
|
*
|
|
23
|
-
* Construct via {@link
|
|
23
|
+
* Construct via {@link forPath} (resolved, with optional cd-folded base) or
|
|
24
|
+
* {@link forLiteral} (literal-only, for an unknown base); the constructor is
|
|
25
|
+
* private.
|
|
24
26
|
*/
|
|
25
27
|
export class AccessPath {
|
|
26
28
|
private constructor(
|
|
@@ -65,24 +67,45 @@ export class AccessPath {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
|
-
* Build an `AccessPath` for a tool-input or bash-token path resolved against
|
|
69
|
-
* `cwd
|
|
70
|
+
* Build an `AccessPath` for a tool-input or bash-token path, resolved against
|
|
71
|
+
* `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
|
|
70
72
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - `
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
73
|
+
* Serves every path surface: the tool path gate, the tool external-directory
|
|
74
|
+
* gate, and the bash path/external-directory gates (which pass a cd-resolved
|
|
75
|
+
* `resolveBase`).
|
|
76
|
+
*
|
|
77
|
+
* - `matchValues()` returns the lexical alias union from `getPathPolicyValues`
|
|
78
|
+
* plus the canonical alias from `canonicalNormalizePathForComparison`
|
|
79
|
+
* (#418), so a config pattern on either the typed or symlink-resolved form
|
|
80
|
+
* matches.
|
|
81
|
+
* - `boundaryValue()` returns
|
|
82
|
+
* `canonicalNormalizePathForComparison(pathValue, resolveBase)`, which is
|
|
83
|
+
* win32-lowercased (#382) — do not substitute a raw `canonicalizePath`
|
|
84
|
+
* output here.
|
|
85
|
+
* - `value()` returns `normalizePathForComparison(pathValue, resolveBase)`,
|
|
86
|
+
* the absolute lexical form.
|
|
80
87
|
*/
|
|
81
|
-
static
|
|
88
|
+
static forPath(
|
|
89
|
+
pathValue: string,
|
|
90
|
+
options: { cwd: string; resolveBase?: string },
|
|
91
|
+
): AccessPath {
|
|
92
|
+
const { cwd, resolveBase = cwd } = options;
|
|
82
93
|
return new AccessPath(
|
|
83
|
-
normalizePathForComparison(pathValue,
|
|
84
|
-
getPathPolicyValues(pathValue, { cwd }),
|
|
85
|
-
canonicalNormalizePathForComparison(pathValue,
|
|
94
|
+
normalizePathForComparison(pathValue, resolveBase),
|
|
95
|
+
getPathPolicyValues(pathValue, { cwd, resolveBase }),
|
|
96
|
+
canonicalNormalizePathForComparison(pathValue, resolveBase),
|
|
86
97
|
);
|
|
87
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a literal-only `AccessPath` for a path whose effective base is
|
|
102
|
+
* unknown (a relative bash token after a non-literal `cd`).
|
|
103
|
+
*
|
|
104
|
+
* Carries no canonical alias and no absolute resolution — `matchValues()` is
|
|
105
|
+
* `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
|
|
106
|
+
* spurious absolute or symlink-resolved rule can match (#393).
|
|
107
|
+
*/
|
|
108
|
+
static forLiteral(literal: string): AccessPath {
|
|
109
|
+
return new AccessPath(literal, literal ? [literal] : [], "");
|
|
110
|
+
}
|
|
88
111
|
}
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
} from "#src/access-intent/bash/token-collection";
|
|
18
18
|
import { canonicalizePath } from "#src/canonicalize-path";
|
|
19
19
|
import {
|
|
20
|
-
getPathPolicyValues,
|
|
21
20
|
isPathWithinDirectory,
|
|
22
21
|
isSafeSystemPath,
|
|
23
22
|
normalizePathForComparison,
|
|
@@ -54,8 +53,8 @@ interface PathCandidate {
|
|
|
54
53
|
export interface BashPathRuleCandidate {
|
|
55
54
|
/** Raw path-like token shown in prompts, logs, and session approvals. */
|
|
56
55
|
readonly token: string;
|
|
57
|
-
/**
|
|
58
|
-
readonly
|
|
56
|
+
/** The path's lexical and canonical forms for permission policy matching. */
|
|
57
|
+
readonly path: AccessPath;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
// ── Walk-time constants ──────────────────────────────────────────────────────
|
|
@@ -379,18 +378,20 @@ function isRelativeCandidate(candidate: string): boolean {
|
|
|
379
378
|
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
380
379
|
}
|
|
381
380
|
|
|
382
|
-
function
|
|
381
|
+
function buildRuleCandidatePath(
|
|
383
382
|
candidate: string,
|
|
384
383
|
base: EffectiveBase,
|
|
385
384
|
cwd: string,
|
|
386
|
-
):
|
|
385
|
+
): AccessPath {
|
|
386
|
+
// An unknown base + relative candidate stays literal-only: a resolved
|
|
387
|
+
// absolute or canonical alias would resolve against the wrong directory and
|
|
388
|
+
// could spuriously match a rule (#393).
|
|
387
389
|
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
388
|
-
|
|
389
|
-
return literal ? [literal] : [];
|
|
390
|
+
return AccessPath.forLiteral(normalizePathPolicyLiteral(candidate));
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
393
|
-
return
|
|
394
|
+
return AccessPath.forPath(candidate, { cwd, resolveBase });
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
// ── Projection functions ─────────────────────────────────────────────────────
|
|
@@ -435,7 +436,7 @@ export function projectExternalPaths(
|
|
|
435
436
|
seen.add(canonical);
|
|
436
437
|
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
437
438
|
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
438
|
-
externalPaths.push(AccessPath.
|
|
439
|
+
externalPaths.push(AccessPath.forPath(lexical, { cwd }));
|
|
439
440
|
}
|
|
440
441
|
continue;
|
|
441
442
|
}
|
|
@@ -457,7 +458,7 @@ export function projectExternalPaths(
|
|
|
457
458
|
seen.add(canonical);
|
|
458
459
|
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
459
460
|
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
460
|
-
externalPaths.push(AccessPath.
|
|
461
|
+
externalPaths.push(AccessPath.forPath(lexical, { cwd }));
|
|
461
462
|
}
|
|
462
463
|
}
|
|
463
464
|
|
|
@@ -485,13 +486,14 @@ export function projectRuleCandidates(
|
|
|
485
486
|
const candidate = classifyTokenAsRuleCandidate(token);
|
|
486
487
|
if (!candidate) continue;
|
|
487
488
|
|
|
488
|
-
const
|
|
489
|
-
|
|
489
|
+
const path = buildRuleCandidatePath(candidate, base, cwd);
|
|
490
|
+
const matchValues = path.matchValues();
|
|
491
|
+
if (matchValues.length === 0) continue;
|
|
490
492
|
|
|
491
|
-
const key =
|
|
493
|
+
const key = matchValues.join("\0");
|
|
492
494
|
if (seen.has(key)) continue;
|
|
493
495
|
seen.add(key);
|
|
494
|
-
result.push({ token: candidate,
|
|
496
|
+
result.push({ token: candidate, path });
|
|
495
497
|
}
|
|
496
498
|
|
|
497
499
|
return result;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { SessionApproval } from "#src/session-approval";
|
|
@@ -41,20 +42,20 @@ export function describeBashPathGate(
|
|
|
41
42
|
const tokens = candidates.map(({ token }) => token);
|
|
42
43
|
|
|
43
44
|
// Tokens whose resolved state needs a check (deny/ask), paired with the raw
|
|
44
|
-
// token (prompt/decision display) and its
|
|
45
|
-
//
|
|
45
|
+
// token (prompt/decision display) and its `AccessPath` (whose `value()` is
|
|
46
|
+
// the lexical absolute path the approval pattern is derived from).
|
|
46
47
|
const uncovered: Array<{
|
|
47
48
|
token: string;
|
|
48
|
-
|
|
49
|
+
path: AccessPath;
|
|
49
50
|
check: PermissionCheckResult;
|
|
50
51
|
}> = [];
|
|
51
52
|
let allSessionCovered = true;
|
|
52
53
|
|
|
53
|
-
for (const { token,
|
|
54
|
+
for (const { token, path } of candidates) {
|
|
54
55
|
const check = resolver.resolve({
|
|
55
|
-
kind: "path
|
|
56
|
+
kind: "access-path",
|
|
56
57
|
surface: "path",
|
|
57
|
-
|
|
58
|
+
path,
|
|
58
59
|
agentName: tcc.agentName ?? undefined,
|
|
59
60
|
});
|
|
60
61
|
|
|
@@ -71,11 +72,11 @@ export function describeBashPathGate(
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
if (check.state === "deny") {
|
|
74
|
-
uncovered.push({ token,
|
|
75
|
+
uncovered.push({ token, path, check });
|
|
75
76
|
break; // Short-circuit on deny.
|
|
76
77
|
}
|
|
77
78
|
if (check.state === "ask") {
|
|
78
|
-
uncovered.push({ token,
|
|
79
|
+
uncovered.push({ token, path, check });
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -108,11 +109,10 @@ export function describeBashPathGate(
|
|
|
108
109
|
// All tokens evaluate to allow — no restriction.
|
|
109
110
|
if (!worstCheck || !worstToken || !worstEntry) return null;
|
|
110
111
|
|
|
111
|
-
// Derive the pattern from the
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
const pattern = deriveApprovalPattern(approvalBase);
|
|
112
|
+
// Derive the pattern from the lexical absolute form (the cd-aware resolved
|
|
113
|
+
// path), so it matches the values a later call produces. For an unknown base
|
|
114
|
+
// (`forLiteral`) `value()` is the raw token.
|
|
115
|
+
const pattern = deriveApprovalPattern(worstEntry.path.value());
|
|
116
116
|
const askMessage = formatPathAskPrompt(
|
|
117
117
|
tcc.toolName,
|
|
118
118
|
worstToken,
|
|
@@ -42,10 +42,9 @@ 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 accessPath = AccessPath.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
45
|
+
const accessPath = AccessPath.forPath(externalDirectoryPath, {
|
|
46
|
+
cwd: tcc.cwd,
|
|
47
|
+
});
|
|
49
48
|
const canonicalExtPath = accessPath.boundaryValue();
|
|
50
49
|
|
|
51
50
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import { getToolInputPath } from "#src/path-utils";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -22,10 +23,14 @@ export function describePathGate(
|
|
|
22
23
|
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
|
23
24
|
if (!filePath) return null;
|
|
24
25
|
|
|
26
|
+
// Emit an access-path intent so the resolver matches the lexical aliases
|
|
27
|
+
// *and* the canonical (symlink-resolved) form, the same set
|
|
28
|
+
// `external_directory` matches (#418, #486).
|
|
29
|
+
const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
|
|
25
30
|
const check = resolver.resolve({
|
|
26
|
-
kind: "
|
|
31
|
+
kind: "access-path",
|
|
27
32
|
surface: "path",
|
|
28
|
-
|
|
33
|
+
path: accessPath,
|
|
29
34
|
agentName: tcc.agentName ?? undefined,
|
|
30
35
|
});
|
|
31
36
|
|
|
@@ -36,10 +41,9 @@ export function describePathGate(
|
|
|
36
41
|
// "path" key should not trigger path-level prompts (#58).
|
|
37
42
|
if (check.matchedPattern === undefined) return null;
|
|
38
43
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
const pattern = deriveApprovalPattern(approvalPath);
|
|
44
|
+
// Derive the approval pattern from the lexical absolute form so it matches
|
|
45
|
+
// the policy values a later call produces.
|
|
46
|
+
const pattern = deriveApprovalPattern(accessPath.value());
|
|
43
47
|
|
|
44
48
|
const descriptor: GateDescriptor = {
|
|
45
49
|
surface: "path",
|
|
@@ -21,7 +21,7 @@ vi.mock("node:fs", () => ({
|
|
|
21
21
|
|
|
22
22
|
import { AccessPath } from "#src/access-intent/access-path";
|
|
23
23
|
|
|
24
|
-
describe("AccessPath.
|
|
24
|
+
describe("AccessPath.forPath", () => {
|
|
25
25
|
const cwd = "/projects/my-app";
|
|
26
26
|
|
|
27
27
|
beforeEach(() => {
|
|
@@ -35,30 +35,59 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
35
35
|
realpathSync.mockImplementation((p: string) =>
|
|
36
36
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
37
37
|
);
|
|
38
|
-
expect(
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).matchValues()).toEqual([
|
|
39
|
+
"/tmp/x",
|
|
40
|
+
"/private/tmp/x",
|
|
41
|
+
]);
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
test("deduplicates when the canonical form equals the lexical form", () => {
|
|
44
|
-
expect(
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
expect(AccessPath.forPath("/etc/hosts", { cwd }).matchValues()).toEqual([
|
|
46
|
+
"/etc/hosts",
|
|
47
|
+
]);
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
|
50
|
-
expect(
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
expect(AccessPath.forPath("src/foo.ts", { cwd }).matchValues()).toEqual([
|
|
52
|
+
"/projects/my-app/src/foo.ts",
|
|
53
|
+
"src/foo.ts",
|
|
54
|
+
]);
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
test("includes only the lexical aliases when canonical is empty", () => {
|
|
56
58
|
// Force canonicalizePath to return the original (no-op symlink resolution
|
|
57
59
|
// effectively means canonical === lexical, handled by dedup).
|
|
58
60
|
expect(
|
|
59
|
-
AccessPath.
|
|
61
|
+
AccessPath.forPath("/etc/hosts", { cwd }).matchValues(),
|
|
60
62
|
).not.toHaveLength(0);
|
|
61
63
|
});
|
|
64
|
+
|
|
65
|
+
test("resolves a relative token against an explicit resolveBase", () => {
|
|
66
|
+
// The cd-folded effective base differs from cwd (the bash-path case).
|
|
67
|
+
expect(
|
|
68
|
+
AccessPath.forPath("foo.ts", {
|
|
69
|
+
cwd,
|
|
70
|
+
resolveBase: "/projects/my-app/sub",
|
|
71
|
+
}).matchValues(),
|
|
72
|
+
).toEqual(["/projects/my-app/sub/foo.ts", "sub/foo.ts", "foo.ts"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("adds the canonical alias resolved against resolveBase", () => {
|
|
76
|
+
realpathSync.mockImplementation((p: string) =>
|
|
77
|
+
p === "/projects/my-app/sub/foo.ts" ? "/real/foo.ts" : p,
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
AccessPath.forPath("foo.ts", {
|
|
81
|
+
cwd,
|
|
82
|
+
resolveBase: "/projects/my-app/sub",
|
|
83
|
+
}).matchValues(),
|
|
84
|
+
).toEqual([
|
|
85
|
+
"/projects/my-app/sub/foo.ts",
|
|
86
|
+
"sub/foo.ts",
|
|
87
|
+
"foo.ts",
|
|
88
|
+
"/real/foo.ts",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
62
91
|
});
|
|
63
92
|
|
|
64
93
|
describe("boundaryValue()", () => {
|
|
@@ -66,19 +95,19 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
66
95
|
realpathSync.mockImplementation((p: string) =>
|
|
67
96
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
68
97
|
);
|
|
69
|
-
expect(
|
|
70
|
-
|
|
71
|
-
)
|
|
98
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).boundaryValue()).toBe(
|
|
99
|
+
"/private/tmp/x",
|
|
100
|
+
);
|
|
72
101
|
});
|
|
73
102
|
|
|
74
103
|
test("returns the lexical form when path has no symlinks", () => {
|
|
75
|
-
expect(
|
|
76
|
-
|
|
77
|
-
)
|
|
104
|
+
expect(AccessPath.forPath("/etc/hosts", { cwd }).boundaryValue()).toBe(
|
|
105
|
+
"/etc/hosts",
|
|
106
|
+
);
|
|
78
107
|
});
|
|
79
108
|
|
|
80
109
|
test("returns empty string for empty input", () => {
|
|
81
|
-
expect(AccessPath.
|
|
110
|
+
expect(AccessPath.forPath("", { cwd }).boundaryValue()).toBe("");
|
|
82
111
|
});
|
|
83
112
|
});
|
|
84
113
|
|
|
@@ -88,20 +117,51 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
88
117
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
89
118
|
);
|
|
90
119
|
// Even when the path resolves to a different canonical, value() stays lexical.
|
|
91
|
-
expect(AccessPath.
|
|
92
|
-
"/tmp/x",
|
|
93
|
-
);
|
|
120
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).value()).toBe("/tmp/x");
|
|
94
121
|
});
|
|
95
122
|
|
|
96
123
|
test("normalizes the path against cwd", () => {
|
|
97
124
|
// A relative path becomes an absolute lexical value.
|
|
98
|
-
expect(AccessPath.
|
|
125
|
+
expect(AccessPath.forPath("src/foo.ts", { cwd }).value()).toBe(
|
|
99
126
|
"/projects/my-app/src/foo.ts",
|
|
100
127
|
);
|
|
101
128
|
});
|
|
102
129
|
|
|
130
|
+
test("normalizes a relative path against an explicit resolveBase", () => {
|
|
131
|
+
expect(
|
|
132
|
+
AccessPath.forPath("foo.ts", {
|
|
133
|
+
cwd,
|
|
134
|
+
resolveBase: "/projects/my-app/sub",
|
|
135
|
+
}).value(),
|
|
136
|
+
).toBe("/projects/my-app/sub/foo.ts");
|
|
137
|
+
});
|
|
138
|
+
|
|
103
139
|
test("returns empty string for empty input", () => {
|
|
104
|
-
expect(AccessPath.
|
|
140
|
+
expect(AccessPath.forPath("", { cwd }).value()).toBe("");
|
|
105
141
|
});
|
|
106
142
|
});
|
|
107
143
|
});
|
|
144
|
+
|
|
145
|
+
describe("AccessPath.forLiteral", () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
realpathSync.mockReset();
|
|
148
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("matchValues() carries only the literal — no canonical, no absolute", () => {
|
|
152
|
+
expect(AccessPath.forLiteral("foo.ts").matchValues()).toEqual(["foo.ts"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("boundaryValue() is empty (no outside-cwd notion for an unknown base)", () => {
|
|
156
|
+
expect(AccessPath.forLiteral("foo.ts").boundaryValue()).toBe("");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("value() returns the literal", () => {
|
|
160
|
+
expect(AccessPath.forLiteral("foo.ts").value()).toBe("foo.ts");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an empty literal yields no match values", () => {
|
|
164
|
+
expect(AccessPath.forLiteral("").matchValues()).toEqual([]);
|
|
165
|
+
expect(AccessPath.forLiteral("").value()).toBe("");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -16,14 +16,20 @@ describe("BashProgram", () => {
|
|
|
16
16
|
describe("pathRuleCandidates", () => {
|
|
17
17
|
const cwd = "/projects/my-app";
|
|
18
18
|
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
realpathSync.mockReset();
|
|
21
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
25
|
const program = await BashProgram.parse("cat src/foo.ts", cwd);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const candidates = program.pathRuleCandidates();
|
|
27
|
+
expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
|
|
28
|
+
expect(candidates[0].path.matchValues()).toEqual([
|
|
29
|
+
"/projects/my-app/src/foo.ts",
|
|
30
|
+
"src/foo.ts",
|
|
26
31
|
]);
|
|
32
|
+
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
@@ -34,14 +40,28 @@ describe("BashProgram", () => {
|
|
|
34
40
|
const fileCandidate = program
|
|
35
41
|
.pathRuleCandidates()
|
|
36
42
|
.find((candidate) => candidate.token === "src/file.txt");
|
|
37
|
-
expect(fileCandidate).toEqual(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
expect(fileCandidate?.path.matchValues()).toEqual([
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
]);
|
|
48
|
+
expect(fileCandidate?.path.value()).toBe(
|
|
49
|
+
"/projects/my-app/nested/src/file.txt",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("adds the canonical alias for a symlinked token (#486)", async () => {
|
|
54
|
+
// /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
|
|
55
|
+
realpathSync.mockImplementation((p: string) =>
|
|
56
|
+
p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
|
|
57
|
+
);
|
|
58
|
+
const program = await BashProgram.parse("cat src/foo.ts", cwd);
|
|
59
|
+
const candidate = program.pathRuleCandidates()[0];
|
|
60
|
+
expect(candidate.path.matchValues()).toEqual([
|
|
61
|
+
"/projects/my-app/src/foo.ts",
|
|
62
|
+
"src/foo.ts",
|
|
63
|
+
"/vault/foo.ts",
|
|
64
|
+
]);
|
|
45
65
|
});
|
|
46
66
|
|
|
47
67
|
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
@@ -52,10 +72,22 @@ describe("BashProgram", () => {
|
|
|
52
72
|
const fileCandidate = program
|
|
53
73
|
.pathRuleCandidates()
|
|
54
74
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
55
|
-
expect(fileCandidate).toEqual(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
76
|
+
expect(fileCandidate?.path.value()).toBe("src/foo.ts");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
|
|
80
|
+
// A canonical alias here would resolve against the wrong (unknown) base.
|
|
81
|
+
realpathSync.mockImplementation(() => "/somewhere/else");
|
|
82
|
+
const program = await BashProgram.parse(
|
|
83
|
+
'cd "$DIR" && cat src/foo.ts',
|
|
84
|
+
cwd,
|
|
85
|
+
);
|
|
86
|
+
const fileCandidate = program
|
|
87
|
+
.pathRuleCandidates()
|
|
88
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
89
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
90
|
+
expect(fileCandidate?.path.boundaryValue()).toBe("");
|
|
59
91
|
});
|
|
60
92
|
});
|
|
61
93
|
|
|
@@ -44,7 +44,6 @@ function makeCheckResult(
|
|
|
44
44
|
/** Extract the policy match values a resolve(intent) call carries. */
|
|
45
45
|
function intentValues(intent: AccessIntent): readonly string[] {
|
|
46
46
|
if (intent.kind === "access-path") return intent.path.matchValues();
|
|
47
|
-
if (intent.kind === "path-values") return intent.values;
|
|
48
47
|
return [];
|
|
49
48
|
}
|
|
50
49
|
|
|
@@ -9,6 +9,7 @@ vi.mock("node:os", () => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
12
13
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
13
14
|
import { describeBashPathGate } from "#src/handlers/gates/bash-path";
|
|
14
15
|
import type {
|
|
@@ -229,13 +230,12 @@ describe("describeBashPathGate", () => {
|
|
|
229
230
|
)) as GateDescriptor;
|
|
230
231
|
|
|
231
232
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
232
|
-
kind: "path
|
|
233
|
+
kind: "access-path",
|
|
233
234
|
surface: "path",
|
|
234
|
-
|
|
235
|
-
"/test/project
|
|
236
|
-
"
|
|
237
|
-
|
|
238
|
-
],
|
|
235
|
+
path: AccessPath.forPath("src/file.txt", {
|
|
236
|
+
cwd: "/test/project",
|
|
237
|
+
resolveBase: "/test/project/nested",
|
|
238
|
+
}),
|
|
239
239
|
agentName: undefined,
|
|
240
240
|
});
|
|
241
241
|
// The raw token drives the prompt, denial context, and session approval.
|
|
@@ -256,9 +256,9 @@ describe("describeBashPathGate", () => {
|
|
|
256
256
|
);
|
|
257
257
|
|
|
258
258
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
259
|
-
kind: "path
|
|
259
|
+
kind: "access-path",
|
|
260
260
|
surface: "path",
|
|
261
|
-
|
|
261
|
+
path: AccessPath.forLiteral("src/foo.ts"),
|
|
262
262
|
agentName: undefined,
|
|
263
263
|
});
|
|
264
264
|
});
|
|
@@ -25,7 +25,7 @@ function makeCheckResult(
|
|
|
25
25
|
|
|
26
26
|
describe("resolveExternalDirectoryPolicy", () => {
|
|
27
27
|
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
|
|
28
|
-
const path = AccessPath.
|
|
28
|
+
const path = AccessPath.forPath("/outside/a.ts", { cwd });
|
|
29
29
|
const resolver = makeResolver(makeCheckResult("ask"));
|
|
30
30
|
|
|
31
31
|
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
|
|
@@ -40,7 +40,7 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it("threads the agent name through to the resolver", () => {
|
|
43
|
-
const path = AccessPath.
|
|
43
|
+
const path = AccessPath.forPath("/outside/a.ts", { cwd });
|
|
44
44
|
const resolver = makeResolver(makeCheckResult("allow"));
|
|
45
45
|
|
|
46
46
|
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
|
|
@@ -57,8 +57,8 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
57
57
|
describe("selectUncoveredExternalPaths", () => {
|
|
58
58
|
it("returns no uncovered paths when every path resolves to allow", () => {
|
|
59
59
|
const paths = [
|
|
60
|
-
AccessPath.
|
|
61
|
-
AccessPath.
|
|
60
|
+
AccessPath.forPath("/outside/a.ts", { cwd }),
|
|
61
|
+
AccessPath.forPath("/outside/b.ts", { cwd }),
|
|
62
62
|
];
|
|
63
63
|
const resolver = makeResolver(makeCheckResult("allow"));
|
|
64
64
|
|
|
@@ -73,16 +73,12 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it("collects only paths whose resolved state is not allow", () => {
|
|
76
|
-
const allowed = AccessPath.
|
|
77
|
-
const asked = AccessPath.
|
|
76
|
+
const allowed = AccessPath.forPath("/outside/ok.ts", { cwd });
|
|
77
|
+
const asked = AccessPath.forPath("/outside/ask.ts", { cwd });
|
|
78
78
|
const resolver = makeResolver();
|
|
79
79
|
resolver.resolve.mockImplementation((intent) => {
|
|
80
80
|
const values =
|
|
81
|
-
intent.kind === "access-path"
|
|
82
|
-
? intent.path.matchValues()
|
|
83
|
-
: intent.kind === "path-values"
|
|
84
|
-
? intent.values
|
|
85
|
-
: [];
|
|
81
|
+
intent.kind === "access-path" ? intent.path.matchValues() : [];
|
|
86
82
|
return values.includes("/outside/ok.ts")
|
|
87
83
|
? makeCheckResult("allow")
|
|
88
84
|
: makeCheckResult("ask");
|
|
@@ -98,16 +94,12 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
98
94
|
});
|
|
99
95
|
|
|
100
96
|
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
|
|
101
|
-
const asked = AccessPath.
|
|
102
|
-
const denied = AccessPath.
|
|
97
|
+
const asked = AccessPath.forPath("/outside/ask.ts", { cwd });
|
|
98
|
+
const denied = AccessPath.forPath("/outside/deny.ts", { cwd });
|
|
103
99
|
const resolver = makeResolver();
|
|
104
100
|
resolver.resolve.mockImplementation((intent) => {
|
|
105
101
|
const values =
|
|
106
|
-
intent.kind === "access-path"
|
|
107
|
-
? intent.path.matchValues()
|
|
108
|
-
: intent.kind === "path-values"
|
|
109
|
-
? intent.values
|
|
110
|
-
: [];
|
|
102
|
+
intent.kind === "access-path" ? intent.path.matchValues() : [];
|
|
111
103
|
return values.includes("/outside/deny.ts")
|
|
112
104
|
? makeCheckResult("deny")
|
|
113
105
|
: makeCheckResult("ask");
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
4
|
+
// Default implementation is identity — lexical tests are unaffected.
|
|
5
|
+
const realpathSync = vi.hoisted(() =>
|
|
6
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
7
|
+
);
|
|
8
|
+
vi.mock("node:fs", () => ({
|
|
9
|
+
realpathSync,
|
|
10
|
+
default: { realpathSync },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
3
14
|
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
4
15
|
import { isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
5
16
|
import { describePathGate } from "#src/handlers/gates/path";
|
|
@@ -27,6 +38,11 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
27
38
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
28
39
|
|
|
29
40
|
describe("describePathGate", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
realpathSync.mockReset();
|
|
43
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
44
|
+
});
|
|
45
|
+
|
|
30
46
|
it("returns null for non-path-bearing tools", () => {
|
|
31
47
|
const resolver = makeResolver();
|
|
32
48
|
const result = describePathGate(
|
|
@@ -152,16 +168,31 @@ describe("describePathGate", () => {
|
|
|
152
168
|
expect(result.decision.value).toBe(".env");
|
|
153
169
|
});
|
|
154
170
|
|
|
155
|
-
it("resolves the path surface with
|
|
171
|
+
it("resolves the path surface with an access-path intent and agent name", () => {
|
|
156
172
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
157
173
|
describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
|
|
158
174
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
159
|
-
kind: "
|
|
175
|
+
kind: "access-path",
|
|
160
176
|
surface: "path",
|
|
161
|
-
|
|
177
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
162
178
|
agentName: "my-agent",
|
|
163
179
|
});
|
|
164
180
|
});
|
|
181
|
+
|
|
182
|
+
it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => {
|
|
183
|
+
// /test/project/.env is a symlink to /vault/secret.env.
|
|
184
|
+
realpathSync.mockImplementation((p: string) =>
|
|
185
|
+
p === "/test/project/.env" ? "/vault/secret.env" : p,
|
|
186
|
+
);
|
|
187
|
+
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
188
|
+
describePathGate(makeTcc(), resolver);
|
|
189
|
+
|
|
190
|
+
const intent = resolver.resolve.mock.lastCall?.[0];
|
|
191
|
+
expect(intent?.kind).toBe("access-path");
|
|
192
|
+
expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual(
|
|
193
|
+
["/test/project/.env", ".env", "/vault/secret.env"],
|
|
194
|
+
);
|
|
195
|
+
});
|
|
165
196
|
});
|
|
166
197
|
|
|
167
198
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
@@ -189,9 +220,9 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
189
220
|
pathValue: "~/.ssh/config",
|
|
190
221
|
});
|
|
191
222
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
192
|
-
kind: "
|
|
223
|
+
kind: "access-path",
|
|
193
224
|
surface: "path",
|
|
194
|
-
|
|
225
|
+
path: AccessPath.forPath("~/.ssh/config", { cwd: "/test/project" }),
|
|
195
226
|
agentName: undefined,
|
|
196
227
|
});
|
|
197
228
|
});
|
|
@@ -246,9 +277,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
246
277
|
);
|
|
247
278
|
expect(isGateDescriptor(result)).toBe(true);
|
|
248
279
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
249
|
-
kind: "
|
|
280
|
+
kind: "access-path",
|
|
250
281
|
surface: "path",
|
|
251
|
-
|
|
282
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
252
283
|
agentName: undefined,
|
|
253
284
|
});
|
|
254
285
|
});
|
|
@@ -263,9 +294,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
263
294
|
);
|
|
264
295
|
expect(isGateDescriptor(result)).toBe(true);
|
|
265
296
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
266
|
-
kind: "
|
|
297
|
+
kind: "access-path",
|
|
267
298
|
surface: "path",
|
|
268
|
-
|
|
299
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
269
300
|
agentName: undefined,
|
|
270
301
|
});
|
|
271
302
|
});
|
|
@@ -280,9 +311,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
280
311
|
extractorLookup("ffgrep", "target"),
|
|
281
312
|
);
|
|
282
313
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
283
|
-
kind: "
|
|
314
|
+
kind: "access-path",
|
|
284
315
|
surface: "path",
|
|
285
|
-
|
|
316
|
+
path: AccessPath.forPath("/etc/passwd", { cwd: "/test/project" }),
|
|
286
317
|
agentName: undefined,
|
|
287
318
|
});
|
|
288
319
|
});
|
|
@@ -213,8 +213,7 @@ export function makePathDispatchResolver(
|
|
|
213
213
|
}
|
|
214
214
|
return defaultResult;
|
|
215
215
|
}
|
|
216
|
-
const values =
|
|
217
|
-
intent.kind === "access-path" ? intent.path.matchValues() : intent.values;
|
|
216
|
+
const values = intent.path.matchValues();
|
|
218
217
|
for (const value of values) {
|
|
219
218
|
if (value in byPath) return byPath[value];
|
|
220
219
|
}
|
|
@@ -120,28 +120,7 @@ describe("PermissionResolver", () => {
|
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
describe("resolve —
|
|
124
|
-
it("forwards a path-values intent with the current session ruleset", () => {
|
|
125
|
-
const { resolver, permissionManager } = makeResolver();
|
|
126
|
-
|
|
127
|
-
resolver.resolve({
|
|
128
|
-
kind: "path-values",
|
|
129
|
-
surface: "path",
|
|
130
|
-
values: ["/proj/src/a.ts", "src/a.ts"],
|
|
131
|
-
agentName: "agent-x",
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
expect(permissionManager.check).toHaveBeenCalledWith(
|
|
135
|
-
{
|
|
136
|
-
kind: "path-values",
|
|
137
|
-
surface: "path",
|
|
138
|
-
values: ["/proj/src/a.ts", "src/a.ts"],
|
|
139
|
-
agentName: "agent-x",
|
|
140
|
-
},
|
|
141
|
-
[],
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
123
|
+
describe("resolve — session ruleset threading", () => {
|
|
145
124
|
it("applies a recorded session approval on the next call", () => {
|
|
146
125
|
const pm = makePermissionManager();
|
|
147
126
|
const sessionRules = new SessionRules();
|
|
@@ -151,9 +130,9 @@ describe("PermissionResolver", () => {
|
|
|
151
130
|
SessionApproval.single("path", "src/*"),
|
|
152
131
|
);
|
|
153
132
|
resolver.resolve({
|
|
154
|
-
kind: "path
|
|
133
|
+
kind: "access-path",
|
|
155
134
|
surface: "path",
|
|
156
|
-
|
|
135
|
+
path: AccessPath.forPath("src/a.ts", { cwd: "/proj" }),
|
|
157
136
|
});
|
|
158
137
|
|
|
159
138
|
const passedRules = vi.mocked(pm.check).mock.calls[0][1];
|
|
@@ -169,10 +148,7 @@ describe("PermissionResolver", () => {
|
|
|
169
148
|
describe("resolve — access-path intent", () => {
|
|
170
149
|
it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
|
|
171
150
|
const { resolver, permissionManager } = makeResolver();
|
|
172
|
-
const accessPath = AccessPath.
|
|
173
|
-
"/tmp/x",
|
|
174
|
-
"/workspace",
|
|
175
|
-
);
|
|
151
|
+
const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
|
|
176
152
|
|
|
177
153
|
resolver.resolve({
|
|
178
154
|
kind: "access-path",
|
|
@@ -202,10 +178,7 @@ describe("PermissionResolver", () => {
|
|
|
202
178
|
matchedPattern: "/tmp/*",
|
|
203
179
|
});
|
|
204
180
|
const { resolver } = makeResolver(pm);
|
|
205
|
-
const accessPath = AccessPath.
|
|
206
|
-
"/tmp/x",
|
|
207
|
-
"/workspace",
|
|
208
|
-
);
|
|
181
|
+
const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
|
|
209
182
|
|
|
210
183
|
const result = resolver.resolve({
|
|
211
184
|
kind: "access-path",
|