@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
@@ -1,5 +1,5 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
- import type { Rule } from "#src/rule";
2
+ import type { PermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
@@ -9,14 +9,6 @@ import type { GateResult } from "./descriptor";
9
9
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
10
10
  import type { ToolCallContext } from "./types";
11
11
 
12
- /** Function type for checkPermission used by the descriptor factory. */
13
- type CheckPermissionFn = (
14
- surface: string,
15
- input: unknown,
16
- agentName?: string,
17
- sessionRules?: Rule[],
18
- ) => PermissionCheckResult;
19
-
20
12
  /**
21
13
  * Build a pure descriptor for the bash external-directory permission gate.
22
14
  *
@@ -29,8 +21,7 @@ type CheckPermissionFn = (
29
21
  export function describeBashExternalDirectoryGate(
30
22
  tcc: ToolCallContext,
31
23
  bashProgram: BashProgram | null,
32
- checkPermission: CheckPermissionFn,
33
- getSessionRuleset: () => Rule[],
24
+ resolver: PermissionResolver,
34
25
  ): GateResult {
35
26
  if (tcc.toolName !== "bash" || !tcc.cwd) return null;
36
27
 
@@ -42,8 +33,6 @@ export function describeBashExternalDirectoryGate(
42
33
  const externalPaths = bashProgram.externalPaths(tcc.cwd);
43
34
  if (externalPaths.length === 0) return null;
44
35
 
45
- const bashSessionRules = getSessionRuleset();
46
-
47
36
  // Collect paths whose resolved state is not already "allow".
48
37
  // Checking state (not source) ensures config-level allow rules (source: "special")
49
38
  // suppress the prompt just as session-level allow rules (source: "session") do.
@@ -52,11 +41,10 @@ export function describeBashExternalDirectoryGate(
52
41
  check: PermissionCheckResult;
53
42
  }> = [];
54
43
  for (const p of externalPaths) {
55
- const check = checkPermission(
44
+ const check = resolver.resolve(
56
45
  "external_directory",
57
46
  { path: p },
58
47
  tcc.agentName ?? undefined,
59
- bashSessionRules,
60
48
  );
61
49
  if (check.state !== "allow") {
62
50
  uncoveredEntries.push({ path: p, check });
@@ -1,5 +1,5 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
- import type { Rule } from "#src/rule";
2
+ import type { PermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
@@ -9,14 +9,6 @@ import type { GateResult } from "./descriptor";
9
9
  import { formatPathAskPrompt } from "./path";
10
10
  import type { ToolCallContext } from "./types";
11
11
 
12
- /** Function type for checkPermission used by the descriptor factory. */
13
- type CheckPermissionFn = (
14
- surface: string,
15
- input: unknown,
16
- agentName?: string,
17
- sessionRules?: Rule[],
18
- ) => PermissionCheckResult;
19
-
20
12
  /**
21
13
  * Build a pure descriptor for the cross-cutting path permission gate (bash).
22
14
  *
@@ -33,8 +25,7 @@ type CheckPermissionFn = (
33
25
  export function describeBashPathGate(
34
26
  tcc: ToolCallContext,
35
27
  bashProgram: BashProgram | null,
36
- checkPermission: CheckPermissionFn,
37
- getSessionRuleset: () => Rule[],
28
+ resolver: PermissionResolver,
38
29
  ): GateResult {
39
30
  if (tcc.toolName !== "bash") return null;
40
31
 
@@ -46,20 +37,16 @@ export function describeBashPathGate(
46
37
  const tokens = bashProgram.pathTokens();
47
38
  if (tokens.length === 0) return null;
48
39
 
49
- // Check each token against path rules with session rules appended.
50
- const sessionRules = getSessionRuleset();
51
-
52
40
  // Tokens whose resolved state needs a check (deny/ask), paired with the
53
41
  // token that produced them so the descriptor can derive its pattern.
54
42
  const uncovered: Array<{ token: string; check: PermissionCheckResult }> = [];
55
43
  let allSessionCovered = true;
56
44
 
57
45
  for (const token of tokens) {
58
- const check = checkPermission(
46
+ const check = resolver.resolve(
59
47
  "path",
60
48
  { path: token },
61
49
  tcc.agentName ?? undefined,
62
- sessionRules,
63
50
  );
64
51
 
65
52
  // No explicit path rule matched — only the universal default fired.
@@ -1,8 +1,6 @@
1
1
  import type { DenialContext } from "#src/denial-messages";
2
- import type { PermissionPromptDecision } from "#src/permission-dialog";
3
2
  import type { PermissionDecisionEvent } from "#src/permission-events";
4
3
  import type { PromptPermissionDetails } from "#src/permission-prompter";
5
- import type { Rule } from "#src/rule";
6
4
  import type { SessionApproval } from "#src/session-approval";
7
5
  import type { PermissionCheckResult, PermissionState } from "#src/types";
8
6
 
@@ -70,32 +68,6 @@ export interface GateBypass {
70
68
  /** Union of possible gate function return values. */
71
69
  export type GateResult = GateDescriptor | GateBypass | null;
72
70
 
73
- // ── Runner dependency interface ────────────────────────────────────────────
74
-
75
- /**
76
- * Infrastructure dependencies for the gate runner.
77
- *
78
- * Built once in the orchestrator and reused for all gates.
79
- * Handles all side effects: permission checks, logging, event emission,
80
- * session-rule recording.
81
- */
82
- export interface GateRunnerDeps {
83
- checkPermission(
84
- surface: string,
85
- input: unknown,
86
- agentName?: string,
87
- sessionRules?: Rule[],
88
- ): PermissionCheckResult;
89
- getSessionRuleset(): Rule[];
90
- recordSessionApproval(approval: SessionApproval): void;
91
- writeReviewLog(event: string, details: Record<string, unknown>): void;
92
- emitDecision(event: PermissionDecisionEvent): void;
93
- canConfirm(): boolean;
94
- promptPermission(
95
- details: PromptPermissionDetails,
96
- ): Promise<PermissionPromptDecision>;
97
- }
98
-
99
71
  // ── Type guard helpers ─────────────────────────────────────────────────────
100
72
 
101
73
  /** Check whether a GateResult is a GateBypass (early allow). */
@@ -1,19 +1,10 @@
1
1
  import { getPathBearingToolPath } from "#src/path-utils";
2
- import type { Rule } from "#src/rule";
2
+ import type { PermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
- import type { PermissionCheckResult } from "#src/types";
6
5
  import type { GateDescriptor, GateResult } from "./descriptor";
7
6
  import type { ToolCallContext } from "./types";
8
7
 
9
- /** Function type for checkPermission used by the descriptor factory. */
10
- type CheckPermissionFn = (
11
- surface: string,
12
- input: unknown,
13
- agentName?: string,
14
- sessionRules?: Rule[],
15
- ) => PermissionCheckResult;
16
-
17
8
  /**
18
9
  * Build a pure descriptor for the cross-cutting path permission gate (tools).
19
10
  *
@@ -24,18 +15,15 @@ type CheckPermissionFn = (
24
15
  */
25
16
  export function describePathGate(
26
17
  tcc: ToolCallContext,
27
- checkPermission: CheckPermissionFn,
28
- getSessionRuleset: () => Rule[],
18
+ resolver: PermissionResolver,
29
19
  ): GateResult {
30
20
  const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
31
21
  if (!filePath) return null;
32
22
 
33
- const sessionRules = getSessionRuleset();
34
- const check = checkPermission(
23
+ const check = resolver.resolve(
35
24
  "path",
36
25
  { path: filePath },
37
26
  tcc.agentName ?? undefined,
38
- sessionRules,
39
27
  );
40
28
 
41
29
  if (check.state === "allow") return null;
@@ -1,133 +1,170 @@
1
+ import type { DecisionReporter } from "#src/decision-reporter";
1
2
  import {
2
3
  formatDenyReason,
3
4
  formatUnavailableReason,
4
5
  formatUserDeniedReason,
5
6
  } from "#src/denial-messages";
7
+ import type { GatePrompter } from "#src/gate-prompter";
6
8
  import type { PermissionPromptDecision } from "#src/permission-dialog";
7
9
  import { applyPermissionGate } from "#src/permission-gate";
10
+ import type { PermissionResolver } from "#src/permission-resolver";
11
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
8
12
  import type { PermissionCheckResult } from "#src/types";
9
- import type { GateDescriptor, GateRunnerDeps } from "./descriptor";
13
+ import type { GateDescriptor, GateResult } from "./descriptor";
14
+ import { isGateBypass } from "./descriptor";
10
15
  import { buildDecisionEvent, deriveResolution } from "./helpers";
11
16
  import type { GateOutcome } from "./types";
12
17
 
18
+ // ── GateRunner class ───────────────────────────────────────────────────────
19
+
13
20
  /**
14
- * Execute the full check→log→emit→approve cycle for a gate descriptor.
15
- *
16
- * This is the single site for:
17
- * - Permission checking (or using pre-resolved state)
18
- * - Session-hit fast path
19
- * - Interactive prompt orchestration
20
- * - Decision event emission
21
- * - Session-rule recording
21
+ * Executes permission gate checks for a single gate result (null, bypass, or
22
+ * descriptor).
22
23
  *
23
- * Gate functions produce descriptors; this runner executes them.
24
+ * Constructed once per handler with its four role collaborators and reused
25
+ * for every gate in a tool-call pipeline. The `run` method absorbs the null /
26
+ * bypass / descriptor dispatch that previously lived as an anonymous closure
27
+ * in `PermissionGateHandler.handleToolCall`.
24
28
  */
25
- export async function runGateCheck(
26
- descriptor: GateDescriptor,
27
- agentName: string | null,
28
- toolCallId: string,
29
- deps: GateRunnerDeps,
30
- ): Promise<GateOutcome> {
31
- // 1. Resolve permission state — pre-check, pre-resolved, or via checkPermission
32
- let check: PermissionCheckResult;
33
- if (descriptor.preCheck) {
34
- check = descriptor.preCheck;
35
- } else if (descriptor.preResolved) {
36
- check = {
37
- state: descriptor.preResolved.state,
38
- toolName: descriptor.surface,
39
- source: "tool",
40
- origin: "builtin",
41
- };
42
- } else {
43
- check = deps.checkPermission(
44
- descriptor.surface,
45
- descriptor.input,
46
- agentName ?? undefined,
47
- deps.getSessionRuleset(),
48
- );
29
+ export class GateRunner {
30
+ constructor(
31
+ private readonly resolver: PermissionResolver,
32
+ private readonly recorder: SessionApprovalRecorder,
33
+ private readonly prompter: GatePrompter,
34
+ private readonly reporter: DecisionReporter,
35
+ ) {}
36
+
37
+ /**
38
+ * Execute a gate: null → allow; bypass → log/emit side effects then allow;
39
+ * descriptor full check→log→emit→approve cycle.
40
+ */
41
+ async run(
42
+ gate: GateResult,
43
+ agentName: string | null,
44
+ toolCallId: string,
45
+ ): Promise<GateOutcome> {
46
+ if (!gate) {
47
+ return { action: "allow" };
48
+ }
49
+ if (isGateBypass(gate)) {
50
+ if (gate.log) {
51
+ this.reporter.writeReviewLog(gate.log.event, gate.log.details);
52
+ }
53
+ if (gate.decision) {
54
+ this.reporter.emitDecision(gate.decision);
55
+ }
56
+ return { action: "allow" };
57
+ }
58
+ return this.runDescriptor(gate, agentName, toolCallId);
49
59
  }
50
60
 
51
- // 2. Session-hit fast path
52
- if (check.source === "session") {
53
- deps.writeReviewLog("permission_request.session_approved", {
54
- ...descriptor.logContext,
55
- agentName,
56
- resolution: "session_approved",
57
- sessionApprovalPattern: check.matchedPattern,
61
+ // ── Private helpers ──────────────────────────────────────────────────────
62
+
63
+ private async runDescriptor(
64
+ descriptor: GateDescriptor,
65
+ agentName: string | null,
66
+ toolCallId: string,
67
+ ): Promise<GateOutcome> {
68
+ // 1. Resolve permission state — pre-check, pre-resolved, or via resolver
69
+ let check: PermissionCheckResult;
70
+ if (descriptor.preCheck) {
71
+ check = descriptor.preCheck;
72
+ } else if (descriptor.preResolved) {
73
+ check = {
74
+ state: descriptor.preResolved.state,
75
+ toolName: descriptor.surface,
76
+ source: "tool",
77
+ origin: "builtin",
78
+ };
79
+ } else {
80
+ check = this.resolver.resolve(
81
+ descriptor.surface,
82
+ descriptor.input,
83
+ agentName ?? undefined,
84
+ );
85
+ }
86
+
87
+ // 2. Session-hit fast path
88
+ if (check.source === "session") {
89
+ this.reporter.writeReviewLog("permission_request.session_approved", {
90
+ ...descriptor.logContext,
91
+ agentName,
92
+ resolution: "session_approved",
93
+ sessionApprovalPattern: check.matchedPattern,
94
+ });
95
+ this.reporter.emitDecision(
96
+ buildDecisionEvent(
97
+ descriptor.decision,
98
+ check,
99
+ agentName,
100
+ "allow",
101
+ "session_approved",
102
+ ),
103
+ );
104
+ return { action: "allow" };
105
+ }
106
+
107
+ // 3. Apply the deny/ask/allow gate
108
+ const canConfirm = this.prompter.canConfirm();
109
+
110
+ // Construct messages from the centralized formatter.
111
+ const messages = {
112
+ denyReason: formatDenyReason(descriptor.denialContext),
113
+ unavailableReason: formatUnavailableReason(descriptor.denialContext),
114
+ userDeniedReason: (decision: PermissionPromptDecision) =>
115
+ formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
116
+ };
117
+
118
+ let autoApproved = false;
119
+ const gateResult = await applyPermissionGate({
120
+ state: check.state,
121
+ canConfirm,
122
+ sessionApproval: descriptor.sessionApproval?.toGateApproval(),
123
+ promptForApproval: async () => {
124
+ const decision = await this.prompter.promptPermission({
125
+ requestId: toolCallId,
126
+ ...descriptor.promptDetails,
127
+ });
128
+ autoApproved = decision.autoApproved === true;
129
+ return decision;
130
+ },
131
+ writeLog: (event, details) =>
132
+ this.reporter.writeReviewLog(event, details),
133
+ logContext: { ...descriptor.logContext, agentName },
134
+ messages,
58
135
  });
59
- deps.emitDecision(
136
+
137
+ // 4. Determine whether session approval was granted
138
+ const hasSessionApproval =
139
+ gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
140
+
141
+ // 5. Emit decision event
142
+ this.reporter.emitDecision(
60
143
  buildDecisionEvent(
61
144
  descriptor.decision,
62
145
  check,
63
146
  agentName,
64
- "allow",
65
- "session_approved",
147
+ gateResult.action === "allow" ? "allow" : "deny",
148
+ deriveResolution(
149
+ check.state,
150
+ gateResult.action,
151
+ hasSessionApproval,
152
+ canConfirm,
153
+ autoApproved,
154
+ ),
66
155
  ),
67
156
  );
68
- return { action: "allow" };
69
- }
70
157
 
71
- // 3. Apply the deny/ask/allow gate
72
- const canConfirm = deps.canConfirm();
73
-
74
- // Construct messages from the centralized formatter.
75
- const messages = {
76
- denyReason: formatDenyReason(descriptor.denialContext),
77
- unavailableReason: formatUnavailableReason(descriptor.denialContext),
78
- userDeniedReason: (decision: PermissionPromptDecision) =>
79
- formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
80
- };
81
-
82
- let autoApproved = false;
83
- const gateResult = await applyPermissionGate({
84
- state: check.state,
85
- canConfirm,
86
- sessionApproval: descriptor.sessionApproval?.toGateApproval(),
87
- promptForApproval: async () => {
88
- const decision = await deps.promptPermission({
89
- requestId: toolCallId,
90
- ...descriptor.promptDetails,
91
- });
92
- autoApproved = decision.autoApproved === true;
93
- return decision;
94
- },
95
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain functions; no this-binding issue
96
- writeLog: deps.writeReviewLog,
97
- logContext: { ...descriptor.logContext, agentName },
98
- messages,
99
- });
158
+ // 6. Record session approval — tell the store; it owns the per-pattern loop
159
+ // hasSessionApproval already implies gateResult.action === "allow"
160
+ if (hasSessionApproval && descriptor.sessionApproval) {
161
+ this.recorder.recordSessionApproval(descriptor.sessionApproval);
162
+ }
100
163
 
101
- // 4. Determine whether session approval was granted
102
- const hasSessionApproval =
103
- gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
164
+ if (gateResult.action === "block") {
165
+ return { action: "block", reason: gateResult.reason };
166
+ }
104
167
 
105
- // 5. Emit decision event
106
- deps.emitDecision(
107
- buildDecisionEvent(
108
- descriptor.decision,
109
- check,
110
- agentName,
111
- gateResult.action === "allow" ? "allow" : "deny",
112
- deriveResolution(
113
- check.state,
114
- gateResult.action,
115
- hasSessionApproval,
116
- canConfirm,
117
- autoApproved,
118
- ),
119
- ),
120
- );
121
-
122
- // 6. Record session approval — tell the store; it owns the per-pattern loop
123
- // hasSessionApproval already implies gateResult.action === "allow"
124
- if (hasSessionApproval && descriptor.sessionApproval) {
125
- deps.recordSessionApproval(descriptor.sessionApproval);
126
- }
127
-
128
- if (gateResult.action === "block") {
129
- return { action: "block", reason: gateResult.reason };
168
+ return { action: "allow" };
130
169
  }
131
-
132
- return { action: "allow" };
133
170
  }
@@ -0,0 +1,104 @@
1
+ import type { PermissionCheckResult } from "#src/types";
2
+ import type { GateRunner } from "./runner";
3
+ import { describeSkillInputGate } from "./skill-input";
4
+ import type { GateOutcome } from "./types";
5
+
6
+ // ── Interfaces ────────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Narrow interface the pipeline needs from its session-side dependency.
10
+ *
11
+ * A raw `checkPermission` (no session rules) — preserves the skill-input
12
+ * semantics established in #326 where the skill-input gate intentionally
13
+ * bypasses session-rule resolution.
14
+ *
15
+ * `PermissionSession` satisfies this structurally at the construction call
16
+ * site; no `implements` clause is needed and would create a layer-inversion
17
+ * import from the domain module into the handler layer.
18
+ */
19
+ export interface SkillInputGateInputs {
20
+ checkPermission(
21
+ surface: string,
22
+ input: unknown,
23
+ agentName?: string,
24
+ ): PermissionCheckResult;
25
+ }
26
+
27
+ /**
28
+ * Narrow UI seam: warn the user when a skill is denied.
29
+ *
30
+ * The handler builds this per-event from `ctx`, encapsulating the `hasUI`
31
+ * guard so the pipeline never touches `ExtensionContext` directly
32
+ * (Tell-Don't-Ask: the pipeline tells the notifier to warn; the notifier
33
+ * decides whether a UI is present).
34
+ */
35
+ export interface GateNotifier {
36
+ warn(message: string): void;
37
+ }
38
+
39
+ // ── Pipeline ─────────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Owns the skill-input gate assembly: raw permission pre-check, deny notify,
43
+ * `describeSkillInputGate` descriptor, request-id mint, and `runner.run(...)`.
44
+ *
45
+ * Constructed once in the composition root and injected into
46
+ * `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
47
+ * path.
48
+ *
49
+ * `evaluate` is not `async` because it has no `await` of its own — it returns
50
+ * `runner.run(...)` directly (`@typescript-eslint/require-await` would reject
51
+ * an `async` body with no `await`).
52
+ */
53
+ export class SkillInputGatePipeline {
54
+ constructor(private readonly inputs: SkillInputGateInputs) {}
55
+
56
+ evaluate(
57
+ skillName: string,
58
+ agentName: string | null,
59
+ notifier: GateNotifier,
60
+ runner: GateRunner,
61
+ ): Promise<GateOutcome> {
62
+ const check = this.inputs.checkPermission(
63
+ "skill",
64
+ { name: skillName },
65
+ agentName ?? undefined,
66
+ );
67
+ if (check.state === "deny") {
68
+ notifier.warn(formatSkillDenyNotice(skillName, agentName));
69
+ }
70
+ return runner.run(
71
+ describeSkillInputGate(skillName, agentName, check),
72
+ agentName,
73
+ createSkillInputRequestId(),
74
+ );
75
+ }
76
+ }
77
+
78
+ // ── Helpers ───────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Mint a unique id for a skill-input permission request.
82
+ *
83
+ * Format is `skill-input-<timestamp>-<random>-<pid>`, matching the
84
+ * `createPermissionRequestId("skill-input")` pattern it replaces (#330).
85
+ */
86
+ export function createSkillInputRequestId(): string {
87
+ return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
88
+ }
89
+
90
+ /**
91
+ * Format the deny warning shown in the UI when a skill is blocked.
92
+ *
93
+ * Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
94
+ * UI notify distinct from the gate deny reasons the runner routes through
95
+ * `formatDenyReason`.
96
+ */
97
+ export function formatSkillDenyNotice(
98
+ skillName: string,
99
+ agentName: string | null,
100
+ ): string {
101
+ return agentName
102
+ ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
103
+ : `Skill '${skillName}' is not permitted by the current skill policy.`;
104
+ }
@@ -0,0 +1,44 @@
1
+ import { formatSkillAskPrompt } from "#src/permission-prompts";
2
+ import type { PermissionCheckResult } from "#src/types";
3
+ import type { GateDescriptor } from "./descriptor";
4
+
5
+ /**
6
+ * Build a pure descriptor for the skill-input permission gate.
7
+ *
8
+ * Takes the pre-computed check result so the gate can reuse the result the
9
+ * caller already obtained (e.g. to conditionally emit a deny warning) without
10
+ * re-running the check inside the runner.
11
+ */
12
+ export function describeSkillInputGate(
13
+ skillName: string,
14
+ agentName: string | null,
15
+ preCheck: PermissionCheckResult,
16
+ ): GateDescriptor {
17
+ const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
18
+ return {
19
+ surface: "skill",
20
+ input: { name: skillName },
21
+ preCheck,
22
+ denialContext: {
23
+ kind: "skill_input",
24
+ skillName,
25
+ agentName: agentName ?? undefined,
26
+ },
27
+ promptDetails: {
28
+ source: "skill_input",
29
+ agentName,
30
+ message,
31
+ skillName,
32
+ },
33
+ logContext: {
34
+ source: "skill_input",
35
+ skillName,
36
+ agentName,
37
+ message,
38
+ },
39
+ decision: {
40
+ surface: "skill",
41
+ value: skillName,
42
+ },
43
+ };
44
+ }