@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,94 @@
1
+ import type {
2
+ BeforeAgentStartEventResult,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { PermissionResolver } from "#src/permission-resolver";
6
+ import type { PermissionSession } from "#src/permission-session";
7
+ import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
8
+ import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
9
+ import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
10
+ import type { PermissionState } from "#src/types";
11
+
12
+ /** Minimal subset of BeforeAgentStartEvent used by this handler. */
13
+ interface BeforeAgentStartPayload {
14
+ systemPrompt: string;
15
+ }
16
+
17
+ /**
18
+ * Pure helper: returns true when the tool should be exposed to the agent.
19
+ * Checks the tool-level permission (not command-level) so that a blanket
20
+ * `bash: deny` hides the tool entirely before any invocation is attempted.
21
+ */
22
+ export function shouldExposeTool(
23
+ toolName: string,
24
+ agentName: string | null,
25
+ getToolPermission: (toolName: string, agentName?: string) => PermissionState,
26
+ ): boolean {
27
+ const toolPermission = getToolPermission(toolName, agentName ?? undefined);
28
+ return toolPermission !== "deny";
29
+ }
30
+
31
+ /**
32
+ * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
33
+ *
34
+ * Recomputes the active tool set and the returned system-prompt override on
35
+ * every fire (no memoization): the override must be returned each turn so that
36
+ * skill filtering is reapplied and the wire prompt stays byte-stable, rather
37
+ * than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
38
+ *
39
+ * Constructor deps:
40
+ * - `session` — encapsulates all mutable session state and lifecycle operations
41
+ * - `resolver` — owns permission-query surface: `getToolPermission`, skill check
42
+ * - `toolRegistry` — Pi tool API subset (getActive + setActive)
43
+ */
44
+ export class AgentPrepHandler {
45
+ constructor(
46
+ private readonly session: PermissionSession,
47
+ private readonly resolver: PermissionResolver,
48
+ private readonly toolRegistry: ToolRegistry,
49
+ ) {}
50
+
51
+ // eslint-disable-next-line @typescript-eslint/require-await
52
+ async handle(
53
+ event: BeforeAgentStartPayload,
54
+ ctx: ExtensionContext,
55
+ ): Promise<BeforeAgentStartEventResult> {
56
+ this.session.activate(ctx);
57
+ this.session.refreshConfig(ctx);
58
+
59
+ const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
60
+ const activeTools = this.toolRegistry.getActive();
61
+ const allowedTools: string[] = [];
62
+
63
+ for (const tool of activeTools) {
64
+ const toolName = getToolNameFromValue(tool);
65
+ if (!toolName) {
66
+ continue;
67
+ }
68
+ if (
69
+ shouldExposeTool(toolName, agentName, (t, a) =>
70
+ this.resolver.getToolPermission(t, a),
71
+ )
72
+ ) {
73
+ allowedTools.push(toolName);
74
+ }
75
+ }
76
+
77
+ this.toolRegistry.setActive(allowedTools);
78
+
79
+ const toolPromptResult = sanitizeAvailableToolsSection(
80
+ event.systemPrompt,
81
+ allowedTools,
82
+ );
83
+ const skillPromptResult = resolveSkillPromptEntries(
84
+ toolPromptResult.prompt,
85
+ this.resolver,
86
+ agentName,
87
+ ctx.cwd,
88
+ );
89
+ this.session.setActiveSkillEntries(skillPromptResult.entries);
90
+ return skillPromptResult.prompt !== event.systemPrompt
91
+ ? { systemPrompt: skillPromptResult.prompt }
92
+ : {};
93
+ }
94
+ }
@@ -0,0 +1,75 @@
1
+ import type { BashCommand } from "#src/handlers/gates/bash-program";
2
+ import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import type { PermissionCheckResult } from "#src/types";
5
+
6
+ /**
7
+ * Resolve the bash command-pattern decision for a (possibly chained) command.
8
+ *
9
+ * A bash invocation may be a shell program with several commands joined by
10
+ * `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
11
+ * bash patterns lets a denied command ride through on an allowed leading one
12
+ * (issue #301). Instead, the caller supplies the program's command units (from
13
+ * the shared `BashProgram.commands()` parse) — including those nested inside
14
+ * substitutions and subshells (#306); each is evaluated on the `bash` surface
15
+ * and the most restrictive result wins (`deny > ask > allow`).
16
+ *
17
+ * The selected result carries the offending sub-command in `command`, its rule
18
+ * in `matchedPattern`, and the offending command's execution context in
19
+ * `commandContext` (set only for a nested command), so the prompt,
20
+ * session-approval suggestion, and decision event scope to that command.
21
+ *
22
+ * When `commands` is empty there are two cases. A trivially-empty command (an
23
+ * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
24
+ * so the whole `command` is resolved as before. A non-empty command that parsed
25
+ * to zero command units (a parse anomaly or an opaque program) fails closed to
26
+ * a synthetic `ask` so a permissive top-level `*` cannot silently allow an
27
+ * unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
28
+ * the empty-parse path) — #452.
29
+ *
30
+ * Pure and synchronous: the (async, tree-sitter) parse happens once in the
31
+ * handler, which passes the decomposed `commands` here.
32
+ */
33
+ export function resolveBashCommandCheck(
34
+ command: string,
35
+ commands: BashCommand[],
36
+ agentName: string | undefined,
37
+ resolver: ScopedPermissionResolver,
38
+ ): PermissionCheckResult {
39
+ if (commands.length === 0) {
40
+ if (isTriviallyEmptyCommand(command)) {
41
+ return resolver.resolve("bash", { command }, agentName);
42
+ }
43
+ return {
44
+ state: "ask",
45
+ toolName: "bash",
46
+ source: "bash",
47
+ origin: "builtin",
48
+ command,
49
+ matchedPattern: "<unparseable-bash-command>",
50
+ };
51
+ }
52
+
53
+ const results = commands.map((cmd) => {
54
+ const result = resolver.resolve("bash", { command: cmd.text }, agentName);
55
+ return cmd.context ? { ...result, commandContext: cmd.context } : result;
56
+ });
57
+ return (
58
+ pickMostRestrictive(results) ??
59
+ resolver.resolve("bash", { command }, agentName)
60
+ );
61
+ }
62
+
63
+ /**
64
+ * True when a command has genuinely nothing to gate: it is empty,
65
+ * whitespace-only, or contains only comment lines (every non-blank line starts
66
+ * with `#`). Such a command yields zero command units legitimately, so the
67
+ * whole-string resolve is safe rather than a parse anomaly.
68
+ */
69
+ function isTriviallyEmptyCommand(command: string): boolean {
70
+ const lines = command
71
+ .split("\n")
72
+ .map((line) => line.trim())
73
+ .filter((line) => line.length > 0);
74
+ return lines.every((line) => line.startsWith("#"));
75
+ }
@@ -0,0 +1,127 @@
1
+ import { getNonEmptyString, toRecord } from "#src/common";
2
+ import { getExternalDirectoryPolicyValues } from "#src/path-utils";
3
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import { SessionApproval } from "#src/session-approval";
5
+ import { deriveApprovalPattern } from "#src/session-rules";
6
+ import type { PermissionCheckResult } from "#src/types";
7
+ import type { BashProgram } from "./bash-program";
8
+ import { pickMostRestrictive } from "./candidate-check";
9
+ import type { GateResult } from "./descriptor";
10
+ import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
11
+ import type { ToolCallContext } from "./types";
12
+
13
+ /**
14
+ * Build a pure descriptor for the bash external-directory permission gate.
15
+ *
16
+ * Reads the external paths from the injected `BashProgram` and checks whether
17
+ * any reference directories outside the working directory. Returns `null` when the gate
18
+ * does not apply (tool is not bash, no CWD, or no external paths found).
19
+ * Returns a `GateBypass` when all paths are allowed (by config or session rule).
20
+ * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
21
+ */
22
+ export function describeBashExternalDirectoryGate(
23
+ tcc: ToolCallContext,
24
+ bashProgram: BashProgram | null,
25
+ resolver: ScopedPermissionResolver,
26
+ ): GateResult {
27
+ if (tcc.toolName !== "bash" || !tcc.cwd) return null;
28
+
29
+ const command = getNonEmptyString(toRecord(tcc.input).command);
30
+ if (!command) return null;
31
+
32
+ if (!bashProgram) return null;
33
+
34
+ const externalPaths = bashProgram.externalPaths(tcc.cwd);
35
+ if (externalPaths.length === 0) return null;
36
+
37
+ // Collect paths whose resolved state is not already "allow".
38
+ // Checking state (not source) ensures config-level allow rules (source: "special")
39
+ // suppress the prompt just as session-level allow rules (source: "session") do.
40
+ const uncoveredEntries: Array<{
41
+ path: string;
42
+ check: PermissionCheckResult;
43
+ }> = [];
44
+ for (const p of externalPaths) {
45
+ // Match each path against both its typed and symlink-resolved aliases on
46
+ // the external_directory surface, so a config pattern on either form
47
+ // applies (#418).
48
+ const check = resolver.resolvePathPolicy(
49
+ getExternalDirectoryPolicyValues(p, tcc.cwd),
50
+ tcc.agentName ?? undefined,
51
+ "external_directory",
52
+ );
53
+ if (check.state !== "allow") {
54
+ uncoveredEntries.push({ path: p, check });
55
+ }
56
+ }
57
+ const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
58
+
59
+ if (uncoveredPaths.length === 0) {
60
+ return {
61
+ action: "allow",
62
+ log: {
63
+ event: "permission_request.session_approved",
64
+ details: {
65
+ source: "tool_call",
66
+ toolCallId: tcc.toolCallId,
67
+ toolName: tcc.toolName,
68
+ agentName: tcc.agentName,
69
+ command,
70
+ externalPaths,
71
+ resolution: "session_approved",
72
+ },
73
+ },
74
+ };
75
+ }
76
+
77
+ // Use the most restrictive check among uncovered paths as the pre-check result.
78
+ // This ensures a config-level "deny" rule is not downgraded to "ask" by the
79
+ // generic "*" catch-all that the old path-less checkPermission call returned.
80
+ const worstCheck =
81
+ pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
82
+ uncoveredEntries[0].check;
83
+
84
+ const bashExtMessage = formatBashExternalDirectoryAskPrompt(
85
+ command,
86
+ uncoveredPaths,
87
+ tcc.cwd,
88
+ tcc.agentName ?? undefined,
89
+ );
90
+
91
+ const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
92
+
93
+ return {
94
+ surface: "external_directory",
95
+ input: {},
96
+ denialContext: {
97
+ kind: "bash_external_directory",
98
+ command,
99
+ externalPaths: uncoveredPaths,
100
+ cwd: tcc.cwd,
101
+ agentName: tcc.agentName ?? undefined,
102
+ },
103
+ sessionApproval: SessionApproval.multiple("external_directory", patterns),
104
+ promptDetails: {
105
+ source: "tool_call",
106
+ agentName: tcc.agentName,
107
+ message: bashExtMessage,
108
+ toolCallId: tcc.toolCallId,
109
+ toolName: tcc.toolName,
110
+ command,
111
+ },
112
+ logContext: {
113
+ source: "tool_call",
114
+ toolCallId: tcc.toolCallId,
115
+ toolName: tcc.toolName,
116
+ agentName: tcc.agentName,
117
+ command,
118
+ externalPaths: uncoveredPaths,
119
+ message: bashExtMessage,
120
+ },
121
+ decision: {
122
+ surface: "external_directory",
123
+ value: command,
124
+ },
125
+ preCheck: worstCheck,
126
+ };
127
+ }
@@ -0,0 +1,15 @@
1
+ import { BashProgram } from "./bash-program";
2
+
3
+ /**
4
+ * Extract paths from a bash command string that resolve outside CWD.
5
+ *
6
+ * Thin facade over {@link BashProgram.externalPaths}; parses the command and
7
+ * returns the cd-aware external paths. See `BashProgram` for the parsing and
8
+ * resolution semantics.
9
+ */
10
+ export async function extractExternalPathsFromBashCommand(
11
+ command: string,
12
+ cwd: string,
13
+ ): Promise<string[]> {
14
+ return (await BashProgram.parse(command)).externalPaths(cwd);
15
+ }
@@ -0,0 +1,152 @@
1
+ import { getNonEmptyString, toRecord } from "#src/common";
2
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
+ import { SessionApproval } from "#src/session-approval";
4
+ import { deriveApprovalPattern } from "#src/session-rules";
5
+ import type { PermissionCheckResult } from "#src/types";
6
+ import type { BashProgram } from "./bash-program";
7
+ import { pickMostRestrictive } from "./candidate-check";
8
+ import type { GateResult } from "./descriptor";
9
+ import { formatPathAskPrompt } from "./path";
10
+ import type { ToolCallContext } from "./types";
11
+
12
+ /**
13
+ * Build a pure descriptor for the cross-cutting path permission gate (bash).
14
+ *
15
+ * Reads path-rule candidates from the injected `BashProgram` (the broader
16
+ * `path`-rule filter, accepting dot-files and relative paths). Each candidate
17
+ * pairs the raw token with cd-aware policy values; the gate evaluates those
18
+ * values against the `path` permission surface and returns the most
19
+ * restrictive result, while prompts, logs, and session approvals use the raw
20
+ * token.
21
+ *
22
+ * Returns `null` when the gate does not apply (tool is not bash, no command,
23
+ * no tokens extracted, or all tokens evaluate to `allow`).
24
+ * Returns a `GateBypass` when all tokens are session-covered.
25
+ * Returns a `GateDescriptor` for the most restrictive token needing a check.
26
+ */
27
+ export function describeBashPathGate(
28
+ tcc: ToolCallContext,
29
+ bashProgram: BashProgram | null,
30
+ resolver: ScopedPermissionResolver,
31
+ ): GateResult {
32
+ if (tcc.toolName !== "bash") return null;
33
+
34
+ const command = getNonEmptyString(toRecord(tcc.input).command);
35
+ if (!command) return null;
36
+
37
+ if (!bashProgram) return null;
38
+
39
+ const candidates = bashProgram.pathRuleCandidates(tcc.cwd);
40
+ if (candidates.length === 0) return null;
41
+ const tokens = candidates.map(({ token }) => token);
42
+
43
+ // Tokens whose resolved state needs a check (deny/ask), paired with the raw
44
+ // token (prompt/decision display) and its policy values (the first of which
45
+ // is the canonical absolute path the approval pattern is derived from).
46
+ const uncovered: Array<{
47
+ token: string;
48
+ policyValues: readonly string[];
49
+ check: PermissionCheckResult;
50
+ }> = [];
51
+ let allSessionCovered = true;
52
+
53
+ for (const { token, policyValues } of candidates) {
54
+ const check = resolver.resolvePathPolicy(
55
+ policyValues,
56
+ tcc.agentName ?? undefined,
57
+ );
58
+
59
+ // No explicit path rule matched — only the universal default fired.
60
+ // Treat this token as unrestricted to preserve backward compatibility
61
+ // for configs without a "path" key (#58).
62
+ if (check.matchedPattern === undefined && check.source !== "session") {
63
+ allSessionCovered = false;
64
+ continue;
65
+ }
66
+
67
+ if (check.source !== "session") {
68
+ allSessionCovered = false;
69
+ }
70
+
71
+ if (check.state === "deny") {
72
+ uncovered.push({ token, policyValues, check });
73
+ break; // Short-circuit on deny.
74
+ }
75
+ if (check.state === "ask") {
76
+ uncovered.push({ token, policyValues, check });
77
+ }
78
+ }
79
+
80
+ // All tokens are session-covered — bypass.
81
+ if (allSessionCovered) {
82
+ return {
83
+ action: "allow",
84
+ log: {
85
+ event: "permission_request.session_approved",
86
+ details: {
87
+ source: "tool_call",
88
+ toolCallId: tcc.toolCallId,
89
+ toolName: tcc.toolName,
90
+ agentName: tcc.agentName,
91
+ command,
92
+ tokens,
93
+ resolution: "session_approved",
94
+ },
95
+ },
96
+ };
97
+ }
98
+
99
+ // Pick the most restrictive (deny > ask > allow, first-wins) uncovered token.
100
+ const worstCheck = pickMostRestrictive(uncovered.map(({ check }) => check));
101
+ const worstEntry = worstCheck
102
+ ? uncovered.find(({ check }) => check === worstCheck)
103
+ : undefined;
104
+ const worstToken = worstEntry?.token ?? null;
105
+
106
+ // All tokens evaluate to allow — no restriction.
107
+ if (!worstCheck || !worstToken || !worstEntry) return null;
108
+
109
+ // Derive the pattern from the canonical absolute policy value (the cd-aware
110
+ // resolved path), so it matches the values a later call produces. Falls back
111
+ // to the raw token only when no base was resolvable (no cwd / unknown cd).
112
+ const approvalBase = worstEntry.policyValues[0] ?? worstToken;
113
+ const pattern = deriveApprovalPattern(approvalBase);
114
+ const askMessage = formatPathAskPrompt(
115
+ tcc.toolName,
116
+ worstToken,
117
+ tcc.agentName ?? undefined,
118
+ );
119
+
120
+ return {
121
+ surface: "path",
122
+ input: { path: worstToken },
123
+ denialContext: {
124
+ kind: "bash_path",
125
+ command,
126
+ pathValue: worstToken,
127
+ agentName: tcc.agentName ?? undefined,
128
+ },
129
+ sessionApproval: SessionApproval.single("path", pattern),
130
+ promptDetails: {
131
+ source: "tool_call",
132
+ agentName: tcc.agentName,
133
+ message: askMessage,
134
+ toolCallId: tcc.toolCallId,
135
+ toolName: tcc.toolName,
136
+ command,
137
+ },
138
+ logContext: {
139
+ source: "tool_call",
140
+ toolCallId: tcc.toolCallId,
141
+ toolName: tcc.toolName,
142
+ agentName: tcc.agentName,
143
+ command,
144
+ path: worstToken,
145
+ },
146
+ decision: {
147
+ surface: "path",
148
+ value: worstToken,
149
+ },
150
+ preCheck: worstCheck,
151
+ };
152
+ }