@gotgenes/pi-permission-system 17.1.0 → 17.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +16 -12
- package/src/access-intent/bash/token-classification.ts +23 -0
- package/src/handlers/before-agent-start.ts +1 -2
- package/src/handlers/gates/external-directory.ts +2 -12
- package/src/handlers/gates/skill-read.ts +4 -8
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -2
- package/src/path-normalizer.ts +30 -0
- package/src/skill-prompt-sanitizer.ts +8 -21
- package/test/access-intent/bash/token-classification.test.ts +75 -0
- package/test/bash-external-directory.test.ts +38 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -3
- package/test/handlers/gates/external-directory.test.ts +0 -1
- package/test/handlers/gates/skill-read.test.ts +16 -12
- package/test/path-normalizer.test.ts +43 -0
- package/test/skill-prompt-sanitizer.test.ts +31 -65
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [17.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.0...pi-permission-system-v17.1.1) (2026-06-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** gate Windows drive-letter paths in bash external_directory ([#508](https://github.com/gotgenes/pi-packages/issues/508)) ([2d33183](https://github.com/gotgenes/pi-packages/commit/2d331834254f58bcb0782b3da353132821536296))
|
|
14
|
+
|
|
8
15
|
## [17.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.0.0...pi-permission-system-v17.1.0) (2026-06-28)
|
|
9
16
|
|
|
10
17
|
|
package/package.json
CHANGED
|
@@ -347,7 +347,7 @@ export class BashPathResolver {
|
|
|
347
347
|
// anywhere, so flag it conservatively (resolved against the baked cwd
|
|
348
348
|
// only for a display path). Absolute / `~` candidates are base-independent
|
|
349
349
|
// below.
|
|
350
|
-
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
350
|
+
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
|
351
351
|
const accessPath = this.normalizer.forPath(candidate);
|
|
352
352
|
const canonical = accessPath.boundaryValue();
|
|
353
353
|
if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
|
|
@@ -421,7 +421,7 @@ export class BashPathResolver {
|
|
|
421
421
|
// An unknown base + relative candidate stays literal-only: a resolved
|
|
422
422
|
// absolute or canonical alias would resolve against the wrong directory and
|
|
423
423
|
// could spuriously match a rule (#393).
|
|
424
|
-
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
424
|
+
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
|
425
425
|
return this.normalizer.forLiteral(normalizePathPolicyLiteral(candidate));
|
|
426
426
|
}
|
|
427
427
|
|
|
@@ -431,6 +431,20 @@ export class BashPathResolver {
|
|
|
431
431
|
: undefined;
|
|
432
432
|
return this.normalizer.forPath(candidate, { resolveBase });
|
|
433
433
|
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* True when a path candidate is relative (resolved against the effective
|
|
437
|
+
* directory) rather than absolute or home-relative (`~…`), which are
|
|
438
|
+
* base-independent.
|
|
439
|
+
*
|
|
440
|
+
* Delegates the absoluteness decision to the platform-aware `PathNormalizer`
|
|
441
|
+
* rather than a POSIX-only `startsWith("/")` check, so Windows drive-letter
|
|
442
|
+
* paths (`C:/…`, `C:\…`) are correctly treated as absolute on win32 and as
|
|
443
|
+
* relative on POSIX (where they denote an in-CWD path).
|
|
444
|
+
*/
|
|
445
|
+
private isRelativeCandidate(candidate: string): boolean {
|
|
446
|
+
return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~");
|
|
447
|
+
}
|
|
434
448
|
}
|
|
435
449
|
|
|
436
450
|
// ── Pure AST/string helpers ──────────────────────────────────────────────────
|
|
@@ -519,13 +533,3 @@ function literalTextOf(node: TSNode): string | null {
|
|
|
519
533
|
return null;
|
|
520
534
|
}
|
|
521
535
|
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* True when a path candidate is relative (resolved against the effective
|
|
525
|
-
* directory) rather than absolute (`/…`) or home-relative (`~…`), which are
|
|
526
|
-
* base-independent.
|
|
527
|
-
* Used to decide which candidates an unknown base affects.
|
|
528
|
-
*/
|
|
529
|
-
function isRelativeCandidate(candidate: string): boolean {
|
|
530
|
-
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
531
|
-
}
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* Both classifiers share the private `rejectNonPathToken` predicate that captures
|
|
9
9
|
* the seven rejection cases common to both (the production clone this module was
|
|
10
10
|
* extracted to eliminate).
|
|
11
|
+
*
|
|
12
|
+
* Both classifiers recognize Windows drive-letter absolute paths (`C:/…`, `C:\…`)
|
|
13
|
+
* unconditionally on all platforms. On POSIX the token resolves as a real in-CWD
|
|
14
|
+
* relative path and is gated by the `path` surface; on Windows the `PathNormalizer`
|
|
15
|
+
* routes it through the absolute-path branch. Shape recognition is platform-independent
|
|
16
|
+
* string matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
@@ -19,6 +25,7 @@
|
|
|
19
25
|
* - Absolute paths (starting with `/`)
|
|
20
26
|
* - Home-relative paths (starting with `~/`)
|
|
21
27
|
* - Parent-traversal paths (containing `..`)
|
|
28
|
+
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
|
|
22
29
|
*
|
|
23
30
|
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
24
31
|
*/
|
|
@@ -28,6 +35,7 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
|
|
|
28
35
|
if (token.startsWith("/")) return token;
|
|
29
36
|
if (token.startsWith("~/")) return token;
|
|
30
37
|
if (token.includes("..")) return token;
|
|
38
|
+
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
|
|
31
39
|
|
|
32
40
|
return null;
|
|
33
41
|
}
|
|
@@ -38,8 +46,13 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
|
|
|
38
46
|
* Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
|
|
39
47
|
* - Dot-files and `./`-relative paths (starting with `.`)
|
|
40
48
|
* - Any relative path containing `/` (e.g. `src/foo.ts`)
|
|
49
|
+
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
|
|
41
50
|
*
|
|
42
51
|
* The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
|
|
52
|
+
* The forward-slash drive form (`C:/…`) is also caught by `includes("/")`, but the
|
|
53
|
+
* explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
|
|
54
|
+
* and order-independent, and covers the backslash-only form (`D:\…`) which `includes("/")`
|
|
55
|
+
* cannot reach.
|
|
43
56
|
*
|
|
44
57
|
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
|
|
45
58
|
* gate that the external-directory classifier uses.
|
|
@@ -52,12 +65,22 @@ export function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
|
52
65
|
if (token.startsWith(".")) return token;
|
|
53
66
|
if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
|
|
54
67
|
if (token.includes("..")) return token; // bare ".." (no slash)
|
|
68
|
+
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
|
|
55
69
|
|
|
56
70
|
return null;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// ── Private rejection predicate ────────────────────────────────────────────
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Windows drive-letter absolute path: a single ASCII letter, a colon, then a
|
|
77
|
+
* separator (`/` or `\`). Matches `C:/…` and `C:\…` but not drive-relative
|
|
78
|
+
* `C:foo` (no separator) or multi-letter schemes (`https:`, `mailto:`).
|
|
79
|
+
* Single-letter schemes with `//` (e.g. `c://x`) are already rejected by
|
|
80
|
+
* `URL_PATTERN` before this pattern is tested.
|
|
81
|
+
*/
|
|
82
|
+
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
|
|
83
|
+
|
|
61
84
|
/**
|
|
62
85
|
* URL pattern to skip tokens that look like URLs rather than paths.
|
|
63
86
|
*/
|
|
@@ -84,8 +84,7 @@ export class AgentPrepHandler {
|
|
|
84
84
|
toolPromptResult.prompt,
|
|
85
85
|
this.resolver,
|
|
86
86
|
agentName,
|
|
87
|
-
|
|
88
|
-
this.session.getPlatform(),
|
|
87
|
+
this.session.getPathNormalizer(),
|
|
89
88
|
);
|
|
90
89
|
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
|
91
90
|
return skillPromptResult.prompt !== event.systemPrompt
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
|
-
import { getToolInputPath
|
|
2
|
+
import { getToolInputPath } from "#src/path-utils";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import { SessionApproval } from "#src/session-approval";
|
|
5
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -22,7 +22,6 @@ export function describeExternalDirectoryGate(
|
|
|
22
22
|
infraDirs: string[],
|
|
23
23
|
resolver: ScopedPermissionResolver,
|
|
24
24
|
normalizer: PathNormalizer,
|
|
25
|
-
platform: NodeJS.Platform,
|
|
26
25
|
extractors?: ToolAccessExtractorLookup,
|
|
27
26
|
): GateResult {
|
|
28
27
|
const externalDirectoryPath = getToolInputPath(
|
|
@@ -40,18 +39,9 @@ export function describeExternalDirectoryGate(
|
|
|
40
39
|
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
41
40
|
// uses the typed and resolved aliases (#418).
|
|
42
41
|
const accessPath = normalizer.forPath(externalDirectoryPath);
|
|
43
|
-
const canonicalExtPath = accessPath.boundaryValue();
|
|
44
42
|
|
|
45
43
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
46
|
-
if (
|
|
47
|
-
isPiInfrastructureRead(
|
|
48
|
-
tcc.toolName,
|
|
49
|
-
canonicalExtPath,
|
|
50
|
-
infraDirs,
|
|
51
|
-
tcc.cwd,
|
|
52
|
-
platform,
|
|
53
|
-
)
|
|
54
|
-
) {
|
|
44
|
+
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
|
|
55
45
|
return {
|
|
56
46
|
action: "allow",
|
|
57
47
|
log: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
2
|
import { formatSkillPathAskPrompt } from "#src/permission-prompts";
|
|
3
3
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
4
4
|
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
|
@@ -15,7 +15,7 @@ import type { ToolCallContext } from "./types";
|
|
|
15
15
|
*/
|
|
16
16
|
export function describeSkillReadGate(
|
|
17
17
|
tcc: ToolCallContext,
|
|
18
|
-
|
|
18
|
+
normalizer: PathNormalizer,
|
|
19
19
|
getActiveSkillEntries: () => SkillPromptEntry[],
|
|
20
20
|
): GateDescriptor | null {
|
|
21
21
|
const activeSkillEntries = getActiveSkillEntries();
|
|
@@ -30,15 +30,11 @@ export function describeSkillReadGate(
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const normalizedReadPath =
|
|
34
|
-
path,
|
|
35
|
-
tcc.cwd,
|
|
36
|
-
platform,
|
|
37
|
-
);
|
|
33
|
+
const normalizedReadPath = normalizer.comparableValue(path);
|
|
38
34
|
const matchedSkill = findSkillPathMatch(
|
|
39
35
|
normalizedReadPath,
|
|
40
36
|
activeSkillEntries,
|
|
41
|
-
|
|
37
|
+
normalizer,
|
|
42
38
|
);
|
|
43
39
|
|
|
44
40
|
if (!matchedSkill) {
|
|
@@ -85,7 +85,7 @@ export class ToolCallGatePipeline {
|
|
|
85
85
|
|
|
86
86
|
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
87
87
|
() =>
|
|
88
|
-
describeSkillReadGate(tcc,
|
|
88
|
+
describeSkillReadGate(tcc, normalizer, () =>
|
|
89
89
|
this.inputs.getActiveSkillEntries(),
|
|
90
90
|
),
|
|
91
91
|
() =>
|
|
@@ -96,7 +96,6 @@ export class ToolCallGatePipeline {
|
|
|
96
96
|
infraDirs,
|
|
97
97
|
this.resolver,
|
|
98
98
|
normalizer,
|
|
99
|
-
platform,
|
|
100
99
|
this.customExtractors,
|
|
101
100
|
),
|
|
102
101
|
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
package/src/path-normalizer.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { AccessPath } from "./access-intent/access-path";
|
|
|
4
4
|
import {
|
|
5
5
|
isPathOutsideWorkingDirectory,
|
|
6
6
|
isPathWithinDirectory,
|
|
7
|
+
isPiInfrastructureRead,
|
|
8
|
+
normalizePathForComparison,
|
|
7
9
|
} from "./path-utils";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -67,4 +69,32 @@ export class PathNormalizer {
|
|
|
67
69
|
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
68
70
|
return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
|
|
69
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lexical (not symlink-resolved) comparison value, resolved against the baked
|
|
75
|
+
* cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
|
|
76
|
+
* touches no filesystem, unlike {@link forPath}'s canonical alias.
|
|
77
|
+
*/
|
|
78
|
+
comparableValue(pathValue: string): string {
|
|
79
|
+
return normalizePathForComparison(pathValue, this.cwd, this.platform);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pi infrastructure-read containment for a read-only tool, decided against
|
|
84
|
+
* the canonical (symlink-resolved) path and the baked cwd/platform. Takes the
|
|
85
|
+
* already-built {@link AccessPath} so the caller does not re-resolve it.
|
|
86
|
+
*/
|
|
87
|
+
isInfrastructureRead(
|
|
88
|
+
toolName: string,
|
|
89
|
+
accessPath: AccessPath,
|
|
90
|
+
infraDirs: readonly string[],
|
|
91
|
+
): boolean {
|
|
92
|
+
return isPiInfrastructureRead(
|
|
93
|
+
toolName,
|
|
94
|
+
accessPath.boundaryValue(),
|
|
95
|
+
infraDirs,
|
|
96
|
+
this.cwd,
|
|
97
|
+
this.platform,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
70
100
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { dirname } from "node:path";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
isPathWithinDirectory,
|
|
5
|
-
normalizePathForComparison,
|
|
6
|
-
} from "./path-utils";
|
|
3
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
7
4
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
8
5
|
|
|
9
6
|
/**
|
|
@@ -151,24 +148,15 @@ function resolvePermissionState(
|
|
|
151
148
|
function createResolvedSkillEntry(
|
|
152
149
|
entry: ParsedSkillPromptEntry,
|
|
153
150
|
state: PermissionState,
|
|
154
|
-
|
|
155
|
-
platform: NodeJS.Platform,
|
|
151
|
+
normalizer: PathNormalizer,
|
|
156
152
|
): SkillPromptEntry {
|
|
157
153
|
return {
|
|
158
154
|
name: entry.name,
|
|
159
155
|
description: entry.description,
|
|
160
156
|
location: entry.location,
|
|
161
157
|
state,
|
|
162
|
-
normalizedLocation:
|
|
163
|
-
|
|
164
|
-
cwd,
|
|
165
|
-
platform,
|
|
166
|
-
),
|
|
167
|
-
normalizedBaseDir: normalizePathForComparison(
|
|
168
|
-
dirname(entry.location),
|
|
169
|
-
cwd,
|
|
170
|
-
platform,
|
|
171
|
-
),
|
|
158
|
+
normalizedLocation: normalizer.comparableValue(entry.location),
|
|
159
|
+
normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
|
|
172
160
|
};
|
|
173
161
|
}
|
|
174
162
|
|
|
@@ -198,8 +186,7 @@ export function resolveSkillPromptEntries(
|
|
|
198
186
|
prompt: string,
|
|
199
187
|
permissionManager: SkillPermissionChecker,
|
|
200
188
|
agentName: string | null,
|
|
201
|
-
|
|
202
|
-
platform: NodeJS.Platform,
|
|
189
|
+
normalizer: PathNormalizer,
|
|
203
190
|
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
204
191
|
const sections = parseAllSkillPromptSections(prompt);
|
|
205
192
|
if (sections.length === 0) {
|
|
@@ -219,7 +206,7 @@ export function resolveSkillPromptEntries(
|
|
|
219
206
|
agentName,
|
|
220
207
|
permissionCache,
|
|
221
208
|
);
|
|
222
|
-
return createResolvedSkillEntry(entry, state,
|
|
209
|
+
return createResolvedSkillEntry(entry, state, normalizer);
|
|
223
210
|
});
|
|
224
211
|
|
|
225
212
|
const visibleSectionEntries = resolvedEntries.filter(
|
|
@@ -267,7 +254,7 @@ export function resolveSkillPromptEntries(
|
|
|
267
254
|
export function findSkillPathMatch(
|
|
268
255
|
normalizedPath: string,
|
|
269
256
|
entries: readonly SkillPromptEntry[],
|
|
270
|
-
|
|
257
|
+
normalizer: PathNormalizer,
|
|
271
258
|
): SkillPromptEntry | null {
|
|
272
259
|
if (!normalizedPath || entries.length === 0) {
|
|
273
260
|
return null;
|
|
@@ -286,7 +273,7 @@ export function findSkillPathMatch(
|
|
|
286
273
|
for (const entry of entries) {
|
|
287
274
|
if (
|
|
288
275
|
!entry.normalizedBaseDir ||
|
|
289
|
-
!
|
|
276
|
+
!normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)
|
|
290
277
|
) {
|
|
291
278
|
continue;
|
|
292
279
|
}
|
|
@@ -114,6 +114,40 @@ describe("classifyTokenAsPathCandidate", () => {
|
|
|
114
114
|
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("Windows drive-letter acceptance gate", () => {
|
|
119
|
+
test("forward-slash drive path → returned as-is", () => {
|
|
120
|
+
expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
|
|
121
|
+
"C:/Windows/win.ini",
|
|
122
|
+
);
|
|
123
|
+
expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
|
|
124
|
+
"D:/secrets/password.txt",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("backslash drive path → returned as-is", () => {
|
|
129
|
+
expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
|
|
130
|
+
"C:\\Windows\\win.ini",
|
|
131
|
+
);
|
|
132
|
+
expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
|
|
133
|
+
"D:\\secrets\\password.txt",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("lowercase drive letter → returned as-is", () => {
|
|
138
|
+
expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
|
|
142
|
+
// c:// matches URL_PATTERN before the drive-letter check runs.
|
|
143
|
+
expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("drive-relative path without separator (C:foo) → null", () => {
|
|
147
|
+
// No / or \ after the colon — not an absolute drive path per node:path.
|
|
148
|
+
expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
117
151
|
});
|
|
118
152
|
|
|
119
153
|
describe("classifyTokenAsRuleCandidate", () => {
|
|
@@ -204,6 +238,35 @@ describe("classifyTokenAsRuleCandidate", () => {
|
|
|
204
238
|
});
|
|
205
239
|
});
|
|
206
240
|
|
|
241
|
+
describe("Windows drive-letter acceptance gate", () => {
|
|
242
|
+
test("forward-slash drive path → returned as-is", () => {
|
|
243
|
+
// Forward-slash form was already accepted via token.includes("/").
|
|
244
|
+
// The explicit branch makes it first-class and order-independent.
|
|
245
|
+
expect(classifyTokenAsRuleCandidate("C:/Windows/win.ini")).toBe(
|
|
246
|
+
"C:/Windows/win.ini",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("backslash drive path → returned as-is (new: no forward slash)", () => {
|
|
251
|
+
// Previously dropped by both classifiers; the backslash form has no /
|
|
252
|
+
// so the includes("/") branch could not catch it.
|
|
253
|
+
expect(classifyTokenAsRuleCandidate("D:\\secrets\\password.txt")).toBe(
|
|
254
|
+
"D:\\secrets\\password.txt",
|
|
255
|
+
);
|
|
256
|
+
expect(classifyTokenAsRuleCandidate("C:\\Windows\\win.ini")).toBe(
|
|
257
|
+
"C:\\Windows\\win.ini",
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("lowercase drive letter (backslash) → returned as-is", () => {
|
|
262
|
+
expect(classifyTokenAsRuleCandidate("c:\\foo")).toBe("c:\\foo");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("drive-relative path without separator (C:foo) → null", () => {
|
|
266
|
+
expect(classifyTokenAsRuleCandidate("C:foo")).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
207
270
|
describe("rule-vs-path divergence", () => {
|
|
208
271
|
const dotFiles = [".env", ".gitignore", ".eslintrc"];
|
|
209
272
|
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
|
|
@@ -230,6 +293,18 @@ describe("classifyTokenAsRuleCandidate", () => {
|
|
|
230
293
|
});
|
|
231
294
|
}
|
|
232
295
|
|
|
296
|
+
const winDrivePaths = [
|
|
297
|
+
"C:/Windows/win.ini",
|
|
298
|
+
"D:\\secrets\\password.txt",
|
|
299
|
+
"c:/foo",
|
|
300
|
+
];
|
|
301
|
+
for (const tok of winDrivePaths) {
|
|
302
|
+
test(`Windows drive path "${tok}": both classifiers accept`, () => {
|
|
303
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
304
|
+
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
233
308
|
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
|
|
234
309
|
for (const tok of sharedRejected) {
|
|
235
310
|
test(`"${tok}": both classifiers reject`, () => {
|
|
@@ -953,6 +953,44 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
953
953
|
});
|
|
954
954
|
});
|
|
955
955
|
|
|
956
|
+
describe("Windows drive-letter paths (win32 semantics)", () => {
|
|
957
|
+
const windowsCwd = "C:/projects/app";
|
|
958
|
+
|
|
959
|
+
async function extractWin32(command: string): Promise<string[]> {
|
|
960
|
+
return extractWithNormalizer(
|
|
961
|
+
command,
|
|
962
|
+
new PathNormalizer("win32", windowsCwd),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
test("forward-slash drive path outside CWD is flagged", async () => {
|
|
967
|
+
const result = await extractWin32("cat C:/Windows/win.ini");
|
|
968
|
+
expect(result).not.toHaveLength(0);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("different drive letter outside CWD is flagged", async () => {
|
|
972
|
+
const result = await extractWin32("cat D:/secrets/password.txt");
|
|
973
|
+
expect(result).not.toHaveLength(0);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test("drive path inside CWD is not flagged (known base)", async () => {
|
|
977
|
+
const result = await extractWin32("cat C:/projects/app/inside.txt");
|
|
978
|
+
expect(result).toHaveLength(0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
|
|
982
|
+
// Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
|
|
983
|
+
// check treats C:/ as relative on win32, so the unknown-base conservative
|
|
984
|
+
// branch fires and over-flags an inside-CWD drive path.
|
|
985
|
+
// After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
|
|
986
|
+
// and routes to the resolved branch with its inside-CWD check.
|
|
987
|
+
const result = await extractWin32(
|
|
988
|
+
'cd "$D" && cat C:/projects/app/inside.txt',
|
|
989
|
+
);
|
|
990
|
+
expect(result).toHaveLength(0);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
956
994
|
describe("bash external-directory denial messages (centralized)", () => {
|
|
957
995
|
test("denial message includes command, paths, and extension tag", () => {
|
|
958
996
|
const result = formatDenyReason({
|
|
@@ -89,7 +89,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
89
89
|
[],
|
|
90
90
|
resolver,
|
|
91
91
|
new PathNormalizer(process.platform, cwd),
|
|
92
|
-
"linux",
|
|
93
92
|
);
|
|
94
93
|
expect(isGateDescriptor(result)).toBe(true);
|
|
95
94
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
@@ -113,7 +112,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
113
112
|
[],
|
|
114
113
|
resolver,
|
|
115
114
|
new PathNormalizer(process.platform, cwd),
|
|
116
|
-
"linux",
|
|
117
115
|
);
|
|
118
116
|
expect(isGateDescriptor(result)).toBe(true);
|
|
119
117
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
@@ -132,7 +130,6 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
132
130
|
[],
|
|
133
131
|
resolver,
|
|
134
132
|
new PathNormalizer(process.platform, cwd),
|
|
135
|
-
"linux",
|
|
136
133
|
);
|
|
137
134
|
expect(isGateDescriptor(result)).toBe(true);
|
|
138
135
|
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { describeSkillReadGate } from "#src/handlers/gates/skill-read";
|
|
3
3
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
4
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
4
5
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
5
6
|
|
|
7
|
+
// All test tccs use cwd "/test/project"; one normalizer serves every call.
|
|
8
|
+
const normalizer = new PathNormalizer("linux", "/test/project");
|
|
9
|
+
|
|
6
10
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
7
11
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
8
12
|
const original =
|
|
@@ -43,21 +47,21 @@ describe("describeSkillReadGate", () => {
|
|
|
43
47
|
it("returns null when tool is not read", () => {
|
|
44
48
|
const result = describeSkillReadGate(
|
|
45
49
|
makeTcc({ toolName: "write" }),
|
|
46
|
-
|
|
50
|
+
normalizer,
|
|
47
51
|
() => [makeSkillEntry()],
|
|
48
52
|
);
|
|
49
53
|
expect(result).toBeNull();
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
it("returns null when no active skill entries", () => {
|
|
53
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
57
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => []);
|
|
54
58
|
expect(result).toBeNull();
|
|
55
59
|
});
|
|
56
60
|
|
|
57
61
|
it("returns null when read path does not match any skill", () => {
|
|
58
62
|
const result = describeSkillReadGate(
|
|
59
63
|
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
|
|
60
|
-
|
|
64
|
+
normalizer,
|
|
61
65
|
() => [makeSkillEntry()],
|
|
62
66
|
);
|
|
63
67
|
expect(result).toBeNull();
|
|
@@ -66,14 +70,14 @@ describe("describeSkillReadGate", () => {
|
|
|
66
70
|
it("returns null when input has no path", () => {
|
|
67
71
|
const result = describeSkillReadGate(
|
|
68
72
|
makeTcc({ input: {} }),
|
|
69
|
-
|
|
73
|
+
normalizer,
|
|
70
74
|
() => [makeSkillEntry()],
|
|
71
75
|
);
|
|
72
76
|
expect(result).toBeNull();
|
|
73
77
|
});
|
|
74
78
|
|
|
75
79
|
it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
|
|
76
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
80
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
77
81
|
makeSkillEntry({ state: "ask" }),
|
|
78
82
|
]);
|
|
79
83
|
expect(result).not.toBeNull();
|
|
@@ -82,7 +86,7 @@ describe("describeSkillReadGate", () => {
|
|
|
82
86
|
});
|
|
83
87
|
|
|
84
88
|
it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
|
|
85
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
89
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
86
90
|
makeSkillEntry({ state: "allow" }),
|
|
87
91
|
]);
|
|
88
92
|
expect(result).not.toBeNull();
|
|
@@ -91,7 +95,7 @@ describe("describeSkillReadGate", () => {
|
|
|
91
95
|
});
|
|
92
96
|
|
|
93
97
|
it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
|
|
94
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
98
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
95
99
|
makeSkillEntry({ state: "deny" }),
|
|
96
100
|
]);
|
|
97
101
|
expect(result).not.toBeNull();
|
|
@@ -100,7 +104,7 @@ describe("describeSkillReadGate", () => {
|
|
|
100
104
|
});
|
|
101
105
|
|
|
102
106
|
it("decision surface is 'skill' and decision value is the skill name", () => {
|
|
103
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
107
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
104
108
|
makeSkillEntry({ name: "my-skill" }),
|
|
105
109
|
])!;
|
|
106
110
|
expect(result.decision.surface).toBe("skill");
|
|
@@ -108,7 +112,7 @@ describe("describeSkillReadGate", () => {
|
|
|
108
112
|
});
|
|
109
113
|
|
|
110
114
|
it("denialContext contains the skill name and read path", () => {
|
|
111
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
115
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
112
116
|
makeSkillEntry({ name: "librarian" }),
|
|
113
117
|
])!;
|
|
114
118
|
expect(result.denialContext).toEqual({
|
|
@@ -122,7 +126,7 @@ describe("describeSkillReadGate", () => {
|
|
|
122
126
|
it("promptDetails includes skill_read source and skillName", () => {
|
|
123
127
|
const result = describeSkillReadGate(
|
|
124
128
|
makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
|
|
125
|
-
|
|
129
|
+
normalizer,
|
|
126
130
|
() => [makeSkillEntry({ name: "my-skill" })],
|
|
127
131
|
)!;
|
|
128
132
|
expect(result.promptDetails).toMatchObject({
|
|
@@ -138,7 +142,7 @@ describe("describeSkillReadGate", () => {
|
|
|
138
142
|
it("logContext includes skill_read source and skillName", () => {
|
|
139
143
|
const result = describeSkillReadGate(
|
|
140
144
|
makeTcc({ agentName: "agent-1" }),
|
|
141
|
-
|
|
145
|
+
normalizer,
|
|
142
146
|
() => [makeSkillEntry({ name: "librarian" })],
|
|
143
147
|
)!;
|
|
144
148
|
expect(result.logContext).toMatchObject({
|
|
@@ -149,7 +153,7 @@ describe("describeSkillReadGate", () => {
|
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
it("surface is 'skill' on the descriptor", () => {
|
|
152
|
-
const result = describeSkillReadGate(makeTcc(),
|
|
156
|
+
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
|
|
153
157
|
makeSkillEntry(),
|
|
154
158
|
])!;
|
|
155
159
|
expect(result.surface).toBe("skill");
|
|
@@ -77,6 +77,13 @@ describe("PathNormalizer", () => {
|
|
|
77
77
|
);
|
|
78
78
|
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
test("comparableValue returns the lexical absolute form (no FS)", () => {
|
|
82
|
+
expect(normalizer.comparableValue("src/foo.ts")).toBe(
|
|
83
|
+
"/projects/my-app/src/foo.ts",
|
|
84
|
+
);
|
|
85
|
+
expect(normalizer.comparableValue("/etc/hosts")).toBe("/etc/hosts");
|
|
86
|
+
});
|
|
80
87
|
});
|
|
81
88
|
|
|
82
89
|
describe("win32 flavor", () => {
|
|
@@ -119,5 +126,41 @@ describe("PathNormalizer", () => {
|
|
|
119
126
|
).toBe(false);
|
|
120
127
|
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
|
|
121
128
|
});
|
|
129
|
+
|
|
130
|
+
test("comparableValue case-folds the lexical absolute form", () => {
|
|
131
|
+
expect(normalizer.comparableValue("src\\foo.ts")).toBe(
|
|
132
|
+
"c:\\projects\\app\\src\\foo.ts",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("isInfrastructureRead", () => {
|
|
138
|
+
const normalizer = new PathNormalizer("linux", "/projects/my-app");
|
|
139
|
+
|
|
140
|
+
test("allows a read-only tool targeting a configured infra dir", () => {
|
|
141
|
+
const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
|
|
142
|
+
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not allow a write tool targeting an infra dir", () => {
|
|
148
|
+
const ap = normalizer.forPath("/infra/git/pkg/file.ts");
|
|
149
|
+
expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
|
|
150
|
+
false,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does not allow a read-only tool outside any infra dir", () => {
|
|
155
|
+
const ap = normalizer.forPath("/elsewhere/file.ts");
|
|
156
|
+
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
|
|
157
|
+
false,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
|
|
162
|
+
const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
|
|
163
|
+
expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
|
|
164
|
+
});
|
|
122
165
|
});
|
|
123
166
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
3
4
|
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
4
5
|
import {
|
|
5
6
|
findSkillPathMatch,
|
|
@@ -30,6 +31,11 @@ afterEach(() => {
|
|
|
30
31
|
|
|
31
32
|
const CWD = "/projects/my-app";
|
|
32
33
|
|
|
34
|
+
// `findSkillPathMatch` only uses the normalizer's platform (it compares two
|
|
35
|
+
// already-absolute paths via `isWithinDirectory`), so this CWD-baked instance
|
|
36
|
+
// serves every call regardless of the entries' cwd.
|
|
37
|
+
const normalizer = new PathNormalizer("linux", CWD);
|
|
38
|
+
|
|
33
39
|
function makeManager(
|
|
34
40
|
defaultState: "allow" | "deny" | "ask" = "allow",
|
|
35
41
|
overrides: Record<string, "allow" | "deny" | "ask"> = {},
|
|
@@ -72,13 +78,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
72
78
|
test("returns unchanged prompt and empty entries when no skills section present", () => {
|
|
73
79
|
const input = "You are a helpful assistant.";
|
|
74
80
|
const manager = makeManager("allow");
|
|
75
|
-
const result = resolveSkillPromptEntries(
|
|
76
|
-
input,
|
|
77
|
-
manager,
|
|
78
|
-
null,
|
|
79
|
-
CWD,
|
|
80
|
-
"linux",
|
|
81
|
-
);
|
|
81
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
82
82
|
expect(result.prompt).toBe(input);
|
|
83
83
|
expect(result.entries).toEqual([]);
|
|
84
84
|
expect(manager.checkPermission).not.toHaveBeenCalled();
|
|
@@ -87,13 +87,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
87
87
|
test("keeps all skills when all are allowed", () => {
|
|
88
88
|
const input = availableSkillsSection("librarian", "ask-user");
|
|
89
89
|
const manager = makeManager("allow");
|
|
90
|
-
const result = resolveSkillPromptEntries(
|
|
91
|
-
input,
|
|
92
|
-
manager,
|
|
93
|
-
null,
|
|
94
|
-
CWD,
|
|
95
|
-
"linux",
|
|
96
|
-
);
|
|
90
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
97
91
|
expect(result.prompt).toContain("librarian");
|
|
98
92
|
expect(result.prompt).toContain("ask-user");
|
|
99
93
|
expect(result.entries).toHaveLength(2);
|
|
@@ -102,13 +96,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
102
96
|
test("removes denied skill from section", () => {
|
|
103
97
|
const input = availableSkillsSection("librarian", "dangerous");
|
|
104
98
|
const manager = makeManager("allow", { dangerous: "deny" });
|
|
105
|
-
const result = resolveSkillPromptEntries(
|
|
106
|
-
input,
|
|
107
|
-
manager,
|
|
108
|
-
null,
|
|
109
|
-
CWD,
|
|
110
|
-
"linux",
|
|
111
|
-
);
|
|
99
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
112
100
|
expect(result.prompt).toContain("librarian");
|
|
113
101
|
expect(result.prompt).not.toContain("dangerous");
|
|
114
102
|
// denied skill is excluded from returned entries
|
|
@@ -118,13 +106,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
118
106
|
test("removes entire section when all skills are denied", () => {
|
|
119
107
|
const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
|
|
120
108
|
const manager = makeManager("deny");
|
|
121
|
-
const result = resolveSkillPromptEntries(
|
|
122
|
-
input,
|
|
123
|
-
manager,
|
|
124
|
-
null,
|
|
125
|
-
CWD,
|
|
126
|
-
"linux",
|
|
127
|
-
);
|
|
109
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
128
110
|
expect(result.prompt).not.toContain("<available_skills>");
|
|
129
111
|
expect(result.prompt).toContain("Intro");
|
|
130
112
|
expect(result.prompt).toContain("Outro");
|
|
@@ -134,13 +116,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
134
116
|
test("keeps ask-state skills in section and entries", () => {
|
|
135
117
|
const input = availableSkillsSection("librarian");
|
|
136
118
|
const manager = makeManager("ask");
|
|
137
|
-
const result = resolveSkillPromptEntries(
|
|
138
|
-
input,
|
|
139
|
-
manager,
|
|
140
|
-
null,
|
|
141
|
-
CWD,
|
|
142
|
-
"linux",
|
|
143
|
-
);
|
|
119
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
144
120
|
expect(result.prompt).toContain("librarian");
|
|
145
121
|
expect(result.entries).toHaveLength(1);
|
|
146
122
|
expect(result.entries[0].state).toBe("ask");
|
|
@@ -149,7 +125,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
149
125
|
test("delegates permission check to permissionManager for each skill", () => {
|
|
150
126
|
const input = availableSkillsSection("alpha", "beta");
|
|
151
127
|
const manager = makeManager("allow");
|
|
152
|
-
resolveSkillPromptEntries(input, manager, null,
|
|
128
|
+
resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
153
129
|
expect(manager.checkPermission).toHaveBeenCalledWith(
|
|
154
130
|
"skill",
|
|
155
131
|
{ name: "alpha" },
|
|
@@ -165,7 +141,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
165
141
|
test("passes agentName to permissionManager", () => {
|
|
166
142
|
const input = availableSkillsSection("librarian");
|
|
167
143
|
const manager = makeManager("allow");
|
|
168
|
-
resolveSkillPromptEntries(input, manager, "my-agent",
|
|
144
|
+
resolveSkillPromptEntries(input, manager, "my-agent", normalizer);
|
|
169
145
|
expect(manager.checkPermission).toHaveBeenCalledWith(
|
|
170
146
|
"skill",
|
|
171
147
|
{ name: "librarian" },
|
|
@@ -180,7 +156,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
180
156
|
availableSkillsSection("librarian"),
|
|
181
157
|
].join("\n");
|
|
182
158
|
const manager = makeManager("allow");
|
|
183
|
-
resolveSkillPromptEntries(input, manager, null,
|
|
159
|
+
resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
184
160
|
// Should only be called once despite appearing twice.
|
|
185
161
|
expect(manager.checkPermission).toHaveBeenCalledTimes(1);
|
|
186
162
|
});
|
|
@@ -189,13 +165,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
189
165
|
const location = "/skills/librarian/SKILL.md";
|
|
190
166
|
const input = availableSkillsSection("librarian");
|
|
191
167
|
const manager = makeManager("allow");
|
|
192
|
-
const result = resolveSkillPromptEntries(
|
|
193
|
-
input,
|
|
194
|
-
manager,
|
|
195
|
-
null,
|
|
196
|
-
CWD,
|
|
197
|
-
"linux",
|
|
198
|
-
);
|
|
168
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
199
169
|
expect(result.entries[0].normalizedLocation).toBe(location);
|
|
200
170
|
expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
|
|
201
171
|
});
|
|
@@ -205,13 +175,7 @@ describe("resolveSkillPromptEntries", () => {
|
|
|
205
175
|
const section2 = availableSkillsSection("beta");
|
|
206
176
|
const input = `${section1}\n${section2}`;
|
|
207
177
|
const manager = makeManager("allow", { beta: "deny" });
|
|
208
|
-
const result = resolveSkillPromptEntries(
|
|
209
|
-
input,
|
|
210
|
-
manager,
|
|
211
|
-
null,
|
|
212
|
-
CWD,
|
|
213
|
-
"linux",
|
|
214
|
-
);
|
|
178
|
+
const result = resolveSkillPromptEntries(input, manager, null, normalizer);
|
|
215
179
|
expect(result.entries.map((e) => e.name)).toContain("alpha");
|
|
216
180
|
expect(result.entries.map((e) => e.name)).not.toContain("beta");
|
|
217
181
|
});
|
|
@@ -240,12 +204,12 @@ describe("findSkillPathMatch", () => {
|
|
|
240
204
|
];
|
|
241
205
|
|
|
242
206
|
test("returns null for empty normalized path", () => {
|
|
243
|
-
expect(findSkillPathMatch("", entries,
|
|
207
|
+
expect(findSkillPathMatch("", entries, normalizer)).toBeNull();
|
|
244
208
|
});
|
|
245
209
|
|
|
246
210
|
test("returns null for empty entries array", () => {
|
|
247
211
|
expect(
|
|
248
|
-
findSkillPathMatch("/skills/librarian/SKILL.md", [],
|
|
212
|
+
findSkillPathMatch("/skills/librarian/SKILL.md", [], normalizer),
|
|
249
213
|
).toBeNull();
|
|
250
214
|
});
|
|
251
215
|
|
|
@@ -253,7 +217,7 @@ describe("findSkillPathMatch", () => {
|
|
|
253
217
|
const match = findSkillPathMatch(
|
|
254
218
|
"/skills/librarian/SKILL.md",
|
|
255
219
|
entries,
|
|
256
|
-
|
|
220
|
+
normalizer,
|
|
257
221
|
);
|
|
258
222
|
expect(match?.name).toBe("librarian");
|
|
259
223
|
});
|
|
@@ -262,13 +226,17 @@ describe("findSkillPathMatch", () => {
|
|
|
262
226
|
const match = findSkillPathMatch(
|
|
263
227
|
"/skills/librarian/extra/helper.md",
|
|
264
228
|
entries,
|
|
265
|
-
|
|
229
|
+
normalizer,
|
|
266
230
|
);
|
|
267
231
|
expect(match?.name).toBe("librarian");
|
|
268
232
|
});
|
|
269
233
|
|
|
270
234
|
test("returns null for path not within any skill directory", () => {
|
|
271
|
-
const match = findSkillPathMatch(
|
|
235
|
+
const match = findSkillPathMatch(
|
|
236
|
+
"/other/path/file.md",
|
|
237
|
+
entries,
|
|
238
|
+
normalizer,
|
|
239
|
+
);
|
|
272
240
|
expect(match).toBeNull();
|
|
273
241
|
});
|
|
274
242
|
|
|
@@ -277,7 +245,7 @@ describe("findSkillPathMatch", () => {
|
|
|
277
245
|
const match = findSkillPathMatch(
|
|
278
246
|
"/skills/librarian-extra/SKILL.md",
|
|
279
247
|
entries,
|
|
280
|
-
|
|
248
|
+
normalizer,
|
|
281
249
|
);
|
|
282
250
|
expect(match).toBeNull();
|
|
283
251
|
});
|
|
@@ -304,7 +272,7 @@ describe("findSkillPathMatch", () => {
|
|
|
304
272
|
const match = findSkillPathMatch(
|
|
305
273
|
"/skills/parent/child/helper.md",
|
|
306
274
|
nestedEntries,
|
|
307
|
-
|
|
275
|
+
normalizer,
|
|
308
276
|
);
|
|
309
277
|
expect(match?.name).toBe("child");
|
|
310
278
|
});
|
|
@@ -380,8 +348,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
|
|
|
380
348
|
prompt,
|
|
381
349
|
asChecker(manager),
|
|
382
350
|
null,
|
|
383
|
-
"/cwd",
|
|
384
|
-
"linux",
|
|
351
|
+
new PathNormalizer("linux", "/cwd"),
|
|
385
352
|
);
|
|
386
353
|
|
|
387
354
|
expect(result.prompt).not.toContain("denied-skill");
|
|
@@ -428,20 +395,19 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
|
|
|
428
395
|
prompt,
|
|
429
396
|
asChecker(manager),
|
|
430
397
|
null,
|
|
431
|
-
"/cwd",
|
|
432
|
-
"linux",
|
|
398
|
+
new PathNormalizer("linux", "/cwd"),
|
|
433
399
|
);
|
|
434
400
|
const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
|
|
435
401
|
const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
|
|
436
402
|
const matchedVisibleSkill = findSkillPathMatch(
|
|
437
403
|
process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
|
|
438
404
|
result.entries,
|
|
439
|
-
|
|
405
|
+
normalizer,
|
|
440
406
|
);
|
|
441
407
|
const matchedBlockedSkill = findSkillPathMatch(
|
|
442
408
|
process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
|
|
443
409
|
result.entries,
|
|
444
|
-
|
|
410
|
+
normalizer,
|
|
445
411
|
);
|
|
446
412
|
|
|
447
413
|
expect(matchedVisibleSkill?.name).toBe("visible-skill");
|