@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,186 @@
1
+ import type { DecisionReporter } from "#src/decision-reporter";
2
+ import {
3
+ formatDenyReason,
4
+ formatUnavailableReason,
5
+ formatUserDeniedReason,
6
+ } from "#src/denial-messages";
7
+ import type { GatePrompter } from "#src/gate-prompter";
8
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
9
+ import { applyPermissionGate } from "#src/permission-gate";
10
+ import type { PersistentApprovalRecorder } from "#src/persistent-approval-recorder";
11
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
12
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
13
+ import type { PermissionCheckResult } from "#src/types";
14
+ import type { GateDescriptor, GateResult } from "./descriptor";
15
+ import { isGateBypass } from "./descriptor";
16
+ import { buildDecisionEvent, deriveResolution } from "./helpers";
17
+ import type { GateOutcome } from "./types";
18
+
19
+ // ── GateRunner class ───────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Executes permission gate checks for a single gate result (null, bypass, or
23
+ * descriptor).
24
+ *
25
+ * Constructed once per handler with its four role collaborators and reused
26
+ * for every gate in a tool-call pipeline. The `run` method absorbs the null /
27
+ * bypass / descriptor dispatch that previously lived as an anonymous closure
28
+ * in `PermissionGateHandler.handleToolCall`.
29
+ */
30
+ export class GateRunner {
31
+ constructor(
32
+ private readonly resolver: ScopedPermissionResolver,
33
+ private readonly recorder: SessionApprovalRecorder,
34
+ private readonly prompter: GatePrompter,
35
+ private readonly reporter: DecisionReporter,
36
+ private readonly persistentRecorder: PersistentApprovalRecorder,
37
+ ) {}
38
+
39
+ /**
40
+ * Execute a gate: null → allow; bypass → log/emit side effects then allow;
41
+ * descriptor → full check→log→emit→approve cycle.
42
+ */
43
+ async run(
44
+ gate: GateResult,
45
+ agentName: string | null,
46
+ toolCallId: string,
47
+ ): Promise<GateOutcome> {
48
+ if (!gate) {
49
+ return { action: "allow" };
50
+ }
51
+ if (isGateBypass(gate)) {
52
+ if (gate.log) {
53
+ this.reporter.writeReviewLog(gate.log.event, gate.log.details);
54
+ }
55
+ if (gate.decision) {
56
+ this.reporter.emitDecision(gate.decision);
57
+ }
58
+ return { action: "allow" };
59
+ }
60
+ return this.runDescriptor(gate, agentName, toolCallId);
61
+ }
62
+
63
+ // ── Private helpers ──────────────────────────────────────────────────────
64
+
65
+ private async runDescriptor(
66
+ descriptor: GateDescriptor,
67
+ agentName: string | null,
68
+ toolCallId: string,
69
+ ): Promise<GateOutcome> {
70
+ // 1. Resolve permission state — pre-check, pre-resolved, or via resolver
71
+ let check: PermissionCheckResult;
72
+ if (descriptor.preCheck) {
73
+ check = descriptor.preCheck;
74
+ } else if (descriptor.preResolved) {
75
+ check = {
76
+ state: descriptor.preResolved.state,
77
+ toolName: descriptor.surface,
78
+ source: "tool",
79
+ origin: "builtin",
80
+ };
81
+ } else {
82
+ check = this.resolver.resolve(
83
+ descriptor.surface,
84
+ descriptor.input,
85
+ agentName ?? undefined,
86
+ );
87
+ }
88
+
89
+ // 2. Session-hit fast path
90
+ if (check.source === "session") {
91
+ this.reporter.writeReviewLog("permission_request.session_approved", {
92
+ ...descriptor.logContext,
93
+ agentName,
94
+ resolution: "session_approved",
95
+ sessionApprovalPattern: check.matchedPattern,
96
+ });
97
+ this.reporter.emitDecision(
98
+ buildDecisionEvent(
99
+ descriptor.decision,
100
+ check,
101
+ agentName,
102
+ "allow",
103
+ "session_approved",
104
+ ),
105
+ );
106
+ return { action: "allow" };
107
+ }
108
+
109
+ // 3. Apply the deny/ask/allow gate
110
+ const canConfirm = this.prompter.canConfirm();
111
+
112
+ // Construct messages from the centralized formatter.
113
+ const messages = {
114
+ denyReason: formatDenyReason(descriptor.denialContext),
115
+ unavailableReason: formatUnavailableReason(descriptor.denialContext),
116
+ userDeniedReason: (decision: PermissionPromptDecision) =>
117
+ formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
118
+ };
119
+
120
+ let autoApproved = false;
121
+ const gateResult = await applyPermissionGate({
122
+ state: check.state,
123
+ canConfirm,
124
+ sessionApproval: descriptor.sessionApproval?.toGateApproval(),
125
+ promptForApproval: async () => {
126
+ const decision = await this.prompter.prompt({
127
+ requestId: toolCallId,
128
+ ...descriptor.promptDetails,
129
+ });
130
+ autoApproved = decision.autoApproved === true;
131
+ return decision;
132
+ },
133
+ writeLog: (event, details) =>
134
+ this.reporter.writeReviewLog(event, details),
135
+ logContext: { ...descriptor.logContext, agentName },
136
+ messages,
137
+ });
138
+
139
+ // 4. Determine whether session approval was granted
140
+ const hasSessionApproval =
141
+ gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
142
+
143
+ // 5. Emit decision event
144
+ this.reporter.emitDecision(
145
+ buildDecisionEvent(
146
+ descriptor.decision,
147
+ check,
148
+ agentName,
149
+ gateResult.action === "allow" ? "allow" : "deny",
150
+ deriveResolution(
151
+ check.state,
152
+ gateResult.action,
153
+ hasSessionApproval,
154
+ canConfirm,
155
+ autoApproved,
156
+ gateResult.action === "allow"
157
+ ? gateResult.persistentApprovalScope
158
+ : undefined,
159
+ ),
160
+ ),
161
+ );
162
+
163
+ // 6. Record session approval — tell the store; it owns the per-pattern loop
164
+ // hasSessionApproval already implies gateResult.action === "allow"
165
+ if (hasSessionApproval && descriptor.sessionApproval) {
166
+ this.recorder.recordSessionApproval(descriptor.sessionApproval);
167
+ }
168
+
169
+ if (
170
+ gateResult.action === "allow" &&
171
+ gateResult.persistentApprovalScope &&
172
+ descriptor.sessionApproval
173
+ ) {
174
+ this.persistentRecorder.recordApproval(
175
+ gateResult.persistentApprovalScope,
176
+ descriptor.sessionApproval,
177
+ );
178
+ }
179
+
180
+ if (gateResult.action === "block") {
181
+ return { action: "block", reason: gateResult.reason };
182
+ }
183
+
184
+ return { action: "allow" };
185
+ }
186
+ }
@@ -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,46 @@
1
+ import { formatSkillAskPrompt } from "#src/permission-prompts";
2
+ import { SessionApproval } from "#src/session-approval";
3
+ import type { PermissionCheckResult } from "#src/types";
4
+ import type { GateDescriptor } from "./descriptor";
5
+
6
+ /**
7
+ * Build a pure descriptor for the skill-input permission gate.
8
+ *
9
+ * Takes the pre-computed check result so the gate can reuse the result the
10
+ * caller already obtained (e.g. to conditionally emit a deny warning) without
11
+ * re-running the check inside the runner.
12
+ */
13
+ export function describeSkillInputGate(
14
+ skillName: string,
15
+ agentName: string | null,
16
+ preCheck: PermissionCheckResult,
17
+ ): GateDescriptor {
18
+ const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
19
+ return {
20
+ surface: "skill",
21
+ input: { name: skillName },
22
+ preCheck,
23
+ denialContext: {
24
+ kind: "skill_input",
25
+ skillName,
26
+ agentName: agentName ?? undefined,
27
+ },
28
+ promptDetails: {
29
+ source: "skill_input",
30
+ agentName,
31
+ message,
32
+ skillName,
33
+ },
34
+ logContext: {
35
+ source: "skill_input",
36
+ skillName,
37
+ agentName,
38
+ message,
39
+ },
40
+ decision: {
41
+ surface: "skill",
42
+ value: skillName,
43
+ },
44
+ sessionApproval: SessionApproval.single("skill", skillName),
45
+ };
46
+ }
@@ -0,0 +1,87 @@
1
+ import { toRecord } from "#src/common";
2
+ import { normalizePathForComparison } from "#src/path-utils";
3
+ import { formatSkillPathAskPrompt } from "#src/permission-prompts";
4
+ import { SessionApproval } from "#src/session-approval";
5
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
6
+ import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
7
+ import type { GateDescriptor } from "./descriptor";
8
+ import type { ToolCallContext } from "./types";
9
+
10
+ /**
11
+ * Build a pure descriptor for the skill-read permission gate.
12
+ *
13
+ * Returns `null` when the gate does not apply (tool is not `read`, no active
14
+ * skill entries, or the read path does not match any skill).
15
+ * Returns a GateDescriptor with preResolved state from the matched skill entry.
16
+ */
17
+ export function describeSkillReadGate(
18
+ tcc: ToolCallContext,
19
+ getActiveSkillEntries: () => SkillPromptEntry[],
20
+ ): GateDescriptor | null {
21
+ const activeSkillEntries = getActiveSkillEntries();
22
+
23
+ if (tcc.toolName !== "read" || activeSkillEntries.length === 0) {
24
+ return null;
25
+ }
26
+
27
+ const inputRecord = toRecord(tcc.input);
28
+ const path = typeof inputRecord.path === "string" ? inputRecord.path : "";
29
+ if (!path) {
30
+ return null;
31
+ }
32
+
33
+ if (tcc.cwd === undefined) {
34
+ return null;
35
+ }
36
+
37
+ const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
38
+ const matchedSkill = findSkillPathMatch(
39
+ normalizedReadPath,
40
+ activeSkillEntries,
41
+ );
42
+
43
+ if (!matchedSkill) {
44
+ return null;
45
+ }
46
+
47
+ const skillReadMessage = formatSkillPathAskPrompt(
48
+ matchedSkill,
49
+ path,
50
+ tcc.agentName ?? undefined,
51
+ );
52
+
53
+ return {
54
+ surface: "skill",
55
+ input: { name: matchedSkill.name },
56
+ denialContext: {
57
+ kind: "skill_read",
58
+ skillName: matchedSkill.name,
59
+ readPath: path,
60
+ agentName: tcc.agentName ?? undefined,
61
+ },
62
+ promptDetails: {
63
+ source: "skill_read",
64
+ agentName: tcc.agentName,
65
+ message: skillReadMessage,
66
+ toolCallId: tcc.toolCallId,
67
+ toolName: tcc.toolName,
68
+ skillName: matchedSkill.name,
69
+ path,
70
+ },
71
+ logContext: {
72
+ source: "skill_read",
73
+ skillName: matchedSkill.name,
74
+ agentName: tcc.agentName,
75
+ path,
76
+ message: skillReadMessage,
77
+ },
78
+ decision: {
79
+ surface: "skill",
80
+ value: matchedSkill.name,
81
+ },
82
+ preResolved: {
83
+ state: matchedSkill.state,
84
+ },
85
+ sessionApproval: SessionApproval.single("skill", matchedSkill.name),
86
+ };
87
+ }
@@ -0,0 +1,129 @@
1
+ import { getNonEmptyString, toRecord } from "#src/common";
2
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
5
+ import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
6
+ import {
7
+ ToolPreviewFormatter,
8
+ type ToolPreviewFormatterOptions,
9
+ } from "#src/tool-preview-formatter";
10
+ import { resolveBashCommandCheck } from "./bash-command";
11
+ import { describeBashExternalDirectoryGate } from "./bash-external-directory";
12
+ import { describeBashPathGate } from "./bash-path";
13
+ import { BashProgram } from "./bash-program";
14
+ import type { GateResult } from "./descriptor";
15
+ import { describeExternalDirectoryGate } from "./external-directory";
16
+ import { describePathGate } from "./path";
17
+ import type { GateRunner } from "./runner";
18
+ import { describeSkillReadGate } from "./skill-read";
19
+ import { describeToolGate } from "./tool";
20
+ import type { GateOutcome, ToolCallContext } from "./types";
21
+
22
+ /**
23
+ * Narrow interface the pipeline needs from its session-side dependency.
24
+ *
25
+ * The three query methods needed to assemble gate inputs.
26
+ * The resolver is injected separately as a constructor parameter.
27
+ *
28
+ * `PermissionSession` satisfies this structurally at the construction call
29
+ * site; no `implements` clause is needed and would create a layer-inversion
30
+ * import from the domain module into the handler layer.
31
+ */
32
+ export interface ToolCallGateInputs {
33
+ /** Active skill prompt entries for the skill-read gate. */
34
+ getActiveSkillEntries(): SkillPromptEntry[];
35
+ /** Combined infrastructure read directories (static + config-derived). */
36
+ getInfrastructureReadDirs(): string[];
37
+ /** Resolved tool-preview formatter options from the current config. */
38
+ getToolPreviewLimits(): ToolPreviewFormatterOptions;
39
+ }
40
+
41
+ /**
42
+ * Owns the ordered tool-call gate-producer assembly and the run loop.
43
+ *
44
+ * Constructed once in the composition root and injected into
45
+ * `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
46
+ * - bash-command extraction and single `BashProgram.parse` (#308)
47
+ * - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
48
+ * - infrastructure-dir list from `getInfrastructureReadDirs()`
49
+ * - all six gate producers in their prescribed order
50
+ * - the run loop that returns the first block outcome, or allow
51
+ */
52
+ export class ToolCallGatePipeline {
53
+ constructor(
54
+ private readonly resolver: ScopedPermissionResolver,
55
+ private readonly inputs: ToolCallGateInputs,
56
+ private readonly customFormatters?: ToolInputFormatterLookup,
57
+ private readonly customExtractors?: ToolAccessExtractorLookup,
58
+ ) {}
59
+
60
+ async evaluate(
61
+ tcc: ToolCallContext,
62
+ runner: GateRunner,
63
+ ): Promise<GateOutcome> {
64
+ // Parse the bash command exactly once per evaluate; the three bash gates
65
+ // share this single BashProgram instead of each re-parsing (#308).
66
+ const command = getNonEmptyString(toRecord(tcc.input).command);
67
+ const bashProgram =
68
+ tcc.toolName === "bash" && command
69
+ ? await BashProgram.parse(command)
70
+ : null;
71
+
72
+ const formatter = new ToolPreviewFormatter(
73
+ this.inputs.getToolPreviewLimits(),
74
+ this.customFormatters,
75
+ );
76
+
77
+ const infraDirs = this.inputs.getInfrastructureReadDirs();
78
+
79
+ const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
80
+ () =>
81
+ describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
82
+ () => describePathGate(tcc, this.resolver, this.customExtractors),
83
+ () =>
84
+ describeExternalDirectoryGate(
85
+ tcc,
86
+ infraDirs,
87
+ this.resolver,
88
+ this.customExtractors,
89
+ ),
90
+ () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
91
+ () => describeBashPathGate(tcc, bashProgram, this.resolver),
92
+ () => {
93
+ // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
94
+ // evaluate each unit from the shared parse on the bash surface and
95
+ // select the most restrictive, rather than matching the whole program
96
+ // string (#301). Other tools evaluate their single input directly.
97
+ const toolCheck =
98
+ tcc.toolName === "bash" && bashProgram
99
+ ? resolveBashCommandCheck(
100
+ command ?? "",
101
+ bashProgram.commands(),
102
+ tcc.agentName ?? undefined,
103
+ this.resolver,
104
+ )
105
+ : this.resolver.resolve(
106
+ tcc.toolName,
107
+ tcc.input,
108
+ tcc.agentName ?? undefined,
109
+ );
110
+ const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
111
+ toolDescriptor.preCheck = toolCheck;
112
+ return toolDescriptor;
113
+ },
114
+ ];
115
+
116
+ for (const produce of gateProducers) {
117
+ const outcome = await runner.run(
118
+ await produce(),
119
+ tcc.agentName,
120
+ tcc.toolCallId,
121
+ );
122
+ if (outcome.action === "block") {
123
+ return outcome;
124
+ }
125
+ }
126
+
127
+ return { action: "allow" };
128
+ }
129
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ getPathBearingToolPath,
3
+ normalizePathForComparison,
4
+ PATH_BEARING_TOOLS,
5
+ } from "#src/path-utils";
6
+ import { suggestSessionPattern } from "#src/pattern-suggest";
7
+ import { formatAskPrompt } from "#src/permission-prompts";
8
+ import { SessionApproval } from "#src/session-approval";
9
+ import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
10
+ import type { PermissionCheckResult } from "#src/types";
11
+ import type { GateDescriptor } from "./descriptor";
12
+ import { deriveDecisionValue } from "./helpers";
13
+ import type { ToolCallContext } from "./types";
14
+
15
+ /**
16
+ * Derive the value used for session-approval pattern suggestions.
17
+ *
18
+ * Bash → command string; MCP → qualified target;
19
+ * path-bearing tools → the file path resolved to its canonical (cwd-anchored,
20
+ * absolute) form so the suggested pattern matches the policy values a later
21
+ * call produces; others → catch-all wildcard.
22
+ */
23
+ function deriveSuggestionValue(
24
+ tcc: ToolCallContext,
25
+ check: PermissionCheckResult,
26
+ ): string {
27
+ if (tcc.toolName === "bash") return check.command ?? "";
28
+ if (tcc.toolName === "mcp") return check.target ?? "mcp";
29
+ const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
+ if (path === null) return "*";
31
+ return tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path;
32
+ }
33
+
34
+ /**
35
+ * Build a pure descriptor for the normal tool permission gate.
36
+ *
37
+ * Takes a pre-computed PermissionCheckResult (from checkPermission) and
38
+ * returns a GateDescriptor that the runner can execute. No side effects.
39
+ */
40
+ export function describeToolGate(
41
+ tcc: ToolCallContext,
42
+ check: PermissionCheckResult,
43
+ formatter: ToolPreviewFormatter,
44
+ ): GateDescriptor {
45
+ const permissionLogContext = formatter.getPermissionLogContext(
46
+ check,
47
+ tcc.input,
48
+ PATH_BEARING_TOOLS,
49
+ );
50
+
51
+ // Compute session approval suggestion for the "for this session" option.
52
+ const suggestion = suggestSessionPattern(
53
+ tcc.toolName,
54
+ deriveSuggestionValue(tcc, check),
55
+ );
56
+
57
+ const askMessage = formatAskPrompt(
58
+ check,
59
+ tcc.agentName ?? undefined,
60
+ tcc.input,
61
+ formatter,
62
+ );
63
+
64
+ return {
65
+ surface: tcc.toolName,
66
+ input: tcc.input,
67
+ denialContext: {
68
+ kind: "tool",
69
+ check,
70
+ agentName: tcc.agentName ?? undefined,
71
+ input: tcc.input,
72
+ },
73
+ sessionApproval: SessionApproval.single(
74
+ suggestion.surface,
75
+ suggestion.pattern,
76
+ ),
77
+ promptDetails: {
78
+ source: "tool_call",
79
+ agentName: tcc.agentName,
80
+ message: askMessage,
81
+ toolCallId: tcc.toolCallId,
82
+ toolName: tcc.toolName,
83
+ sessionLabel: suggestion.label,
84
+ ...permissionLogContext,
85
+ },
86
+ logContext: {
87
+ source: "tool_call",
88
+ toolCallId: tcc.toolCallId,
89
+ toolName: tcc.toolName,
90
+ message: askMessage,
91
+ ...permissionLogContext,
92
+ },
93
+ decision: {
94
+ surface: tcc.toolName,
95
+ value: deriveDecisionValue(
96
+ tcc.toolName,
97
+ check,
98
+ getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
99
+ ),
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,13 @@
1
+ /** Outcome of a single permission gate evaluation. */
2
+ export type GateOutcome =
3
+ | { action: "allow" }
4
+ | { action: "block"; reason: string };
5
+
6
+ /** Pre-validated context shared across all gates. */
7
+ export interface ToolCallContext {
8
+ toolName: string;
9
+ agentName: string | null;
10
+ input: unknown;
11
+ toolCallId: string;
12
+ cwd: string | undefined;
13
+ }
@@ -0,0 +1,3 @@
1
+ export { AgentPrepHandler } from "./before-agent-start";
2
+ export { SessionLifecycleHandler } from "./lifecycle";
3
+ export { PermissionGateHandler } from "./permission-gate-handler";