@gotgenes/pi-permission-system 9.2.0 → 10.1.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 +52 -0
- package/README.md +12 -11
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +50 -68
- package/src/mcp-targets.ts +56 -46
- 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 +27 -56
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +17 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/composition-root.test.ts +5 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-prompter.test.ts +147 -38
- package/test/permission-session.test.ts +160 -27
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -379
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildInputForSurface } from "./input-normalizer";
|
|
2
|
+
import type { PermissionManager } from "./permission-manager";
|
|
3
|
+
import type { PermissionsService } from "./service";
|
|
4
|
+
import type { SessionRules } from "./session-rules";
|
|
5
|
+
import type {
|
|
6
|
+
ToolInputFormatter,
|
|
7
|
+
ToolInputFormatterRegistry,
|
|
8
|
+
} from "./tool-input-formatter-registry";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* In-process implementation of the cross-extension {@link PermissionsService}.
|
|
12
|
+
*
|
|
13
|
+
* Constructed once in the composition root and backed by the runtime's
|
|
14
|
+
* permission manager and session rules. Both injected instances are stable
|
|
15
|
+
* for the lifetime of the factory — `runtime.permissionManager` is never
|
|
16
|
+
* reassigned on the runtime object (only `PermissionSession` reassigns its
|
|
17
|
+
* own internal copy), and `runtime.sessionRules` is `readonly`.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalPermissionsService implements PermissionsService {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly permissionManager: PermissionManager,
|
|
22
|
+
private readonly sessionRules: SessionRules,
|
|
23
|
+
private readonly formatterRegistry: ToolInputFormatterRegistry,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
checkPermission(
|
|
27
|
+
surface: string,
|
|
28
|
+
value?: string,
|
|
29
|
+
agentName?: string,
|
|
30
|
+
): ReturnType<PermissionsService["checkPermission"]> {
|
|
31
|
+
const input = buildInputForSurface(surface, value);
|
|
32
|
+
return this.permissionManager.checkPermission(
|
|
33
|
+
surface,
|
|
34
|
+
input,
|
|
35
|
+
agentName,
|
|
36
|
+
this.sessionRules.getRuleset(),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getToolPermission(
|
|
41
|
+
toolName: string,
|
|
42
|
+
agentName?: string,
|
|
43
|
+
): ReturnType<PermissionsService["getToolPermission"]> {
|
|
44
|
+
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
registerToolInputFormatter(
|
|
48
|
+
toolName: string,
|
|
49
|
+
formatter: ToolInputFormatter,
|
|
50
|
+
): ReturnType<PermissionsService["registerToolInputFormatter"]> {
|
|
51
|
+
return this.formatterRegistry.register(toolName, formatter);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
4
|
+
import {
|
|
5
|
+
type PermissionsService,
|
|
6
|
+
publishPermissionsService,
|
|
7
|
+
unpublishPermissionsService,
|
|
8
|
+
} from "./service";
|
|
9
|
+
import { isRegisteredSubagentChild } from "./subagent-context";
|
|
10
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
11
|
+
|
|
12
|
+
/** The session-scoped service lifecycle that the lifecycle handler drives. */
|
|
13
|
+
export interface ServiceLifecycle {
|
|
14
|
+
activate(ctx: ExtensionContext): void;
|
|
15
|
+
teardown(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns the process-global service publication lifecycle for one extension
|
|
20
|
+
* instance.
|
|
21
|
+
*
|
|
22
|
+
* - `activate` publishes the service (skipped for registered subagent children
|
|
23
|
+
* so they never clobber the parent's slot — see #302), then emits the ready
|
|
24
|
+
* event.
|
|
25
|
+
* - `teardown` runs all session-scoped subscription cleanups in order, then
|
|
26
|
+
* unpublishes the service.
|
|
27
|
+
*/
|
|
28
|
+
export class PermissionServiceLifecycle implements ServiceLifecycle {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly service: PermissionsService,
|
|
31
|
+
private readonly registry: SubagentSessionRegistry,
|
|
32
|
+
private readonly events: PermissionEventBus,
|
|
33
|
+
private readonly subscriptions: readonly (() => void)[],
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
activate(ctx: ExtensionContext): void {
|
|
37
|
+
if (!isRegisteredSubagentChild(ctx, this.registry)) {
|
|
38
|
+
publishPermissionsService(this.service);
|
|
39
|
+
}
|
|
40
|
+
emitReadyEvent(this.events);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
teardown(): void {
|
|
44
|
+
for (const unsubscribe of this.subscriptions) {
|
|
45
|
+
unsubscribe();
|
|
46
|
+
}
|
|
47
|
+
unpublishPermissionsService(this.service);
|
|
48
|
+
}
|
|
49
|
+
}
|
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. */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { SessionLogger } from "./session-logger";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The session surface `SessionLifecycleHandler` invokes across
|
|
7
|
+
* `session_start`, `resources_discover`, and `session_shutdown`: refresh and
|
|
8
|
+
* report config, reset / reload / shut down session state, resolve the agent
|
|
9
|
+
* name, surface config issues, read the runtime context, and log.
|
|
10
|
+
*
|
|
11
|
+
* `activate` is intentionally absent — the lifecycle handler never calls it
|
|
12
|
+
* directly (ISP: do not depend on methods you do not use).
|
|
13
|
+
*/
|
|
14
|
+
export interface SessionLifecycleSession {
|
|
15
|
+
refreshConfig(ctx?: ExtensionContext): void;
|
|
16
|
+
resetForNewSession(ctx: ExtensionContext): void;
|
|
17
|
+
logResolvedConfigPaths(): void;
|
|
18
|
+
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
|
|
19
|
+
getConfigIssues(agentName?: string): string[];
|
|
20
|
+
reload(): void;
|
|
21
|
+
getRuntimeContext(): ExtensionContext | null;
|
|
22
|
+
shutdown(): void;
|
|
23
|
+
readonly logger: SessionLogger;
|
|
24
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { safeJsonStringify } from "./logging";
|
|
3
2
|
|
|
4
3
|
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
@@ -25,67 +24,6 @@ export function formatCount(
|
|
|
25
24
|
return `${value} ${value === 1 ? singular : plural}`;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
29
|
-
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function formatEditInputForPrompt(
|
|
33
|
-
input: Record<string, unknown>,
|
|
34
|
-
): string {
|
|
35
|
-
const path = getPromptPath(input);
|
|
36
|
-
const rawEdits = Array.isArray(input.edits)
|
|
37
|
-
? input.edits
|
|
38
|
-
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
39
|
-
? [{ oldText: input.oldText, newText: input.newText }]
|
|
40
|
-
: [];
|
|
41
|
-
|
|
42
|
-
const edits = rawEdits
|
|
43
|
-
.map((edit) => toRecord(edit))
|
|
44
|
-
.filter(
|
|
45
|
-
(edit) =>
|
|
46
|
-
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const pathPart = path ? `for '${path}'` : "";
|
|
50
|
-
if (edits.length === 0) {
|
|
51
|
-
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const firstEdit = edits[0];
|
|
55
|
-
const oldText = String(firstEdit.oldText);
|
|
56
|
-
const newText = String(firstEdit.newText);
|
|
57
|
-
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
58
|
-
const extraEdits =
|
|
59
|
-
edits.length > 1
|
|
60
|
-
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
61
|
-
: "";
|
|
62
|
-
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
63
|
-
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function formatWriteInputForPrompt(
|
|
67
|
-
input: Record<string, unknown>,
|
|
68
|
-
): string {
|
|
69
|
-
const path = getPromptPath(input);
|
|
70
|
-
const content = typeof input.content === "string" ? input.content : "";
|
|
71
|
-
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
72
|
-
return path ? `for '${path}' ${summary}` : summary;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function formatReadInputForPrompt(
|
|
76
|
-
input: Record<string, unknown>,
|
|
77
|
-
): string {
|
|
78
|
-
const path = getPromptPath(input);
|
|
79
|
-
const parts = path ? [`path '${path}'`] : [];
|
|
80
|
-
if (typeof input.offset === "number") {
|
|
81
|
-
parts.push(`offset ${input.offset}`);
|
|
82
|
-
}
|
|
83
|
-
if (typeof input.limit === "number") {
|
|
84
|
-
parts.push(`limit ${input.limit}`);
|
|
85
|
-
}
|
|
86
|
-
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
27
|
export function serializeToolInputPreview(input: unknown): string {
|
|
90
28
|
const serialized = safeJsonStringify(input);
|
|
91
29
|
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import { countTextLines, formatCount } from "./tool-input-preview";
|
|
3
|
+
|
|
4
|
+
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
5
|
+
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatEditInputForPrompt(
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
): string {
|
|
11
|
+
const path = getPromptPath(input);
|
|
12
|
+
const rawEdits = Array.isArray(input.edits)
|
|
13
|
+
? input.edits
|
|
14
|
+
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
15
|
+
? [{ oldText: input.oldText, newText: input.newText }]
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
const edits = rawEdits
|
|
19
|
+
.map((edit) => toRecord(edit))
|
|
20
|
+
.filter(
|
|
21
|
+
(edit) =>
|
|
22
|
+
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const pathPart = path ? `for '${path}'` : "";
|
|
26
|
+
if (edits.length === 0) {
|
|
27
|
+
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const firstEdit = edits[0];
|
|
31
|
+
const oldText = String(firstEdit.oldText);
|
|
32
|
+
const newText = String(firstEdit.newText);
|
|
33
|
+
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
34
|
+
const extraEdits =
|
|
35
|
+
edits.length > 1
|
|
36
|
+
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
37
|
+
: "";
|
|
38
|
+
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
39
|
+
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatWriteInputForPrompt(
|
|
43
|
+
input: Record<string, unknown>,
|
|
44
|
+
): string {
|
|
45
|
+
const path = getPromptPath(input);
|
|
46
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
47
|
+
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
48
|
+
return path ? `for '${path}' ${summary}` : summary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReadInputForPrompt(
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
): string {
|
|
54
|
+
const path = getPromptPath(input);
|
|
55
|
+
const parts = path ? [`path '${path}'`] : [];
|
|
56
|
+
if (typeof input.offset === "number") {
|
|
57
|
+
parts.push(`offset ${input.offset}`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof input.limit === "number") {
|
|
60
|
+
parts.push(`limit ${input.limit}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
63
|
+
}
|
|
@@ -2,16 +2,18 @@ import { getNonEmptyString, toRecord } from "./common";
|
|
|
2
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
3
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
4
|
import {
|
|
5
|
-
formatEditInputForPrompt,
|
|
6
|
-
formatReadInputForPrompt,
|
|
7
|
-
formatWriteInputForPrompt,
|
|
8
|
-
getPromptPath,
|
|
9
5
|
serializeToolInputPreview,
|
|
10
6
|
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
11
7
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
12
8
|
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
13
9
|
truncateInlineText,
|
|
14
10
|
} from "./tool-input-preview";
|
|
11
|
+
import {
|
|
12
|
+
formatEditInputForPrompt,
|
|
13
|
+
formatReadInputForPrompt,
|
|
14
|
+
formatWriteInputForPrompt,
|
|
15
|
+
getPromptPath,
|
|
16
|
+
} from "./tool-input-prompt-formatters";
|
|
15
17
|
import type { PermissionCheckResult } from "./types";
|
|
16
18
|
|
|
17
19
|
export interface ToolPreviewFormatterOptions {
|
|
@@ -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();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type DecisionReporter,
|
|
5
|
+
GateDecisionReporter,
|
|
6
|
+
} from "#src/decision-reporter";
|
|
7
|
+
import {
|
|
8
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
9
|
+
type PermissionDecisionEvent,
|
|
10
|
+
} from "#src/permission-events";
|
|
11
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
12
|
+
|
|
13
|
+
// ── fixtures ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeLogger(): SessionLogger {
|
|
16
|
+
return {
|
|
17
|
+
debug: vi.fn(),
|
|
18
|
+
review: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeEvents() {
|
|
24
|
+
return {
|
|
25
|
+
emit: vi.fn(),
|
|
26
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDecisionEvent(
|
|
31
|
+
overrides: Partial<PermissionDecisionEvent> = {},
|
|
32
|
+
): PermissionDecisionEvent {
|
|
33
|
+
return {
|
|
34
|
+
surface: "read",
|
|
35
|
+
value: "read",
|
|
36
|
+
result: "allow",
|
|
37
|
+
resolution: "policy_allow",
|
|
38
|
+
origin: "global",
|
|
39
|
+
agentName: null,
|
|
40
|
+
matchedPattern: null,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("GateDecisionReporter", () => {
|
|
48
|
+
it("satisfies the DecisionReporter interface", () => {
|
|
49
|
+
const reporter: DecisionReporter = new GateDecisionReporter(
|
|
50
|
+
makeLogger(),
|
|
51
|
+
makeEvents(),
|
|
52
|
+
);
|
|
53
|
+
expect(reporter).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("writeReviewLog", () => {
|
|
57
|
+
it("delegates to logger.review with event and details", () => {
|
|
58
|
+
const logger = makeLogger();
|
|
59
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
60
|
+
reporter.writeReviewLog("permission_request.blocked", { tool: "bash" });
|
|
61
|
+
expect(logger.review).toHaveBeenCalledWith("permission_request.blocked", {
|
|
62
|
+
tool: "bash",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("delegates with an empty details object", () => {
|
|
67
|
+
const logger = makeLogger();
|
|
68
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
69
|
+
reporter.writeReviewLog("permission_request.session_approved", {});
|
|
70
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
71
|
+
"permission_request.session_approved",
|
|
72
|
+
{},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not call emitDecision", () => {
|
|
77
|
+
const events = makeEvents();
|
|
78
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
79
|
+
reporter.writeReviewLog("some.event", { key: "val" });
|
|
80
|
+
expect(events.emit).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("emitDecision", () => {
|
|
85
|
+
it("emits on the PERMISSIONS_DECISION_CHANNEL with the event", () => {
|
|
86
|
+
const events = makeEvents();
|
|
87
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
88
|
+
const event = makeDecisionEvent();
|
|
89
|
+
reporter.emitDecision(event);
|
|
90
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
91
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
92
|
+
event,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not call writeReviewLog", () => {
|
|
97
|
+
const logger = makeLogger();
|
|
98
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
99
|
+
reporter.emitDecision(makeDecisionEvent());
|
|
100
|
+
expect(logger.review).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not propagate a throwing listener", () => {
|
|
104
|
+
const events = makeEvents();
|
|
105
|
+
events.emit.mockImplementation(() => {
|
|
106
|
+
throw new Error("listener boom");
|
|
107
|
+
});
|
|
108
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
109
|
+
expect(() => reporter.emitDecision(makeDecisionEvent())).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -265,6 +265,31 @@ describe("formatDenyReason", () => {
|
|
|
265
265
|
);
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
describe("skill_input context", () => {
|
|
270
|
+
test("without agent", () => {
|
|
271
|
+
expect(
|
|
272
|
+
formatDenyReason({
|
|
273
|
+
kind: "skill_input",
|
|
274
|
+
skillName: "librarian",
|
|
275
|
+
}),
|
|
276
|
+
).toBe(
|
|
277
|
+
"[pi-permission-system] Current agent is not permitted to access skill 'librarian'.",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("with agent", () => {
|
|
282
|
+
expect(
|
|
283
|
+
formatDenyReason({
|
|
284
|
+
kind: "skill_input",
|
|
285
|
+
skillName: "librarian",
|
|
286
|
+
agentName: "my-agent",
|
|
287
|
+
}),
|
|
288
|
+
).toBe(
|
|
289
|
+
"[pi-permission-system] Agent 'my-agent' is not permitted to access skill 'librarian'.",
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
268
293
|
});
|
|
269
294
|
|
|
270
295
|
// ── formatUnavailableReason ────────────────────────────────────────────────
|
|
@@ -353,6 +378,17 @@ describe("formatUnavailableReason", () => {
|
|
|
353
378
|
"[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
|
|
354
379
|
);
|
|
355
380
|
});
|
|
381
|
+
|
|
382
|
+
test("skill_input", () => {
|
|
383
|
+
expect(
|
|
384
|
+
formatUnavailableReason({
|
|
385
|
+
kind: "skill_input",
|
|
386
|
+
skillName: "librarian",
|
|
387
|
+
}),
|
|
388
|
+
).toBe(
|
|
389
|
+
"[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
|
|
390
|
+
);
|
|
391
|
+
});
|
|
356
392
|
});
|
|
357
393
|
|
|
358
394
|
// ── formatUserDeniedReason ─────────────────────────────────────────────────
|
|
@@ -530,4 +566,30 @@ describe("formatUserDeniedReason", () => {
|
|
|
530
566
|
);
|
|
531
567
|
});
|
|
532
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("skill_input context", () => {
|
|
571
|
+
test("without agent and without reason", () => {
|
|
572
|
+
expect(
|
|
573
|
+
formatUserDeniedReason({
|
|
574
|
+
kind: "skill_input",
|
|
575
|
+
skillName: "librarian",
|
|
576
|
+
}),
|
|
577
|
+
).toBe("[pi-permission-system] User denied access to skill 'librarian'.");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("with agent and with reason", () => {
|
|
581
|
+
expect(
|
|
582
|
+
formatUserDeniedReason(
|
|
583
|
+
{
|
|
584
|
+
kind: "skill_input",
|
|
585
|
+
skillName: "librarian",
|
|
586
|
+
agentName: "code-agent",
|
|
587
|
+
},
|
|
588
|
+
"not permitted",
|
|
589
|
+
),
|
|
590
|
+
).toBe(
|
|
591
|
+
"[pi-permission-system] User denied access to skill 'librarian'. Reason: not permitted.",
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
533
595
|
});
|