@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -11
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/io.ts +29 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  9. package/src/forwarding-manager.ts +3 -7
  10. package/src/gate-handler-session.ts +13 -0
  11. package/src/gate-prompter.ts +14 -0
  12. package/src/handlers/before-agent-start.ts +2 -3
  13. package/src/handlers/gates/bash-command.ts +4 -18
  14. package/src/handlers/gates/bash-external-directory.ts +3 -15
  15. package/src/handlers/gates/bash-path.ts +3 -16
  16. package/src/handlers/gates/descriptor.ts +0 -28
  17. package/src/handlers/gates/path.ts +3 -15
  18. package/src/handlers/gates/runner.ts +142 -105
  19. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  20. package/src/handlers/gates/skill-input.ts +44 -0
  21. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  22. package/src/handlers/lifecycle.ts +9 -9
  23. package/src/handlers/permission-gate-handler.ts +34 -238
  24. package/src/index.ts +50 -68
  25. package/src/mcp-targets.ts +56 -46
  26. package/src/permission-event-rpc.ts +7 -0
  27. package/src/permission-events.ts +89 -8
  28. package/src/permission-forwarding.ts +23 -0
  29. package/src/permission-prompter.ts +27 -56
  30. package/src/permission-resolver.ts +17 -0
  31. package/src/permission-session.ts +77 -9
  32. package/src/permission-ui-prompt.ts +127 -0
  33. package/src/permissions-service.ts +53 -0
  34. package/src/service-lifecycle.ts +49 -0
  35. package/src/service.ts +17 -0
  36. package/src/session-approval-recorder.ts +6 -0
  37. package/src/session-lifecycle-session.ts +24 -0
  38. package/src/tool-input-preview.ts +0 -62
  39. package/src/tool-input-prompt-formatters.ts +63 -0
  40. package/src/tool-preview-formatter.ts +6 -4
  41. package/test/composition-root.test.ts +5 -0
  42. package/test/decision-reporter.test.ts +112 -0
  43. package/test/denial-messages.test.ts +62 -0
  44. package/test/forwarding-manager.test.ts +26 -44
  45. package/test/handlers/before-agent-start.test.ts +45 -21
  46. package/test/handlers/external-directory-integration.test.ts +86 -22
  47. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  48. package/test/handlers/gates/bash-command.test.ts +49 -90
  49. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  50. package/test/handlers/gates/bash-path.test.ts +63 -148
  51. package/test/handlers/gates/path.test.ts +38 -105
  52. package/test/handlers/gates/runner.test.ts +150 -93
  53. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  54. package/test/handlers/gates/skill-input.test.ts +128 -0
  55. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  56. package/test/handlers/input.test.ts +1 -2
  57. package/test/handlers/lifecycle.test.ts +49 -33
  58. package/test/handlers/tool-call-events.test.ts +1 -1
  59. package/test/helpers/gate-fixtures.ts +147 -16
  60. package/test/helpers/handler-fixtures.ts +143 -27
  61. package/test/mcp-targets.test.ts +55 -0
  62. package/test/permission-event-rpc.test.ts +39 -0
  63. package/test/permission-events.test.ts +78 -10
  64. package/test/permission-forwarder.test.ts +295 -0
  65. package/test/permission-prompter.test.ts +147 -38
  66. package/test/permission-session.test.ts +160 -27
  67. package/test/permission-ui-prompt.test.ts +146 -0
  68. package/test/permissions-service.test.ts +151 -0
  69. package/test/runtime.test.ts +0 -4
  70. package/test/service-lifecycle.test.ts +162 -0
  71. package/test/tool-input-preview.test.ts +0 -111
  72. package/test/tool-input-prompt-formatters.test.ts +115 -0
  73. 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,6 @@
1
+ import type { SessionApproval } from "./session-approval";
2
+
3
+ /** Records a granted session-scoped approval into the session ruleset. */
4
+ export interface SessionApprovalRecorder {
5
+ recordSessionApproval(approval: SessionApproval): void;
6
+ }
@@ -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
  });