@gotgenes/pi-permission-system 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.1.0](https://github.com/gotgenes/pi-permission-system/compare/v3.0.5...v3.1.0) (2026-05-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * add bash external-directory format helpers ([#39](https://github.com/gotgenes/pi-permission-system/issues/39)) ([5c7e93c](https://github.com/gotgenes/pi-permission-system/commit/5c7e93cbe5c428ab3ed5e32ab3f2bb8c3fe0431b))
14
+ * enforce external_directory gate on bash commands ([#39](https://github.com/gotgenes/pi-permission-system/issues/39)) ([5342139](https://github.com/gotgenes/pi-permission-system/commit/53421391c5f5f3b277e26ea7cbee23ef06b6db41))
15
+ * extract external paths from bash command tokens ([#39](https://github.com/gotgenes/pi-permission-system/issues/39)) ([8cb3c2a](https://github.com/gotgenes/pi-permission-system/commit/8cb3c2a1b56007ca10e634bd6be5b464ddfea957))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * document bash external_directory gate in README ([#39](https://github.com/gotgenes/pi-permission-system/issues/39)) ([d33e1ea](https://github.com/gotgenes/pi-permission-system/commit/d33e1ea2686e5390f9904d554668c00305702fc1))
21
+ * plan bash external_directory gate ([#39](https://github.com/gotgenes/pi-permission-system/issues/39)) ([ba80c64](https://github.com/gotgenes/pi-permission-system/commit/ba80c647668542f934e2c14148cf94fb11d110da))
22
+
23
+ ## [3.0.5](https://github.com/gotgenes/pi-permission-system/compare/v3.0.4...v3.0.5) (2026-05-03)
24
+
25
+
26
+ ### Miscellaneous Chores
27
+
28
+ * **deps:** update dependencies and clean up unused peers ([d8482a9](https://github.com/gotgenes/pi-permission-system/commit/d8482a9aab4a41798ba50e7b5db9ede1dcb7897a))
29
+
8
30
  ## [3.0.4](https://github.com/gotgenes/pi-permission-system/compare/v3.0.3...v3.0.4) (2026-05-03)
9
31
 
10
32
 
package/README.md CHANGED
@@ -21,7 +21,7 @@ Permission enforcement extension for the Pi coding agent that provides centraliz
21
21
  - **File-Based Review Logging** — Writes permission request/denial review entries to a file by default for later auditing
22
22
  - **Optional Debug Logging** — Keeps verbose extension diagnostics in a separate file when enabled in `config.json`
23
23
  - **JSON Schema Validation** — Full schema for editor autocomplete and config validation
24
- - **External Directory Guard** — Enforces `special.external_directory` for path-bearing file tools that target paths outside the active working directory
24
+ - **External Directory Guard** — Enforces `special.external_directory` for path-bearing file tools and bash commands that reference paths outside the active working directory
25
25
 
26
26
  ## Installation
27
27
 
@@ -97,6 +97,7 @@ The extension integrates via Pi's lifecycle hooks:
97
97
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries instead of raw multiline JSON
98
98
  - Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls so approvals can be audited without writing raw full payloads
99
99
  - Path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) evaluate `special.external_directory` before their normal tool permission when an explicit path points outside `ctx.cwd`
100
+ - Bash commands are scanned for path tokens (absolute, `~/`, or `..`-relative) that resolve outside `ctx.cwd`; matching commands trigger the same `special.external_directory` gate before the normal bash pattern check
100
101
 
101
102
  ## Configuration
102
103
 
@@ -353,7 +354,7 @@ Reserved permission checks:
353
354
  | Key | Description |
354
355
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
355
356
  | `doom_loop` | Controls doom loop detection behavior |
356
- | `external_directory` | Enforces ask/allow/deny decisions for path-bearing built-in tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory |
357
+ | `external_directory` | Enforces ask/allow/deny decisions for path-bearing tools and bash commands that reference paths outside the active working directory |
357
358
 
358
359
  ```jsonc
359
360
  {
@@ -366,6 +367,8 @@ Reserved permission checks:
366
367
 
367
368
  `external_directory` is evaluated before the normal tool permission check. For example, `tools.read: "allow"` can permit ordinary reads while `special.external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`. Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided because they default to the active working directory.
368
369
 
370
+ Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
371
+
369
372
  ---
370
373
 
371
374
  ## Common Recipes
@@ -626,14 +629,14 @@ The old extension-root `config.json` is no longer read from the install director
626
629
  ## Development
627
630
 
628
631
  ```bash
629
- npm run build # Type-check TypeScript (no emit)
630
- npm run lint # Biome lint + format check
631
- npm run lint:fix # Biome lint + format auto-fix
632
- npm run lint:md # markdownlint-cli2 on README etc.
633
- npm run lint:all # lint + lint:md
634
- npm run format # Biome format --write
635
- npm run test # Run tests from ./tests
636
- npm run check # build + lint:all + test
632
+ pnpm run build # Type-check TypeScript (no emit)
633
+ pnpm run lint # Biome lint + format check
634
+ pnpm run lint:fix # Biome lint + format auto-fix
635
+ pnpm run lint:md # markdownlint-cli2 on README etc.
636
+ pnpm run lint:all # lint + lint:md
637
+ pnpm run format # Biome format --write
638
+ pnpm run test # Run tests from ./tests
639
+ pnpm run check # build + lint:all + test
637
640
  ```
638
641
 
639
642
  ### Pre-commit hooks
@@ -642,7 +645,7 @@ This project uses [prek](https://prek.j178.dev/) to run Biome and markdownlint o
642
645
  This catches lint and formatting issues locally instead of waiting for CI.
643
646
 
644
647
  1. Install prek ([installation guide](https://prek.j178.dev/installation/)).
645
- 2. Run `npm install` — the `prepare` script calls `prek install` automatically.
648
+ 2. Run `pnpm install` — the `prepare` script calls `prek install` automatically.
646
649
  If prek is not installed, the script prints a warning and continues.
647
650
  3. Hooks run automatically on `git commit`.
648
651
  To skip in emergencies: `git commit --no-verify`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.0.4",
3
+ "version": "3.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,20 +17,6 @@
17
17
  "CHANGELOG.md",
18
18
  "LICENSE"
19
19
  ],
20
- "scripts": {
21
- "prepare": "command -v prek >/dev/null 2>&1 && prek install || echo 'prek not found — skipping hook install (see README)'",
22
- "build": "tsc -p tsconfig.json",
23
- "lint": "biome check .",
24
- "lint:fix": "biome check --write .",
25
- "lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
26
- "lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
27
- "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/ index.ts",
28
- "lint:all": "npm run lint && npm run lint:md && npm run lint:imports",
29
- "format": "biome format --write .",
30
- "test": "vitest run",
31
- "test:watch": "vitest",
32
- "check": "npm run build && npm run lint:all && npm run test"
33
- },
34
20
  "keywords": [
35
21
  "pi-package",
36
22
  "pi",
@@ -67,20 +53,29 @@
67
53
  ]
68
54
  },
69
55
  "peerDependencies": {
70
- "@mariozechner/pi-ai": "^0.70.5",
71
- "@mariozechner/pi-coding-agent": "^0.70.5",
72
- "@mariozechner/pi-tui": "^0.70.5",
73
- "@sinclair/typebox": "^0.34.49"
56
+ "@mariozechner/pi-coding-agent": "*",
57
+ "@mariozechner/pi-tui": "*"
74
58
  },
75
59
  "devDependencies": {
76
60
  "@biomejs/biome": "^2.4.13",
77
- "@mariozechner/pi-ai": "^0.70.5",
78
- "@mariozechner/pi-coding-agent": "^0.70.5",
79
- "@mariozechner/pi-tui": "^0.70.5",
80
- "@sinclair/typebox": "^0.34.49",
81
- "@types/node": "^22.19.17",
61
+ "@mariozechner/pi-coding-agent": "^0.72.1",
62
+ "@mariozechner/pi-tui": "^0.72.1",
63
+ "@types/node": "^25.6.0",
82
64
  "markdownlint-cli2": "^0.22.1",
83
65
  "typescript": "6.0.3",
84
66
  "vitest": "^4.1.5"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.json",
70
+ "lint": "biome check .",
71
+ "lint:fix": "biome check --write .",
72
+ "lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
73
+ "lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
74
+ "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/ index.ts",
75
+ "lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
76
+ "format": "biome format --write .",
77
+ "test": "vitest run",
78
+ "test:watch": "vitest",
79
+ "check": "pnpm run build && pnpm run lint:all && pnpm run test"
85
80
  }
86
- }
81
+ }
@@ -111,3 +111,105 @@ export function formatExternalDirectoryUserDeniedReason(
111
111
  const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
112
112
  return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
113
113
  }
114
+
115
+ export function formatBashExternalDirectoryAskPrompt(
116
+ command: string,
117
+ externalPaths: string[],
118
+ cwd: string,
119
+ agentName?: string,
120
+ ): string {
121
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
122
+ const pathList = externalPaths.join(", ");
123
+ return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
124
+ }
125
+
126
+ export function formatBashExternalDirectoryDenyReason(
127
+ command: string,
128
+ externalPaths: string[],
129
+ cwd: string,
130
+ agentName?: string,
131
+ ): string {
132
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
133
+ const pathList = externalPaths.join(", ");
134
+ return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
135
+ }
136
+
137
+ /**
138
+ * URL pattern to skip tokens that look like URLs rather than paths.
139
+ */
140
+ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
141
+
142
+ /**
143
+ * Determines whether a token looks like a path candidate worth resolving.
144
+ * Returns the raw token string if it's a candidate, or null to skip.
145
+ */
146
+ function classifyTokenAsPathCandidate(token: string): string | null {
147
+ // Skip empty tokens
148
+ if (!token) return null;
149
+
150
+ // Skip flags
151
+ if (token.startsWith("-")) return null;
152
+
153
+ // Skip env assignments (FOO=/bar)
154
+ const eqIndex = token.indexOf("=");
155
+ const slashIndex = token.indexOf("/");
156
+ if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
157
+ return null;
158
+ }
159
+
160
+ // Skip URLs
161
+ if (URL_PATTERN.test(token)) return null;
162
+
163
+ // Skip @scope/package patterns
164
+ if (token.startsWith("@") && !token.startsWith("@/")) return null;
165
+
166
+ // Must look like a path: starts with /, ~/, or contains ..
167
+ if (token.startsWith("/")) return token;
168
+ if (token.startsWith("~/")) return token;
169
+ if (token.includes("..")) return token;
170
+
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Strips content inside single and double quotes from a command string.
176
+ * Replaces quoted segments with empty string so paths inside quotes are not tokenized.
177
+ * This is a simple regex approach — it cannot handle escaped quotes within strings.
178
+ */
179
+ function stripQuotedStrings(command: string): string {
180
+ return command.replace(/"[^"]*"/g, "").replace(/'[^']*'/g, "");
181
+ }
182
+
183
+ /**
184
+ * Extracts paths from a bash command string that resolve outside CWD.
185
+ * This is a best-effort heuristic (token splitting, not full shell parsing).
186
+ */
187
+ export function extractExternalPathsFromBashCommand(
188
+ command: string,
189
+ cwd: string,
190
+ ): string[] {
191
+ // Strip quoted strings to avoid false positives on paths in messages
192
+ const unquoted = stripQuotedStrings(command);
193
+ // Split on shell metacharacters to isolate tokens
194
+ const tokens = unquoted.split(/[|;&><\s]+/).filter(Boolean);
195
+ const seen = new Set<string>();
196
+ const externalPaths: string[] = [];
197
+
198
+ for (const token of tokens) {
199
+ const candidate = classifyTokenAsPathCandidate(token);
200
+ if (!candidate) continue;
201
+
202
+ const normalized = normalizePathForComparison(candidate, cwd);
203
+ if (!normalized) continue;
204
+
205
+ if (
206
+ isPathOutsideWorkingDirectory(candidate, cwd) &&
207
+ !seen.has(normalized)
208
+ ) {
209
+ seen.add(normalized);
210
+ externalPaths.push(normalized);
211
+ }
212
+ }
213
+
214
+ return externalPaths;
215
+ }
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  createBeforeAgentStartPromptStateKey,
23
23
  shouldApplyCachedAgentStartState,
24
24
  } from "./before-agent-start-cache";
25
- import { toRecord } from "./common";
25
+ import { getNonEmptyString, toRecord } from "./common";
26
26
  import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
27
27
  import { registerPermissionSystemCommand } from "./config-modal";
28
28
  import {
@@ -44,8 +44,12 @@ import {
44
44
  type PermissionSystemExtensionConfig,
45
45
  } from "./extension-config";
46
46
  import {
47
+ extractExternalPathsFromBashCommand,
48
+ formatBashExternalDirectoryAskPrompt,
49
+ formatBashExternalDirectoryDenyReason,
47
50
  formatExternalDirectoryAskPrompt,
48
51
  formatExternalDirectoryDenyReason,
52
+ formatExternalDirectoryHardStopHint,
49
53
  formatExternalDirectoryUserDeniedReason,
50
54
  getPathBearingToolPath,
51
55
  isPathOutsideWorkingDirectory,
@@ -897,6 +901,90 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
897
901
  // state === "allow" → fall through to normal permission check
898
902
  }
899
903
 
904
+ // Bash external directory gate: extract paths from bash commands
905
+ if (ctx.cwd && toolName === "bash") {
906
+ const command = getNonEmptyString(toRecord(input).command);
907
+ if (command) {
908
+ const externalPaths = extractExternalPathsFromBashCommand(
909
+ command,
910
+ ctx.cwd,
911
+ );
912
+ if (externalPaths.length > 0) {
913
+ const extCheck = permissionManager.checkPermission(
914
+ "external_directory",
915
+ {},
916
+ agentName ?? undefined,
917
+ );
918
+
919
+ if (extCheck.state === "deny") {
920
+ writeReviewLog("permission_request.blocked", {
921
+ source: "tool_call",
922
+ toolCallId: event.toolCallId,
923
+ toolName,
924
+ agentName,
925
+ command,
926
+ externalPaths,
927
+ resolution: "policy_denied",
928
+ });
929
+ return {
930
+ block: true,
931
+ reason: formatBashExternalDirectoryDenyReason(
932
+ command,
933
+ externalPaths,
934
+ ctx.cwd,
935
+ agentName ?? undefined,
936
+ ),
937
+ };
938
+ }
939
+
940
+ if (extCheck.state === "ask") {
941
+ const message = formatBashExternalDirectoryAskPrompt(
942
+ command,
943
+ externalPaths,
944
+ ctx.cwd,
945
+ agentName ?? undefined,
946
+ );
947
+ if (!canRequestPermissionConfirmation(ctx)) {
948
+ writeReviewLog("permission_request.blocked", {
949
+ source: "tool_call",
950
+ toolCallId: event.toolCallId,
951
+ toolName,
952
+ agentName,
953
+ command,
954
+ externalPaths,
955
+ message,
956
+ resolution: "confirmation_unavailable",
957
+ });
958
+ return {
959
+ block: true,
960
+ reason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
961
+ };
962
+ }
963
+
964
+ const extDecision = await promptPermission(ctx, {
965
+ requestId: event.toolCallId,
966
+ source: "tool_call",
967
+ agentName,
968
+ message,
969
+ toolCallId: event.toolCallId,
970
+ toolName,
971
+ command,
972
+ });
973
+ if (!extDecision.approved) {
974
+ const reasonSuffix = extDecision.denialReason
975
+ ? ` Reason: ${extDecision.denialReason}.`
976
+ : "";
977
+ return {
978
+ block: true,
979
+ reason: `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`,
980
+ };
981
+ }
982
+ }
983
+ // state === "allow" → fall through to normal bash permission check
984
+ }
985
+ }
986
+ }
987
+
900
988
  const check = permissionManager.checkPermission(
901
989
  toolName,
902
990
  input,
@@ -0,0 +1,301 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ import {
13
+ extractExternalPathsFromBashCommand,
14
+ formatBashExternalDirectoryAskPrompt,
15
+ formatBashExternalDirectoryDenyReason,
16
+ } from "../src/external-directory";
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ describe("extractExternalPathsFromBashCommand", () => {
23
+ const cwd = "/projects/my-app";
24
+
25
+ describe("absolute paths", () => {
26
+ test("detects absolute path outside CWD", () => {
27
+ const result = extractExternalPathsFromBashCommand("cat /etc/hosts", cwd);
28
+ expect(result).toContain("/etc/hosts");
29
+ });
30
+
31
+ test("detects multiple absolute paths outside CWD", () => {
32
+ const result = extractExternalPathsFromBashCommand(
33
+ "diff /etc/hosts /var/log/syslog",
34
+ cwd,
35
+ );
36
+ expect(result).toContain("/etc/hosts");
37
+ expect(result).toContain("/var/log/syslog");
38
+ });
39
+
40
+ test("does not flag absolute path within CWD", () => {
41
+ const result = extractExternalPathsFromBashCommand(
42
+ "cat /projects/my-app/src/index.ts",
43
+ cwd,
44
+ );
45
+ expect(result).toHaveLength(0);
46
+ });
47
+ });
48
+
49
+ describe("home-relative paths", () => {
50
+ test("detects ~/path outside CWD", () => {
51
+ const result = extractExternalPathsFromBashCommand(
52
+ "cat ~/documents/secret.txt",
53
+ cwd,
54
+ );
55
+ expect(result).toContain("/mock/home/documents/secret.txt");
56
+ });
57
+
58
+ test("does not flag ~/path that resolves within CWD", () => {
59
+ // CWD is under /mock/home for this test
60
+ const result = extractExternalPathsFromBashCommand(
61
+ "cat ~/myproject/file.ts",
62
+ "/mock/home/myproject",
63
+ );
64
+ expect(result).toHaveLength(0);
65
+ });
66
+ });
67
+
68
+ describe("dot-dot relative paths", () => {
69
+ test("detects ../ path that resolves outside CWD", () => {
70
+ const result = extractExternalPathsFromBashCommand(
71
+ "cat ../../other-project/secrets.env",
72
+ cwd,
73
+ );
74
+ expect(result).toContain("/other-project/secrets.env");
75
+ });
76
+
77
+ test("does not flag ../ path that stays within CWD", () => {
78
+ const result = extractExternalPathsFromBashCommand(
79
+ "cat src/../lib/utils.ts",
80
+ cwd,
81
+ );
82
+ expect(result).toHaveLength(0);
83
+ });
84
+ });
85
+
86
+ describe("commands within CWD only", () => {
87
+ test("returns empty for relative paths within CWD", () => {
88
+ const result = extractExternalPathsFromBashCommand(
89
+ "cat src/index.ts",
90
+ cwd,
91
+ );
92
+ expect(result).toHaveLength(0);
93
+ });
94
+
95
+ test("returns empty for bare command with no path arguments", () => {
96
+ const result = extractExternalPathsFromBashCommand("git status", cwd);
97
+ expect(result).toHaveLength(0);
98
+ });
99
+ });
100
+
101
+ describe("flags are skipped", () => {
102
+ test("does not treat flags as paths", () => {
103
+ const result = extractExternalPathsFromBashCommand(
104
+ "ls -la --color=auto",
105
+ cwd,
106
+ );
107
+ expect(result).toHaveLength(0);
108
+ });
109
+
110
+ test("detects path after flags", () => {
111
+ const result = extractExternalPathsFromBashCommand(
112
+ "ls -la /etc/passwd",
113
+ cwd,
114
+ );
115
+ expect(result).toContain("/etc/passwd");
116
+ });
117
+ });
118
+
119
+ describe("env assignments are skipped", () => {
120
+ test("does not treat FOO=/bar as a path", () => {
121
+ const result = extractExternalPathsFromBashCommand(
122
+ "FOO=/usr/local/bin command",
123
+ cwd,
124
+ );
125
+ expect(result).toHaveLength(0);
126
+ });
127
+ });
128
+
129
+ describe("shell metacharacters split correctly", () => {
130
+ test("detects path after pipe", () => {
131
+ const result = extractExternalPathsFromBashCommand(
132
+ "echo hello | tee /tmp/output.txt",
133
+ cwd,
134
+ );
135
+ expect(result).toContain("/tmp/output.txt");
136
+ });
137
+
138
+ test("detects path after semicolon", () => {
139
+ const result = extractExternalPathsFromBashCommand(
140
+ "echo done; cat /etc/hosts",
141
+ cwd,
142
+ );
143
+ expect(result).toContain("/etc/hosts");
144
+ });
145
+
146
+ test("detects path after &&", () => {
147
+ const result = extractExternalPathsFromBashCommand(
148
+ "true && cat /etc/hosts",
149
+ cwd,
150
+ );
151
+ expect(result).toContain("/etc/hosts");
152
+ });
153
+
154
+ test("detects path in redirect target", () => {
155
+ const result = extractExternalPathsFromBashCommand(
156
+ "echo hello > /tmp/out.txt",
157
+ cwd,
158
+ );
159
+ expect(result).toContain("/tmp/out.txt");
160
+ });
161
+ });
162
+
163
+ describe("URLs are skipped", () => {
164
+ test("does not treat http:// URL as a path", () => {
165
+ const result = extractExternalPathsFromBashCommand(
166
+ "curl http://example.com/path",
167
+ cwd,
168
+ );
169
+ expect(result).toHaveLength(0);
170
+ });
171
+
172
+ test("does not treat https:// URL as a path", () => {
173
+ const result = extractExternalPathsFromBashCommand(
174
+ "curl https://example.com/etc/hosts",
175
+ cwd,
176
+ );
177
+ expect(result).toHaveLength(0);
178
+ });
179
+ });
180
+
181
+ describe("@scope/package patterns are skipped", () => {
182
+ test("does not treat @scope/package as a path", () => {
183
+ const result = extractExternalPathsFromBashCommand(
184
+ "npm install @types/node",
185
+ cwd,
186
+ );
187
+ expect(result).toHaveLength(0);
188
+ });
189
+ });
190
+
191
+ describe("quoted strings are ignored", () => {
192
+ test("does not flag path inside double-quoted string", () => {
193
+ const result = extractExternalPathsFromBashCommand(
194
+ 'git commit -m "fix: update /etc/hosts handler"',
195
+ cwd,
196
+ );
197
+ expect(result).toHaveLength(0);
198
+ });
199
+
200
+ test("does not flag path inside single-quoted string", () => {
201
+ const result = extractExternalPathsFromBashCommand(
202
+ "echo 'see /usr/local/docs for info'",
203
+ cwd,
204
+ );
205
+ expect(result).toHaveLength(0);
206
+ });
207
+
208
+ test("still flags unquoted path alongside quoted content", () => {
209
+ const result = extractExternalPathsFromBashCommand(
210
+ 'cat /etc/hosts && echo "done"',
211
+ cwd,
212
+ );
213
+ expect(result).toContain("/etc/hosts");
214
+ });
215
+
216
+ test.fails("escaped quotes inside strings cause false positive (known limitation)", () => {
217
+ // The regex-based quote stripping can't handle escaped quotes
218
+ const result = extractExternalPathsFromBashCommand(
219
+ 'echo "path is "/etc/hosts""',
220
+ cwd,
221
+ );
222
+ expect(result).toHaveLength(0);
223
+ });
224
+ });
225
+
226
+ describe("deduplication", () => {
227
+ test("returns deduplicated paths", () => {
228
+ const result = extractExternalPathsFromBashCommand(
229
+ "cat /etc/hosts; grep foo /etc/hosts",
230
+ cwd,
231
+ );
232
+ const etcHostsCount = result.filter((p) => p === "/etc/hosts").length;
233
+ expect(etcHostsCount).toBe(1);
234
+ });
235
+ });
236
+ });
237
+
238
+ describe("formatBashExternalDirectoryAskPrompt", () => {
239
+ test("includes command, external paths, and CWD", () => {
240
+ const result = formatBashExternalDirectoryAskPrompt(
241
+ "cat /etc/hosts",
242
+ ["/etc/hosts"],
243
+ "/projects/my-app",
244
+ );
245
+ expect(result).toContain("cat /etc/hosts");
246
+ expect(result).toContain("/etc/hosts");
247
+ expect(result).toContain("/projects/my-app");
248
+ });
249
+
250
+ test("includes agent name when provided", () => {
251
+ const result = formatBashExternalDirectoryAskPrompt(
252
+ "cat /etc/hosts",
253
+ ["/etc/hosts"],
254
+ "/projects/my-app",
255
+ "my-agent",
256
+ );
257
+ expect(result).toContain("my-agent");
258
+ });
259
+
260
+ test("shows multiple external paths", () => {
261
+ const result = formatBashExternalDirectoryAskPrompt(
262
+ "diff /etc/hosts /var/log/syslog",
263
+ ["/etc/hosts", "/var/log/syslog"],
264
+ "/projects/my-app",
265
+ );
266
+ expect(result).toContain("/etc/hosts");
267
+ expect(result).toContain("/var/log/syslog");
268
+ });
269
+ });
270
+
271
+ describe("formatBashExternalDirectoryDenyReason", () => {
272
+ test("includes command, external paths, and CWD", () => {
273
+ const result = formatBashExternalDirectoryDenyReason(
274
+ "cat /etc/hosts",
275
+ ["/etc/hosts"],
276
+ "/projects/my-app",
277
+ );
278
+ expect(result).toContain("cat /etc/hosts");
279
+ expect(result).toContain("/etc/hosts");
280
+ expect(result).toContain("/projects/my-app");
281
+ });
282
+
283
+ test("includes hard stop hint", () => {
284
+ const result = formatBashExternalDirectoryDenyReason(
285
+ "cat /etc/hosts",
286
+ ["/etc/hosts"],
287
+ "/projects/my-app",
288
+ );
289
+ expect(result).toContain("Hard stop");
290
+ });
291
+
292
+ test("includes agent name when provided", () => {
293
+ const result = formatBashExternalDirectoryDenyReason(
294
+ "cat /etc/hosts",
295
+ ["/etc/hosts"],
296
+ "/projects/my-app",
297
+ "my-agent",
298
+ );
299
+ expect(result).toContain("my-agent");
300
+ });
301
+ });
@@ -2313,6 +2313,162 @@ test("tool_call skips external_directory checks for optional path tools without
2313
2313
  }
2314
2314
  });
2315
2315
 
2316
+ // --- bash external_directory integration tests (#39) ---
2317
+
2318
+ test("tool_call blocks bash command with external path when external_directory is denied", async () => {
2319
+ const harness = createToolCallHarness(
2320
+ {
2321
+ defaultPolicy: {
2322
+ tools: "allow",
2323
+ bash: "allow",
2324
+ mcp: "allow",
2325
+ skills: "allow",
2326
+ special: "ask",
2327
+ },
2328
+ special: { external_directory: "deny" },
2329
+ },
2330
+ ["bash"],
2331
+ );
2332
+
2333
+ try {
2334
+ const result = await runToolCall(harness, {
2335
+ toolName: "bash",
2336
+ toolCallId: "bash-external-deny",
2337
+ input: { command: "cat /etc/hosts" },
2338
+ });
2339
+
2340
+ assert.equal(result.block, true);
2341
+ assert.match(
2342
+ String(result.reason),
2343
+ /external directory permission denial/i,
2344
+ );
2345
+ assert.match(String(result.reason), /\/etc\/hosts/);
2346
+ } finally {
2347
+ await harness.cleanup();
2348
+ }
2349
+ });
2350
+
2351
+ test("tool_call allows bash command with only internal paths when external_directory is denied", async () => {
2352
+ const harness = createToolCallHarness(
2353
+ {
2354
+ defaultPolicy: {
2355
+ tools: "allow",
2356
+ bash: "allow",
2357
+ mcp: "allow",
2358
+ skills: "allow",
2359
+ special: "ask",
2360
+ },
2361
+ special: { external_directory: "deny" },
2362
+ },
2363
+ ["bash"],
2364
+ );
2365
+
2366
+ try {
2367
+ const result = await runToolCall(harness, {
2368
+ toolName: "bash",
2369
+ toolCallId: "bash-internal-allow",
2370
+ input: { command: "cat src/index.ts" },
2371
+ });
2372
+
2373
+ assert.deepEqual(result, {});
2374
+ } finally {
2375
+ await harness.cleanup();
2376
+ }
2377
+ });
2378
+
2379
+ test("tool_call prompts for bash command with external path when external_directory is ask", async () => {
2380
+ const harness = createToolCallHarness(
2381
+ {
2382
+ defaultPolicy: {
2383
+ tools: "allow",
2384
+ bash: "allow",
2385
+ mcp: "allow",
2386
+ skills: "allow",
2387
+ special: "ask",
2388
+ },
2389
+ special: { external_directory: "ask" },
2390
+ },
2391
+ ["bash"],
2392
+ );
2393
+
2394
+ try {
2395
+ const result = await runToolCall(harness, {
2396
+ toolName: "bash",
2397
+ toolCallId: "bash-external-ask-no-ui",
2398
+ input: { command: "cat /etc/hosts" },
2399
+ });
2400
+
2401
+ // No UI available in default harness, so it should block
2402
+ assert.equal(result.block, true);
2403
+ assert.match(
2404
+ String(result.reason),
2405
+ /requires approval.*no interactive UI/i,
2406
+ );
2407
+ } finally {
2408
+ await harness.cleanup();
2409
+ }
2410
+ });
2411
+
2412
+ test("tool_call allows bash command with external path when external_directory is allow", async () => {
2413
+ const harness = createToolCallHarness(
2414
+ {
2415
+ defaultPolicy: {
2416
+ tools: "allow",
2417
+ bash: "allow",
2418
+ mcp: "allow",
2419
+ skills: "allow",
2420
+ special: "ask",
2421
+ },
2422
+ special: { external_directory: "allow" },
2423
+ },
2424
+ ["bash"],
2425
+ );
2426
+
2427
+ try {
2428
+ const result = await runToolCall(harness, {
2429
+ toolName: "bash",
2430
+ toolCallId: "bash-external-allow",
2431
+ input: { command: "cat /etc/hosts" },
2432
+ });
2433
+
2434
+ // Should pass through to normal bash permission (which is also allow)
2435
+ assert.deepEqual(result, {});
2436
+ } finally {
2437
+ await harness.cleanup();
2438
+ }
2439
+ });
2440
+
2441
+ test("tool_call applies bash pattern permissions after external_directory allow", async () => {
2442
+ const harness = createToolCallHarness(
2443
+ {
2444
+ defaultPolicy: {
2445
+ tools: "allow",
2446
+ bash: "allow",
2447
+ mcp: "allow",
2448
+ skills: "allow",
2449
+ special: "ask",
2450
+ },
2451
+ special: { external_directory: "allow" },
2452
+ bash: { "cat *": "deny" },
2453
+ },
2454
+ ["bash"],
2455
+ );
2456
+
2457
+ try {
2458
+ const result = await runToolCall(harness, {
2459
+ toolName: "bash",
2460
+ toolCallId: "bash-pattern-deny-after-ext-allow",
2461
+ input: { command: "cat /etc/hosts" },
2462
+ });
2463
+
2464
+ // external_directory allows, but bash pattern denies
2465
+ assert.equal(result.block, true);
2466
+ assert.match(String(result.reason), /not permitted/i);
2467
+ } finally {
2468
+ await harness.cleanup();
2469
+ }
2470
+ });
2471
+
2316
2472
  test("generic ask prompts include serialized tool input for informed approval", async () => {
2317
2473
  const harness = createToolCallHarness(
2318
2474
  {