@gotgenes/pi-permission-system 10.9.0 → 10.10.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 +28 -0
- package/config/config.example.json +2 -2
- package/package.json +5 -5
- package/schemas/permissions.schema.json +3 -3
- package/src/active-agent.ts +21 -7
- package/src/extension-paths.ts +14 -3
- package/src/forwarded-permissions/permission-forwarder.ts +32 -13
- package/src/index.ts +4 -2
- package/src/path-utils.ts +48 -7
- package/src/rule.ts +11 -1
- package/src/subagent-context.ts +14 -3
- package/src/wildcard-matcher.ts +25 -4
- package/test/active-agent.test.ts +4 -9
- package/test/extension-paths.test.ts +19 -0
- package/test/path-utils.test.ts +74 -0
- package/test/permission-forwarder.test.ts +46 -32
- package/test/rule.test.ts +75 -0
- package/test/subagent-context.test.ts +7 -7
- package/test/wildcard-matcher.test.ts +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [10.10.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.0...pi-permission-system-v10.10.1) (2026-06-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* fix bash rule precedence examples and wording ([#387](https://github.com/gotgenes/pi-packages/issues/387)) ([9e18d6f](https://github.com/gotgenes/pi-packages/commit/9e18d6faab6e3ceaa1a8839f5b6753d5457a2a28))
|
|
14
|
+
|
|
15
|
+
## [10.10.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.9.0...pi-permission-system-v10.10.0) (2026-06-10)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** add case-insensitive and Windows-separator options to wildcard matcher ([587b3e8](https://github.com/gotgenes/pi-packages/commit/587b3e88d0deed365ab690d38145e2f9ce8eaee6))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** auto-allow infrastructure reads case-insensitively on Windows ([a3f137a](https://github.com/gotgenes/pi-packages/commit/a3f137ad5edb8f42378144fa9ca556996954155c))
|
|
26
|
+
* **pi-permission-system:** auto-detect Pi's install directory for infrastructure reads ([#382](https://github.com/gotgenes/pi-packages/issues/382)) ([c3d89ba](https://github.com/gotgenes/pi-packages/commit/c3d89ba4f58805fb5012258beb2db108ef61ebbe))
|
|
27
|
+
* **pi-permission-system:** include an optional Pi package dir in infrastructure reads ([da667ec](https://github.com/gotgenes/pi-packages/commit/da667eca95f02f8de246bdc42ca90df7696fe2ca))
|
|
28
|
+
* **pi-permission-system:** make path containment case-insensitive on Windows via path.relative ([c10b84a](https://github.com/gotgenes/pi-packages/commit/c10b84ad4508b1cf8ead763e3fa0560a1e9ba370))
|
|
29
|
+
* **pi-permission-system:** match external_directory/path patterns case-insensitively on Windows ([3ed92da](https://github.com/gotgenes/pi-packages/commit/3ed92dabc1643fb5e0c52b9eac76e0940f8a8dc4))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** document Windows case-insensitive matching and Pi-install auto-allow ([c98d33b](https://github.com/gotgenes/pi-packages/commit/c98d33b775eea9cbe83f019222841a6ab820942f))
|
|
35
|
+
|
|
8
36
|
## [10.9.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.8.0...pi-permission-system-v10.9.0) (2026-06-10)
|
|
9
37
|
|
|
10
38
|
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"edit": "deny",
|
|
24
24
|
"bash": {
|
|
25
25
|
"*": "ask",
|
|
26
|
+
"git *": "ask",
|
|
26
27
|
"git status": "allow",
|
|
27
|
-
"git diff": "allow"
|
|
28
|
-
"git *": "ask"
|
|
28
|
+
"git diff": "allow"
|
|
29
29
|
},
|
|
30
30
|
"mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
|
|
31
31
|
"skill": { "*": "ask" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.10.1",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
60
|
-
"@earendil-works/pi-tui": ">=0.
|
|
59
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0",
|
|
60
|
+
"@earendil-works/pi-tui": ">=0.79.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@biomejs/biome": "^2.4.16",
|
|
64
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
65
|
-
"@earendil-works/pi-tui": "0.
|
|
64
|
+
"@earendil-works/pi-coding-agent": "0.79.1",
|
|
65
|
+
"@earendil-works/pi-tui": "0.79.1",
|
|
66
66
|
"@types/node": "^22.15.3",
|
|
67
67
|
"rumdl": "^0.2.10",
|
|
68
68
|
"typescript": "^6.0.3",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"piInfrastructureReadPaths": {
|
|
45
45
|
"description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
|
|
46
|
-
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.",
|
|
46
|
+
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
|
47
47
|
"type": "array",
|
|
48
48
|
"items": {
|
|
49
49
|
"type": "string",
|
|
@@ -86,9 +86,9 @@
|
|
|
86
86
|
"edit": "deny",
|
|
87
87
|
"bash": {
|
|
88
88
|
"*": "ask",
|
|
89
|
+
"git *": "ask",
|
|
89
90
|
"git status": "allow",
|
|
90
|
-
"git diff": "allow"
|
|
91
|
-
"git *": "ask"
|
|
91
|
+
"git diff": "allow"
|
|
92
92
|
},
|
|
93
93
|
"mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
|
|
94
94
|
"skill": { "*": "ask", "librarian": "allow" },
|
package/src/active-agent.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Minimal session-entry view: the only fields {@link getActiveAgentName}
|
|
3
|
+
* reads off each entry. Narrowing to this structural slice (rather than the
|
|
4
|
+
* SDK `SessionEntry` discriminated union) keeps callers and test fixtures free
|
|
5
|
+
* of the union's nine unrelated variants.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionEntryView {
|
|
8
|
+
type: string;
|
|
9
|
+
customType?: string;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Narrow context for {@link getActiveAgentName} — it reads only the session
|
|
15
|
+
* entries. A full `ExtensionContext` satisfies this structurally.
|
|
16
|
+
*/
|
|
17
|
+
export interface ActiveAgentContext {
|
|
18
|
+
sessionManager: { getEntries(): readonly SessionEntryView[] };
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
/**
|
|
4
22
|
* Matches the `<active_agent name="...">` tag injected by pi-agent-router
|
|
@@ -16,14 +34,10 @@ export function normalizeAgentName(value: unknown): string | null {
|
|
|
16
34
|
return trimmed ? trimmed : null;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
|
-
export function getActiveAgentName(ctx:
|
|
37
|
+
export function getActiveAgentName(ctx: ActiveAgentContext): string | null {
|
|
20
38
|
const entries = ctx.sessionManager.getEntries();
|
|
21
39
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
22
|
-
const entry = entries[i]
|
|
23
|
-
type: string;
|
|
24
|
-
customType?: string;
|
|
25
|
-
data?: unknown;
|
|
26
|
-
};
|
|
40
|
+
const entry = entries[i];
|
|
27
41
|
if (entry.type !== "custom" || entry.customType !== "active_agent") {
|
|
28
42
|
continue;
|
|
29
43
|
}
|
package/src/extension-paths.ts
CHANGED
|
@@ -17,8 +17,9 @@ export interface ExtensionPaths {
|
|
|
17
17
|
readonly globalLogsDir: string;
|
|
18
18
|
/**
|
|
19
19
|
* Static Pi infrastructure directories used for external-directory
|
|
20
|
-
* read auto-allow. Computed once from `agentDir
|
|
21
|
-
* `discoverGlobalNodeModulesRoot()
|
|
20
|
+
* read auto-allow. Computed once from `agentDir`,
|
|
21
|
+
* `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
|
|
22
|
+
* install directory (`getPackageDir()`). Config-based extras
|
|
22
23
|
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
|
23
24
|
* call time in the handler so they pick up config reloads.
|
|
24
25
|
*/
|
|
@@ -30,8 +31,17 @@ export interface ExtensionPaths {
|
|
|
30
31
|
*
|
|
31
32
|
* Calls `discoverGlobalNodeModulesRoot()` internally so the result is
|
|
32
33
|
* self-contained. Call this once at extension startup, not at module scope.
|
|
34
|
+
*
|
|
35
|
+
* `piPackageDir` is Pi's own install directory (from the coding-agent
|
|
36
|
+
* `getPackageDir()` API, resolved at the composition root). When provided it is
|
|
37
|
+
* auto-allowed for read-only tools so the agent can read Pi's bundled docs and
|
|
38
|
+
* examples regardless of install layout. It is strictly narrower than the
|
|
39
|
+
* discovered global `node_modules` root already included here.
|
|
33
40
|
*/
|
|
34
|
-
export function computeExtensionPaths(
|
|
41
|
+
export function computeExtensionPaths(
|
|
42
|
+
agentDir: string,
|
|
43
|
+
piPackageDir?: string,
|
|
44
|
+
): ExtensionPaths {
|
|
35
45
|
const sessionsDir = join(agentDir, "sessions");
|
|
36
46
|
const subagentSessionsDir = join(agentDir, "subagent-sessions");
|
|
37
47
|
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
|
@@ -42,6 +52,7 @@ export function computeExtensionPaths(agentDir: string): ExtensionPaths {
|
|
|
42
52
|
agentDir,
|
|
43
53
|
join(agentDir, "git"),
|
|
44
54
|
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
|
55
|
+
...(piPackageDir ? [piPackageDir] : []),
|
|
45
56
|
];
|
|
46
57
|
|
|
47
58
|
return {
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
3
|
import {
|
|
6
4
|
getActiveAgentName,
|
|
7
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
|
+
type SessionEntryView,
|
|
8
7
|
} from "#src/active-agent";
|
|
9
8
|
import { toRecord } from "#src/common";
|
|
10
9
|
import type { ConfigReader } from "#src/config-store";
|
|
11
10
|
import type {
|
|
11
|
+
PermissionDecisionUi,
|
|
12
12
|
PermissionPromptDecision,
|
|
13
13
|
RequestPermissionOptions,
|
|
14
14
|
} from "#src/permission-dialog";
|
|
@@ -47,6 +47,25 @@ import {
|
|
|
47
47
|
writeJsonFileAtomic,
|
|
48
48
|
} from "./io";
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
|
|
52
|
+
* surface, and the three session-manager readers it uses directly or via
|
|
53
|
+
* {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
|
|
54
|
+
*
|
|
55
|
+
* `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
|
|
56
|
+
* is intentionally not a typed member. A full `ExtensionContext` satisfies this
|
|
57
|
+
* structurally, so production callers pass `ctx` unchanged.
|
|
58
|
+
*/
|
|
59
|
+
export interface ForwarderContext {
|
|
60
|
+
hasUI: boolean;
|
|
61
|
+
ui: PermissionDecisionUi;
|
|
62
|
+
sessionManager: {
|
|
63
|
+
getSessionId(): string;
|
|
64
|
+
getSessionDir(): string;
|
|
65
|
+
getEntries(): readonly SessionEntryView[];
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
/**
|
|
51
70
|
* Constructor config for `PermissionForwarder`.
|
|
52
71
|
*
|
|
@@ -63,7 +82,7 @@ export interface PermissionForwarderDeps {
|
|
|
63
82
|
events?: PermissionEventBus;
|
|
64
83
|
logger: DebugReviewLogger;
|
|
65
84
|
requestPermissionDecisionFromUi: (
|
|
66
|
-
ui:
|
|
85
|
+
ui: PermissionDecisionUi,
|
|
67
86
|
title: string,
|
|
68
87
|
message: string,
|
|
69
88
|
options?: RequestPermissionOptions,
|
|
@@ -74,7 +93,7 @@ export interface PermissionForwarderDeps {
|
|
|
74
93
|
|
|
75
94
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
76
95
|
|
|
77
|
-
function getSessionId(ctx:
|
|
96
|
+
function getSessionId(ctx: ForwarderContext): string {
|
|
78
97
|
try {
|
|
79
98
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
80
99
|
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
@@ -85,7 +104,7 @@ function getSessionId(ctx: ExtensionContext): string {
|
|
|
85
104
|
return "unknown";
|
|
86
105
|
}
|
|
87
106
|
|
|
88
|
-
function getContextSystemPrompt(ctx:
|
|
107
|
+
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
89
108
|
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
|
90
109
|
if (typeof getSystemPrompt !== "function") {
|
|
91
110
|
return undefined;
|
|
@@ -132,7 +151,7 @@ function formatForwardedPermissionPrompt(
|
|
|
132
151
|
*/
|
|
133
152
|
export interface ApprovalRequester {
|
|
134
153
|
requestApproval(
|
|
135
|
-
ctx:
|
|
154
|
+
ctx: ForwarderContext,
|
|
136
155
|
message: string,
|
|
137
156
|
options?: RequestPermissionOptions,
|
|
138
157
|
forwarded?: ForwardedPromptDisplay,
|
|
@@ -148,7 +167,7 @@ export interface ApprovalRequester {
|
|
|
148
167
|
* `{ processInbox: vi.fn() }` mock.
|
|
149
168
|
*/
|
|
150
169
|
export interface InboxProcessor {
|
|
151
|
-
processInbox(ctx:
|
|
170
|
+
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
152
171
|
}
|
|
153
172
|
|
|
154
173
|
// ── PermissionForwarder ───────────────────────────────────────────────────
|
|
@@ -169,7 +188,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
169
188
|
private readonly events: PermissionEventBus | undefined;
|
|
170
189
|
private readonly logger: DebugReviewLogger;
|
|
171
190
|
private readonly requestPermissionDecisionFromUi: (
|
|
172
|
-
ui:
|
|
191
|
+
ui: PermissionDecisionUi,
|
|
173
192
|
title: string,
|
|
174
193
|
message: string,
|
|
175
194
|
options?: RequestPermissionOptions,
|
|
@@ -193,7 +212,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
193
212
|
* when this session has UI, otherwise forward to the parent session.
|
|
194
213
|
*/
|
|
195
214
|
requestApproval(
|
|
196
|
-
ctx:
|
|
215
|
+
ctx: ForwarderContext,
|
|
197
216
|
message: string,
|
|
198
217
|
options?: RequestPermissionOptions,
|
|
199
218
|
forwarded?: ForwardedPromptDisplay,
|
|
@@ -217,7 +236,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
217
236
|
}
|
|
218
237
|
|
|
219
238
|
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
220
|
-
async processInbox(ctx:
|
|
239
|
+
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
221
240
|
if (!ctx.hasUI) {
|
|
222
241
|
return;
|
|
223
242
|
}
|
|
@@ -263,7 +282,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
263
282
|
// ── Private methods ────────────────────────────────────────────────────
|
|
264
283
|
|
|
265
284
|
private async waitForForwardedApproval(
|
|
266
|
-
ctx:
|
|
285
|
+
ctx: ForwarderContext,
|
|
267
286
|
message: string,
|
|
268
287
|
forwarded?: ForwardedPromptDisplay,
|
|
269
288
|
): Promise<PermissionPromptDecision> {
|
|
@@ -345,7 +364,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
345
364
|
}
|
|
346
365
|
|
|
347
366
|
private buildForwardedRequest(
|
|
348
|
-
ctx:
|
|
367
|
+
ctx: ForwarderContext,
|
|
349
368
|
message: string,
|
|
350
369
|
requesterSessionId: string,
|
|
351
370
|
targetSessionId: string,
|
|
@@ -430,7 +449,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
430
449
|
}
|
|
431
450
|
|
|
432
451
|
private async processSingleForwardedRequest(
|
|
433
|
-
ctx:
|
|
452
|
+
ctx: ForwarderContext,
|
|
434
453
|
request: ForwardedPermissionRequest,
|
|
435
454
|
location: PermissionForwardingLocation,
|
|
436
455
|
requestPath: string,
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
4
4
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
5
5
|
import { getGlobalConfigPath } from "./config-paths";
|
|
@@ -36,7 +36,9 @@ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
|
36
36
|
|
|
37
37
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
38
38
|
const agentDir = getAgentDir();
|
|
39
|
-
|
|
39
|
+
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
|
|
40
|
+
// so the agent can read Pi's bundled docs/examples regardless of layout.
|
|
41
|
+
const paths = computeExtensionPaths(agentDir, getPackageDir());
|
|
40
42
|
const permissionManager = new PermissionManager({ agentDir });
|
|
41
43
|
const sessionRules = new SessionRules();
|
|
42
44
|
const subagentRegistry = getSubagentSessionRegistry();
|
package/src/path-utils.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
join,
|
|
3
|
+
normalize,
|
|
4
|
+
posix as posixPath,
|
|
5
|
+
resolve,
|
|
6
|
+
win32 as winPath,
|
|
7
|
+
} from "node:path";
|
|
2
8
|
|
|
3
9
|
import { canonicalizePath } from "./canonicalize-path";
|
|
4
10
|
import { getNonEmptyString, toRecord } from "./common";
|
|
@@ -24,9 +30,19 @@ export function normalizePathForComparison(
|
|
|
24
30
|
: normalizedAbsolutePath;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Returns true when `pathValue` is `directory` itself or nested inside it.
|
|
35
|
+
*
|
|
36
|
+
* Containment is decided with Node's platform-native `path.relative` rather
|
|
37
|
+
* than a hand-rolled prefix check: on `win32` the comparison folds case (and
|
|
38
|
+
* tolerates either separator), matching the case-insensitive filesystem.
|
|
39
|
+
* `platform` defaults to `process.platform` and is injectable so Windows
|
|
40
|
+
* behavior is testable on a POSIX CI.
|
|
41
|
+
*/
|
|
27
42
|
export function isPathWithinDirectory(
|
|
28
43
|
pathValue: string,
|
|
29
44
|
directory: string,
|
|
45
|
+
platform: NodeJS.Platform = process.platform,
|
|
30
46
|
): boolean {
|
|
31
47
|
if (!pathValue || !directory) {
|
|
32
48
|
return false;
|
|
@@ -36,8 +52,14 @@ export function isPathWithinDirectory(
|
|
|
36
52
|
return true;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
const
|
|
40
|
-
|
|
55
|
+
const impl = platform === "win32" ? winPath : posixPath;
|
|
56
|
+
const rel = impl.relative(directory, pathValue);
|
|
57
|
+
return (
|
|
58
|
+
rel !== "" &&
|
|
59
|
+
rel !== ".." &&
|
|
60
|
+
!rel.startsWith(`..${impl.sep}`) &&
|
|
61
|
+
!impl.isAbsolute(rel)
|
|
62
|
+
);
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
/**
|
|
@@ -79,6 +101,17 @@ export const PATH_BEARING_TOOLS = new Set([
|
|
|
79
101
|
"ls",
|
|
80
102
|
]);
|
|
81
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Surfaces whose patterns are matched against filesystem paths and therefore
|
|
106
|
+
* fold case (and separators) on Windows: the path-bearing tools plus the
|
|
107
|
+
* cross-cutting `path` gate and the `external_directory` boundary gate.
|
|
108
|
+
*/
|
|
109
|
+
export const PATH_SURFACES: ReadonlySet<string> = new Set([
|
|
110
|
+
...PATH_BEARING_TOOLS,
|
|
111
|
+
"external_directory",
|
|
112
|
+
"path",
|
|
113
|
+
]);
|
|
114
|
+
|
|
82
115
|
export function getPathBearingToolPath(
|
|
83
116
|
toolName: string,
|
|
84
117
|
input: unknown,
|
|
@@ -144,16 +177,24 @@ export function isPiInfrastructureRead(
|
|
|
144
177
|
normalizedPath: string,
|
|
145
178
|
infrastructureDirs: readonly string[],
|
|
146
179
|
cwd: string,
|
|
180
|
+
platform: NodeJS.Platform = process.platform,
|
|
147
181
|
): boolean {
|
|
148
182
|
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
149
183
|
return false;
|
|
150
184
|
}
|
|
151
185
|
|
|
186
|
+
// On Windows the path value is canonicalized + lowercased; fold case (and
|
|
187
|
+
// separators) so mixed-case infra dirs and glob patterns still match.
|
|
188
|
+
const matchOptions =
|
|
189
|
+
platform === "win32"
|
|
190
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
191
|
+
: undefined;
|
|
192
|
+
|
|
152
193
|
for (const dir of infrastructureDirs) {
|
|
153
194
|
if (containsGlobChars(dir)) {
|
|
154
|
-
if (wildcardMatch(dir, normalizedPath)) return true;
|
|
195
|
+
if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
|
|
155
196
|
} else {
|
|
156
|
-
if (isPathWithinDirectory(normalizedPath, expandHomePath(dir)))
|
|
197
|
+
if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
|
|
157
198
|
return true;
|
|
158
199
|
}
|
|
159
200
|
}
|
|
@@ -161,10 +202,10 @@ export function isPiInfrastructureRead(
|
|
|
161
202
|
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
162
203
|
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
163
204
|
const projectGitDir = join(cwd, ".pi", "git");
|
|
164
|
-
if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
|
|
205
|
+
if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
|
|
165
206
|
return true;
|
|
166
207
|
}
|
|
167
|
-
if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
|
|
208
|
+
if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
|
|
168
209
|
return true;
|
|
169
210
|
}
|
|
170
211
|
|
package/src/rule.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
1
2
|
import type { PermissionState } from "./types";
|
|
2
3
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
3
4
|
|
|
@@ -52,10 +53,19 @@ export function evaluate(
|
|
|
52
53
|
pattern: string,
|
|
53
54
|
rules: Ruleset,
|
|
54
55
|
defaultAction?: PermissionState,
|
|
56
|
+
platform: NodeJS.Platform = process.platform,
|
|
55
57
|
): Rule {
|
|
58
|
+
// On Windows, path-surface values are canonicalized + lowercased; fold the
|
|
59
|
+
// pattern→value match (case and separators) so mixed-case / forward-slash
|
|
60
|
+
// overrides still match. The surface→surface match stays exact.
|
|
61
|
+
const matchOptions =
|
|
62
|
+
platform === "win32" && PATH_SURFACES.has(surface)
|
|
63
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
64
|
+
: undefined;
|
|
56
65
|
const rule = rules.findLast(
|
|
57
66
|
(r) =>
|
|
58
|
-
wildcardMatch(r.surface, surface) &&
|
|
67
|
+
wildcardMatch(r.surface, surface) &&
|
|
68
|
+
wildcardMatch(r.pattern, pattern, matchOptions),
|
|
59
69
|
);
|
|
60
70
|
if (rule !== undefined) return rule;
|
|
61
71
|
return {
|
package/src/subagent-context.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { normalize } from "node:path";
|
|
2
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
|
|
4
3
|
import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
|
|
5
4
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
6
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Narrow context for subagent detection — the only session-manager readers
|
|
8
|
+
* {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
|
|
9
|
+
* consume. A full `ExtensionContext` satisfies this structurally.
|
|
10
|
+
*/
|
|
11
|
+
export interface SubagentDetectionContext {
|
|
12
|
+
sessionManager: {
|
|
13
|
+
getSessionId(): string;
|
|
14
|
+
getSessionDir(): string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
export function normalizeFilesystemPath(pathValue: string): string {
|
|
8
19
|
const normalizedPath = normalize(pathValue);
|
|
9
20
|
return process.platform === "win32"
|
|
@@ -39,7 +50,7 @@ function isPathWithinDirectoryForSubagent(
|
|
|
39
50
|
* child must not publish over its parent.
|
|
40
51
|
*/
|
|
41
52
|
export function isRegisteredSubagentChild(
|
|
42
|
-
ctx:
|
|
53
|
+
ctx: SubagentDetectionContext,
|
|
43
54
|
registry: SubagentSessionRegistry,
|
|
44
55
|
): boolean {
|
|
45
56
|
try {
|
|
@@ -55,7 +66,7 @@ export function isRegisteredSubagentChild(
|
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export function isSubagentExecutionContext(
|
|
58
|
-
ctx:
|
|
69
|
+
ctx: SubagentDetectionContext,
|
|
59
70
|
subagentSessionsDir: string,
|
|
60
71
|
registry?: SubagentSessionRegistry,
|
|
61
72
|
): boolean {
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -12,6 +12,19 @@ export type WildcardPatternMatch<TState> = {
|
|
|
12
12
|
matchedName: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Optional folding applied when matching path-surface patterns on Windows.
|
|
17
|
+
*
|
|
18
|
+
* - `caseInsensitive` compiles the pattern with the `i` flag so a mixed-case
|
|
19
|
+
* pattern matches a lowercased (canonicalized) path value.
|
|
20
|
+
* - `windowsSeparators` rewrites `/` to `\` in the expanded pattern so a
|
|
21
|
+
* forward-slash pattern matches a backslash-separated path value.
|
|
22
|
+
*/
|
|
23
|
+
export interface WildcardMatchOptions {
|
|
24
|
+
caseInsensitive?: boolean;
|
|
25
|
+
windowsSeparators?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
function escapeRegExp(value: string): string {
|
|
16
29
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
30
|
}
|
|
@@ -19,8 +32,12 @@ function escapeRegExp(value: string): string {
|
|
|
19
32
|
export function compileWildcardPattern<TState>(
|
|
20
33
|
pattern: string,
|
|
21
34
|
state: TState,
|
|
35
|
+
options?: WildcardMatchOptions,
|
|
22
36
|
): CompiledWildcardPattern<TState> {
|
|
23
|
-
|
|
37
|
+
let expanded = expandHomePath(pattern);
|
|
38
|
+
if (options?.windowsSeparators) {
|
|
39
|
+
expanded = expanded.replaceAll("/", "\\");
|
|
40
|
+
}
|
|
24
41
|
let escaped = expanded
|
|
25
42
|
.split("*")
|
|
26
43
|
.map((part) => escapeRegExp(part).replaceAll("\\?", "."))
|
|
@@ -36,7 +53,7 @@ export function compileWildcardPattern<TState>(
|
|
|
36
53
|
return {
|
|
37
54
|
pattern,
|
|
38
55
|
state,
|
|
39
|
-
regex: new RegExp(`^${escaped}$`, "s"),
|
|
56
|
+
regex: new RegExp(`^${escaped}$`, options?.caseInsensitive ? "si" : "s"),
|
|
40
57
|
};
|
|
41
58
|
}
|
|
42
59
|
|
|
@@ -73,8 +90,12 @@ export function findCompiledWildcardMatch<TState>(
|
|
|
73
90
|
* `?` matches exactly one character.
|
|
74
91
|
* Used by evaluate() for rule matching.
|
|
75
92
|
*/
|
|
76
|
-
export function wildcardMatch(
|
|
77
|
-
|
|
93
|
+
export function wildcardMatch(
|
|
94
|
+
pattern: string,
|
|
95
|
+
value: string,
|
|
96
|
+
options?: WildcardMatchOptions,
|
|
97
|
+
): boolean {
|
|
98
|
+
return compileWildcardPattern(pattern, null, options).regex.test(value);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
export function findCompiledWildcardMatchForNames<TState>(
|
|
@@ -1,28 +1,23 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
2
|
import {
|
|
4
3
|
ACTIVE_AGENT_TAG_REGEX,
|
|
4
|
+
type ActiveAgentContext,
|
|
5
5
|
getActiveAgentName,
|
|
6
6
|
getActiveAgentNameFromSystemPrompt,
|
|
7
7
|
normalizeAgentName,
|
|
8
|
+
type SessionEntryView,
|
|
8
9
|
} from "#src/active-agent";
|
|
9
10
|
|
|
10
11
|
afterEach(() => {
|
|
11
12
|
vi.restoreAllMocks();
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
type: string;
|
|
16
|
-
customType?: string;
|
|
17
|
-
data?: unknown;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function makeCtx(entries: SessionEntry[]): ExtensionContext {
|
|
15
|
+
function makeCtx(entries: SessionEntryView[]): ActiveAgentContext {
|
|
21
16
|
return {
|
|
22
17
|
sessionManager: {
|
|
23
18
|
getEntries: vi.fn(() => entries),
|
|
24
19
|
},
|
|
25
|
-
}
|
|
20
|
+
};
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
describe("ACTIVE_AGENT_TAG_REGEX", () => {
|
|
@@ -78,6 +78,25 @@ describe("computeExtensionPaths", () => {
|
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it("includes piPackageDir in piInfrastructureDirs when provided", () => {
|
|
82
|
+
const paths = computeExtensionPaths("/test/agent", "/pi/install");
|
|
83
|
+
expect(paths.piInfrastructureDirs).toContain("/pi/install");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("omits piPackageDir when not provided (current behavior preserved)", () => {
|
|
87
|
+
const paths = computeExtensionPaths("/test/agent");
|
|
88
|
+
expect(paths.piInfrastructureDirs).toEqual([
|
|
89
|
+
"/test/agent",
|
|
90
|
+
"/test/agent/git",
|
|
91
|
+
"/mock/global/node_modules",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("omits piPackageDir when given an empty string", () => {
|
|
96
|
+
const paths = computeExtensionPaths("/test/agent", "");
|
|
97
|
+
expect(paths.piInfrastructureDirs).not.toContain("");
|
|
98
|
+
});
|
|
99
|
+
|
|
81
100
|
it("two calls with different agentDirs produce independent results", () => {
|
|
82
101
|
const a = computeExtensionPaths("/agent/a");
|
|
83
102
|
const b = computeExtensionPaths("/agent/b");
|
package/test/path-utils.test.ts
CHANGED
|
@@ -117,6 +117,42 @@ describe("isPathWithinDirectory", () => {
|
|
|
117
117
|
test("returns false for empty directory", () => {
|
|
118
118
|
expect(isPathWithinDirectory("/a/b", "")).toBe(false);
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
// ── platform-aware containment (Windows is case-insensitive) ────────────
|
|
122
|
+
|
|
123
|
+
test("win32: folds case for a case-different descendant", () => {
|
|
124
|
+
expect(
|
|
125
|
+
isPathWithinDirectory(
|
|
126
|
+
"c:\\users\\foo\\dir\\sub\\x.md",
|
|
127
|
+
"C:\\Users\\Foo\\dir",
|
|
128
|
+
"win32",
|
|
129
|
+
),
|
|
130
|
+
).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("win32: folds case when path equals directory in different case", () => {
|
|
134
|
+
expect(
|
|
135
|
+
isPathWithinDirectory(
|
|
136
|
+
"c:\\users\\foo\\dir\\sub",
|
|
137
|
+
"C:\\USERS\\foo\\DIR",
|
|
138
|
+
"win32",
|
|
139
|
+
),
|
|
140
|
+
).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("win32: rejects a sibling directory", () => {
|
|
144
|
+
expect(
|
|
145
|
+
isPathWithinDirectory(
|
|
146
|
+
"C:\\Users\\Foo\\other",
|
|
147
|
+
"C:\\Users\\Foo\\dir",
|
|
148
|
+
"win32",
|
|
149
|
+
),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("posix platform stays case-sensitive", () => {
|
|
154
|
+
expect(isPathWithinDirectory("/a/B/c", "/a/b", "linux")).toBe(false);
|
|
155
|
+
});
|
|
120
156
|
});
|
|
121
157
|
|
|
122
158
|
describe("PATH_BEARING_TOOLS", () => {
|
|
@@ -404,4 +440,42 @@ describe("isPiInfrastructureRead", () => {
|
|
|
404
440
|
),
|
|
405
441
|
).toBe(true);
|
|
406
442
|
});
|
|
443
|
+
|
|
444
|
+
// ── Windows: case-insensitive infra-read matching ─────────────────────
|
|
445
|
+
|
|
446
|
+
test("win32: plain infra dir matches a case-different path", () => {
|
|
447
|
+
expect(
|
|
448
|
+
isPiInfrastructureRead(
|
|
449
|
+
"read",
|
|
450
|
+
"c:\\users\\foo\\.pi\\agent\\config.json",
|
|
451
|
+
["C:\\Users\\Foo\\.pi\\agent"],
|
|
452
|
+
"C:\\proj",
|
|
453
|
+
"win32",
|
|
454
|
+
),
|
|
455
|
+
).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("win32: glob infra dir matches case-insensitively", () => {
|
|
459
|
+
expect(
|
|
460
|
+
isPiInfrastructureRead(
|
|
461
|
+
"read",
|
|
462
|
+
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
|
|
463
|
+
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
|
|
464
|
+
"C:\\proj",
|
|
465
|
+
"win32",
|
|
466
|
+
),
|
|
467
|
+
).toBe(true);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("win32: rejects a path outside every infra dir", () => {
|
|
471
|
+
expect(
|
|
472
|
+
isPiInfrastructureRead(
|
|
473
|
+
"read",
|
|
474
|
+
"c:\\windows\\system32\\drivers\\etc\\hosts",
|
|
475
|
+
["C:\\Users\\Foo\\.pi\\agent"],
|
|
476
|
+
"C:\\proj",
|
|
477
|
+
"win32",
|
|
478
|
+
),
|
|
479
|
+
).toBe(false);
|
|
480
|
+
});
|
|
407
481
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
6
5
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
7
6
|
import {
|
|
7
|
+
type ForwarderContext,
|
|
8
8
|
PermissionForwarder,
|
|
9
9
|
type PermissionForwarderDeps,
|
|
10
10
|
} from "#src/forwarded-permissions/permission-forwarder";
|
|
@@ -27,6 +27,25 @@ function makeDeps(
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function makeCtx(
|
|
31
|
+
overrides: {
|
|
32
|
+
hasUI?: boolean;
|
|
33
|
+
ui?: ForwarderContext["ui"];
|
|
34
|
+
sessionManager?: Partial<ForwarderContext["sessionManager"]>;
|
|
35
|
+
} = {},
|
|
36
|
+
): ForwarderContext {
|
|
37
|
+
return {
|
|
38
|
+
hasUI: overrides.hasUI ?? false,
|
|
39
|
+
ui: overrides.ui ?? { select: vi.fn(), input: vi.fn() },
|
|
40
|
+
sessionManager: {
|
|
41
|
+
getSessionId: vi.fn(() => ""),
|
|
42
|
+
getSessionDir: vi.fn(() => ""),
|
|
43
|
+
getEntries: vi.fn(() => []),
|
|
44
|
+
...overrides.sessionManager,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
afterEach(() => {
|
|
31
50
|
vi.unstubAllEnvs();
|
|
32
51
|
});
|
|
@@ -48,10 +67,7 @@ describe("requestApproval — UI fast path", () => {
|
|
|
48
67
|
);
|
|
49
68
|
|
|
50
69
|
await forwarder.requestApproval(
|
|
51
|
-
{
|
|
52
|
-
hasUI: true,
|
|
53
|
-
ui: { select: vi.fn(), input: vi.fn() },
|
|
54
|
-
} as unknown as ExtensionContext,
|
|
70
|
+
makeCtx({ hasUI: true }),
|
|
55
71
|
"Allow git push?",
|
|
56
72
|
);
|
|
57
73
|
|
|
@@ -76,12 +92,7 @@ describe("requestApproval — non-UI, non-subagent path", () => {
|
|
|
76
92
|
);
|
|
77
93
|
|
|
78
94
|
const result = await forwarder.requestApproval(
|
|
79
|
-
{
|
|
80
|
-
hasUI: false,
|
|
81
|
-
sessionManager: {
|
|
82
|
-
getSessionDir: vi.fn().mockReturnValue(null),
|
|
83
|
-
},
|
|
84
|
-
} as unknown as ExtensionContext,
|
|
95
|
+
makeCtx({ hasUI: false }),
|
|
85
96
|
"Allow git push?",
|
|
86
97
|
);
|
|
87
98
|
|
|
@@ -136,13 +147,14 @@ describe("processInbox", () => {
|
|
|
136
147
|
}),
|
|
137
148
|
);
|
|
138
149
|
|
|
139
|
-
await forwarder.processInbox(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
await forwarder.processInbox(
|
|
151
|
+
makeCtx({
|
|
152
|
+
hasUI: true,
|
|
153
|
+
sessionManager: {
|
|
154
|
+
getSessionId: vi.fn(() => "parent-session"),
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
146
158
|
|
|
147
159
|
expect(events.emit).toHaveBeenCalledWith(
|
|
148
160
|
"permissions:ui_prompt",
|
|
@@ -207,13 +219,14 @@ describe("processInbox", () => {
|
|
|
207
219
|
}),
|
|
208
220
|
);
|
|
209
221
|
|
|
210
|
-
await forwarder.processInbox(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
await forwarder.processInbox(
|
|
223
|
+
makeCtx({
|
|
224
|
+
hasUI: true,
|
|
225
|
+
sessionManager: {
|
|
226
|
+
getSessionId: vi.fn(() => "parent-session"),
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
217
230
|
|
|
218
231
|
expect(events.emit).toHaveBeenCalledWith(
|
|
219
232
|
"permissions:ui_prompt",
|
|
@@ -276,13 +289,14 @@ describe("processInbox", () => {
|
|
|
276
289
|
}),
|
|
277
290
|
);
|
|
278
291
|
|
|
279
|
-
await forwarder.processInbox(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
292
|
+
await forwarder.processInbox(
|
|
293
|
+
makeCtx({
|
|
294
|
+
hasUI: true,
|
|
295
|
+
sessionManager: {
|
|
296
|
+
getSessionId: vi.fn(() => "parent-session"),
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
286
300
|
|
|
287
301
|
expect(events.emit).not.toHaveBeenCalledWith(
|
|
288
302
|
"permissions:ui_prompt",
|
package/test/rule.test.ts
CHANGED
|
@@ -232,6 +232,81 @@ describe("evaluate", () => {
|
|
|
232
232
|
expect(evaluate("read", "*", [rule]).origin).toBe(origin);
|
|
233
233
|
}
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
// ── Windows: path-surface patterns fold case (last-match-wins) ──────────
|
|
237
|
+
|
|
238
|
+
const denyExternalAll: Rule = {
|
|
239
|
+
surface: "external_directory",
|
|
240
|
+
pattern: "*",
|
|
241
|
+
action: "deny",
|
|
242
|
+
layer: "config",
|
|
243
|
+
origin: "global",
|
|
244
|
+
};
|
|
245
|
+
const allowExternalPi: Rule = {
|
|
246
|
+
surface: "external_directory",
|
|
247
|
+
pattern: "C:\\Users\\Foo\\pi\\*",
|
|
248
|
+
action: "allow",
|
|
249
|
+
layer: "config",
|
|
250
|
+
origin: "global",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => {
|
|
254
|
+
const result = evaluate(
|
|
255
|
+
"external_directory",
|
|
256
|
+
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
257
|
+
[denyExternalAll, allowExternalPi],
|
|
258
|
+
undefined,
|
|
259
|
+
"win32",
|
|
260
|
+
);
|
|
261
|
+
expect(result.action).toBe("allow");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => {
|
|
265
|
+
const result = evaluate(
|
|
266
|
+
"external_directory",
|
|
267
|
+
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
268
|
+
[denyExternalAll, allowExternalPi],
|
|
269
|
+
undefined,
|
|
270
|
+
"linux",
|
|
271
|
+
);
|
|
272
|
+
expect(result.action).toBe("deny");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("win32: a forward-slash external_directory pattern matches a backslash value", () => {
|
|
276
|
+
const allowForwardSlash: Rule = {
|
|
277
|
+
surface: "external_directory",
|
|
278
|
+
pattern: "C:/Users/Foo/pi/*",
|
|
279
|
+
action: "allow",
|
|
280
|
+
layer: "config",
|
|
281
|
+
origin: "global",
|
|
282
|
+
};
|
|
283
|
+
const result = evaluate(
|
|
284
|
+
"external_directory",
|
|
285
|
+
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
286
|
+
[denyExternalAll, allowForwardSlash],
|
|
287
|
+
undefined,
|
|
288
|
+
"win32",
|
|
289
|
+
);
|
|
290
|
+
expect(result.action).toBe("allow");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("win32: bash surface stays case-sensitive (not a path surface)", () => {
|
|
294
|
+
const result = evaluate(
|
|
295
|
+
"bash",
|
|
296
|
+
"GIT push",
|
|
297
|
+
[
|
|
298
|
+
{
|
|
299
|
+
surface: "bash",
|
|
300
|
+
pattern: "git *",
|
|
301
|
+
action: "allow",
|
|
302
|
+
origin: "global",
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
undefined,
|
|
306
|
+
"win32",
|
|
307
|
+
);
|
|
308
|
+
expect(result.action).toBe("ask");
|
|
309
|
+
});
|
|
235
310
|
});
|
|
236
311
|
|
|
237
312
|
describe("evaluateFirst", () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
2
|
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
3
|
import {
|
|
5
4
|
isRegisteredSubagentChild,
|
|
6
5
|
isSubagentExecutionContext,
|
|
7
6
|
normalizeFilesystemPath,
|
|
7
|
+
type SubagentDetectionContext,
|
|
8
8
|
} from "#src/subagent-context";
|
|
9
9
|
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
10
10
|
|
|
@@ -16,13 +16,13 @@ afterEach(() => {
|
|
|
16
16
|
function makeCtx(
|
|
17
17
|
sessionDir: string | null,
|
|
18
18
|
sessionId: string = "",
|
|
19
|
-
):
|
|
19
|
+
): SubagentDetectionContext {
|
|
20
20
|
return {
|
|
21
21
|
sessionManager: {
|
|
22
|
-
getSessionDir: vi.fn(() => sessionDir),
|
|
22
|
+
getSessionDir: vi.fn(() => sessionDir ?? ""),
|
|
23
23
|
getSessionId: vi.fn(() => sessionId),
|
|
24
24
|
},
|
|
25
|
-
}
|
|
25
|
+
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
describe("isRegisteredSubagentChild", () => {
|
|
@@ -52,14 +52,14 @@ describe("isRegisteredSubagentChild", () => {
|
|
|
52
52
|
test("returns false when getSessionId throws", () => {
|
|
53
53
|
const registry = new SubagentSessionRegistry();
|
|
54
54
|
registry.register(childSessionId, {});
|
|
55
|
-
const ctx = {
|
|
55
|
+
const ctx: SubagentDetectionContext = {
|
|
56
56
|
sessionManager: {
|
|
57
|
-
getSessionDir: vi.fn(() =>
|
|
57
|
+
getSessionDir: vi.fn(() => ""),
|
|
58
58
|
getSessionId: vi.fn(() => {
|
|
59
59
|
throw new Error("session id unavailable");
|
|
60
60
|
}),
|
|
61
61
|
},
|
|
62
|
-
}
|
|
62
|
+
};
|
|
63
63
|
expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
|
|
64
64
|
});
|
|
65
65
|
});
|
|
@@ -287,6 +287,46 @@ describe("wildcardMatch", () => {
|
|
|
287
287
|
expect(wildcardMatch("*", "")).toBe(true);
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
describe("match options (Windows path folding)", () => {
|
|
292
|
+
test("caseInsensitive matches a value differing only in case", () => {
|
|
293
|
+
expect(
|
|
294
|
+
wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md", {
|
|
295
|
+
caseInsensitive: true,
|
|
296
|
+
}),
|
|
297
|
+
).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("case folding is off by default", () => {
|
|
301
|
+
expect(wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md")).toBe(
|
|
302
|
+
false,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("windowsSeparators matches a backslash value against a forward-slash pattern", () => {
|
|
307
|
+
expect(
|
|
308
|
+
wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md", {
|
|
309
|
+
windowsSeparators: true,
|
|
310
|
+
}),
|
|
311
|
+
).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("separator normalization is off by default", () => {
|
|
315
|
+
expect(wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md")).toBe(
|
|
316
|
+
false,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("both options fold a mixed-case forward-slash pattern onto a lowercased backslash value", () => {
|
|
321
|
+
expect(
|
|
322
|
+
wildcardMatch(
|
|
323
|
+
"C:/Users/Foo/AppData/Roaming/*",
|
|
324
|
+
"c:\\users\\foo\\appdata\\roaming\\npm\\x.md",
|
|
325
|
+
{ caseInsensitive: true, windowsSeparators: true },
|
|
326
|
+
),
|
|
327
|
+
).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
290
330
|
});
|
|
291
331
|
|
|
292
332
|
describe("? single-character wildcard", () => {
|