@gotgenes/pi-permission-system 7.1.4 → 7.3.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 +26 -0
- package/package.json +2 -2
- package/src/active-agent.ts +1 -1
- package/src/bash-arity.ts +1 -0
- package/src/config-modal.ts +2 -0
- package/src/forwarded-permissions/io.ts +4 -2
- package/src/forwarded-permissions/polling.ts +22 -9
- package/src/forwarding-manager.ts +3 -1
- package/src/handlers/before-agent-start.ts +7 -6
- package/src/handlers/gates/bash-path-extractor.ts +3 -5
- package/src/handlers/gates/bash-path.ts +1 -1
- package/src/handlers/gates/runner.ts +3 -0
- package/src/handlers/lifecycle.ts +9 -8
- package/src/handlers/permission-gate-handler.ts +12 -7
- package/src/index.ts +19 -1
- package/src/logging.ts +3 -0
- package/src/node-modules-discovery.ts +1 -1
- package/src/normalize.ts +1 -0
- package/src/permission-event-rpc.ts +2 -0
- package/src/permission-forwarding.ts +15 -0
- package/src/permission-manager.ts +7 -6
- package/src/permission-merge.ts +4 -2
- package/src/permission-prompter.ts +7 -0
- package/src/permission-prompts.ts +1 -1
- package/src/policy-loader.ts +5 -5
- package/src/service.ts +37 -1
- package/src/skill-prompt-sanitizer.ts +3 -3
- package/src/subagent-context.ts +14 -1
- package/src/subagent-registry.ts +60 -0
- package/src/tool-registry.ts +1 -1
- package/src/yolo-mode.ts +2 -1
- package/test/config-modal.test.ts +6 -8
- package/test/forwarding-manager.test.ts +1 -0
- package/test/handlers/before-agent-start.test.ts +1 -1
- package/test/handlers/external-directory-integration.test.ts +1 -1
- package/test/handlers/gates/skill-read.test.ts +8 -10
- package/test/handlers/gates/tool.test.ts +1 -1
- package/test/handlers/input-events.test.ts +1 -1
- package/test/handlers/input.test.ts +1 -1
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/handlers/tool-call.test.ts +1 -1
- package/test/permission-event-rpc.test.ts +1 -0
- package/test/permission-events.test.ts +2 -0
- package/test/permission-forwarding.test.ts +98 -0
- package/test/permission-manager-unified.test.ts +4 -2
- package/test/permission-session.test.ts +2 -2
- package/test/permission-system.test.ts +8 -8
- package/test/service.test.ts +100 -6
- package/test/subagent-context.test.ts +65 -0
- package/test/subagent-registry.test.ts +94 -0
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
PermissionPromptDecision,
|
|
10
10
|
RequestPermissionOptions,
|
|
11
11
|
} from "./permission-dialog";
|
|
12
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
12
13
|
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
13
14
|
|
|
14
15
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
@@ -54,6 +55,8 @@ export interface PermissionPrompterDeps {
|
|
|
54
55
|
subagentSessionsDir: string;
|
|
55
56
|
/** Directory used for file-based permission forwarding requests/responses. */
|
|
56
57
|
forwardingDir: string;
|
|
58
|
+
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
59
|
+
registry?: SubagentSessionRegistry;
|
|
57
60
|
/** Show the interactive permission dialog in the UI. */
|
|
58
61
|
requestPermissionDecisionFromUi(
|
|
59
62
|
ui: ExtensionContext["ui"],
|
|
@@ -148,14 +151,18 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
148
151
|
private buildForwardingDeps(): PermissionForwardingDeps {
|
|
149
152
|
const { deps } = this;
|
|
150
153
|
const logger: ForwardedPermissionLogger = {
|
|
154
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
151
155
|
writeReviewLog: deps.writeReviewLog,
|
|
152
156
|
writeDebugLog: () => undefined,
|
|
153
157
|
};
|
|
154
158
|
return {
|
|
155
159
|
forwardingDir: deps.forwardingDir,
|
|
156
160
|
subagentSessionsDir: deps.subagentSessionsDir,
|
|
161
|
+
registry: deps.registry,
|
|
157
162
|
logger,
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
158
164
|
writeReviewLog: deps.writeReviewLog,
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
|
|
159
166
|
requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
|
|
160
167
|
shouldAutoApprove: () => false,
|
|
161
168
|
};
|
|
@@ -38,7 +38,7 @@ export function formatAskPrompt(
|
|
|
38
38
|
const patternInfo = result.matchedPattern
|
|
39
39
|
? ` (matched '${result.matchedPattern}')`
|
|
40
40
|
: "";
|
|
41
|
-
return `${subject} requested bash command '${result.command
|
|
41
|
+
return `${subject} requested bash command '${result.command ?? ""}'${patternInfo}. Allow this command?`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
package/src/policy-loader.ts
CHANGED
|
@@ -169,12 +169,12 @@ export class FilePolicyLoader implements PolicyLoader {
|
|
|
169
169
|
|
|
170
170
|
constructor(options: PolicyLoaderOptions = {}) {
|
|
171
171
|
this.globalConfigPath =
|
|
172
|
-
options.globalConfigPath
|
|
173
|
-
this.agentsDir = options.agentsDir
|
|
174
|
-
this.projectGlobalConfigPath = options.projectGlobalConfigPath
|
|
175
|
-
this.projectAgentsDir = options.projectAgentsDir
|
|
172
|
+
options.globalConfigPath ?? defaultGlobalConfigPath();
|
|
173
|
+
this.agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
174
|
+
this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
|
|
175
|
+
this.projectAgentsDir = options.projectAgentsDir ?? null;
|
|
176
176
|
this.globalMcpConfigPath =
|
|
177
|
-
options.globalMcpConfigPath
|
|
177
|
+
options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
|
|
178
178
|
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
179
179
|
? [
|
|
180
180
|
...new Set(
|
package/src/service.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { SubagentSessionInfo } from "./subagent-registry";
|
|
14
15
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
15
16
|
|
|
16
|
-
export type { PermissionCheckResult, PermissionState };
|
|
17
|
+
export type { PermissionCheckResult, PermissionState, SubagentSessionInfo };
|
|
17
18
|
|
|
18
19
|
/** Process-global key for the service slot. */
|
|
19
20
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
@@ -42,6 +43,40 @@ export interface PermissionsService {
|
|
|
42
43
|
value?: string,
|
|
43
44
|
agentName?: string,
|
|
44
45
|
): PermissionCheckResult;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register an in-process subagent session.
|
|
49
|
+
*
|
|
50
|
+
* Call this before `bindExtensions()` so that `isSubagentExecutionContext()`
|
|
51
|
+
* and permission-forwarding target resolution can detect the child session.
|
|
52
|
+
* Always pair with `unregisterSubagentSession()` in a `finally` block.
|
|
53
|
+
*
|
|
54
|
+
* @param sessionKey - Unique session identifier (use the session directory path).
|
|
55
|
+
* @param info - Agent name and optional parent session ID.
|
|
56
|
+
*/
|
|
57
|
+
registerSubagentSession(sessionKey: string, info: SubagentSessionInfo): void;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove a previously registered in-process subagent session.
|
|
61
|
+
*
|
|
62
|
+
* Safe to call even if `registerSubagentSession` was never called for this key.
|
|
63
|
+
*
|
|
64
|
+
* @param sessionKey - The same key passed to `registerSubagentSession`.
|
|
65
|
+
*/
|
|
66
|
+
unregisterSubagentSession(sessionKey: string): void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Query the tool-level permission state for pre-filtering tools before
|
|
70
|
+
* creating a child session.
|
|
71
|
+
*
|
|
72
|
+
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
73
|
+
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
74
|
+
* use `checkPermission` for runtime invocation gates.
|
|
75
|
+
*
|
|
76
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
77
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
78
|
+
*/
|
|
79
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
45
80
|
}
|
|
46
81
|
|
|
47
82
|
/**
|
|
@@ -71,5 +106,6 @@ export function getPermissionsService(): PermissionsService | undefined {
|
|
|
71
106
|
* extension is torn down.
|
|
72
107
|
*/
|
|
73
108
|
export function unpublishPermissionsService(): void {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
74
110
|
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
75
111
|
}
|
|
@@ -70,9 +70,9 @@ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
|
70
70
|
|
|
71
71
|
for (const match of sectionBody.matchAll(skillBlockRegex)) {
|
|
72
72
|
const block = match[1];
|
|
73
|
-
const nameMatch =
|
|
74
|
-
const descriptionMatch =
|
|
75
|
-
const locationMatch =
|
|
73
|
+
const nameMatch = SKILL_NAME_REGEX.exec(block);
|
|
74
|
+
const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
|
|
75
|
+
const locationMatch = SKILL_LOCATION_REGEX.exec(block);
|
|
76
76
|
|
|
77
77
|
if (!nameMatch || !descriptionMatch || !locationMatch) {
|
|
78
78
|
continue;
|
package/src/subagent-context.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { normalize } from "node:path";
|
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
|
|
5
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
5
6
|
|
|
6
7
|
export function normalizeFilesystemPath(pathValue: string): string {
|
|
7
8
|
const normalizedPath = normalize(pathValue);
|
|
@@ -30,7 +31,18 @@ function isPathWithinDirectoryForSubagent(
|
|
|
30
31
|
export function isSubagentExecutionContext(
|
|
31
32
|
ctx: ExtensionContext,
|
|
32
33
|
subagentSessionsDir: string,
|
|
34
|
+
registry?: SubagentSessionRegistry,
|
|
33
35
|
): boolean {
|
|
36
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
37
|
+
|
|
38
|
+
// 1. Explicit registry — in-process subagent extensions register before
|
|
39
|
+
// bindExtensions(); checked first so it takes priority over heuristics.
|
|
40
|
+
if (registry && sessionDir && registry.has(sessionDir)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
|
|
45
|
+
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
|
|
34
46
|
for (const key of SUBAGENT_ENV_HINT_KEYS) {
|
|
35
47
|
const value = process.env[key];
|
|
36
48
|
if (typeof value === "string" && value.trim()) {
|
|
@@ -38,7 +50,8 @@ export function isSubagentExecutionContext(
|
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
|
|
53
|
+
// 3. Filesystem path — fallback heuristic for extensions that store sessions
|
|
54
|
+
// under a known subagent root directory.
|
|
42
55
|
if (!sessionDir) {
|
|
43
56
|
return false;
|
|
44
57
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-registry.ts — In-process subagent session registry.
|
|
3
|
+
*
|
|
4
|
+
* In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
|
|
5
|
+
* each child session here before calling `bindExtensions()` so that
|
|
6
|
+
* `isSubagentExecutionContext()` and permission-forwarding target resolution
|
|
7
|
+
* can detect them without relying on environment variables or filesystem
|
|
8
|
+
* heuristics.
|
|
9
|
+
*
|
|
10
|
+
* The registry is keyed by session directory path, which is unique per
|
|
11
|
+
* session and available to both producer and consumer via
|
|
12
|
+
* `ctx.sessionManager.getSessionDir()`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Signal stored per registered in-process subagent session. */
|
|
16
|
+
export interface SubagentSessionInfo {
|
|
17
|
+
/** Parent session ID for permission forwarding. Omit when unknown. */
|
|
18
|
+
parentSessionId?: string;
|
|
19
|
+
/** Agent name for per-agent policy resolution. */
|
|
20
|
+
agentName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registry of active in-process subagent sessions.
|
|
25
|
+
*
|
|
26
|
+
* Owned by `ExtensionRuntime`; exposed to external callers through the
|
|
27
|
+
* `PermissionsService` interface (`registerSubagentSession` /
|
|
28
|
+
* `unregisterSubagentSession`).
|
|
29
|
+
*
|
|
30
|
+
* Concurrent background agents are safe because each session has a unique
|
|
31
|
+
* directory path as its key — no scalar global flag is needed.
|
|
32
|
+
*/
|
|
33
|
+
export class SubagentSessionRegistry {
|
|
34
|
+
private readonly sessions = new Map<string, SubagentSessionInfo>();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register an in-process subagent session.
|
|
38
|
+
*
|
|
39
|
+
* If a previous entry exists for `sessionKey`, it is overwritten
|
|
40
|
+
* (last-write-wins; single-writer expected per key).
|
|
41
|
+
*/
|
|
42
|
+
register(sessionKey: string, info: SubagentSessionInfo): void {
|
|
43
|
+
this.sessions.set(sessionKey, info);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove a previously registered session. No-op if the key is absent. */
|
|
47
|
+
unregister(sessionKey: string): void {
|
|
48
|
+
this.sessions.delete(sessionKey);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Return the registered info for `sessionKey`, or `undefined` if absent. */
|
|
52
|
+
get(sessionKey: string): SubagentSessionInfo | undefined {
|
|
53
|
+
return this.sessions.get(sessionKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Return `true` when `sessionKey` has a registered entry. */
|
|
57
|
+
has(sessionKey: string): boolean {
|
|
58
|
+
return this.sessions.has(sessionKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -35,7 +35,7 @@ function buildReverseAliases(
|
|
|
35
35
|
const reverse = new Map<string, string[]>();
|
|
36
36
|
|
|
37
37
|
for (const [alias, canonical] of Object.entries(aliases)) {
|
|
38
|
-
const existing = reverse.get(canonical)
|
|
38
|
+
const existing = reverse.get(canonical) ?? [];
|
|
39
39
|
if (!existing.includes(alias)) {
|
|
40
40
|
existing.push(alias);
|
|
41
41
|
}
|
package/src/yolo-mode.ts
CHANGED
|
@@ -10,7 +10,8 @@ export interface AskPermissionResolutionOptions {
|
|
|
10
10
|
export function isYoloModeEnabled(
|
|
11
11
|
config: PermissionSystemExtensionConfig,
|
|
12
12
|
): boolean {
|
|
13
|
-
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- typed as boolean but may be undefined at runtime (untyped callers); Boolean() guards against that
|
|
14
|
+
return Boolean(config.yoloMode);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function shouldAutoApprovePermissionState(
|
|
@@ -68,7 +68,7 @@ function createCommandContext(hasUI: boolean): {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function lastNotification(notifications: Notification[]): Notification {
|
|
71
|
-
return notifications[notifications.length - 1]
|
|
71
|
+
return notifications[notifications.length - 1];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
test("permission-system command completions expose top-level config actions", () => {
|
|
@@ -101,7 +101,7 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
101
101
|
definition = nextDefinition;
|
|
102
102
|
},
|
|
103
103
|
} as never,
|
|
104
|
-
controller
|
|
104
|
+
controller,
|
|
105
105
|
);
|
|
106
106
|
|
|
107
107
|
expect(definition!.getArgumentCompletions).toBeTypeOf("function");
|
|
@@ -172,13 +172,11 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
172
172
|
definition = nextDefinition;
|
|
173
173
|
},
|
|
174
174
|
} as never,
|
|
175
|
-
controller
|
|
175
|
+
controller,
|
|
176
176
|
);
|
|
177
177
|
|
|
178
178
|
expect(registeredName).toBe("permission-system");
|
|
179
|
-
expect(definition!.description
|
|
180
|
-
"Configure pi-permission-system",
|
|
181
|
-
);
|
|
179
|
+
expect(definition!.description).toContain("Configure pi-permission-system");
|
|
182
180
|
|
|
183
181
|
const infoCtx = createCommandContext(true);
|
|
184
182
|
await definition!.handler("show", infoCtx.ctx);
|
|
@@ -267,7 +265,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
267
265
|
definition = nextDef;
|
|
268
266
|
},
|
|
269
267
|
} as never,
|
|
270
|
-
controller
|
|
268
|
+
controller,
|
|
271
269
|
);
|
|
272
270
|
|
|
273
271
|
const ctx = createCommandContext(true);
|
|
@@ -300,7 +298,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
300
298
|
definition = nextDef;
|
|
301
299
|
},
|
|
302
300
|
} as never,
|
|
303
|
-
controller
|
|
301
|
+
controller,
|
|
304
302
|
);
|
|
305
303
|
|
|
306
304
|
const ctx = createCommandContext(true);
|
|
@@ -52,7 +52,7 @@ function makeSession(
|
|
|
52
52
|
activate: vi.fn(),
|
|
53
53
|
refreshConfig: vi.fn(),
|
|
54
54
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
55
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
55
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
56
56
|
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
57
57
|
shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
|
|
58
58
|
commitActiveToolsCacheKey: vi.fn(),
|
|
@@ -110,7 +110,7 @@ function makeSession(
|
|
|
110
110
|
activate: vi.fn(),
|
|
111
111
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
112
112
|
checkPermission: makeCheckPermission("deny"),
|
|
113
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
113
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
114
114
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
115
115
|
approveSessionRule: vi.fn(),
|
|
116
116
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
@@ -74,7 +74,7 @@ describe("describeSkillReadGate", () => {
|
|
|
74
74
|
makeSkillEntry({ state: "ask" }),
|
|
75
75
|
]);
|
|
76
76
|
expect(result).not.toBeNull();
|
|
77
|
-
const desc = result
|
|
77
|
+
const desc = result!;
|
|
78
78
|
expect(desc.preResolved).toEqual({ state: "ask" });
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -83,7 +83,7 @@ describe("describeSkillReadGate", () => {
|
|
|
83
83
|
makeSkillEntry({ state: "allow" }),
|
|
84
84
|
]);
|
|
85
85
|
expect(result).not.toBeNull();
|
|
86
|
-
const desc = result
|
|
86
|
+
const desc = result!;
|
|
87
87
|
expect(desc.preResolved).toEqual({ state: "allow" });
|
|
88
88
|
});
|
|
89
89
|
|
|
@@ -92,14 +92,14 @@ describe("describeSkillReadGate", () => {
|
|
|
92
92
|
makeSkillEntry({ state: "deny" }),
|
|
93
93
|
]);
|
|
94
94
|
expect(result).not.toBeNull();
|
|
95
|
-
const desc = result
|
|
95
|
+
const desc = result!;
|
|
96
96
|
expect(desc.preResolved).toEqual({ state: "deny" });
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it("decision surface is 'skill' and decision value is the skill name", () => {
|
|
100
100
|
const result = describeSkillReadGate(makeTcc(), () => [
|
|
101
101
|
makeSkillEntry({ name: "my-skill" }),
|
|
102
|
-
])
|
|
102
|
+
])!;
|
|
103
103
|
expect(result.decision.surface).toBe("skill");
|
|
104
104
|
expect(result.decision.value).toBe("my-skill");
|
|
105
105
|
});
|
|
@@ -107,7 +107,7 @@ describe("describeSkillReadGate", () => {
|
|
|
107
107
|
it("denialContext contains the skill name and read path", () => {
|
|
108
108
|
const result = describeSkillReadGate(makeTcc(), () => [
|
|
109
109
|
makeSkillEntry({ name: "librarian" }),
|
|
110
|
-
])
|
|
110
|
+
])!;
|
|
111
111
|
expect(result.denialContext).toEqual({
|
|
112
112
|
kind: "skill_read",
|
|
113
113
|
skillName: "librarian",
|
|
@@ -120,7 +120,7 @@ describe("describeSkillReadGate", () => {
|
|
|
120
120
|
const result = describeSkillReadGate(
|
|
121
121
|
makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
|
|
122
122
|
() => [makeSkillEntry({ name: "my-skill" })],
|
|
123
|
-
)
|
|
123
|
+
)!;
|
|
124
124
|
expect(result.promptDetails).toMatchObject({
|
|
125
125
|
source: "skill_read",
|
|
126
126
|
agentName: "test-agent",
|
|
@@ -135,7 +135,7 @@ describe("describeSkillReadGate", () => {
|
|
|
135
135
|
const result = describeSkillReadGate(
|
|
136
136
|
makeTcc({ agentName: "agent-1" }),
|
|
137
137
|
() => [makeSkillEntry({ name: "librarian" })],
|
|
138
|
-
)
|
|
138
|
+
)!;
|
|
139
139
|
expect(result.logContext).toMatchObject({
|
|
140
140
|
source: "skill_read",
|
|
141
141
|
skillName: "librarian",
|
|
@@ -144,9 +144,7 @@ describe("describeSkillReadGate", () => {
|
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
it("surface is 'skill' on the descriptor", () => {
|
|
147
|
-
const result = describeSkillReadGate(makeTcc(), () => [
|
|
148
|
-
makeSkillEntry(),
|
|
149
|
-
]) as GateDescriptor;
|
|
147
|
+
const result = describeSkillReadGate(makeTcc(), () => [makeSkillEntry()])!;
|
|
150
148
|
expect(result.surface).toBe("skill");
|
|
151
149
|
});
|
|
152
150
|
});
|
|
@@ -93,7 +93,7 @@ describe("describeToolGate", () => {
|
|
|
93
93
|
it("populates denialContext with agent name when provided", () => {
|
|
94
94
|
const check = makeCheckResult("ask", { toolName: "read" });
|
|
95
95
|
const desc = describeToolGate(makeTcc({ agentName: "my-agent" }), check);
|
|
96
|
-
expect(desc.denialContext
|
|
96
|
+
expect(desc.denialContext.agentName).toBe("my-agent");
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it("populates denialContext with input for tool context", () => {
|
|
@@ -54,7 +54,7 @@ function makeSession(
|
|
|
54
54
|
origin: "global",
|
|
55
55
|
matchedPattern: "*",
|
|
56
56
|
}),
|
|
57
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
57
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
58
58
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
59
59
|
approveSessionRule: vi.fn(),
|
|
60
60
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
@@ -42,7 +42,7 @@ function makeSession(
|
|
|
42
42
|
activate: vi.fn(),
|
|
43
43
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
44
44
|
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
45
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
45
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
46
46
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
47
47
|
approveSessionRule: vi.fn(),
|
|
48
48
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
@@ -75,7 +75,7 @@ function makeSession(
|
|
|
75
75
|
activate: vi.fn(),
|
|
76
76
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
77
77
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
78
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
78
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
79
79
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
80
80
|
approveSessionRule: vi.fn(),
|
|
81
81
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
@@ -66,7 +66,7 @@ function makeSession(
|
|
|
66
66
|
activate: vi.fn(),
|
|
67
67
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
68
68
|
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
69
|
-
getToolPermission: vi.fn().mockReturnValue("allow"
|
|
69
|
+
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
70
70
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
71
71
|
approveSessionRule: vi.fn(),
|
|
72
72
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-deprecated -- tests the deprecated RPC channel implementation */
|
|
1
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
@@ -164,6 +165,7 @@ describe("type shapes (PermissionsRpcReply)", () => {
|
|
|
164
165
|
error: "no_ui",
|
|
165
166
|
};
|
|
166
167
|
expect(reply.success).toBe(false);
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- narrowing on discriminated union
|
|
167
169
|
if (!reply.success) {
|
|
168
170
|
expect(reply.error).toBe("no_ui");
|
|
169
171
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
5
5
|
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
6
6
|
} from "#src/permission-forwarding";
|
|
7
|
+
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
7
8
|
|
|
8
9
|
afterEach(() => {
|
|
9
10
|
vi.unstubAllEnvs();
|
|
@@ -24,6 +25,7 @@ describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
|
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- test verifying the deprecated alias
|
|
27
29
|
expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
|
|
28
30
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
|
|
29
31
|
);
|
|
@@ -145,3 +147,99 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
|
|
|
145
147
|
).toBe("env-session-abc");
|
|
146
148
|
});
|
|
147
149
|
});
|
|
150
|
+
|
|
151
|
+
describe("resolvePermissionForwardingTargetSessionId — registry resolution", () => {
|
|
152
|
+
const sessionDir =
|
|
153
|
+
"/home/user/projects/.pi/sessions/parent/tasks/session-abc";
|
|
154
|
+
|
|
155
|
+
test("returns parentSessionId from registry when env vars are absent", () => {
|
|
156
|
+
const registry = new SubagentSessionRegistry();
|
|
157
|
+
registry.register(sessionDir, {
|
|
158
|
+
agentName: "Explore",
|
|
159
|
+
parentSessionId: "parent-from-registry",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(
|
|
163
|
+
resolvePermissionForwardingTargetSessionId({
|
|
164
|
+
hasUI: false,
|
|
165
|
+
isSubagent: true,
|
|
166
|
+
sessionDir,
|
|
167
|
+
registry,
|
|
168
|
+
env: {},
|
|
169
|
+
}),
|
|
170
|
+
).toBe("parent-from-registry");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("registry takes priority over env vars", () => {
|
|
174
|
+
const registry = new SubagentSessionRegistry();
|
|
175
|
+
registry.register(sessionDir, {
|
|
176
|
+
agentName: "Explore",
|
|
177
|
+
parentSessionId: "parent-from-registry",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
resolvePermissionForwardingTargetSessionId({
|
|
182
|
+
hasUI: false,
|
|
183
|
+
isSubagent: true,
|
|
184
|
+
sessionDir,
|
|
185
|
+
registry,
|
|
186
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
187
|
+
}),
|
|
188
|
+
).toBe("parent-from-registry");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("falls through to env vars when registry entry has no parentSessionId", () => {
|
|
192
|
+
const registry = new SubagentSessionRegistry();
|
|
193
|
+
registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
resolvePermissionForwardingTargetSessionId({
|
|
197
|
+
hasUI: false,
|
|
198
|
+
isSubagent: true,
|
|
199
|
+
sessionDir,
|
|
200
|
+
registry,
|
|
201
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
202
|
+
}),
|
|
203
|
+
).toBe("parent-from-env");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("falls through to env vars when sessionDir is not in registry", () => {
|
|
207
|
+
const registry = new SubagentSessionRegistry(); // empty
|
|
208
|
+
|
|
209
|
+
expect(
|
|
210
|
+
resolvePermissionForwardingTargetSessionId({
|
|
211
|
+
hasUI: false,
|
|
212
|
+
isSubagent: true,
|
|
213
|
+
sessionDir,
|
|
214
|
+
registry,
|
|
215
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
216
|
+
}),
|
|
217
|
+
).toBe("parent-from-env");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns null when registry entry has no parentSessionId and no env vars set", () => {
|
|
221
|
+
const registry = new SubagentSessionRegistry();
|
|
222
|
+
registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
|
|
223
|
+
|
|
224
|
+
expect(
|
|
225
|
+
resolvePermissionForwardingTargetSessionId({
|
|
226
|
+
hasUI: false,
|
|
227
|
+
isSubagent: true,
|
|
228
|
+
sessionDir,
|
|
229
|
+
registry,
|
|
230
|
+
env: {},
|
|
231
|
+
}),
|
|
232
|
+
).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("omitting registry preserves existing behaviour", () => {
|
|
236
|
+
expect(
|
|
237
|
+
resolvePermissionForwardingTargetSessionId({
|
|
238
|
+
hasUI: false,
|
|
239
|
+
isSubagent: true,
|
|
240
|
+
sessionDir,
|
|
241
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
242
|
+
}),
|
|
243
|
+
).toBe("parent-from-env");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -685,10 +685,12 @@ function createInMemoryPolicyLoader(
|
|
|
685
685
|
): PolicyLoader {
|
|
686
686
|
const issues: string[] = [];
|
|
687
687
|
return {
|
|
688
|
-
loadGlobalConfig: () => scopes.global ?? {},
|
|
689
|
-
loadProjectConfig: () => scopes.project ?? {},
|
|
688
|
+
loadGlobalConfig: () => scopes.global ?? ({} as const),
|
|
689
|
+
loadProjectConfig: () => scopes.project ?? ({} as const),
|
|
690
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
|
|
690
691
|
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
|
|
691
692
|
loadProjectAgentConfig: (name?: string) =>
|
|
693
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
|
|
692
694
|
(name && scopes.projectAgent?.[name]) || {},
|
|
693
695
|
getConfiguredMcpServerNames: () => mcpServerNames,
|
|
694
696
|
getCacheStamp: () => "in-memory",
|
|
@@ -122,7 +122,7 @@ function makePermissionManager(
|
|
|
122
122
|
toolName: "read",
|
|
123
123
|
source: "tool",
|
|
124
124
|
origin: "builtin",
|
|
125
|
-
}
|
|
125
|
+
}),
|
|
126
126
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
127
127
|
getConfigIssues: vi.fn().mockReturnValue([]),
|
|
128
128
|
getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
|
|
@@ -260,7 +260,7 @@ describe("PermissionSession", () => {
|
|
|
260
260
|
toolName: "bash",
|
|
261
261
|
source: "bash",
|
|
262
262
|
origin: "global",
|
|
263
|
-
}
|
|
263
|
+
}),
|
|
264
264
|
});
|
|
265
265
|
mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
|
|
266
266
|
const { session } = createSession();
|