@gotgenes/pi-permission-system 13.1.2 → 14.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 +34 -0
- package/package.json +1 -1
- package/src/config-paths.ts +13 -0
- package/src/handlers/gates/bash-external-directory.ts +7 -3
- package/src/handlers/gates/bash-program.ts +25 -18
- package/src/handlers/gates/external-directory.ts +22 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -1
- package/src/path-utils.ts +20 -0
- package/src/permission-manager.ts +17 -9
- package/src/permission-resolver.ts +13 -5
- package/test/config-paths.test.ts +5 -0
- package/test/handlers/external-directory-session-dedup.test.ts +25 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-external-directory.test.ts +44 -27
- package/test/handlers/gates/bash-program.test.ts +7 -4
- package/test/handlers/gates/external-directory.test.ts +58 -23
- package/test/helpers/handler-fixtures.ts +6 -4
- package/test/path-utils.test.ts +34 -0
- package/test/permission-manager-unified.test.ts +88 -1
- package/test/permission-resolver.test.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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
|
+
## [14.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.2.0...pi-permission-system-v14.0.0) (2026-06-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* project agents' `permission:` frontmatter at `<cwd>/.pi/agents/<name>.md` is now read and enforced. Previously the wrong directory (`<cwd>/.pi/agent/agents`) was checked and the frontmatter was silently ignored, so a session may become more restrictive on upgrade.
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* correct project agents directory path to <cwd>/.pi/agents ([#428](https://github.com/gotgenes/pi-packages/issues/428)) ([eb5af78](https://github.com/gotgenes/pi-packages/commit/eb5af78193fa3cc574da6b8d80efd643ebce0ef9))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* correct project agent override path to <cwd>/.pi/agents ([#428](https://github.com/gotgenes/pi-packages/issues/428)) ([d193d6a](https://github.com/gotgenes/pi-packages/commit/d193d6a61155fb4e4a064800509cdbbd84b0ceb9))
|
|
23
|
+
* fix stale project agents path in troubleshooting and ADR-0001 ([#428](https://github.com/gotgenes/pi-packages/issues/428)) ([95effeb](https://github.com/gotgenes/pi-packages/commit/95effebfd0b2e87db4512c91adc134b2470f26a3))
|
|
24
|
+
|
|
25
|
+
## [13.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.2...pi-permission-system-v13.2.0) (2026-06-17)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** add external-directory typed+resolved policy aliases ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([ae653d1](https://github.com/gotgenes/pi-packages/commit/ae653d11fa52403cc5a78cd0148bc102de923d3c))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
* **pi-permission-system:** match external_directory patterns against typed and resolved paths ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([d08e645](https://github.com/gotgenes/pi-packages/commit/d08e64509d980c818708ecd2b7152ba6fc05946d))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* **pi-permission-system:** document external_directory symlink alias matching ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([8760273](https://github.com/gotgenes/pi-packages/commit/876027313457591ab5f175c689aa3074143db388))
|
|
41
|
+
|
|
8
42
|
## [13.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.1...pi-permission-system-v13.1.2) (2026-06-16)
|
|
9
43
|
|
|
10
44
|
|
package/package.json
CHANGED
package/src/config-paths.ts
CHANGED
|
@@ -21,6 +21,19 @@ export function getProjectConfigPath(cwd: string): string {
|
|
|
21
21
|
return join(cwd, ".pi", "extensions", EXTENSION_ID, "config.json");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Directory holding project-scoped custom agent definition files.
|
|
26
|
+
*
|
|
27
|
+
* `<cwd>/.pi/agents` is a Pi platform convention, also encoded by
|
|
28
|
+
* `@gotgenes/pi-subagents`' `loadCustomAgents` (`config/custom-agents.ts`).
|
|
29
|
+
* The two packages encode it independently — pi-permission-system has no
|
|
30
|
+
* dependency on pi-subagents (ADR-0002) — so this is this package's
|
|
31
|
+
* authoritative copy.
|
|
32
|
+
*/
|
|
33
|
+
export function getProjectAgentsDir(cwd: string): string {
|
|
34
|
+
return join(cwd, ".pi", "agents");
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export function getLegacyGlobalPolicyPath(agentDir: string): string {
|
|
25
38
|
return join(agentDir, "pi-permissions.jsonc");
|
|
26
39
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
|
+
import { getExternalDirectoryPolicyValues } 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";
|
|
@@ -41,10 +42,13 @@ export function describeBashExternalDirectoryGate(
|
|
|
41
42
|
check: PermissionCheckResult;
|
|
42
43
|
}> = [];
|
|
43
44
|
for (const p of externalPaths) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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),
|
|
47
50
|
tcc.agentName ?? undefined,
|
|
51
|
+
"external_directory",
|
|
48
52
|
);
|
|
49
53
|
if (check.state !== "allow") {
|
|
50
54
|
uncoveredEntries.push({ path: p, check });
|
|
@@ -199,13 +199,19 @@ export class BashProgram {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* Deduplicated paths that resolve outside `cwd
|
|
202
|
+
* Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
|
|
203
|
+
* normalized but not symlink-resolved) form.
|
|
203
204
|
*
|
|
204
205
|
* Each candidate is resolved against the effective working directory in force
|
|
205
206
|
* where it appears, projected by folding a sequence of current-shell `cd`
|
|
206
207
|
* commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
|
|
207
208
|
* pipeline or a backgrounded command runs in a subshell and does not update
|
|
208
209
|
* the running directory.
|
|
210
|
+
*
|
|
211
|
+
* The outside-`cwd` decision and the dedup identity use the canonical
|
|
212
|
+
* (symlink-resolved) form, but the returned value is the lexical form so
|
|
213
|
+
* `external_directory` config patterns match the path as the user typed it
|
|
214
|
+
* (#418); the gate re-derives the canonical alias for matching.
|
|
209
215
|
*/
|
|
210
216
|
externalPaths(cwd: string): string[] {
|
|
211
217
|
const normalizedCwd = canonicalizePath(
|
|
@@ -224,36 +230,37 @@ export class BashProgram {
|
|
|
224
230
|
// display path). Absolute / `~` candidates are base-independent and
|
|
225
231
|
// resolve normally below.
|
|
226
232
|
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
);
|
|
233
|
+
const lexical = normalizePathForComparison(candidate, cwd);
|
|
234
|
+
const canonical = canonicalizePath(lexical);
|
|
230
235
|
if (
|
|
231
|
-
|
|
236
|
+
canonical &&
|
|
232
237
|
normalizedCwd !== "" &&
|
|
233
|
-
!isSafeSystemPath(
|
|
234
|
-
!seen.has(
|
|
238
|
+
!isSafeSystemPath(canonical) &&
|
|
239
|
+
!seen.has(canonical)
|
|
235
240
|
) {
|
|
236
|
-
seen.add(
|
|
237
|
-
externalPaths.push(
|
|
241
|
+
seen.add(canonical);
|
|
242
|
+
externalPaths.push(lexical);
|
|
238
243
|
}
|
|
239
244
|
continue;
|
|
240
245
|
}
|
|
241
246
|
|
|
242
247
|
const resolveBase =
|
|
243
248
|
base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
const lexical = normalizePathForComparison(candidate, resolveBase);
|
|
250
|
+
if (!lexical) continue;
|
|
251
|
+
// The boundary decision and dedup identity use the canonical
|
|
252
|
+
// (symlink-resolved) form, but the returned value is the lexical form so
|
|
253
|
+
// config patterns match the path as the user typed it (#418).
|
|
254
|
+
const canonical = canonicalizePath(lexical);
|
|
248
255
|
|
|
249
256
|
if (
|
|
250
257
|
normalizedCwd !== "" &&
|
|
251
|
-
!isSafeSystemPath(
|
|
252
|
-
!isPathWithinDirectory(
|
|
253
|
-
!seen.has(
|
|
258
|
+
!isSafeSystemPath(canonical) &&
|
|
259
|
+
!isPathWithinDirectory(canonical, normalizedCwd) &&
|
|
260
|
+
!seen.has(canonical)
|
|
254
261
|
) {
|
|
255
|
-
seen.add(
|
|
256
|
-
externalPaths.push(
|
|
262
|
+
seen.add(canonical);
|
|
263
|
+
externalPaths.push(lexical);
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
canonicalNormalizePathForComparison,
|
|
3
|
+
getExternalDirectoryPolicyValues,
|
|
3
4
|
getToolInputPath,
|
|
4
5
|
isPathOutsideWorkingDirectory,
|
|
5
6
|
isPiInfrastructureRead,
|
|
7
|
+
normalizePathForComparison,
|
|
6
8
|
} from "#src/path-utils";
|
|
9
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
7
10
|
import { SessionApproval } from "#src/session-approval";
|
|
8
11
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
9
12
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
@@ -22,6 +25,7 @@ import type { ToolCallContext } from "./types";
|
|
|
22
25
|
export function describeExternalDirectoryGate(
|
|
23
26
|
tcc: ToolCallContext,
|
|
24
27
|
infraDirs: string[],
|
|
28
|
+
resolver: ScopedPermissionResolver,
|
|
25
29
|
extractors?: ToolAccessExtractorLookup,
|
|
26
30
|
): GateResult {
|
|
27
31
|
if (!tcc.cwd) return null;
|
|
@@ -37,14 +41,17 @@ export function describeExternalDirectoryGate(
|
|
|
37
41
|
return null;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
// The boundary decision (above) and the infrastructure-read containment
|
|
45
|
+
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
46
|
+
// uses the typed and resolved aliases (#418).
|
|
47
|
+
const canonicalExtPath = canonicalNormalizePathForComparison(
|
|
41
48
|
externalDirectoryPath,
|
|
42
49
|
tcc.cwd,
|
|
43
50
|
);
|
|
44
51
|
|
|
45
52
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
46
53
|
if (
|
|
47
|
-
isPiInfrastructureRead(tcc.toolName,
|
|
54
|
+
isPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd)
|
|
48
55
|
) {
|
|
49
56
|
return {
|
|
50
57
|
action: "allow",
|
|
@@ -78,11 +85,22 @@ export function describeExternalDirectoryGate(
|
|
|
78
85
|
tcc.agentName ?? undefined,
|
|
79
86
|
);
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
// Match against both the typed and symlink-resolved aliases on the
|
|
89
|
+
// external_directory surface, so a config pattern on either form applies
|
|
90
|
+
// (#418). The runner consumes this preCheck and skips its own resolve.
|
|
91
|
+
const preCheck = resolver.resolvePathPolicy(
|
|
92
|
+
getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
|
|
93
|
+
tcc.agentName ?? undefined,
|
|
94
|
+
"external_directory",
|
|
95
|
+
);
|
|
96
|
+
const pattern = deriveApprovalPattern(
|
|
97
|
+
normalizePathForComparison(externalDirectoryPath, tcc.cwd),
|
|
98
|
+
);
|
|
82
99
|
|
|
83
100
|
return {
|
|
84
101
|
surface: "external_directory",
|
|
85
|
-
input: {
|
|
102
|
+
input: {},
|
|
103
|
+
preCheck,
|
|
86
104
|
denialContext: {
|
|
87
105
|
kind: "external_directory",
|
|
88
106
|
toolName: tcc.toolName,
|
|
@@ -81,7 +81,12 @@ export class ToolCallGatePipeline {
|
|
|
81
81
|
describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
|
|
82
82
|
() => describePathGate(tcc, this.resolver, this.customExtractors),
|
|
83
83
|
() =>
|
|
84
|
-
describeExternalDirectoryGate(
|
|
84
|
+
describeExternalDirectoryGate(
|
|
85
|
+
tcc,
|
|
86
|
+
infraDirs,
|
|
87
|
+
this.resolver,
|
|
88
|
+
this.customExtractors,
|
|
89
|
+
),
|
|
85
90
|
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
|
86
91
|
() => describeBashPathGate(tcc, bashProgram, this.resolver),
|
|
87
92
|
() => {
|
package/src/path-utils.ts
CHANGED
|
@@ -112,6 +112,26 @@ 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
|
+
|
|
115
135
|
function getAbsolutePathPolicyValues(
|
|
116
136
|
pathValue: string,
|
|
117
137
|
options: PathPolicyValueOptions,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { isPermissionState } from "./common";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getGlobalConfigPath,
|
|
5
|
+
getProjectAgentsDir,
|
|
6
|
+
getProjectConfigPath,
|
|
7
|
+
} from "./config-paths";
|
|
4
8
|
import { normalizeInput } from "./input-normalizer";
|
|
5
9
|
import { normalizeFlatConfig } from "./normalize";
|
|
6
10
|
import { PATH_SURFACES } from "./path-utils";
|
|
@@ -65,15 +69,18 @@ export interface ScopedPermissionManager {
|
|
|
65
69
|
sessionRules?: Ruleset,
|
|
66
70
|
): PermissionCheckResult;
|
|
67
71
|
/**
|
|
68
|
-
* Evaluate
|
|
69
|
-
* equivalent policy values (e.g. bash tokens already
|
|
70
|
-
* preceding literal `cd
|
|
71
|
-
*
|
|
72
|
+
* Evaluate a path-shaped surface (`path` or `external_directory`) against a
|
|
73
|
+
* caller-supplied set of equivalent policy values (e.g. bash tokens already
|
|
74
|
+
* resolved against a preceding literal `cd`, or a path's typed and
|
|
75
|
+
* symlink-resolved aliases). The values are trusted because they are computed
|
|
76
|
+
* internally, never read from a field on raw tool input. `surface` defaults
|
|
77
|
+
* to `path`.
|
|
72
78
|
*/
|
|
73
79
|
checkPathPolicy(
|
|
74
80
|
values: readonly string[],
|
|
75
81
|
agentName?: string,
|
|
76
82
|
sessionRules?: Ruleset,
|
|
83
|
+
surface?: string,
|
|
77
84
|
): PermissionCheckResult;
|
|
78
85
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
79
86
|
getConfigIssues(agentName?: string): string[];
|
|
@@ -277,6 +284,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
277
284
|
values: readonly string[],
|
|
278
285
|
agentName?: string,
|
|
279
286
|
sessionRules?: Ruleset,
|
|
287
|
+
surface = "path",
|
|
280
288
|
): PermissionCheckResult {
|
|
281
289
|
const { composedRules } = this.resolvePermissions(agentName);
|
|
282
290
|
const fullRules: Ruleset = sessionRules?.length
|
|
@@ -285,11 +293,11 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
285
293
|
|
|
286
294
|
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
287
295
|
return buildCheckResult(
|
|
288
|
-
|
|
296
|
+
surface,
|
|
289
297
|
lookupValues,
|
|
290
298
|
{},
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
surface,
|
|
300
|
+
surface,
|
|
293
301
|
fullRules,
|
|
294
302
|
);
|
|
295
303
|
}
|
|
@@ -346,7 +354,7 @@ function derivePolicyLoaderOptions(
|
|
|
346
354
|
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
347
355
|
agentsDir: join(agentDir, "agents"),
|
|
348
356
|
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
|
|
349
|
-
projectAgentsDir: cwd ?
|
|
357
|
+
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
|
|
350
358
|
};
|
|
351
359
|
}
|
|
352
360
|
|
|
@@ -18,13 +18,16 @@ export interface ScopedPermissionResolver {
|
|
|
18
18
|
agentName?: string,
|
|
19
19
|
): PermissionCheckResult;
|
|
20
20
|
/**
|
|
21
|
-
* Resolve
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Resolve a path-shaped surface against a caller-supplied set of equivalent
|
|
22
|
+
* policy values, applying the current session rules. Used by the bash path
|
|
23
|
+
* gate (`path`) and the external-directory gates (`external_directory`),
|
|
24
|
+
* which compute equivalent path aliases per token. `surface` defaults to
|
|
25
|
+
* `path`.
|
|
24
26
|
*/
|
|
25
27
|
resolvePathPolicy(
|
|
26
28
|
values: readonly string[],
|
|
27
29
|
agentName?: string,
|
|
30
|
+
surface?: string,
|
|
28
31
|
): PermissionCheckResult;
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -63,17 +66,22 @@ export class PermissionResolver implements ScopedPermissionResolver {
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
|
-
* Resolve
|
|
67
|
-
* current session ruleset so callers
|
|
69
|
+
* Resolve a path-shaped surface (`path` or `external_directory`) for
|
|
70
|
+
* precomputed policy values, composing the current session ruleset so callers
|
|
71
|
+
* never thread it by hand. `surface` defaults to `path`; the external-directory
|
|
72
|
+
* gates pass `external_directory` so a path's typed and symlink-resolved
|
|
73
|
+
* aliases match against the `external_directory` rules.
|
|
68
74
|
*/
|
|
69
75
|
resolvePathPolicy(
|
|
70
76
|
values: readonly string[],
|
|
71
77
|
agentName?: string,
|
|
78
|
+
surface = "path",
|
|
72
79
|
): PermissionCheckResult {
|
|
73
80
|
return this.permissionManager.checkPathPolicy(
|
|
74
81
|
values,
|
|
75
82
|
agentName,
|
|
76
83
|
this.sessionRules.getRuleset(),
|
|
84
|
+
surface,
|
|
77
85
|
);
|
|
78
86
|
}
|
|
79
87
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getLegacyExtensionConfigPath,
|
|
10
10
|
getLegacyGlobalPolicyPath,
|
|
11
11
|
getLegacyProjectPolicyPath,
|
|
12
|
+
getProjectAgentsDir,
|
|
12
13
|
getProjectConfigPath,
|
|
13
14
|
REVIEW_LOG_FILENAME,
|
|
14
15
|
} from "#src/config-paths";
|
|
@@ -42,6 +43,10 @@ describe("config-paths", () => {
|
|
|
42
43
|
join(cwd, ".pi", "extensions", "pi-permission-system", "config.json"),
|
|
43
44
|
);
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
it("getProjectAgentsDir returns .pi/agents under cwd", () => {
|
|
48
|
+
expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents"));
|
|
49
|
+
});
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
describe("legacy paths", () => {
|
|
@@ -98,6 +98,19 @@ function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
|
98
98
|
},
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
+
// The external-directory gates resolve through checkPathPolicy (#418); route
|
|
102
|
+
// it through the same configured checkPermission so session-approval dedup
|
|
103
|
+
// applies to the typed path alias.
|
|
104
|
+
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
105
|
+
(values, agentName, rules, surface = "path") =>
|
|
106
|
+
permissionManager.checkPermission(
|
|
107
|
+
surface,
|
|
108
|
+
{ path: values[0] ?? "*" },
|
|
109
|
+
agentName,
|
|
110
|
+
rules,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
101
114
|
const events = makeEvents();
|
|
102
115
|
const reporter = new GateDecisionReporter(logger, events);
|
|
103
116
|
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
@@ -351,6 +364,18 @@ describe("session shutdown clears external-directory approvals", () => {
|
|
|
351
364
|
},
|
|
352
365
|
);
|
|
353
366
|
|
|
367
|
+
// The external-directory tool gate resolves through checkPathPolicy (#418);
|
|
368
|
+
// route it through the same configured checkPermission.
|
|
369
|
+
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
370
|
+
(values, agentName, rules, surface = "path") =>
|
|
371
|
+
permissionManager.checkPermission(
|
|
372
|
+
surface,
|
|
373
|
+
{ path: values[0] ?? "*" },
|
|
374
|
+
agentName,
|
|
375
|
+
rules,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
|
|
354
379
|
const events = makeEvents();
|
|
355
380
|
const reporter = new GateDecisionReporter(logger, events);
|
|
356
381
|
const prompter: GatePrompter = {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance test for issue #418.
|
|
3
|
+
*
|
|
4
|
+
* Reproduces the reported bug with a real symlink (no `realpathSync` mock):
|
|
5
|
+
* an `external_directory` allow configured for the path as the user types it
|
|
6
|
+
* (`<link>/*`) must allow access even though the OS resolves `<link>` to a
|
|
7
|
+
* different canonical directory. Exercised end-to-end through the real
|
|
8
|
+
* `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
|
|
9
|
+
* a bash command, and for an allow keyed on the symlink-resolved form too.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
|
|
18
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
19
|
+
import {
|
|
20
|
+
type GateDescriptor,
|
|
21
|
+
isGateBypass,
|
|
22
|
+
isGateDescriptor,
|
|
23
|
+
} from "#src/handlers/gates/descriptor";
|
|
24
|
+
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
|
25
|
+
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
26
|
+
import { PermissionResolver } from "#src/permission-resolver";
|
|
27
|
+
import { SessionRules } from "#src/session-rules";
|
|
28
|
+
import type { ScopeConfig } from "#src/types";
|
|
29
|
+
|
|
30
|
+
import { createManager } from "#test/helpers/manager-harness";
|
|
31
|
+
|
|
32
|
+
// ── real symlink fixture ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let realDir: string;
|
|
35
|
+
let linkDir: string;
|
|
36
|
+
let cwd: string;
|
|
37
|
+
const tempRoots: string[] = [];
|
|
38
|
+
|
|
39
|
+
function mkTemp(prefix: string): string {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
41
|
+
tempRoots.push(dir);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
realDir = mkTemp("ext-real-");
|
|
47
|
+
const linkParent = mkTemp("ext-link-");
|
|
48
|
+
linkDir = join(linkParent, "link");
|
|
49
|
+
symlinkSync(realDir, linkDir);
|
|
50
|
+
cwd = mkTemp("ext-cwd-");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
while (tempRoots.length > 0) {
|
|
55
|
+
const dir = tempRoots.pop();
|
|
56
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function makeResolver(config: ScopeConfig) {
|
|
61
|
+
const { manager, cleanup } = createManager(config);
|
|
62
|
+
manager.configureForCwd(cwd);
|
|
63
|
+
const resolver = new PermissionResolver(manager, new SessionRules());
|
|
64
|
+
return { resolver, cleanup };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readTcc(): ToolCallContext {
|
|
68
|
+
return {
|
|
69
|
+
toolName: "read",
|
|
70
|
+
agentName: null,
|
|
71
|
+
input: { path: join(linkDir, "file.ts") },
|
|
72
|
+
toolCallId: "tc-1",
|
|
73
|
+
cwd,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── tests ────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe("external_directory symlink acceptance (#418)", () => {
|
|
80
|
+
it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
|
|
81
|
+
const { resolver, cleanup } = makeResolver({
|
|
82
|
+
permission: {
|
|
83
|
+
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const result = describeExternalDirectoryGate(readTcc(), [], resolver);
|
|
88
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
89
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
90
|
+
} finally {
|
|
91
|
+
cleanup();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
|
|
96
|
+
// Key the allow on the fully symlink-resolved directory (on macOS the
|
|
97
|
+
// tmpdir root itself is a symlink, e.g. /var -> /private/var).
|
|
98
|
+
const resolved = realpathSync(realDir);
|
|
99
|
+
const { resolver, cleanup } = makeResolver({
|
|
100
|
+
permission: {
|
|
101
|
+
external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const result = describeExternalDirectoryGate(readTcc(), [], resolver);
|
|
106
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
107
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
108
|
+
} finally {
|
|
109
|
+
cleanup();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("still prompts (ask) when no external_directory allow matches", () => {
|
|
114
|
+
const { resolver, cleanup } = makeResolver({
|
|
115
|
+
permission: { external_directory: { "*": "ask" } },
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
const result = describeExternalDirectoryGate(readTcc(), [], resolver);
|
|
119
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
120
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
|
|
121
|
+
} finally {
|
|
122
|
+
cleanup();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("allows a bash command referencing the typed (symlinked) path", async () => {
|
|
127
|
+
const { resolver, cleanup } = makeResolver({
|
|
128
|
+
permission: {
|
|
129
|
+
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const command = `cat ${join(linkDir, "file.ts")}`;
|
|
134
|
+
const tcc: ToolCallContext = {
|
|
135
|
+
toolName: "bash",
|
|
136
|
+
agentName: null,
|
|
137
|
+
input: { command },
|
|
138
|
+
toolCallId: "tc-2",
|
|
139
|
+
cwd,
|
|
140
|
+
};
|
|
141
|
+
const program = await BashProgram.parse(command);
|
|
142
|
+
const result = describeBashExternalDirectoryGate(tcc, program, resolver);
|
|
143
|
+
// All external paths are covered by the allow → bypass, no prompt.
|
|
144
|
+
expect(isGateBypass(result)).toBe(true);
|
|
145
|
+
} finally {
|
|
146
|
+
cleanup();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -84,6 +84,20 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
84
84
|
expect(result).toBeNull();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
it("resolves each external path on the external_directory surface via resolvePathPolicy (#418)", async () => {
|
|
88
|
+
const resolver = makeResolver(makeCheckResult("ask"));
|
|
89
|
+
await describeGate(
|
|
90
|
+
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
|
91
|
+
resolver,
|
|
92
|
+
);
|
|
93
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
94
|
+
["/outside/a.ts"],
|
|
95
|
+
undefined,
|
|
96
|
+
"external_directory",
|
|
97
|
+
);
|
|
98
|
+
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
87
101
|
it("returns GateBypass when all external paths are session-covered", async () => {
|
|
88
102
|
const resolver = makeResolver(
|
|
89
103
|
makeCheckResult("allow", { source: "session" }),
|
|
@@ -116,11 +130,12 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
116
130
|
// not just session-level allow. This was the bug: source !== "session"
|
|
117
131
|
// kept config-allowed paths in the uncovered set.
|
|
118
132
|
const resolver = makeResolver();
|
|
119
|
-
resolver.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
134
|
+
(values: readonly string[]) =>
|
|
135
|
+
values.length > 0
|
|
136
|
+
? makeCheckResult("allow", { source: "special" })
|
|
137
|
+
: makeCheckResult("ask"),
|
|
138
|
+
);
|
|
124
139
|
const result = await describeGate(makeTcc(), resolver);
|
|
125
140
|
expect(result).not.toBeNull();
|
|
126
141
|
expect(isGateBypass(result)).toBe(true);
|
|
@@ -131,12 +146,12 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
131
146
|
// silently downgrading a config-level deny to ask. After the fix, the
|
|
132
147
|
// descriptor's preCheck is derived from the actual path check result.
|
|
133
148
|
const resolver = makeResolver();
|
|
134
|
-
resolver.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
150
|
+
(values: readonly string[]) =>
|
|
151
|
+
values.length > 0
|
|
152
|
+
? makeCheckResult("deny", { source: "special" })
|
|
153
|
+
: makeCheckResult("ask"),
|
|
154
|
+
);
|
|
140
155
|
const result = await describeGate(makeTcc(), resolver);
|
|
141
156
|
expect(isGateDescriptor(result)).toBe(true);
|
|
142
157
|
const desc = result as GateDescriptor;
|
|
@@ -192,11 +207,12 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
192
207
|
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
|
|
193
208
|
// One path config-allowed, one config-ask → descriptor with only the ask path.
|
|
194
209
|
const resolver = makeResolver();
|
|
195
|
-
resolver.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
211
|
+
(values: readonly string[]) =>
|
|
212
|
+
values.includes("/outside/a.ts")
|
|
213
|
+
? makeCheckResult("allow", { source: "special" })
|
|
214
|
+
: makeCheckResult("ask"),
|
|
215
|
+
);
|
|
200
216
|
const result = await describeGate(
|
|
201
217
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
202
218
|
resolver,
|
|
@@ -212,11 +228,12 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
212
228
|
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
|
|
213
229
|
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
|
|
214
230
|
const resolver = makeResolver();
|
|
215
|
-
resolver.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
232
|
+
(values: readonly string[]) =>
|
|
233
|
+
values.includes("/outside/a.ts")
|
|
234
|
+
? makeCheckResult("deny", { source: "special" })
|
|
235
|
+
: makeCheckResult("ask"),
|
|
236
|
+
);
|
|
220
237
|
const result = await describeGate(
|
|
221
238
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
222
239
|
resolver,
|
|
@@ -232,12 +249,12 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
232
249
|
|
|
233
250
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
234
251
|
const resolver = makeResolver();
|
|
235
|
-
resolver.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
resolver.resolvePathPolicy.mockImplementation(
|
|
253
|
+
(values: readonly string[]) =>
|
|
254
|
+
values.includes("/outside/a.ts")
|
|
255
|
+
? makeCheckResult("allow", { source: "session" })
|
|
256
|
+
: makeCheckResult("ask"),
|
|
257
|
+
);
|
|
241
258
|
const result = await describeGate(
|
|
242
259
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
243
260
|
resolver,
|
|
@@ -188,11 +188,12 @@ describe("BashProgram", () => {
|
|
|
188
188
|
});
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
-
it("flags an absolute in-cwd path that resolves externally via a symlink", async () => {
|
|
191
|
+
it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
|
|
192
192
|
// The strict classifier only processes absolute tokens, so the escape
|
|
193
193
|
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
|
|
194
|
-
//
|
|
195
|
-
//
|
|
194
|
+
// The boundary decision still uses the canonical form (so the path is
|
|
195
|
+
// flagged), but the returned value is the typed/lexical form so config
|
|
196
|
+
// patterns match the path as the user wrote it (#418).
|
|
196
197
|
realpathSync.mockImplementation((p: string) => {
|
|
197
198
|
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
198
199
|
return p;
|
|
@@ -200,7 +201,9 @@ describe("BashProgram", () => {
|
|
|
200
201
|
const program = await BashProgram.parse(
|
|
201
202
|
"cat /projects/my-app/link/hosts",
|
|
202
203
|
);
|
|
203
|
-
|
|
204
|
+
const external = program.externalPaths(cwd);
|
|
205
|
+
expect(external).toContain("/projects/my-app/link/hosts");
|
|
206
|
+
expect(external).not.toContain("/etc/hosts");
|
|
204
207
|
});
|
|
205
208
|
|
|
206
209
|
it("does not flag a token that resolves within a symlinked cwd", async () => {
|
|
@@ -7,6 +7,10 @@ import type {
|
|
|
7
7
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
8
8
|
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
|
9
9
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
10
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
11
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
12
|
+
import { makeResolver } from "#test/helpers/gate-fixtures";
|
|
13
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
10
14
|
|
|
11
15
|
// ── helpers ───────────────────────────��────────────────────────────��───────
|
|
12
16
|
|
|
@@ -21,18 +25,31 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
21
25
|
};
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
// Default resolver for descriptor-shape tests that do not assert the resolved
|
|
29
|
+
// state: returns `ask` for the external_directory surface so a descriptor is
|
|
30
|
+
// produced. Tests that assert the typed+resolved matching pass an explicit
|
|
31
|
+
// resolver to `describeExternalDirectoryGate` directly.
|
|
32
|
+
function gateUnderTest(
|
|
33
|
+
tcc: ToolCallContext,
|
|
34
|
+
infraDirs: string[],
|
|
35
|
+
extractors?: ToolAccessExtractorLookup,
|
|
36
|
+
resolver: ScopedPermissionResolver = makeResolver(
|
|
37
|
+
makeCheckResult({ state: "ask", toolName: "external_directory" }),
|
|
38
|
+
),
|
|
39
|
+
) {
|
|
40
|
+
return describeExternalDirectoryGate(tcc, infraDirs, resolver, extractors);
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
// ── tests ────────────────────��────────────────────────────────────��────────
|
|
25
44
|
|
|
26
45
|
describe("describeExternalDirectoryGate", () => {
|
|
27
46
|
it("returns null when no CWD", () => {
|
|
28
|
-
const result =
|
|
29
|
-
"/test/agent",
|
|
30
|
-
]);
|
|
47
|
+
const result = gateUnderTest(makeTcc({ cwd: undefined }), ["/test/agent"]);
|
|
31
48
|
expect(result).toBeNull();
|
|
32
49
|
});
|
|
33
50
|
|
|
34
51
|
it("returns null when tool is not path-bearing", () => {
|
|
35
|
-
const result =
|
|
52
|
+
const result = gateUnderTest(
|
|
36
53
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
37
54
|
["/test/agent"],
|
|
38
55
|
);
|
|
@@ -40,7 +57,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
40
57
|
});
|
|
41
58
|
|
|
42
59
|
it("returns null when path is inside CWD", () => {
|
|
43
|
-
const result =
|
|
60
|
+
const result = gateUnderTest(
|
|
44
61
|
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
|
|
45
62
|
["/test/agent"],
|
|
46
63
|
);
|
|
@@ -50,7 +67,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
50
67
|
// ── Pi infrastructure read bypass ─────────────────���────────────────────
|
|
51
68
|
|
|
52
69
|
it("returns GateBypass for read targeting an infra dir", () => {
|
|
53
|
-
const result =
|
|
70
|
+
const result = gateUnderTest(
|
|
54
71
|
makeTcc({
|
|
55
72
|
toolName: "read",
|
|
56
73
|
input: { path: "/test/agent/git/some-package/SKILL.md" },
|
|
@@ -71,7 +88,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
71
88
|
});
|
|
72
89
|
|
|
73
90
|
it("returns GateBypass respecting custom infraDirs", () => {
|
|
74
|
-
const result =
|
|
91
|
+
const result = gateUnderTest(
|
|
75
92
|
makeTcc({
|
|
76
93
|
toolName: "read",
|
|
77
94
|
input: { path: "/custom/infra/SKILL.md" },
|
|
@@ -82,7 +99,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
82
99
|
});
|
|
83
100
|
|
|
84
101
|
it("does NOT bypass for write tools targeting infra dirs", () => {
|
|
85
|
-
const result =
|
|
102
|
+
const result = gateUnderTest(
|
|
86
103
|
makeTcc({
|
|
87
104
|
toolName: "write",
|
|
88
105
|
input: { path: "/test/agent/git/some-file.ts", content: "x" },
|
|
@@ -97,14 +114,14 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
97
114
|
// ── GateDescriptor for external paths ─────────────────────────────────��
|
|
98
115
|
|
|
99
116
|
it("returns GateDescriptor with surface 'external_directory'", () => {
|
|
100
|
-
const result =
|
|
117
|
+
const result = gateUnderTest(makeTcc(), ["/test/agent"]);
|
|
101
118
|
expect(isGateDescriptor(result)).toBe(true);
|
|
102
119
|
const desc = result as GateDescriptor;
|
|
103
120
|
expect(desc.surface).toBe("external_directory");
|
|
104
121
|
});
|
|
105
122
|
|
|
106
123
|
it("decision value is the external path", () => {
|
|
107
|
-
const result =
|
|
124
|
+
const result = gateUnderTest(
|
|
108
125
|
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
|
109
126
|
["/test/agent"],
|
|
110
127
|
) as GateDescriptor;
|
|
@@ -112,16 +129,36 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
112
129
|
expect(result.decision.surface).toBe("external_directory");
|
|
113
130
|
});
|
|
114
131
|
|
|
115
|
-
it("input
|
|
116
|
-
const result =
|
|
132
|
+
it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
|
|
133
|
+
const result = gateUnderTest(
|
|
117
134
|
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
|
118
135
|
["/test/agent"],
|
|
119
136
|
) as GateDescriptor;
|
|
120
|
-
expect(result.input).
|
|
137
|
+
expect(result.input).toEqual({});
|
|
138
|
+
expect(result.preCheck).toBeDefined();
|
|
139
|
+
expect(result.preCheck?.state).toBe("ask");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
|
|
143
|
+
const resolver = makeResolver(
|
|
144
|
+
makeCheckResult({ state: "ask", toolName: "external_directory" }),
|
|
145
|
+
);
|
|
146
|
+
gateUnderTest(
|
|
147
|
+
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
|
148
|
+
["/test/agent"],
|
|
149
|
+
undefined,
|
|
150
|
+
resolver,
|
|
151
|
+
);
|
|
152
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
153
|
+
["/outside/project/file.ts"],
|
|
154
|
+
undefined,
|
|
155
|
+
"external_directory",
|
|
156
|
+
);
|
|
157
|
+
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
121
158
|
});
|
|
122
159
|
|
|
123
160
|
it("sessionApproval uses deriveApprovalPattern", () => {
|
|
124
|
-
const result =
|
|
161
|
+
const result = gateUnderTest(
|
|
125
162
|
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
|
126
163
|
["/test/agent"],
|
|
127
164
|
) as GateDescriptor;
|
|
@@ -131,7 +168,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
131
168
|
});
|
|
132
169
|
|
|
133
170
|
it("denialContext contains the external path and cwd", () => {
|
|
134
|
-
const result =
|
|
171
|
+
const result = gateUnderTest(
|
|
135
172
|
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
|
136
173
|
["/test/agent"],
|
|
137
174
|
) as GateDescriptor;
|
|
@@ -144,7 +181,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
144
181
|
});
|
|
145
182
|
|
|
146
183
|
it("promptDetails includes path and tool_call source", () => {
|
|
147
|
-
const result =
|
|
184
|
+
const result = gateUnderTest(
|
|
148
185
|
makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
|
|
149
186
|
["/test/agent"],
|
|
150
187
|
) as GateDescriptor;
|
|
@@ -158,9 +195,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
158
195
|
});
|
|
159
196
|
|
|
160
197
|
it("logContext includes path and message", () => {
|
|
161
|
-
const result =
|
|
162
|
-
"/test/agent",
|
|
163
|
-
]) as GateDescriptor;
|
|
198
|
+
const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
|
|
164
199
|
expect(result.logContext).toMatchObject({
|
|
165
200
|
source: "tool_call",
|
|
166
201
|
path: "/outside/project/file.ts",
|
|
@@ -173,7 +208,7 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
173
208
|
|
|
174
209
|
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
|
|
175
210
|
it("gates an extension tool with an external input.path", () => {
|
|
176
|
-
const result =
|
|
211
|
+
const result = gateUnderTest(
|
|
177
212
|
makeTcc({
|
|
178
213
|
toolName: "my-ext",
|
|
179
214
|
input: { path: "/outside/project/file.ts" },
|
|
@@ -185,7 +220,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
|
|
|
185
220
|
});
|
|
186
221
|
|
|
187
222
|
it("gates an MCP tool with an external arguments.path", () => {
|
|
188
|
-
const result =
|
|
223
|
+
const result = gateUnderTest(
|
|
189
224
|
makeTcc({
|
|
190
225
|
toolName: "mcp",
|
|
191
226
|
input: { arguments: { path: "/outside/project/file.ts" } },
|
|
@@ -203,7 +238,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
|
|
|
203
238
|
typeof input.target === "string" ? input.target : undefined
|
|
204
239
|
: undefined,
|
|
205
240
|
};
|
|
206
|
-
const result =
|
|
241
|
+
const result = gateUnderTest(
|
|
207
242
|
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
|
|
208
243
|
["/test/agent"],
|
|
209
244
|
extractors,
|
|
@@ -212,7 +247,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
|
|
|
212
247
|
});
|
|
213
248
|
|
|
214
249
|
it("returns null for an extension tool whose path is inside cwd", () => {
|
|
215
|
-
const result =
|
|
250
|
+
const result = gateUnderTest(
|
|
216
251
|
makeTcc({
|
|
217
252
|
toolName: "my-ext",
|
|
218
253
|
input: { path: "/test/project/src/x.ts" },
|
|
@@ -241,12 +241,14 @@ export function makeHandler(overrides?: {
|
|
|
241
241
|
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
242
242
|
surfaceCheck,
|
|
243
243
|
);
|
|
244
|
-
// The bash path
|
|
245
|
-
// the same surface dispatcher
|
|
244
|
+
// The bash path and external-directory gates resolve through
|
|
245
|
+
// checkPathPolicy; route it through the same surface dispatcher (threading
|
|
246
|
+
// the real surface) so `path` / `external_directory` overrides apply to
|
|
247
|
+
// bash tokens and tool paths alike (#418).
|
|
246
248
|
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
247
|
-
(values, agentName, sessionRules) =>
|
|
249
|
+
(values, agentName, sessionRules, surface = "path") =>
|
|
248
250
|
surfaceCheck(
|
|
249
|
-
|
|
251
|
+
surface,
|
|
250
252
|
{ path: values[0] ?? "*" },
|
|
251
253
|
agentName,
|
|
252
254
|
sessionRules,
|
package/test/path-utils.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ vi.mock("node:fs", () => ({
|
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalNormalizePathForComparison,
|
|
25
|
+
getExternalDirectoryPolicyValues,
|
|
25
26
|
getPathBearingToolPath,
|
|
26
27
|
getPathPolicyValues,
|
|
27
28
|
getToolInputPath,
|
|
@@ -614,3 +615,36 @@ describe("getPathPolicyValues", () => {
|
|
|
614
615
|
expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
|
|
615
616
|
});
|
|
616
617
|
});
|
|
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
|
+
});
|
|
@@ -8,7 +8,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { describe, expect, it, test } from "vitest";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getGlobalConfigPath,
|
|
13
|
+
getProjectAgentsDir,
|
|
14
|
+
getProjectConfigPath,
|
|
15
|
+
} from "#src/config-paths";
|
|
12
16
|
import {
|
|
13
17
|
PermissionManager,
|
|
14
18
|
type ScopedPermissionManager,
|
|
@@ -1669,6 +1673,53 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
|
|
|
1669
1673
|
cleanup();
|
|
1670
1674
|
}
|
|
1671
1675
|
});
|
|
1676
|
+
|
|
1677
|
+
it("configureForCwd(cwd) derives projectAgentsDir at <cwd>/.pi/agents (regression: #428)", () => {
|
|
1678
|
+
// Bug: old code derived <cwd>/.pi/agent/agents instead of <cwd>/.pi/agents.
|
|
1679
|
+
// This test pins the correct path and verifies agentsDir is unchanged.
|
|
1680
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1681
|
+
globalPermission: { read: "allow" },
|
|
1682
|
+
});
|
|
1683
|
+
try {
|
|
1684
|
+
const manager = new PermissionManager({ agentDir });
|
|
1685
|
+
manager.configureForCwd(cwd);
|
|
1686
|
+
const paths = manager.getResolvedPolicyPaths();
|
|
1687
|
+
expect(paths.projectAgentsDir).toBe(getProjectAgentsDir(cwd));
|
|
1688
|
+
expect(paths.agentsDir).toBe(join(agentDir, "agents"));
|
|
1689
|
+
} finally {
|
|
1690
|
+
cleanup();
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
it("configureForCwd(cwd) enforces permission: frontmatter from <cwd>/.pi/agents/<agent>.md (regression: #428)", () => {
|
|
1695
|
+
// Bug: wrong directory meant project-agent frontmatter was never loaded.
|
|
1696
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1697
|
+
globalPermission: { read: "allow" },
|
|
1698
|
+
});
|
|
1699
|
+
try {
|
|
1700
|
+
// Write a project agent definition with a deny override.
|
|
1701
|
+
const projectAgentsDir = getProjectAgentsDir(cwd);
|
|
1702
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
1703
|
+
writeFileSync(
|
|
1704
|
+
join(projectAgentsDir, "coder.md"),
|
|
1705
|
+
"---\npermission:\n read: deny\n---\n# Coder\n",
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
const manager = new PermissionManager({ agentDir });
|
|
1709
|
+
manager.configureForCwd(cwd);
|
|
1710
|
+
|
|
1711
|
+
// Without an agent name: global allow applies.
|
|
1712
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1713
|
+
"allow",
|
|
1714
|
+
);
|
|
1715
|
+
// With the "coder" agent: project-agent deny overrides global allow.
|
|
1716
|
+
expect(
|
|
1717
|
+
manager.checkPermission("read", { path: "foo.txt" }, "coder").state,
|
|
1718
|
+
).toBe("deny");
|
|
1719
|
+
} finally {
|
|
1720
|
+
cleanup();
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1672
1723
|
});
|
|
1673
1724
|
|
|
1674
1725
|
// ---------------------------------------------------------------------------
|
|
@@ -3279,4 +3330,40 @@ describe("checkPathPolicy", () => {
|
|
|
3279
3330
|
cleanup();
|
|
3280
3331
|
}
|
|
3281
3332
|
});
|
|
3333
|
+
|
|
3334
|
+
it("evaluates against the external_directory surface when one is provided", () => {
|
|
3335
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3336
|
+
external_directory: { "*": "ask", "/tmp/*": "allow" },
|
|
3337
|
+
});
|
|
3338
|
+
try {
|
|
3339
|
+
const result = manager.checkPathPolicy(
|
|
3340
|
+
["/tmp/x"],
|
|
3341
|
+
undefined,
|
|
3342
|
+
undefined,
|
|
3343
|
+
"external_directory",
|
|
3344
|
+
);
|
|
3345
|
+
expect(result.state).toBe("allow");
|
|
3346
|
+
expect(result.matchedPattern).toBe("/tmp/*");
|
|
3347
|
+
expect(result.source).toBe("special");
|
|
3348
|
+
expect(result.toolName).toBe("external_directory");
|
|
3349
|
+
} finally {
|
|
3350
|
+
cleanup();
|
|
3351
|
+
}
|
|
3352
|
+
});
|
|
3353
|
+
|
|
3354
|
+
it("defaults to the path surface when no surface is provided", () => {
|
|
3355
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3356
|
+
external_directory: { "*": "ask", "/tmp/*": "allow" },
|
|
3357
|
+
path: { "*": "allow" },
|
|
3358
|
+
});
|
|
3359
|
+
try {
|
|
3360
|
+
// No path rule denies; the external_directory allow must NOT apply here.
|
|
3361
|
+
const result = manager.checkPathPolicy(["/tmp/x"]);
|
|
3362
|
+
expect(result.toolName).toBe("path");
|
|
3363
|
+
expect(result.state).toBe("allow");
|
|
3364
|
+
expect(result.matchedPattern).toBe("*");
|
|
3365
|
+
} finally {
|
|
3366
|
+
cleanup();
|
|
3367
|
+
}
|
|
3368
|
+
});
|
|
3282
3369
|
});
|
|
@@ -143,6 +143,20 @@ describe("PermissionResolver", () => {
|
|
|
143
143
|
["/proj/src/a.ts", "src/a.ts"],
|
|
144
144
|
"agent-x",
|
|
145
145
|
[],
|
|
146
|
+
"path",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("forwards an explicit surface to checkPathPolicy", () => {
|
|
151
|
+
const { resolver, permissionManager } = makeResolver();
|
|
152
|
+
|
|
153
|
+
resolver.resolvePathPolicy(["/tmp/x"], "agent-x", "external_directory");
|
|
154
|
+
|
|
155
|
+
expect(permissionManager.checkPathPolicy).toHaveBeenCalledWith(
|
|
156
|
+
["/tmp/x"],
|
|
157
|
+
"agent-x",
|
|
158
|
+
[],
|
|
159
|
+
"external_directory",
|
|
146
160
|
);
|
|
147
161
|
});
|
|
148
162
|
|