@gotgenes/pi-permission-system 7.2.0 → 7.3.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 +31 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/extension-config.ts +1 -1
- package/src/forwarded-permissions/polling.ts +14 -2
- package/src/forwarding-manager.ts +3 -1
- package/src/handlers/before-agent-start.ts +1 -0
- package/src/handlers/gates/bash-path-extractor.ts +0 -8
- package/src/handlers/lifecycle.ts +1 -0
- package/src/handlers/permission-gate-handler.ts +1 -0
- package/src/index.ts +19 -1
- package/src/permission-dialog.ts +0 -6
- package/src/permission-forwarding.ts +15 -0
- package/src/permission-manager.ts +1 -1
- package/src/permission-merge.ts +1 -1
- package/src/permission-prompter.ts +4 -0
- package/src/service.ts +36 -1
- package/src/skill-prompt-sanitizer.ts +0 -27
- package/src/subagent-context.ts +14 -1
- package/src/subagent-registry.ts +60 -0
- package/src/types.ts +0 -11
- package/test/forwarding-manager.test.ts +1 -0
- package/test/handlers/before-agent-start.test.ts +0 -1
- package/test/handlers/gates/bash-external-directory.test.ts +17 -12
- package/test/handlers/gates/skill-read.test.ts +0 -2
- package/test/handlers/input-events.test.ts +0 -1
- package/test/handlers/input.test.ts +0 -1
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/handlers/tool-call.test.ts +1 -1
- package/test/permission-events.test.ts +1 -1
- package/test/permission-forwarding.test.ts +97 -0
- package/test/permission-session.test.ts +0 -1
- package/test/service.test.ts +100 -6
- package/test/subagent-context.test.ts +65 -0
- package/test/subagent-registry.test.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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
|
+
## [7.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.0...pi-permission-system-v7.3.1) (2026-05-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* resolve pre-existing lint errors in pi-autoformat and pi-permission-system ([68fd516](https://github.com/gotgenes/pi-packages/commit/68fd516e33ddbb9a5e37ef19e949ee9ecdc37252))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* update subagent integration docs for native permission bridge ([#101](https://github.com/gotgenes/pi-packages/issues/101)) ([0bd456b](https://github.com/gotgenes/pi-packages/commit/0bd456befa8ea6918e74f4393d844868795edc77))
|
|
19
|
+
|
|
20
|
+
## [7.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.2.0...pi-permission-system-v7.3.0) (2026-05-25)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** add SubagentSessionRegistry class ([a0ef16b](https://github.com/gotgenes/pi-packages/commit/a0ef16b8302f95b30cc11cb121441dbd164c276c))
|
|
26
|
+
* **pi-permission-system:** detect in-process subagents via session registry ([c90b824](https://github.com/gotgenes/pi-packages/commit/c90b824b4515a1d5ca259348ae0b60c7d70f29d4))
|
|
27
|
+
* **pi-permission-system:** expose registry and getToolPermission on PermissionsService ([984d2bb](https://github.com/gotgenes/pi-packages/commit/984d2bbb76f08cea91b5c0117eb356ae576ad6be))
|
|
28
|
+
* **pi-permission-system:** resolve forwarding target from subagent registry ([5eb15af](https://github.com/gotgenes/pi-packages/commit/5eb15afe680bfd36627c2c21165b59a0ea5e227c))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
* **pi-permission-system:** document subagent session registry API ([93c5c3e](https://github.com/gotgenes/pi-packages/commit/93c5c3e72b2b757a99eba17d1c6885ea49271403))
|
|
34
|
+
* **pi-permission-system:** update architecture for subagent registry ([7b32e6a](https://github.com/gotgenes/pi-packages/commit/7b32e6a247e789b927e5cb3f19a367db0c110353))
|
|
35
|
+
* plan subagent session registry and tool-level permission query ([#221](https://github.com/gotgenes/pi-packages/issues/221)) ([a11d91a](https://github.com/gotgenes/pi-packages/commit/a11d91aa1e13e846030deb0af37444c44eeda7c8))
|
|
36
|
+
* **retro:** add planning stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([cf434c2](https://github.com/gotgenes/pi-packages/commit/cf434c2f9711f26290a4635aea519f1f56e98cc7))
|
|
37
|
+
* **retro:** add TDD stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([e050898](https://github.com/gotgenes/pi-packages/commit/e05089840ee6bbb07cbeab5c55367e2dcd304866))
|
|
38
|
+
|
|
8
39
|
## [7.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.4...pi-permission-system-v7.2.0) (2026-05-24)
|
|
9
40
|
|
|
10
41
|
|
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
20
20
|
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
22
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
23
|
+
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
|
23
24
|
|
|
24
25
|
## Install
|
|
25
26
|
|
package/package.json
CHANGED
package/src/extension-config.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
22
22
|
} from "#src/permission-forwarding";
|
|
23
23
|
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
24
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
24
25
|
|
|
25
26
|
import {
|
|
26
27
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
@@ -40,6 +41,8 @@ import {
|
|
|
40
41
|
export interface PermissionForwardingDeps {
|
|
41
42
|
forwardingDir: string;
|
|
42
43
|
subagentSessionsDir: string;
|
|
44
|
+
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
45
|
+
registry?: SubagentSessionRegistry;
|
|
43
46
|
logger: ForwardedPermissionLogger;
|
|
44
47
|
writeReviewLog: (event: string, details: Record<string, unknown>) => void;
|
|
45
48
|
requestPermissionDecisionFromUi: (
|
|
@@ -102,11 +105,18 @@ export async function waitForForwardedPermissionApproval(
|
|
|
102
105
|
deps: PermissionForwardingDeps,
|
|
103
106
|
): Promise<PermissionPromptDecision> {
|
|
104
107
|
const requesterSessionId = getSessionId(ctx);
|
|
108
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
105
109
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
106
110
|
hasUI: ctx.hasUI,
|
|
107
|
-
isSubagent: isSubagentExecutionContext(
|
|
111
|
+
isSubagent: isSubagentExecutionContext(
|
|
112
|
+
ctx,
|
|
113
|
+
deps.subagentSessionsDir,
|
|
114
|
+
deps.registry,
|
|
115
|
+
),
|
|
108
116
|
currentSessionId: requesterSessionId,
|
|
109
117
|
env: process.env,
|
|
118
|
+
sessionDir,
|
|
119
|
+
registry: deps.registry,
|
|
110
120
|
});
|
|
111
121
|
|
|
112
122
|
if (!targetSessionId) {
|
|
@@ -360,7 +370,9 @@ export async function confirmPermission(
|
|
|
360
370
|
);
|
|
361
371
|
}
|
|
362
372
|
|
|
363
|
-
if (
|
|
373
|
+
if (
|
|
374
|
+
!isSubagentExecutionContext(ctx, deps.subagentSessionsDir, deps.registry)
|
|
375
|
+
) {
|
|
364
376
|
return { approved: false, state: "denied" };
|
|
365
377
|
}
|
|
366
378
|
|
|
@@ -4,6 +4,7 @@ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
|
|
|
4
4
|
import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
|
|
5
5
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
6
6
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
7
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
|
@@ -30,6 +31,7 @@ export class ForwardingManager {
|
|
|
30
31
|
constructor(
|
|
31
32
|
private readonly subagentSessionsDir: string,
|
|
32
33
|
private readonly forwardingDeps: PermissionForwardingDeps,
|
|
34
|
+
private readonly registry?: SubagentSessionRegistry,
|
|
33
35
|
) {}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -41,7 +43,7 @@ export class ForwardingManager {
|
|
|
41
43
|
start(ctx: ExtensionContext): void {
|
|
42
44
|
if (
|
|
43
45
|
!ctx.hasUI ||
|
|
44
|
-
isSubagentExecutionContext(ctx, this.subagentSessionsDir)
|
|
46
|
+
isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
|
|
45
47
|
) {
|
|
46
48
|
this.stop();
|
|
47
49
|
return;
|
|
@@ -41,6 +41,7 @@ export function shouldExposeTool(
|
|
|
41
41
|
*/
|
|
42
42
|
export class AgentPrepHandler {
|
|
43
43
|
constructor(
|
|
44
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
|
|
44
45
|
private readonly session: PermissionSession,
|
|
45
46
|
private readonly toolRegistry: ToolRegistry,
|
|
46
47
|
) {}
|
|
@@ -48,14 +48,6 @@ function getParser(): Promise<TSParser> {
|
|
|
48
48
|
return parserPromise;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
* Reset the cached parser promise. Only used by tests to avoid
|
|
53
|
-
* cross-test pollution or to inject a mock parser.
|
|
54
|
-
*/
|
|
55
|
-
function resetParserForTesting(): void {
|
|
56
|
-
parserPromise = null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
// ── AST walker ─────────────────────────────────────────────────────────────
|
|
60
52
|
|
|
61
53
|
/**
|
|
@@ -22,6 +22,7 @@ interface ResourcesDiscoverPayload {
|
|
|
22
22
|
*/
|
|
23
23
|
export class SessionLifecycleHandler {
|
|
24
24
|
constructor(
|
|
25
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
|
|
25
26
|
private readonly session: PermissionSession,
|
|
26
27
|
private readonly cleanupRpc: () => void,
|
|
27
28
|
) {}
|
|
@@ -47,6 +47,7 @@ interface InputPayload {
|
|
|
47
47
|
*/
|
|
48
48
|
export class PermissionGateHandler {
|
|
49
49
|
constructor(
|
|
50
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via destructuring (const { session } = this)
|
|
50
51
|
private readonly session: PermissionSession,
|
|
51
52
|
private readonly events: PermissionEventBus,
|
|
52
53
|
private readonly toolRegistry: ToolRegistry,
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./service";
|
|
28
28
|
import { createSessionLogger } from "./session-logger";
|
|
29
29
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
30
|
+
import { SubagentSessionRegistry } from "./subagent-registry";
|
|
30
31
|
import {
|
|
31
32
|
canResolveAskPermissionRequest,
|
|
32
33
|
shouldAutoApprovePermissionState,
|
|
@@ -34,18 +35,21 @@ import {
|
|
|
34
35
|
|
|
35
36
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
36
37
|
const runtime = createExtensionRuntime();
|
|
38
|
+
const subagentRegistry = new SubagentSessionRegistry();
|
|
37
39
|
|
|
38
40
|
const prompter = new PermissionPrompter({
|
|
39
41
|
getConfig: () => runtime.config,
|
|
40
42
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
41
43
|
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
42
44
|
forwardingDir: runtime.forwardingDir,
|
|
45
|
+
registry: subagentRegistry,
|
|
43
46
|
requestPermissionDecisionFromUi,
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
const forwardingDeps: PermissionForwardingDeps = {
|
|
47
50
|
forwardingDir: runtime.forwardingDir,
|
|
48
51
|
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
52
|
+
registry: subagentRegistry,
|
|
49
53
|
logger: {
|
|
50
54
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
51
55
|
writeDebugLog: runtime.writeDebugLog.bind(runtime),
|
|
@@ -61,7 +65,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
61
65
|
const session = new PermissionSession(
|
|
62
66
|
runtime,
|
|
63
67
|
createSessionLogger(runtime),
|
|
64
|
-
new ForwardingManager(
|
|
68
|
+
new ForwardingManager(
|
|
69
|
+
runtime.subagentSessionsDir,
|
|
70
|
+
forwardingDeps,
|
|
71
|
+
subagentRegistry,
|
|
72
|
+
),
|
|
65
73
|
{
|
|
66
74
|
refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
|
|
67
75
|
logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
|
|
@@ -73,6 +81,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
73
81
|
isSubagent: isSubagentExecutionContext(
|
|
74
82
|
ctx,
|
|
75
83
|
runtime.subagentSessionsDir,
|
|
84
|
+
subagentRegistry,
|
|
76
85
|
),
|
|
77
86
|
}),
|
|
78
87
|
promptPermission: (ctx, details) => prompter.prompt(ctx, details),
|
|
@@ -108,6 +117,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
108
117
|
sessionRules,
|
|
109
118
|
);
|
|
110
119
|
},
|
|
120
|
+
registerSubagentSession(sessionKey, info) {
|
|
121
|
+
subagentRegistry.register(sessionKey, info);
|
|
122
|
+
},
|
|
123
|
+
unregisterSubagentSession(sessionKey) {
|
|
124
|
+
subagentRegistry.unregister(sessionKey);
|
|
125
|
+
},
|
|
126
|
+
getToolPermission(toolName, agentName) {
|
|
127
|
+
return runtime.permissionManager.getToolPermission(toolName, agentName);
|
|
128
|
+
},
|
|
111
129
|
};
|
|
112
130
|
publishPermissionsService(permissionsService);
|
|
113
131
|
|
package/src/permission-dialog.ts
CHANGED
|
@@ -25,12 +25,6 @@ const APPROVE_OPTION = "Yes";
|
|
|
25
25
|
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
|
26
26
|
const DENY_OPTION = "No";
|
|
27
27
|
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
|
28
|
-
const PERMISSION_DECISION_OPTIONS = [
|
|
29
|
-
APPROVE_OPTION,
|
|
30
|
-
APPROVE_FOR_SESSION_OPTION,
|
|
31
|
-
DENY_OPTION,
|
|
32
|
-
DENY_WITH_REASON_OPTION,
|
|
33
|
-
] as const;
|
|
34
28
|
|
|
35
29
|
export function normalizePermissionDenialReason(
|
|
36
30
|
value: unknown,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
4
5
|
|
|
5
6
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
6
7
|
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -118,6 +119,10 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
118
119
|
isSubagent: boolean;
|
|
119
120
|
currentSessionId?: string | null;
|
|
120
121
|
env?: NodeJS.ProcessEnv;
|
|
122
|
+
/** Session directory key for registry lookup. */
|
|
123
|
+
sessionDir?: string;
|
|
124
|
+
/** In-process subagent session registry (checked before env vars). */
|
|
125
|
+
registry?: SubagentSessionRegistry;
|
|
121
126
|
}): string | null {
|
|
122
127
|
if (options.hasUI) {
|
|
123
128
|
return normalizePermissionForwardingSessionId(options.currentSessionId);
|
|
@@ -127,6 +132,16 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
127
132
|
return null;
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
// 1. Registry — in-process subagents register parentSessionId explicitly.
|
|
136
|
+
if (options.registry && options.sessionDir) {
|
|
137
|
+
const entry = options.registry.get(options.sessionDir);
|
|
138
|
+
const resolved = normalizePermissionForwardingSessionId(
|
|
139
|
+
entry?.parentSessionId,
|
|
140
|
+
);
|
|
141
|
+
if (resolved) return resolved;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. Env vars — process-based subagent extensions.
|
|
130
145
|
const env = options.env ?? process.env;
|
|
131
146
|
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
|
132
147
|
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
|
@@ -120,7 +120,7 @@ export class PermissionManager {
|
|
|
120
120
|
// existing patterns from lower scopes keep their earlier origin.
|
|
121
121
|
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
122
122
|
for (const pattern of Object.keys(value)) {
|
|
123
|
-
origins.get(surface)
|
|
123
|
+
origins.get(surface)?.set(pattern, scopeName);
|
|
124
124
|
}
|
|
125
125
|
} else {
|
|
126
126
|
// Full replacement: this scope takes over the entire surface entry.
|
package/src/permission-merge.ts
CHANGED
|
@@ -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"],
|
|
@@ -155,6 +158,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
155
158
|
return {
|
|
156
159
|
forwardingDir: deps.forwardingDir,
|
|
157
160
|
subagentSessionsDir: deps.subagentSessionsDir,
|
|
161
|
+
registry: deps.registry,
|
|
158
162
|
logger,
|
|
159
163
|
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
160
164
|
writeReviewLog: deps.writeReviewLog,
|
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
|
/**
|
|
@@ -92,33 +92,6 @@ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
|
92
92
|
return entries;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function parseSkillPromptSection(prompt: string): SkillPromptSection | null {
|
|
96
|
-
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG);
|
|
97
|
-
if (start === -1) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const closeStart = prompt.indexOf(
|
|
102
|
-
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
103
|
-
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
104
|
-
);
|
|
105
|
-
if (closeStart === -1) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
|
|
110
|
-
const sectionBody = prompt.slice(
|
|
111
|
-
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
112
|
-
closeStart,
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
start,
|
|
117
|
-
end,
|
|
118
|
-
entries: parseSkillEntries(sectionBody),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
95
|
export function parseAllSkillPromptSections(
|
|
123
96
|
prompt: string,
|
|
124
97
|
): SkillPromptSection[] {
|
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/types.ts
CHANGED
|
@@ -14,17 +14,6 @@ export type FlatPermissionConfig = Record<
|
|
|
14
14
|
PermissionState | Record<string, PermissionState>
|
|
15
15
|
>;
|
|
16
16
|
|
|
17
|
-
type BuiltInToolName =
|
|
18
|
-
| "bash"
|
|
19
|
-
| "read"
|
|
20
|
-
| "write"
|
|
21
|
-
| "edit"
|
|
22
|
-
| "grep"
|
|
23
|
-
| "find"
|
|
24
|
-
| "ls";
|
|
25
|
-
|
|
26
|
-
type SpecialPermissionName = "external_directory";
|
|
27
|
-
|
|
28
17
|
/**
|
|
29
18
|
* Per-scope permission config shape after loading and validation.
|
|
30
19
|
* Holds only the flat permission map — all policy is expressed there.
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
} from "#src/handlers/before-agent-start";
|
|
8
8
|
import type { PermissionSession } from "#src/permission-session";
|
|
9
9
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
10
|
-
import type { PermissionState } from "#src/types";
|
|
11
10
|
|
|
12
11
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
12
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -101,12 +101,15 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
101
101
|
it("uses config-level checkPermission for the policy state", async () => {
|
|
102
102
|
const checkPermission = vi
|
|
103
103
|
.fn()
|
|
104
|
-
.mockImplementation(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
.mockImplementation(
|
|
105
|
+
(_surface: string, input: Record<string, unknown>) => {
|
|
106
|
+
// Path-specific check returns session for coverage filtering
|
|
107
|
+
if (input.path)
|
|
108
|
+
return makeCheckResult("allow", { source: "special" });
|
|
109
|
+
// Config-level check (no path) returns deny
|
|
110
|
+
return makeCheckResult("deny");
|
|
111
|
+
},
|
|
112
|
+
);
|
|
110
113
|
const result = await describeBashExternalDirectoryGate(
|
|
111
114
|
makeTcc(),
|
|
112
115
|
checkPermission,
|
|
@@ -172,12 +175,14 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
172
175
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
173
176
|
const checkPermission = vi
|
|
174
177
|
.fn()
|
|
175
|
-
.mockImplementation(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
.mockImplementation(
|
|
179
|
+
(_surface: string, input: Record<string, unknown>) => {
|
|
180
|
+
if (input.path === "/outside/a.ts") {
|
|
181
|
+
return makeCheckResult("allow", { source: "session" });
|
|
182
|
+
}
|
|
183
|
+
return makeCheckResult("ask");
|
|
184
|
+
},
|
|
185
|
+
);
|
|
181
186
|
const result = await describeBashExternalDirectoryGate(
|
|
182
187
|
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
183
188
|
checkPermission,
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
4
2
|
import { describeSkillReadGate } from "#src/handlers/gates/skill-read";
|
|
5
3
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
6
4
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
@@ -9,7 +9,6 @@ import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
|
9
9
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
10
10
|
import type { PermissionSession } from "#src/permission-session";
|
|
11
11
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
12
|
-
import type { PermissionState } from "#src/types";
|
|
13
12
|
|
|
14
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
15
14
|
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
} from "#src/handlers/permission-gate-handler";
|
|
8
8
|
import type { PermissionSession } from "#src/permission-session";
|
|
9
9
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
10
|
-
import type { PermissionState } from "#src/types";
|
|
11
10
|
|
|
12
11
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
13
12
|
|
|
@@ -10,7 +10,7 @@ import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
|
10
10
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
11
11
|
import type { PermissionSession } from "#src/permission-session";
|
|
12
12
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
13
|
-
import type { PermissionCheckResult
|
|
13
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
14
14
|
|
|
15
15
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "#src/handlers/permission-gate-handler";
|
|
8
8
|
import type { PermissionSession } from "#src/permission-session";
|
|
9
9
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
10
|
-
import type { PermissionCheckResult
|
|
10
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
11
11
|
|
|
12
12
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
13
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -264,7 +264,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
264
264
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
265
265
|
writeFileSync(
|
|
266
266
|
globalConfigPath,
|
|
267
|
-
JSON.stringify({ permission: { "*": "ask" } })
|
|
267
|
+
`${JSON.stringify({ permission: { "*": "ask" } })}\n`,
|
|
268
268
|
"utf8",
|
|
269
269
|
);
|
|
270
270
|
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
@@ -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();
|
|
@@ -146,3 +147,99 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
|
|
|
146
147
|
).toBe("env-session-abc");
|
|
147
148
|
});
|
|
148
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
|
+
});
|
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
} from "#src/permission-session";
|
|
39
39
|
import type { SessionLogger } from "#src/session-logger";
|
|
40
40
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
41
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
42
41
|
|
|
43
42
|
function makeSkillEntry(
|
|
44
43
|
name: string,
|
package/test/service.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
publishPermissionsService,
|
|
7
7
|
unpublishPermissionsService,
|
|
8
8
|
} from "#src/service";
|
|
9
|
+
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
9
10
|
import type { PermissionCheckResult } from "#src/types";
|
|
10
11
|
|
|
11
12
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -15,6 +16,9 @@ function makeService(
|
|
|
15
16
|
): PermissionsService {
|
|
16
17
|
return {
|
|
17
18
|
checkPermission: vi.fn(),
|
|
19
|
+
registerSubagentSession: vi.fn(),
|
|
20
|
+
unregisterSubagentSession: vi.fn(),
|
|
21
|
+
getToolPermission: vi.fn(),
|
|
18
22
|
...overrides,
|
|
19
23
|
};
|
|
20
24
|
}
|
|
@@ -85,12 +89,12 @@ describe("service adapter delegation", () => {
|
|
|
85
89
|
];
|
|
86
90
|
|
|
87
91
|
// Build the adapter the same way index.ts will
|
|
88
|
-
const service
|
|
92
|
+
const service = makeService({
|
|
89
93
|
checkPermission(surface, value, agentName) {
|
|
90
94
|
const input = buildInputForSurface(surface, value);
|
|
91
95
|
return checkPermission(surface, input, agentName, sessionRules);
|
|
92
96
|
},
|
|
93
|
-
};
|
|
97
|
+
});
|
|
94
98
|
|
|
95
99
|
publishPermissionsService(service);
|
|
96
100
|
const retrieved = getPermissionsService()!;
|
|
@@ -108,12 +112,12 @@ describe("service adapter delegation", () => {
|
|
|
108
112
|
it("checkPermission passes agentName through", () => {
|
|
109
113
|
const checkPermission = vi.fn().mockReturnValue(fakeResult);
|
|
110
114
|
|
|
111
|
-
const service
|
|
115
|
+
const service = makeService({
|
|
112
116
|
checkPermission(surface, value, agentName) {
|
|
113
117
|
const input = buildInputForSurface(surface, value);
|
|
114
118
|
return checkPermission(surface, input, agentName, []);
|
|
115
119
|
},
|
|
116
|
-
};
|
|
120
|
+
});
|
|
117
121
|
|
|
118
122
|
publishPermissionsService(service);
|
|
119
123
|
getPermissionsService()!.checkPermission("skill", "my-skill", "Explore");
|
|
@@ -126,15 +130,105 @@ describe("service adapter delegation", () => {
|
|
|
126
130
|
);
|
|
127
131
|
});
|
|
128
132
|
|
|
133
|
+
it("registerSubagentSession delegates to the registry", () => {
|
|
134
|
+
const registry = new SubagentSessionRegistry();
|
|
135
|
+
const service: PermissionsService = {
|
|
136
|
+
checkPermission: vi.fn(),
|
|
137
|
+
registerSubagentSession(key, info) {
|
|
138
|
+
registry.register(key, info);
|
|
139
|
+
},
|
|
140
|
+
unregisterSubagentSession(key) {
|
|
141
|
+
registry.unregister(key);
|
|
142
|
+
},
|
|
143
|
+
getToolPermission: vi.fn((): "allow" => "allow"),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
publishPermissionsService(service);
|
|
147
|
+
getPermissionsService()!.registerSubagentSession("/sessions/task-1", {
|
|
148
|
+
agentName: "Explore",
|
|
149
|
+
parentSessionId: "parent-abc",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(registry.has("/sessions/task-1")).toBe(true);
|
|
153
|
+
expect(registry.get("/sessions/task-1")).toEqual({
|
|
154
|
+
agentName: "Explore",
|
|
155
|
+
parentSessionId: "parent-abc",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("unregisterSubagentSession delegates to the registry", () => {
|
|
160
|
+
const registry = new SubagentSessionRegistry();
|
|
161
|
+
const service: PermissionsService = {
|
|
162
|
+
checkPermission: vi.fn(),
|
|
163
|
+
registerSubagentSession(key, info) {
|
|
164
|
+
registry.register(key, info);
|
|
165
|
+
},
|
|
166
|
+
unregisterSubagentSession(key) {
|
|
167
|
+
registry.unregister(key);
|
|
168
|
+
},
|
|
169
|
+
getToolPermission: vi.fn((): "allow" => "allow"),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
publishPermissionsService(service);
|
|
173
|
+
const svc = getPermissionsService()!;
|
|
174
|
+
svc.registerSubagentSession("/sessions/task-1", { agentName: "Explore" });
|
|
175
|
+
svc.unregisterSubagentSession("/sessions/task-1");
|
|
176
|
+
|
|
177
|
+
expect(registry.has("/sessions/task-1")).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("getToolPermission delegates to the permission manager", () => {
|
|
181
|
+
const getToolPermissionFn = vi.fn(
|
|
182
|
+
(_t: string, _a?: string): "deny" => "deny",
|
|
183
|
+
);
|
|
184
|
+
const service: PermissionsService = {
|
|
185
|
+
checkPermission: vi.fn(),
|
|
186
|
+
registerSubagentSession: vi.fn(),
|
|
187
|
+
unregisterSubagentSession: vi.fn(),
|
|
188
|
+
getToolPermission(toolName, agentName) {
|
|
189
|
+
return getToolPermissionFn(toolName, agentName);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
publishPermissionsService(service);
|
|
194
|
+
const result = getPermissionsService()!.getToolPermission(
|
|
195
|
+
"bash",
|
|
196
|
+
"Explore",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(result).toBe("deny");
|
|
200
|
+
expect(getToolPermissionFn).toHaveBeenCalledWith("bash", "Explore");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("getToolPermission works without agentName", () => {
|
|
204
|
+
const getToolPermissionFn = vi.fn(
|
|
205
|
+
(_t: string, _a?: string): "ask" => "ask",
|
|
206
|
+
);
|
|
207
|
+
const service: PermissionsService = {
|
|
208
|
+
checkPermission: vi.fn(),
|
|
209
|
+
registerSubagentSession: vi.fn(),
|
|
210
|
+
unregisterSubagentSession: vi.fn(),
|
|
211
|
+
getToolPermission(toolName, agentName) {
|
|
212
|
+
return getToolPermissionFn(toolName, agentName);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
publishPermissionsService(service);
|
|
217
|
+
const result = getPermissionsService()!.getToolPermission("write");
|
|
218
|
+
|
|
219
|
+
expect(result).toBe("ask");
|
|
220
|
+
expect(getToolPermissionFn).toHaveBeenCalledWith("write", undefined);
|
|
221
|
+
});
|
|
222
|
+
|
|
129
223
|
it("checkPermission uses empty object for unknown surfaces", () => {
|
|
130
224
|
const checkPermission = vi.fn().mockReturnValue(fakeResult);
|
|
131
225
|
|
|
132
|
-
const service
|
|
226
|
+
const service = makeService({
|
|
133
227
|
checkPermission(surface, value, agentName) {
|
|
134
228
|
const input = buildInputForSurface(surface, value);
|
|
135
229
|
return checkPermission(surface, input, agentName, []);
|
|
136
230
|
},
|
|
137
|
-
};
|
|
231
|
+
});
|
|
138
232
|
|
|
139
233
|
publishPermissionsService(service);
|
|
140
234
|
getPermissionsService()!.checkPermission("read", "/tmp/file");
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isSubagentExecutionContext,
|
|
6
6
|
normalizeFilesystemPath,
|
|
7
7
|
} from "#src/subagent-context";
|
|
8
|
+
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
8
9
|
|
|
9
10
|
afterEach(() => {
|
|
10
11
|
vi.unstubAllEnvs();
|
|
@@ -197,3 +198,67 @@ describe("isSubagentExecutionContext — session dir detection", () => {
|
|
|
197
198
|
expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
|
|
198
199
|
});
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("isSubagentExecutionContext — registry detection", () => {
|
|
203
|
+
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
|
|
204
|
+
const outsideDir =
|
|
205
|
+
"/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
|
|
206
|
+
|
|
207
|
+
test("returns true when session dir is registered (no env vars, outside filesystem root)", () => {
|
|
208
|
+
const registry = new SubagentSessionRegistry();
|
|
209
|
+
registry.register(outsideDir, { agentName: "Explore" });
|
|
210
|
+
expect(
|
|
211
|
+
isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
|
|
212
|
+
).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("returns true when registered session has a parentSessionId", () => {
|
|
216
|
+
const registry = new SubagentSessionRegistry();
|
|
217
|
+
registry.register(outsideDir, {
|
|
218
|
+
agentName: "Plan",
|
|
219
|
+
parentSessionId: "parent-123",
|
|
220
|
+
});
|
|
221
|
+
expect(
|
|
222
|
+
isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
|
|
223
|
+
).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns false when registry is provided but session dir is not registered", () => {
|
|
227
|
+
const registry = new SubagentSessionRegistry();
|
|
228
|
+
expect(
|
|
229
|
+
isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
|
|
230
|
+
).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns false when session dir is null and registry has no matching entry", () => {
|
|
234
|
+
const registry = new SubagentSessionRegistry();
|
|
235
|
+
expect(
|
|
236
|
+
isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
|
|
237
|
+
).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("registry check takes priority over env var detection", () => {
|
|
241
|
+
// Registry says registered; env var not set — should still return true.
|
|
242
|
+
const registry = new SubagentSessionRegistry();
|
|
243
|
+
registry.register(outsideDir, { agentName: "Explore" });
|
|
244
|
+
// Confirm no env var is set
|
|
245
|
+
expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
|
|
246
|
+
expect(
|
|
247
|
+
isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
|
|
248
|
+
).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("unregistered session falls through to env var detection", () => {
|
|
252
|
+
vi.stubEnv("PI_IS_SUBAGENT", "true");
|
|
253
|
+
const registry = new SubagentSessionRegistry(); // empty — outsideDir not registered
|
|
254
|
+
// Env var present → still true even without registry entry
|
|
255
|
+
expect(
|
|
256
|
+
isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
|
|
257
|
+
).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("no registry passed — existing behaviour unchanged", () => {
|
|
261
|
+
// Ensure the parameter is truly optional (no registry arg)
|
|
262
|
+
expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type SubagentSessionInfo,
|
|
4
|
+
SubagentSessionRegistry,
|
|
5
|
+
} from "#src/subagent-registry";
|
|
6
|
+
|
|
7
|
+
function makeInfo(
|
|
8
|
+
overrides: Partial<SubagentSessionInfo> = {},
|
|
9
|
+
): SubagentSessionInfo {
|
|
10
|
+
return {
|
|
11
|
+
agentName: "Explore",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("SubagentSessionRegistry", () => {
|
|
17
|
+
test("has() returns false for an unregistered key", () => {
|
|
18
|
+
const registry = new SubagentSessionRegistry();
|
|
19
|
+
expect(registry.has("/sessions/task-abc")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("get() returns undefined for an unregistered key", () => {
|
|
23
|
+
const registry = new SubagentSessionRegistry();
|
|
24
|
+
expect(registry.get("/sessions/task-abc")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("has() returns true after register()", () => {
|
|
28
|
+
const registry = new SubagentSessionRegistry();
|
|
29
|
+
registry.register("/sessions/task-abc", makeInfo());
|
|
30
|
+
expect(registry.has("/sessions/task-abc")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("get() returns the registered info after register()", () => {
|
|
34
|
+
const registry = new SubagentSessionRegistry();
|
|
35
|
+
const info = makeInfo({ parentSessionId: "parent-123" });
|
|
36
|
+
registry.register("/sessions/task-abc", info);
|
|
37
|
+
expect(registry.get("/sessions/task-abc")).toEqual(info);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("register() stores agentName without parentSessionId", () => {
|
|
41
|
+
const registry = new SubagentSessionRegistry();
|
|
42
|
+
registry.register("/sessions/task-abc", makeInfo());
|
|
43
|
+
expect(registry.get("/sessions/task-abc")).toEqual({
|
|
44
|
+
agentName: "Explore",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("has() returns false after unregister()", () => {
|
|
49
|
+
const registry = new SubagentSessionRegistry();
|
|
50
|
+
registry.register("/sessions/task-abc", makeInfo());
|
|
51
|
+
registry.unregister("/sessions/task-abc");
|
|
52
|
+
expect(registry.has("/sessions/task-abc")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("get() returns undefined after unregister()", () => {
|
|
56
|
+
const registry = new SubagentSessionRegistry();
|
|
57
|
+
registry.register("/sessions/task-abc", makeInfo());
|
|
58
|
+
registry.unregister("/sessions/task-abc");
|
|
59
|
+
expect(registry.get("/sessions/task-abc")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("unregister() is a no-op for an unknown key", () => {
|
|
63
|
+
const registry = new SubagentSessionRegistry();
|
|
64
|
+
expect(() => registry.unregister("/sessions/nonexistent")).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("register() overwrites a previous entry for the same key", () => {
|
|
68
|
+
const registry = new SubagentSessionRegistry();
|
|
69
|
+
registry.register(
|
|
70
|
+
"/sessions/task-abc",
|
|
71
|
+
makeInfo({ parentSessionId: "parent-1" }),
|
|
72
|
+
);
|
|
73
|
+
registry.register(
|
|
74
|
+
"/sessions/task-abc",
|
|
75
|
+
makeInfo({ parentSessionId: "parent-2" }),
|
|
76
|
+
);
|
|
77
|
+
expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
|
|
78
|
+
"parent-2",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("multiple keys are independent", () => {
|
|
83
|
+
const registry = new SubagentSessionRegistry();
|
|
84
|
+
registry.register("/sessions/task-1", makeInfo({ agentName: "Explore" }));
|
|
85
|
+
registry.register("/sessions/task-2", makeInfo({ agentName: "Plan" }));
|
|
86
|
+
|
|
87
|
+
expect(registry.get("/sessions/task-1")?.agentName).toBe("Explore");
|
|
88
|
+
expect(registry.get("/sessions/task-2")?.agentName).toBe("Plan");
|
|
89
|
+
|
|
90
|
+
registry.unregister("/sessions/task-1");
|
|
91
|
+
expect(registry.has("/sessions/task-1")).toBe(false);
|
|
92
|
+
expect(registry.has("/sessions/task-2")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|