@gotgenes/pi-permission-system 9.1.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/polling.ts +34 -2
- package/src/handlers/gates/bash-program.ts +237 -69
- package/src/index.ts +2 -0
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +22 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/service.ts +17 -0
- package/test/bash-external-directory.test.ts +10 -9
- package/test/composition-root.test.ts +5 -0
- package/test/handlers/gates/bash-program.test.ts +109 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarding.test.ts +282 -0
- package/test/permission-prompter.test.ts +120 -0
- package/test/permission-ui-prompt.test.ts +146 -0
|
@@ -21,11 +21,13 @@ import type {
|
|
|
21
21
|
PermissionsRpcReply,
|
|
22
22
|
} from "./permission-events";
|
|
23
23
|
import {
|
|
24
|
+
emitUiPromptEvent,
|
|
24
25
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
25
26
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
26
27
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
27
28
|
} from "./permission-events";
|
|
28
29
|
import type { PermissionManager } from "./permission-manager";
|
|
30
|
+
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
29
31
|
import type { Rule } from "./rule";
|
|
30
32
|
|
|
31
33
|
/** Dependencies injected into the RPC handler registry. */
|
|
@@ -155,6 +157,11 @@ async function handlePromptRpc(
|
|
|
155
157
|
? `Permission request${agentName ? ` from ${agentName}` : ""}`
|
|
156
158
|
: "Permission request";
|
|
157
159
|
|
|
160
|
+
emitUiPromptEvent(
|
|
161
|
+
events,
|
|
162
|
+
buildRpcUiPrompt({ requestId, surface, value, agentName, message }),
|
|
163
|
+
);
|
|
164
|
+
|
|
158
165
|
const decision = await deps.requestPermissionDecisionFromUi(
|
|
159
166
|
ctx.ui,
|
|
160
167
|
title,
|
package/src/permission-events.ts
CHANGED
|
@@ -27,6 +27,9 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
|
|
|
27
27
|
/** Emitted at `session_start`, after the service is published. */
|
|
28
28
|
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
29
29
|
|
|
30
|
+
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
31
|
+
export const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
|
32
|
+
|
|
30
33
|
/** Emitted after every permission gate resolution. */
|
|
31
34
|
export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
32
35
|
|
|
@@ -61,9 +64,63 @@ export type PermissionsRpcReply<T = void> =
|
|
|
61
64
|
|
|
62
65
|
// ── permissions:ready ──────────────────────────────────────────────────────
|
|
63
66
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Payload emitted on `permissions:ready`.
|
|
69
|
+
*
|
|
70
|
+
* Intentionally empty: the channel is a readiness signal. Version negotiation
|
|
71
|
+
* lives in the RPC envelope (`PermissionsRpcReply`), not in broadcast payloads —
|
|
72
|
+
* the published types plus package semver define the broadcast contract.
|
|
73
|
+
*/
|
|
74
|
+
export type PermissionsReadyEvent = Record<string, never>;
|
|
75
|
+
|
|
76
|
+
// ── permissions:ui_prompt ──────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Origin of a UI prompt.
|
|
80
|
+
*
|
|
81
|
+
* Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
|
|
82
|
+
* original source and is identified by a non-null `forwarding` field, not by a
|
|
83
|
+
* dedicated source value.
|
|
84
|
+
*/
|
|
85
|
+
export type PermissionUiPromptSource =
|
|
86
|
+
| "tool_call"
|
|
87
|
+
| "skill_input"
|
|
88
|
+
| "skill_read"
|
|
89
|
+
| "rpc_prompt";
|
|
90
|
+
|
|
91
|
+
/** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
|
|
92
|
+
export interface ForwardedPromptContext {
|
|
93
|
+
/** Requesting subagent's display name, when known. */
|
|
94
|
+
requesterAgentName: string | null;
|
|
95
|
+
/** Requesting subagent's session id, when known. */
|
|
96
|
+
requesterSessionId: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Payload emitted on `permissions:ui_prompt`, immediately before the active
|
|
101
|
+
* user-facing permission UI is shown.
|
|
102
|
+
*
|
|
103
|
+
* Lean by design: `surface`/`value` are the normalized display projection a
|
|
104
|
+
* notification consumer reads; `source` is the origin; `forwarding` is non-null
|
|
105
|
+
* only for forwarded subagent prompts. There is no `protocolVersion` — the
|
|
106
|
+
* published types plus package semver define the broadcast contract, and
|
|
107
|
+
* consumers should read defensively.
|
|
108
|
+
*/
|
|
109
|
+
export interface PermissionUiPromptEvent {
|
|
110
|
+
/** Unique ID for the permission request being prompted. */
|
|
111
|
+
requestId: string;
|
|
112
|
+
/** Prompt origin. */
|
|
113
|
+
source: PermissionUiPromptSource;
|
|
114
|
+
/** Normalized display surface (e.g. "bash", "skill"), when known. */
|
|
115
|
+
surface: string | null;
|
|
116
|
+
/** Normalized display value (command, path, skill name, etc.), when known. */
|
|
117
|
+
value: string | null;
|
|
118
|
+
/** Agent name (when known). */
|
|
119
|
+
agentName: string | null;
|
|
120
|
+
/** Message displayed to the user. */
|
|
121
|
+
message: string;
|
|
122
|
+
/** Forwarding context, or null for a direct prompt. */
|
|
123
|
+
forwarding: ForwardedPromptContext | null;
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
// ── permissions:decision ───────────────────────────────────────────────────
|
|
@@ -164,10 +221,29 @@ export interface PermissionsPromptReplyData {
|
|
|
164
221
|
* reacting to ready can immediately resolve `getPermissionsService()`.
|
|
165
222
|
*/
|
|
166
223
|
export function emitReadyEvent(events: PermissionEventBus): void {
|
|
167
|
-
const payload: PermissionsReadyEvent = {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
224
|
+
const payload: PermissionsReadyEvent = {};
|
|
225
|
+
try {
|
|
226
|
+
events.emit(PERMISSIONS_READY_CHANNEL, payload);
|
|
227
|
+
} catch {
|
|
228
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
229
|
+
// permission system from completing session startup.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Emit a `permissions:ui_prompt` broadcast.
|
|
235
|
+
* Call immediately before invoking the active user-facing permission UI.
|
|
236
|
+
*/
|
|
237
|
+
export function emitUiPromptEvent(
|
|
238
|
+
events: PermissionEventBus,
|
|
239
|
+
event: PermissionUiPromptEvent,
|
|
240
|
+
): void {
|
|
241
|
+
try {
|
|
242
|
+
events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
|
|
243
|
+
} catch {
|
|
244
|
+
// UI-prompt broadcasts are observational. A consumer failure must not block
|
|
245
|
+
// the permission dialog itself.
|
|
246
|
+
}
|
|
171
247
|
}
|
|
172
248
|
|
|
173
249
|
/**
|
|
@@ -178,5 +254,10 @@ export function emitDecisionEvent(
|
|
|
178
254
|
events: PermissionEventBus,
|
|
179
255
|
event: PermissionDecisionEvent,
|
|
180
256
|
): void {
|
|
181
|
-
|
|
257
|
+
try {
|
|
258
|
+
events.emit(PERMISSIONS_DECISION_CHANNEL, event);
|
|
259
|
+
} catch {
|
|
260
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
261
|
+
// permission gate from resolving.
|
|
262
|
+
}
|
|
182
263
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
|
+
import type { PermissionUiPromptSource } from "./permission-events";
|
|
4
5
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
5
6
|
|
|
6
7
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
@@ -38,6 +39,19 @@ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
|
|
38
39
|
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
|
39
40
|
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Display fields relayed from a forwarding child to the parent UI so the parent
|
|
44
|
+
* can emit a non-degraded `permissions:ui_prompt` event.
|
|
45
|
+
*
|
|
46
|
+
* Carried separately from the prompt message because the parent reconstructs
|
|
47
|
+
* the original event through `buildForwardedUiPrompt`, not from the message text.
|
|
48
|
+
*/
|
|
49
|
+
export interface ForwardedPromptDisplay {
|
|
50
|
+
source: PermissionUiPromptSource;
|
|
51
|
+
surface: string | null;
|
|
52
|
+
value: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
export type ForwardedPermissionRequest = {
|
|
42
56
|
id: string;
|
|
43
57
|
createdAt: number;
|
|
@@ -45,6 +59,15 @@ export type ForwardedPermissionRequest = {
|
|
|
45
59
|
targetSessionId: string;
|
|
46
60
|
requesterAgentName: string;
|
|
47
61
|
message: string;
|
|
62
|
+
/**
|
|
63
|
+
* Original prompt display fields, persisted so the parent emits a
|
|
64
|
+
* non-degraded event. Optional for version-skew tolerance: a parent on a
|
|
65
|
+
* newer version may read a request written by an older child during an
|
|
66
|
+
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
|
|
67
|
+
*/
|
|
68
|
+
source?: PermissionUiPromptSource;
|
|
69
|
+
surface?: string | null;
|
|
70
|
+
value?: string | null;
|
|
48
71
|
};
|
|
49
72
|
|
|
50
73
|
export type ForwardedPermissionResponse = {
|
|
@@ -9,6 +9,11 @@ import type {
|
|
|
9
9
|
PermissionPromptDecision,
|
|
10
10
|
RequestPermissionOptions,
|
|
11
11
|
} from "./permission-dialog";
|
|
12
|
+
import {
|
|
13
|
+
emitUiPromptEvent,
|
|
14
|
+
type PermissionEventBus,
|
|
15
|
+
} from "./permission-events";
|
|
16
|
+
import { buildDirectUiPrompt } from "./permission-ui-prompt";
|
|
12
17
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
13
18
|
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
14
19
|
|
|
@@ -57,6 +62,8 @@ export interface PermissionPrompterDeps {
|
|
|
57
62
|
forwardingDir: string;
|
|
58
63
|
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
59
64
|
registry?: SubagentSessionRegistry;
|
|
65
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
66
|
+
events: PermissionEventBus;
|
|
60
67
|
/** Show the interactive permission dialog in the UI. */
|
|
61
68
|
requestPermissionDecisionFromUi(
|
|
62
69
|
ui: ExtensionContext["ui"],
|
|
@@ -91,11 +98,25 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
91
98
|
|
|
92
99
|
this.writeReviewEntry("permission_request.waiting", details);
|
|
93
100
|
|
|
101
|
+
// Build the event once. When this session has UI it broadcasts directly;
|
|
102
|
+
// when it does not (a forwarding subagent), the display fields ride along
|
|
103
|
+
// to the parent so the parent emits a non-degraded event from the
|
|
104
|
+
// forwarded path instead of here.
|
|
105
|
+
const uiPrompt = buildDirectUiPrompt(details);
|
|
106
|
+
if (ctx.hasUI) {
|
|
107
|
+
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
const decision = await confirmPermission(
|
|
95
111
|
ctx,
|
|
96
112
|
details.message,
|
|
97
113
|
this.buildForwardingDeps(),
|
|
98
114
|
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
115
|
+
{
|
|
116
|
+
source: uiPrompt.source,
|
|
117
|
+
surface: uiPrompt.surface,
|
|
118
|
+
value: uiPrompt.value,
|
|
119
|
+
},
|
|
99
120
|
);
|
|
100
121
|
|
|
101
122
|
this.writeReviewEntry(
|
|
@@ -159,6 +180,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
159
180
|
forwardingDir: deps.forwardingDir,
|
|
160
181
|
subagentSessionsDir: deps.subagentSessionsDir,
|
|
161
182
|
registry: deps.registry,
|
|
183
|
+
events: deps.events,
|
|
162
184
|
logger,
|
|
163
185
|
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
164
186
|
writeReviewLog: deps.writeReviewLog,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized construction for `permissions:ui_prompt` payloads.
|
|
3
|
+
*
|
|
4
|
+
* Every emit site builds its event through one of these functions, so the
|
|
5
|
+
* public contract's shape — including the normalized `surface`/`value`
|
|
6
|
+
* projection — lives in exactly one place and cannot drift by source.
|
|
7
|
+
*
|
|
8
|
+
* This module is a leaf: it owns narrow input types that each call site's
|
|
9
|
+
* domain object satisfies structurally, so it imports nothing from the
|
|
10
|
+
* prompter, RPC, or forwarding modules (no import cycles, correct layering).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
PermissionUiPromptEvent,
|
|
15
|
+
PermissionUiPromptSource,
|
|
16
|
+
} from "./permission-events";
|
|
17
|
+
|
|
18
|
+
/** Input for a direct (non-forwarded) tool or skill prompt. */
|
|
19
|
+
export interface DirectPromptInput {
|
|
20
|
+
requestId: string;
|
|
21
|
+
source: "tool_call" | "skill_input" | "skill_read";
|
|
22
|
+
agentName: string | null;
|
|
23
|
+
message: string;
|
|
24
|
+
toolName?: string;
|
|
25
|
+
skillName?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
command?: string;
|
|
28
|
+
target?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Input for a `permissions:rpc:prompt` forwarded UI prompt. */
|
|
32
|
+
export interface RpcPromptInput {
|
|
33
|
+
requestId: string;
|
|
34
|
+
surface?: string | null;
|
|
35
|
+
value?: string | null;
|
|
36
|
+
agentName?: string | null;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Input for a file-forwarded subagent prompt shown by the parent UI. */
|
|
41
|
+
export interface ForwardedPromptInput {
|
|
42
|
+
requestId: string;
|
|
43
|
+
message: string;
|
|
44
|
+
requesterAgentName: string | null;
|
|
45
|
+
requesterSessionId: string | null;
|
|
46
|
+
/** Original prompt origin, when the forwarded request carries it. */
|
|
47
|
+
source?: PermissionUiPromptSource | null;
|
|
48
|
+
/** Original normalized surface, when the forwarded request carries it. */
|
|
49
|
+
surface?: string | null;
|
|
50
|
+
/** Original normalized value, when the forwarded request carries it. */
|
|
51
|
+
value?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalized display surface for a direct prompt. */
|
|
55
|
+
function directSurface(input: DirectPromptInput): string | null {
|
|
56
|
+
if (input.source === "skill_input" || input.source === "skill_read") {
|
|
57
|
+
return "skill";
|
|
58
|
+
}
|
|
59
|
+
return input.toolName ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalized display value for a direct prompt. */
|
|
63
|
+
function directValue(input: DirectPromptInput): string | null {
|
|
64
|
+
return (
|
|
65
|
+
input.command ??
|
|
66
|
+
input.path ??
|
|
67
|
+
input.target ??
|
|
68
|
+
input.skillName ??
|
|
69
|
+
input.toolName ??
|
|
70
|
+
null
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the UI prompt event for a direct tool/skill prompt. */
|
|
75
|
+
export function buildDirectUiPrompt(
|
|
76
|
+
input: DirectPromptInput,
|
|
77
|
+
): PermissionUiPromptEvent {
|
|
78
|
+
return {
|
|
79
|
+
requestId: input.requestId,
|
|
80
|
+
source: input.source,
|
|
81
|
+
surface: directSurface(input),
|
|
82
|
+
value: directValue(input),
|
|
83
|
+
agentName: input.agentName,
|
|
84
|
+
message: input.message,
|
|
85
|
+
forwarding: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build the UI prompt event for an RPC-forwarded prompt. */
|
|
90
|
+
export function buildRpcUiPrompt(
|
|
91
|
+
input: RpcPromptInput,
|
|
92
|
+
): PermissionUiPromptEvent {
|
|
93
|
+
return {
|
|
94
|
+
requestId: input.requestId,
|
|
95
|
+
source: "rpc_prompt",
|
|
96
|
+
surface: input.surface ?? null,
|
|
97
|
+
value: input.value ?? null,
|
|
98
|
+
agentName: input.agentName ?? null,
|
|
99
|
+
message: input.message,
|
|
100
|
+
forwarding: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the UI prompt event for a file-forwarded subagent prompt.
|
|
106
|
+
*
|
|
107
|
+
* `source` defaults to `"tool_call"` (the dominant forwarded origin) when the
|
|
108
|
+
* persisted request predates carrying it — a parent on a newer version may read
|
|
109
|
+
* a request written by an older child during an upgrade. The consumer still
|
|
110
|
+
* receives the notify-now signal, message, and forwarding context.
|
|
111
|
+
*/
|
|
112
|
+
export function buildForwardedUiPrompt(
|
|
113
|
+
input: ForwardedPromptInput,
|
|
114
|
+
): PermissionUiPromptEvent {
|
|
115
|
+
return {
|
|
116
|
+
requestId: input.requestId,
|
|
117
|
+
source: input.source ?? "tool_call",
|
|
118
|
+
surface: input.surface ?? null,
|
|
119
|
+
value: input.value ?? null,
|
|
120
|
+
agentName: input.requesterAgentName,
|
|
121
|
+
message: input.message,
|
|
122
|
+
forwarding: {
|
|
123
|
+
requesterAgentName: input.requesterAgentName,
|
|
124
|
+
requesterSessionId: input.requesterSessionId,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -14,6 +14,23 @@
|
|
|
14
14
|
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
15
15
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
16
16
|
|
|
17
|
+
export type {
|
|
18
|
+
ForwardedPromptContext,
|
|
19
|
+
PermissionDecisionEvent,
|
|
20
|
+
PermissionsPromptReplyData,
|
|
21
|
+
PermissionsPromptRequest,
|
|
22
|
+
PermissionsReadyEvent,
|
|
23
|
+
PermissionsRpcReply,
|
|
24
|
+
PermissionUiPromptEvent,
|
|
25
|
+
PermissionUiPromptSource,
|
|
26
|
+
} from "./permission-events";
|
|
27
|
+
export {
|
|
28
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
29
|
+
PERMISSIONS_PROTOCOL_VERSION,
|
|
30
|
+
PERMISSIONS_READY_CHANNEL,
|
|
31
|
+
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
32
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
33
|
+
} from "./permission-events";
|
|
17
34
|
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
18
35
|
|
|
19
36
|
/** Process-global key for the service slot. */
|
|
@@ -852,15 +852,16 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
852
852
|
expect(result).toHaveLength(0);
|
|
853
853
|
});
|
|
854
854
|
|
|
855
|
-
test("cd to external dir: paths
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
855
|
+
test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => {
|
|
856
|
+
// The effective directory is tracked faithfully: `cd /tmp` makes /tmp the
|
|
857
|
+
// base, so the cd target itself is flagged AND ../etc/hosts resolves to
|
|
858
|
+
// /etc/hosts (both outside cwd).
|
|
859
859
|
const result = await extractExternalPathsFromBashCommand(
|
|
860
860
|
"cd /tmp && cat ../etc/hosts",
|
|
861
861
|
cwd,
|
|
862
862
|
);
|
|
863
|
-
expect(result
|
|
863
|
+
expect(result).toContain("/tmp");
|
|
864
|
+
expect(result).toContain("/etc/hosts");
|
|
864
865
|
});
|
|
865
866
|
|
|
866
867
|
test("cd with relative target: resolves inside cwd", async () => {
|
|
@@ -880,14 +881,14 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
880
881
|
expect(result.length).toBeGreaterThan(0);
|
|
881
882
|
});
|
|
882
883
|
|
|
883
|
-
test("cd is not first command
|
|
884
|
-
// cd
|
|
884
|
+
test("sequential fold: a cd that is not the first command still updates the base", async () => {
|
|
885
|
+
// The current-shell `cd` folds even though it is not the first command;
|
|
886
|
+
// ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt.
|
|
885
887
|
const result = await extractExternalPathsFromBashCommand(
|
|
886
888
|
"echo hello && cd /projects/my-app/src && cat ../../outside.txt",
|
|
887
889
|
cwd,
|
|
888
890
|
);
|
|
889
|
-
|
|
890
|
-
expect(result.length).toBeGreaterThan(0);
|
|
891
|
+
expect(result).toContain("/projects/outside.txt");
|
|
891
892
|
});
|
|
892
893
|
|
|
893
894
|
test("cd with semicolon separator", async () => {
|
|
@@ -256,6 +256,11 @@ describe("subagent registry sharing across factory instances", () => {
|
|
|
256
256
|
);
|
|
257
257
|
expect(request.targetSessionId).toBe(parentSessionId);
|
|
258
258
|
expect(request.requesterSessionId).toBe(childSessionId);
|
|
259
|
+
// The child persists the original display fields so the parent emits a
|
|
260
|
+
// non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
|
|
261
|
+
expect(request.source).toBe("tool_call");
|
|
262
|
+
expect(request.surface).toBe("read");
|
|
263
|
+
expect(request.value).toBe(join(externalDir, "secret.txt"));
|
|
259
264
|
|
|
260
265
|
const result = (await firePromise) as { block?: true };
|
|
261
266
|
expect(result.block).toBeUndefined();
|
|
@@ -33,6 +33,115 @@ describe("BashProgram", () => {
|
|
|
33
33
|
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
34
|
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
describe("effective working directory projection", () => {
|
|
38
|
+
it("folds a sequence of current-shell cd commands", async () => {
|
|
39
|
+
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
40
|
+
const program = await BashProgram.parse("cd a && cd b && cat ../c");
|
|
41
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
45
|
+
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
46
|
+
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
47
|
+
const program = await BashProgram.parse(
|
|
48
|
+
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
49
|
+
);
|
|
50
|
+
expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("folds a cd that is not the first command", async () => {
|
|
54
|
+
// The single-base model ignored a cd that was not first; now `cd a`
|
|
55
|
+
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
56
|
+
const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
|
|
57
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not fold a backgrounded cd", async () => {
|
|
61
|
+
// `cd a &` runs in a subshell, so it must not update the running
|
|
62
|
+
// directory; ../b resolves against cwd and escapes.
|
|
63
|
+
const program = await BashProgram.parse("cd a & cat ../b");
|
|
64
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not fold a cd inside a pipeline", async () => {
|
|
68
|
+
// Pipeline members run in subshells; the cd must not leak.
|
|
69
|
+
const program = await BashProgram.parse("cd nested | cat ../b");
|
|
70
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
74
|
+
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
75
|
+
const program = await BashProgram.parse("( cd sub && cat ../x )");
|
|
76
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not leak a subshell cd to following commands", async () => {
|
|
80
|
+
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
81
|
+
const program = await BashProgram.parse("( cd sub ) && cat ../y");
|
|
82
|
+
expect(program.externalPaths(cwd)).toContain("/projects/y");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
86
|
+
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
87
|
+
const program = await BashProgram.parse("{ cd sub; cat ../x; }");
|
|
88
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("persists a brace-group cd to following sibling commands", async () => {
|
|
92
|
+
const program = await BashProgram.parse("{ cd sub; } && cat ../x");
|
|
93
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
97
|
+
// Interior cd folding inside substitutions is deferred: the interior
|
|
98
|
+
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
99
|
+
// resolved against cwd/q. Conservative — never misses an escape.
|
|
100
|
+
const program = await BashProgram.parse("echo $(cd q && cat ../r)");
|
|
101
|
+
expect(program.externalPaths(cwd)).toContain("/projects/r");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
105
|
+
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
106
|
+
// so it is flagged (least-privilege).
|
|
107
|
+
const program = await BashProgram.parse('cd "$DIR" && cat ../x');
|
|
108
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
112
|
+
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
113
|
+
// flagged because the effective dir is unknown.
|
|
114
|
+
const program = await BashProgram.parse(
|
|
115
|
+
'cd "$DIR" && cat src/../within.txt',
|
|
116
|
+
);
|
|
117
|
+
expect(program.externalPaths(cwd)).toContain(
|
|
118
|
+
"/projects/my-app/within.txt",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
123
|
+
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
124
|
+
// even when the effective dir is unknown.
|
|
125
|
+
const program = await BashProgram.parse(
|
|
126
|
+
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
127
|
+
);
|
|
128
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("treats `cd -` as an unknown effective directory", async () => {
|
|
132
|
+
const program = await BashProgram.parse("cd - && cat ../x");
|
|
133
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("recovers a known base when a later cd is absolute", async () => {
|
|
137
|
+
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
138
|
+
// ../x resolves to cwd and is not flagged.
|
|
139
|
+
const program = await BashProgram.parse(
|
|
140
|
+
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
141
|
+
);
|
|
142
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
36
145
|
});
|
|
37
146
|
|
|
38
147
|
describe("commands", () => {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
14
14
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
15
15
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
16
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
16
17
|
} from "#src/permission-events";
|
|
17
18
|
|
|
18
19
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -317,6 +318,44 @@ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
|
|
|
317
318
|
}
|
|
318
319
|
});
|
|
319
320
|
|
|
321
|
+
it("emits a UI prompt broadcast before awaiting the UI decision", async () => {
|
|
322
|
+
const bus = createEventBus();
|
|
323
|
+
const ctx = makeCtxWithUi();
|
|
324
|
+
const requestUi = vi
|
|
325
|
+
.fn()
|
|
326
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
327
|
+
const deps = makeDeps({
|
|
328
|
+
getRuntimeContext: vi.fn().mockReturnValue(ctx),
|
|
329
|
+
requestPermissionDecisionFromUi: requestUi,
|
|
330
|
+
});
|
|
331
|
+
registerPermissionRpcHandlers(bus, deps);
|
|
332
|
+
|
|
333
|
+
const promptPromise = waitForReply(bus, PERMISSIONS_UI_PROMPT_CHANNEL);
|
|
334
|
+
const replyPromise = waitForReply(
|
|
335
|
+
bus,
|
|
336
|
+
`${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-broadcast`,
|
|
337
|
+
);
|
|
338
|
+
bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
|
|
339
|
+
requestId: "req-prompt-broadcast",
|
|
340
|
+
surface: "bash",
|
|
341
|
+
value: "git push",
|
|
342
|
+
message: "Allow git push?",
|
|
343
|
+
agentName: "Worker",
|
|
344
|
+
sessionLabel: "Allow git *",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await expect(promptPromise).resolves.toEqual({
|
|
348
|
+
requestId: "req-prompt-broadcast",
|
|
349
|
+
source: "rpc_prompt",
|
|
350
|
+
surface: "bash",
|
|
351
|
+
value: "git push",
|
|
352
|
+
agentName: "Worker",
|
|
353
|
+
message: "Allow git push?",
|
|
354
|
+
forwarding: null,
|
|
355
|
+
});
|
|
356
|
+
await replyPromise;
|
|
357
|
+
});
|
|
358
|
+
|
|
320
359
|
it("passes the message to requestPermissionDecisionFromUi", async () => {
|
|
321
360
|
const bus = createEventBus();
|
|
322
361
|
const ctx = makeCtxWithUi();
|