@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 +22 -0
- package/README.md +14 -11
- package/package.json +20 -25
- package/src/external-directory.ts +102 -0
- package/src/index.ts +89 -1
- package/tests/bash-external-directory.test.ts +301 -0
- package/tests/permission-system.test.ts +156 -0
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
|
|
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
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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 `
|
|
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
|
|
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-
|
|
71
|
-
"@mariozechner/pi-
|
|
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-
|
|
78
|
-
"@mariozechner/pi-
|
|
79
|
-
"@
|
|
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
|
{
|