@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.
- package/CHANGELOG.md +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,956 @@
|
|
|
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
|
+
// Mock node:fs with an identity realpathSync so canonicalizePath
|
|
13
|
+
// (used by BashProgram.externalPaths) leaves test paths unchanged and
|
|
14
|
+
// existing expected-value literals remain accurate across platforms.
|
|
15
|
+
vi.mock("node:fs", () => ({
|
|
16
|
+
realpathSync: (p: string) => p,
|
|
17
|
+
default: { realpathSync: (p: string) => p },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { formatDenyReason } from "#src/denial-messages";
|
|
21
|
+
import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
|
|
22
|
+
import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("extractExternalPathsFromBashCommand", () => {
|
|
29
|
+
const cwd = "/projects/my-app";
|
|
30
|
+
|
|
31
|
+
describe("absolute paths", () => {
|
|
32
|
+
test("detects absolute path outside CWD", async () => {
|
|
33
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
34
|
+
"cat /etc/hosts",
|
|
35
|
+
cwd,
|
|
36
|
+
);
|
|
37
|
+
expect(result).toContain("/etc/hosts");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("detects multiple absolute paths outside CWD", async () => {
|
|
41
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
42
|
+
"diff /etc/hosts /var/log/syslog",
|
|
43
|
+
cwd,
|
|
44
|
+
);
|
|
45
|
+
expect(result).toContain("/etc/hosts");
|
|
46
|
+
expect(result).toContain("/var/log/syslog");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not flag absolute path within CWD", async () => {
|
|
50
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
51
|
+
"cat /projects/my-app/src/index.ts",
|
|
52
|
+
cwd,
|
|
53
|
+
);
|
|
54
|
+
expect(result).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("home-relative paths", () => {
|
|
59
|
+
test("detects ~/path outside CWD", async () => {
|
|
60
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
61
|
+
"cat ~/documents/secret.txt",
|
|
62
|
+
cwd,
|
|
63
|
+
);
|
|
64
|
+
expect(result).toContain("/mock/home/documents/secret.txt");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("does not flag ~/path that resolves within CWD", async () => {
|
|
68
|
+
// CWD is under /mock/home for this test
|
|
69
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
70
|
+
"cat ~/myproject/file.ts",
|
|
71
|
+
"/mock/home/myproject",
|
|
72
|
+
);
|
|
73
|
+
expect(result).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("dot-dot relative paths", () => {
|
|
78
|
+
test("detects ../ path that resolves outside CWD", async () => {
|
|
79
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
80
|
+
"cat ../../other-project/secrets.env",
|
|
81
|
+
cwd,
|
|
82
|
+
);
|
|
83
|
+
expect(result).toContain("/other-project/secrets.env");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("does not flag ../ path that stays within CWD", async () => {
|
|
87
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
88
|
+
"cat src/../lib/utils.ts",
|
|
89
|
+
cwd,
|
|
90
|
+
);
|
|
91
|
+
expect(result).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("commands within CWD only", () => {
|
|
96
|
+
test("returns empty for relative paths within CWD", async () => {
|
|
97
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
98
|
+
"cat src/index.ts",
|
|
99
|
+
cwd,
|
|
100
|
+
);
|
|
101
|
+
expect(result).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns empty for bare command with no path arguments", async () => {
|
|
105
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
106
|
+
"git status",
|
|
107
|
+
cwd,
|
|
108
|
+
);
|
|
109
|
+
expect(result).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("flags are skipped", () => {
|
|
114
|
+
test("does not treat flags as paths", async () => {
|
|
115
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
116
|
+
"ls -la --color=auto",
|
|
117
|
+
cwd,
|
|
118
|
+
);
|
|
119
|
+
expect(result).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("detects path after flags", async () => {
|
|
123
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
124
|
+
"ls -la /etc/passwd",
|
|
125
|
+
cwd,
|
|
126
|
+
);
|
|
127
|
+
expect(result).toContain("/etc/passwd");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("env assignments are skipped", () => {
|
|
132
|
+
test("does not treat FOO=/bar as a path", async () => {
|
|
133
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
134
|
+
"FOO=/usr/local/bin command",
|
|
135
|
+
cwd,
|
|
136
|
+
);
|
|
137
|
+
expect(result).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("shell metacharacters split correctly", () => {
|
|
142
|
+
test("detects path after pipe", async () => {
|
|
143
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
144
|
+
"echo hello | tee /tmp/output.txt",
|
|
145
|
+
cwd,
|
|
146
|
+
);
|
|
147
|
+
expect(result).toContain("/tmp/output.txt");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("detects path after semicolon", async () => {
|
|
151
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
152
|
+
"echo done; cat /etc/hosts",
|
|
153
|
+
cwd,
|
|
154
|
+
);
|
|
155
|
+
expect(result).toContain("/etc/hosts");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("detects path after &&", async () => {
|
|
159
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
160
|
+
"true && cat /etc/hosts",
|
|
161
|
+
cwd,
|
|
162
|
+
);
|
|
163
|
+
expect(result).toContain("/etc/hosts");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("detects path in redirect target", async () => {
|
|
167
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
168
|
+
"echo hello > /tmp/out.txt",
|
|
169
|
+
cwd,
|
|
170
|
+
);
|
|
171
|
+
expect(result).toContain("/tmp/out.txt");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("URLs are skipped", () => {
|
|
176
|
+
test("does not treat http:// URL as a path", async () => {
|
|
177
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
178
|
+
"curl http://example.com/path",
|
|
179
|
+
cwd,
|
|
180
|
+
);
|
|
181
|
+
expect(result).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("does not treat https:// URL as a path", async () => {
|
|
185
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
186
|
+
"curl https://example.com/etc/hosts",
|
|
187
|
+
cwd,
|
|
188
|
+
);
|
|
189
|
+
expect(result).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("@scope/package patterns are skipped", () => {
|
|
194
|
+
test("does not treat @scope/package as a path", async () => {
|
|
195
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
196
|
+
"npm install @types/node",
|
|
197
|
+
cwd,
|
|
198
|
+
);
|
|
199
|
+
expect(result).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("quoted strings are ignored", () => {
|
|
204
|
+
test("does not flag path inside double-quoted string", async () => {
|
|
205
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
206
|
+
'git commit -m "fix: update /etc/hosts handler"',
|
|
207
|
+
cwd,
|
|
208
|
+
);
|
|
209
|
+
expect(result).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("does not flag path inside single-quoted string", async () => {
|
|
213
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
214
|
+
"echo 'see /usr/local/docs for info'",
|
|
215
|
+
cwd,
|
|
216
|
+
);
|
|
217
|
+
expect(result).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("still flags unquoted path alongside quoted content", async () => {
|
|
221
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
222
|
+
'cat /etc/hosts && echo "done"',
|
|
223
|
+
cwd,
|
|
224
|
+
);
|
|
225
|
+
expect(result).toContain("/etc/hosts");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("does not flag path when adjacent quoted segments form one word", async () => {
|
|
229
|
+
// tree-sitter parses adjacent quoted/unquoted segments as a concatenation node
|
|
230
|
+
// whose resolved text is 'path is /etc/hosts' (one token, not a path candidate).
|
|
231
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
232
|
+
'echo "path is "/etc/hosts""',
|
|
233
|
+
cwd,
|
|
234
|
+
);
|
|
235
|
+
expect(result).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("safe system paths are filtered", () => {
|
|
240
|
+
test("does not flag /dev/null in stderr redirect", async () => {
|
|
241
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
242
|
+
"command 2>/dev/null",
|
|
243
|
+
cwd,
|
|
244
|
+
);
|
|
245
|
+
expect(result).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("does not flag /dev/null as a redirect target", async () => {
|
|
249
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
250
|
+
"echo hello > /dev/null",
|
|
251
|
+
cwd,
|
|
252
|
+
);
|
|
253
|
+
expect(result).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("does not flag /dev/stdin", async () => {
|
|
257
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
258
|
+
"cat /dev/stdin",
|
|
259
|
+
cwd,
|
|
260
|
+
);
|
|
261
|
+
expect(result).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("does not flag /dev/stdout", async () => {
|
|
265
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
266
|
+
"cat /dev/stdout",
|
|
267
|
+
cwd,
|
|
268
|
+
);
|
|
269
|
+
expect(result).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("does not flag /dev/stderr", async () => {
|
|
273
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
274
|
+
"cat /dev/stderr",
|
|
275
|
+
cwd,
|
|
276
|
+
);
|
|
277
|
+
expect(result).toHaveLength(0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("still flags a real external path alongside /dev/null", async () => {
|
|
281
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
282
|
+
"cat /etc/hosts 2>/dev/null",
|
|
283
|
+
cwd,
|
|
284
|
+
);
|
|
285
|
+
expect(result).toContain("/etc/hosts");
|
|
286
|
+
expect(result).not.toContain("/dev/null");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("does not flag /dev/null/subdir (not a safe path)", async () => {
|
|
290
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
291
|
+
"cat /dev/null/subdir",
|
|
292
|
+
cwd,
|
|
293
|
+
);
|
|
294
|
+
expect(result).toContain("/dev/null/subdir");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("bare-slash tokens are skipped", () => {
|
|
299
|
+
test("does not flag // token", async () => {
|
|
300
|
+
const result = await extractExternalPathsFromBashCommand("echo //", cwd);
|
|
301
|
+
expect(result).toHaveLength(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("does not flag / token", async () => {
|
|
305
|
+
const result = await extractExternalPathsFromBashCommand("echo /", cwd);
|
|
306
|
+
expect(result).toHaveLength(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("does not flag /// token", async () => {
|
|
310
|
+
const result = await extractExternalPathsFromBashCommand("echo ///", cwd);
|
|
311
|
+
expect(result).toHaveLength(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("does not flag // in echo with other args", async () => {
|
|
315
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
316
|
+
"echo // hello",
|
|
317
|
+
cwd,
|
|
318
|
+
);
|
|
319
|
+
expect(result).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("bare-slash guard is still needed: tree-sitter emits / as a word node", async () => {
|
|
323
|
+
// tree-sitter parses 'echo /' with '/' as a word argument node.
|
|
324
|
+
// classifyTokenAsPathCandidate must still reject it.
|
|
325
|
+
// This test documents that the /^\/+$/ guard remains a necessary
|
|
326
|
+
// defense-in-depth layer even with tree-sitter as the parser.
|
|
327
|
+
const result = await extractExternalPathsFromBashCommand("echo /", cwd);
|
|
328
|
+
expect(result).toHaveLength(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("bare double-slash guard with tree-sitter", async () => {
|
|
332
|
+
// tree-sitter also emits '//' as a word node — guard must reject it.
|
|
333
|
+
const result = await extractExternalPathsFromBashCommand("echo //", cwd);
|
|
334
|
+
expect(result).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("still flags real external path alongside //", async () => {
|
|
338
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
339
|
+
"cat /etc/hosts; echo //",
|
|
340
|
+
cwd,
|
|
341
|
+
);
|
|
342
|
+
expect(result).toContain("/etc/hosts");
|
|
343
|
+
expect(result).toHaveLength(1);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("node -e and multi-line commands", () => {
|
|
348
|
+
test("does not flag path inside single-quoted string in node -e argument", async () => {
|
|
349
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
350
|
+
"node -e \"const p = '/etc/hosts'; console.log(p);\"",
|
|
351
|
+
cwd,
|
|
352
|
+
);
|
|
353
|
+
expect(result).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("does not flag path inside multi-line node -e argument", async () => {
|
|
357
|
+
// Actual newlines inside the double-quoted -e argument.
|
|
358
|
+
const cmd =
|
|
359
|
+
"node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\"";
|
|
360
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
361
|
+
expect(result).toHaveLength(0);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("does not flag path that appears after escaped quote in multi-line node -e argument", async () => {
|
|
365
|
+
// This is the shape of the command that triggered a prompt during dog-fooding.
|
|
366
|
+
// The outer \"...\" arg contains both actual newlines and \\" escape sequences,
|
|
367
|
+
// with /etc/hosts appearing after a \\" boundary.
|
|
368
|
+
const cmd = [
|
|
369
|
+
'node -e "',
|
|
370
|
+
"import('shell-quote').then(({ parse }) => {",
|
|
371
|
+
" const cmd = \\\"cat << 'EOF'\\n/etc/hosts\\nsome content\\nEOF\\\";",
|
|
372
|
+
" console.log(JSON.stringify(parse(cmd)));",
|
|
373
|
+
"});",
|
|
374
|
+
'"',
|
|
375
|
+
].join("\n");
|
|
376
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
377
|
+
expect(result).toHaveLength(0);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("tokenizer edge cases", () => {
|
|
382
|
+
test("does not flag path inside string when escaped quote is present", async () => {
|
|
383
|
+
// tree-sitter correctly parses the escaped quote and keeps the path inside the string.
|
|
384
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
385
|
+
'git commit -m "fix: update \\"the /etc/hosts\\" handler"',
|
|
386
|
+
cwd,
|
|
387
|
+
);
|
|
388
|
+
expect(result).toHaveLength(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("does not flag path appearing only in a shell comment", async () => {
|
|
392
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
393
|
+
"echo hello # /etc/shadow",
|
|
394
|
+
cwd,
|
|
395
|
+
);
|
|
396
|
+
expect(result).toHaveLength(0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("flags real path before comment but not path inside comment", async () => {
|
|
400
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
401
|
+
"cat /etc/hosts # see also /etc/shadow",
|
|
402
|
+
cwd,
|
|
403
|
+
);
|
|
404
|
+
expect(result).toContain("/etc/hosts");
|
|
405
|
+
expect(result).not.toContain("/etc/shadow");
|
|
406
|
+
expect(result).toHaveLength(1);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("heredoc handling", () => {
|
|
411
|
+
test("does not flag path inside single-quoted heredoc delimiter", async () => {
|
|
412
|
+
const cmd = "cat << 'EOF'\n/etc/hosts\nEOF";
|
|
413
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
414
|
+
expect(result).toHaveLength(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("does not flag path inside double-quoted heredoc delimiter", async () => {
|
|
418
|
+
const cmd = 'cat << "EOF"\n/etc/hosts\nEOF';
|
|
419
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
420
|
+
expect(result).toHaveLength(0);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("does not flag path inside unquoted heredoc delimiter", async () => {
|
|
424
|
+
const cmd = "cat << EOF\n/etc/hosts\nEOF";
|
|
425
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
426
|
+
expect(result).toHaveLength(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("flags real path alongside heredoc but not heredoc content", async () => {
|
|
430
|
+
const cmd = "cat /etc/hosts << 'EOF'\nsome content\nEOF";
|
|
431
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
432
|
+
expect(result).toContain("/etc/hosts");
|
|
433
|
+
expect(result).toHaveLength(1);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("does not flag path inside indented heredoc (<<-)", async () => {
|
|
437
|
+
const cmd = "cat <<- 'EOF'\n\t/etc/hosts\nEOF";
|
|
438
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
439
|
+
expect(result).toHaveLength(0);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("defense-in-depth guards with tree-sitter", () => {
|
|
444
|
+
test("env assignment is a variable_assignment node, not a command argument", async () => {
|
|
445
|
+
// tree-sitter parses FOO=/usr/local/bin as a variable_assignment node.
|
|
446
|
+
// The walker skips variable_assignment, so the env-assignment guard in
|
|
447
|
+
// classifyTokenAsPathCandidate is defense-in-depth.
|
|
448
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
449
|
+
"FOO=/usr/local/bin command",
|
|
450
|
+
cwd,
|
|
451
|
+
);
|
|
452
|
+
expect(result).toHaveLength(0);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("URL is a word argument, classifyTokenAsPathCandidate rejects it", async () => {
|
|
456
|
+
// tree-sitter emits the URL as a plain word argument.
|
|
457
|
+
// classifyTokenAsPathCandidate's URL pattern must still reject it.
|
|
458
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
459
|
+
"curl https://example.com/etc/hosts",
|
|
460
|
+
cwd,
|
|
461
|
+
);
|
|
462
|
+
expect(result).toHaveLength(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("flag arguments are word nodes, classifyTokenAsPathCandidate rejects them", async () => {
|
|
466
|
+
// tree-sitter emits '-la' as a word argument.
|
|
467
|
+
// classifyTokenAsPathCandidate's flag check must still reject it.
|
|
468
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
469
|
+
"ls -la --color=auto",
|
|
470
|
+
cwd,
|
|
471
|
+
);
|
|
472
|
+
expect(result).toHaveLength(0);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("command substitution", () => {
|
|
477
|
+
test("detects path inside command substitution", async () => {
|
|
478
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
479
|
+
"echo $(cat /etc/hosts)",
|
|
480
|
+
cwd,
|
|
481
|
+
);
|
|
482
|
+
expect(result).toContain("/etc/hosts");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("detects path inside nested command substitution", async () => {
|
|
486
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
487
|
+
"echo $(echo $(cat /etc/hosts))",
|
|
488
|
+
cwd,
|
|
489
|
+
);
|
|
490
|
+
expect(result).toContain("/etc/hosts");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("does not flag command substitution inside single-quoted heredoc", async () => {
|
|
494
|
+
// Single-quoted heredoc delimiter prevents expansion — content is literal.
|
|
495
|
+
const cmd = "cat << 'EOF'\n$(cat /etc/hosts)\nEOF";
|
|
496
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
497
|
+
expect(result).toHaveLength(0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("detects path in subshell", async () => {
|
|
501
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
502
|
+
"(cat /etc/hosts)",
|
|
503
|
+
cwd,
|
|
504
|
+
);
|
|
505
|
+
expect(result).toContain("/etc/hosts");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("redirect targets", () => {
|
|
510
|
+
test("detects path in output redirect", async () => {
|
|
511
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
512
|
+
"echo hello > /tmp/out.txt",
|
|
513
|
+
cwd,
|
|
514
|
+
);
|
|
515
|
+
expect(result).toContain("/tmp/out.txt");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("detects path in append redirect", async () => {
|
|
519
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
520
|
+
"echo hello >> /tmp/out.txt",
|
|
521
|
+
cwd,
|
|
522
|
+
);
|
|
523
|
+
expect(result).toContain("/tmp/out.txt");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("detects path in input redirect", async () => {
|
|
527
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
528
|
+
"sort < /etc/hosts",
|
|
529
|
+
cwd,
|
|
530
|
+
);
|
|
531
|
+
expect(result).toContain("/etc/hosts");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("detects path in stderr redirect", async () => {
|
|
535
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
536
|
+
"command 2>/tmp/errors.log",
|
|
537
|
+
cwd,
|
|
538
|
+
);
|
|
539
|
+
expect(result).toContain("/tmp/errors.log");
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("deduplication", () => {
|
|
544
|
+
test("returns deduplicated paths", async () => {
|
|
545
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
546
|
+
"cat /etc/hosts; grep foo /etc/hosts",
|
|
547
|
+
cwd,
|
|
548
|
+
);
|
|
549
|
+
const etcHostsCount = result.filter((p) => p === "/etc/hosts").length;
|
|
550
|
+
expect(etcHostsCount).toBe(1);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("command-aware extraction", () => {
|
|
555
|
+
describe("sed", () => {
|
|
556
|
+
test("issue #91 reproducer: sed address pattern is not flagged", async () => {
|
|
557
|
+
const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`;
|
|
558
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
559
|
+
expect(result).toHaveLength(0);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("sed script is skipped but file argument is extracted", async () => {
|
|
563
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
564
|
+
"sed 's/foo/bar/g' /etc/hosts",
|
|
565
|
+
cwd,
|
|
566
|
+
);
|
|
567
|
+
expect(result).toContain("/etc/hosts");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("sed address pattern starting with / is skipped", async () => {
|
|
571
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
572
|
+
"sed '/pattern/d' /etc/hosts",
|
|
573
|
+
cwd,
|
|
574
|
+
);
|
|
575
|
+
expect(result).toContain("/etc/hosts");
|
|
576
|
+
expect(result).toHaveLength(1);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("sed with only in-CWD file returns empty", async () => {
|
|
580
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
581
|
+
"sed 's/foo/bar/' src/index.ts",
|
|
582
|
+
cwd,
|
|
583
|
+
);
|
|
584
|
+
expect(result).toHaveLength(0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("sed -e: script consumed by flag, file extracted", async () => {
|
|
588
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
589
|
+
"sed -e 's/foo/bar/' /etc/hosts",
|
|
590
|
+
cwd,
|
|
591
|
+
);
|
|
592
|
+
expect(result).toContain("/etc/hosts");
|
|
593
|
+
expect(result).toHaveLength(1);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("sed -n: regular flag does not consume next arg", async () => {
|
|
597
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
598
|
+
"sed -n '/pattern/p' /etc/hosts",
|
|
599
|
+
cwd,
|
|
600
|
+
);
|
|
601
|
+
expect(result).toContain("/etc/hosts");
|
|
602
|
+
expect(result).toHaveLength(1);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("sed -f: script file is extracted as path", async () => {
|
|
606
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
607
|
+
"sed -f /etc/sed-script.sed input.txt",
|
|
608
|
+
cwd,
|
|
609
|
+
);
|
|
610
|
+
expect(result).toContain("/etc/sed-script.sed");
|
|
611
|
+
expect(result).toHaveLength(1);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("sed -i '': extension consumed, script skipped, file extracted", async () => {
|
|
615
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
616
|
+
"sed -i '' 's/foo/bar/' /etc/hosts",
|
|
617
|
+
cwd,
|
|
618
|
+
);
|
|
619
|
+
expect(result).toContain("/etc/hosts");
|
|
620
|
+
expect(result).toHaveLength(1);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe("grep", () => {
|
|
625
|
+
test("grep: pattern skipped, file extracted", async () => {
|
|
626
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
627
|
+
"grep '/etc/' /var/log/syslog",
|
|
628
|
+
cwd,
|
|
629
|
+
);
|
|
630
|
+
expect(result).toContain("/var/log/syslog");
|
|
631
|
+
expect(result).toHaveLength(1);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("grep -e: pattern consumed by flag, file extracted", async () => {
|
|
635
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
636
|
+
"grep -e '/etc/' /var/log/syslog",
|
|
637
|
+
cwd,
|
|
638
|
+
);
|
|
639
|
+
expect(result).toContain("/var/log/syslog");
|
|
640
|
+
expect(result).toHaveLength(1);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe("awk", () => {
|
|
645
|
+
test("awk: program skipped, file extracted", async () => {
|
|
646
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
647
|
+
"awk '{print}' /etc/hosts",
|
|
648
|
+
cwd,
|
|
649
|
+
);
|
|
650
|
+
expect(result).toContain("/etc/hosts");
|
|
651
|
+
expect(result).toHaveLength(1);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("awk -F: separator consumed, program skipped, file extracted", async () => {
|
|
655
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
656
|
+
"awk -F: '{print $1}' /etc/passwd",
|
|
657
|
+
cwd,
|
|
658
|
+
);
|
|
659
|
+
expect(result).toContain("/etc/passwd");
|
|
660
|
+
expect(result).toHaveLength(1);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe("rg", () => {
|
|
665
|
+
test("rg: pattern skipped, path extracted", async () => {
|
|
666
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
667
|
+
"rg '/usr/local' /etc/profile.d/",
|
|
668
|
+
cwd,
|
|
669
|
+
);
|
|
670
|
+
expect(result).toContain("/etc/profile.d");
|
|
671
|
+
expect(result).toHaveLength(1);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("rg -e: pattern consumed by flag, path extracted", async () => {
|
|
675
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
676
|
+
"rg -e '/usr/local' /etc/profile.d/",
|
|
677
|
+
cwd,
|
|
678
|
+
);
|
|
679
|
+
expect(result).toContain("/etc/profile.d");
|
|
680
|
+
expect(result).toHaveLength(1);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
describe("sd", () => {
|
|
685
|
+
test("sd: both pattern positionals skipped, file extracted", async () => {
|
|
686
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
687
|
+
"sd '/usr/local/bin' '/opt/bin' /etc/profile",
|
|
688
|
+
cwd,
|
|
689
|
+
);
|
|
690
|
+
expect(result).toContain("/etc/profile");
|
|
691
|
+
expect(result).toHaveLength(1);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("sd with only in-CWD file returns empty", async () => {
|
|
695
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
696
|
+
"sd 'foo' 'bar' src/index.ts",
|
|
697
|
+
cwd,
|
|
698
|
+
);
|
|
699
|
+
expect(result).toHaveLength(0);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe("unknown commands", () => {
|
|
704
|
+
test("unknown command: all args go through generic extraction", async () => {
|
|
705
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
706
|
+
"some-tool /etc/hosts",
|
|
707
|
+
cwd,
|
|
708
|
+
);
|
|
709
|
+
expect(result).toContain("/etc/hosts");
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe("edge cases", () => {
|
|
714
|
+
test("full-path command invocation: /usr/bin/sed", async () => {
|
|
715
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
716
|
+
"/usr/bin/sed 's/foo/bar/' /etc/hosts",
|
|
717
|
+
cwd,
|
|
718
|
+
);
|
|
719
|
+
expect(result).toContain("/etc/hosts");
|
|
720
|
+
expect(result).toHaveLength(1);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("-- end-of-flags: all remaining args are positional files", async () => {
|
|
724
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
725
|
+
"grep -- '/etc/' /var/log/syslog",
|
|
726
|
+
cwd,
|
|
727
|
+
);
|
|
728
|
+
// After --, '/etc/' is the pattern positional, /var/log/syslog is a file
|
|
729
|
+
expect(result).toContain("/var/log/syslog");
|
|
730
|
+
expect(result).toHaveLength(1);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("redirect target still extracted for pattern-first command", async () => {
|
|
734
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
735
|
+
"sed 's/foo/bar/' input.txt > /tmp/output.txt",
|
|
736
|
+
cwd,
|
|
737
|
+
);
|
|
738
|
+
expect(result).toContain("/tmp/output.txt");
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("pipeline: sed piped to cat with external path", async () => {
|
|
742
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
743
|
+
"sed 's/foo/bar/' src/file.ts | cat /etc/hosts",
|
|
744
|
+
cwd,
|
|
745
|
+
);
|
|
746
|
+
expect(result).toContain("/etc/hosts");
|
|
747
|
+
expect(result).toHaveLength(1);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("command substitution inside pattern-first command", async () => {
|
|
751
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
752
|
+
"grep 'pattern' $(cat /etc/file-list)",
|
|
753
|
+
cwd,
|
|
754
|
+
);
|
|
755
|
+
// /etc/file-list is an argument to cat inside command substitution
|
|
756
|
+
expect(result).toContain("/etc/file-list");
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe("known limitations", () => {
|
|
761
|
+
test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => {
|
|
762
|
+
// GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is
|
|
763
|
+
// the inline script and /etc/hosts is the input file. Our logic
|
|
764
|
+
// treats -i as arg-consuming (correct for BSD sed -i ''), so it
|
|
765
|
+
// consumes the script as the -i extension and /etc/hosts becomes
|
|
766
|
+
// the first positional — which is skipped as the inline script.
|
|
767
|
+
// This is a known false negative. The bash permission gate still
|
|
768
|
+
// applies, so external access is not silently allowed.
|
|
769
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
770
|
+
"sed -i 's/foo/bar/' /etc/hosts",
|
|
771
|
+
cwd,
|
|
772
|
+
);
|
|
773
|
+
// Ideally this would detect /etc/hosts, but position tracking
|
|
774
|
+
// treats it as the inline script. Assert current behavior so
|
|
775
|
+
// a future fix can flip this expectation.
|
|
776
|
+
expect(result).toHaveLength(0);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe("regex patterns are not mistaken for paths", () => {
|
|
782
|
+
test("grep -v with //.*pattern is not flagged", async () => {
|
|
783
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
784
|
+
'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30',
|
|
785
|
+
cwd,
|
|
786
|
+
);
|
|
787
|
+
expect(result).toHaveLength(0);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => {
|
|
791
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
792
|
+
'grep -v "//.*foo" file.txt',
|
|
793
|
+
cwd,
|
|
794
|
+
);
|
|
795
|
+
expect(result).toHaveLength(0);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("grep with backslash-pipe alternation is not flagged", async () => {
|
|
799
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
800
|
+
'grep "foo\\|bar\\|baz" src/file.ts',
|
|
801
|
+
cwd,
|
|
802
|
+
);
|
|
803
|
+
expect(result).toHaveLength(0);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("grep -E with ^/ anchored regex is not flagged", async () => {
|
|
807
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
808
|
+
'grep -E "^/usr/bin" file.txt',
|
|
809
|
+
cwd,
|
|
810
|
+
);
|
|
811
|
+
expect(result).toHaveLength(0);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("sed with regex containing slashes is not flagged", async () => {
|
|
815
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
816
|
+
'sed "s/foo.*/bar/g" file.txt',
|
|
817
|
+
cwd,
|
|
818
|
+
);
|
|
819
|
+
expect(result).toHaveLength(0);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("real external paths are still detected alongside regex args", async () => {
|
|
823
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
824
|
+
'grep -v "//.*pattern" /etc/hosts',
|
|
825
|
+
cwd,
|
|
826
|
+
);
|
|
827
|
+
expect(result).toContain("/etc/hosts");
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
describe("leading cd prefix", () => {
|
|
832
|
+
test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => {
|
|
833
|
+
// Real-world command that triggered a false-positive external-directory
|
|
834
|
+
// prompt. The relative path .pi/../../../.pi/skills/... resolves inside
|
|
835
|
+
// cwd when resolved from the cd target, but outside cwd when resolved
|
|
836
|
+
// from cwd itself.
|
|
837
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
838
|
+
'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
839
|
+
cwd,
|
|
840
|
+
);
|
|
841
|
+
expect(result).toHaveLength(0);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("cd to subdir: still flags genuinely external paths after cd", async () => {
|
|
845
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
846
|
+
"cd /projects/my-app/packages/sub && cat /etc/hosts",
|
|
847
|
+
cwd,
|
|
848
|
+
);
|
|
849
|
+
expect(result).toContain("/etc/hosts");
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("cd to subdir: relative path that stays inside cwd is not flagged", async () => {
|
|
853
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
854
|
+
"cd /projects/my-app/src && cat ../README.md",
|
|
855
|
+
cwd,
|
|
856
|
+
);
|
|
857
|
+
expect(result).toHaveLength(0);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => {
|
|
861
|
+
// The effective directory is tracked faithfully: `cd /tmp` makes /tmp the
|
|
862
|
+
// base, so the cd target itself is flagged AND ../etc/hosts resolves to
|
|
863
|
+
// /etc/hosts (both outside cwd).
|
|
864
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
865
|
+
"cd /tmp && cat ../etc/hosts",
|
|
866
|
+
cwd,
|
|
867
|
+
);
|
|
868
|
+
expect(result).toContain("/tmp");
|
|
869
|
+
expect(result).toContain("/etc/hosts");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("cd with relative target: resolves inside cwd", async () => {
|
|
873
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
874
|
+
'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
875
|
+
cwd,
|
|
876
|
+
);
|
|
877
|
+
expect(result).toHaveLength(0);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("no cd prefix: ../ path that escapes cwd is flagged", async () => {
|
|
881
|
+
// Without the cd prefix, the path resolves against cwd and escapes.
|
|
882
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
883
|
+
'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
884
|
+
cwd,
|
|
885
|
+
);
|
|
886
|
+
expect(result.length).toBeGreaterThan(0);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("sequential fold: a cd that is not the first command still updates the base", async () => {
|
|
890
|
+
// The current-shell `cd` folds even though it is not the first command;
|
|
891
|
+
// ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt.
|
|
892
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
893
|
+
"echo hello && cd /projects/my-app/src && cat ../../outside.txt",
|
|
894
|
+
cwd,
|
|
895
|
+
);
|
|
896
|
+
expect(result).toContain("/projects/outside.txt");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("cd with semicolon separator", async () => {
|
|
900
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
901
|
+
"cd /projects/my-app/src ; cat ../README.md",
|
|
902
|
+
cwd,
|
|
903
|
+
);
|
|
904
|
+
expect(result).toHaveLength(0);
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
910
|
+
test("includes command, external paths, and CWD", () => {
|
|
911
|
+
const result = formatBashExternalDirectoryAskPrompt(
|
|
912
|
+
"cat /etc/hosts",
|
|
913
|
+
["/etc/hosts"],
|
|
914
|
+
"/projects/my-app",
|
|
915
|
+
);
|
|
916
|
+
expect(result).toContain("cat /etc/hosts");
|
|
917
|
+
expect(result).toContain("/etc/hosts");
|
|
918
|
+
expect(result).toContain("/projects/my-app");
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("includes agent name when provided", () => {
|
|
922
|
+
const result = formatBashExternalDirectoryAskPrompt(
|
|
923
|
+
"cat /etc/hosts",
|
|
924
|
+
["/etc/hosts"],
|
|
925
|
+
"/projects/my-app",
|
|
926
|
+
"my-agent",
|
|
927
|
+
);
|
|
928
|
+
expect(result).toContain("my-agent");
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("shows multiple external paths", () => {
|
|
932
|
+
const result = formatBashExternalDirectoryAskPrompt(
|
|
933
|
+
"diff /etc/hosts /var/log/syslog",
|
|
934
|
+
["/etc/hosts", "/var/log/syslog"],
|
|
935
|
+
"/projects/my-app",
|
|
936
|
+
);
|
|
937
|
+
expect(result).toContain("/etc/hosts");
|
|
938
|
+
expect(result).toContain("/var/log/syslog");
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
describe("bash external-directory denial messages (centralized)", () => {
|
|
943
|
+
test("denial message includes command, paths, and extension tag", () => {
|
|
944
|
+
const result = formatDenyReason({
|
|
945
|
+
kind: "bash_external_directory",
|
|
946
|
+
command: "cat /etc/hosts",
|
|
947
|
+
externalPaths: ["/etc/hosts"],
|
|
948
|
+
cwd: "/projects/my-app",
|
|
949
|
+
});
|
|
950
|
+
expect(result).toContain("cat /etc/hosts");
|
|
951
|
+
expect(result).toContain("/etc/hosts");
|
|
952
|
+
expect(result).toContain("/projects/my-app");
|
|
953
|
+
expect(result).toContain("[pi-permission-system]");
|
|
954
|
+
expect(result).not.toContain("Hard stop");
|
|
955
|
+
});
|
|
956
|
+
});
|