@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type SecureCommandManifest,
|
|
4
|
+
MANIFEST_SCHEMA_VERSION,
|
|
5
|
+
EgressMode,
|
|
6
|
+
isDeniedBinary,
|
|
7
|
+
DENIED_BINARIES,
|
|
8
|
+
} from "../commands/profiles.js";
|
|
9
|
+
import { AuthAdapterType } from "../commands/auth-adapters.js";
|
|
10
|
+
import {
|
|
11
|
+
validateManifest,
|
|
12
|
+
validateCommand,
|
|
13
|
+
matchesArgvPattern,
|
|
14
|
+
} from "../commands/validator.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a minimal valid manifest for testing. Override individual fields
|
|
22
|
+
* by passing partial overrides.
|
|
23
|
+
*/
|
|
24
|
+
function buildManifest(
|
|
25
|
+
overrides: Partial<SecureCommandManifest> = {},
|
|
26
|
+
): SecureCommandManifest {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
29
|
+
bundleDigest: "sha256:abc123def456",
|
|
30
|
+
bundleId: "gh-cli",
|
|
31
|
+
version: "2.45.0",
|
|
32
|
+
entrypoint: "bin/gh",
|
|
33
|
+
commandProfiles: {
|
|
34
|
+
"api-read": {
|
|
35
|
+
description: "Read-only GitHub API calls",
|
|
36
|
+
allowedArgvPatterns: [
|
|
37
|
+
{
|
|
38
|
+
name: "api-get",
|
|
39
|
+
tokens: ["api", "<endpoint>", "--method", "GET"],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
deniedSubcommands: ["auth login", "auth logout"],
|
|
43
|
+
deniedFlags: ["--exec"],
|
|
44
|
+
allowedNetworkTargets: [
|
|
45
|
+
{
|
|
46
|
+
hostPattern: "api.github.com",
|
|
47
|
+
protocols: ["https"],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
authAdapter: {
|
|
53
|
+
type: AuthAdapterType.EnvVar,
|
|
54
|
+
envVarName: "GH_TOKEN",
|
|
55
|
+
},
|
|
56
|
+
egressMode: EgressMode.ProxyRequired,
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Manifest validation
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("validateManifest", () => {
|
|
66
|
+
test("accepts a valid manifest", () => {
|
|
67
|
+
const result = validateManifest(buildManifest());
|
|
68
|
+
expect(result.valid).toBe(true);
|
|
69
|
+
expect(result.errors).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// -- Denied binaries (entrypoint) -----------------------------------------
|
|
73
|
+
|
|
74
|
+
test("rejects curl as entrypoint", () => {
|
|
75
|
+
const result = validateManifest(
|
|
76
|
+
buildManifest({ entrypoint: "/usr/bin/curl", bundleId: "curl-tool" }),
|
|
77
|
+
);
|
|
78
|
+
expect(result.valid).toBe(false);
|
|
79
|
+
expect(result.errors.some((e) => e.includes("curl"))).toBe(true);
|
|
80
|
+
expect(
|
|
81
|
+
result.errors.some((e) => e.includes("structurally denied binary")),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rejects wget as entrypoint", () => {
|
|
86
|
+
const result = validateManifest(
|
|
87
|
+
buildManifest({ entrypoint: "bin/wget" }),
|
|
88
|
+
);
|
|
89
|
+
expect(result.valid).toBe(false);
|
|
90
|
+
expect(result.errors.some((e) => e.includes("wget"))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rejects httpie as entrypoint", () => {
|
|
94
|
+
const result = validateManifest(
|
|
95
|
+
buildManifest({ entrypoint: "/usr/local/bin/http" }),
|
|
96
|
+
);
|
|
97
|
+
expect(result.valid).toBe(false);
|
|
98
|
+
expect(result.errors.some((e) => e.includes("http"))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects python interpreter as entrypoint", () => {
|
|
102
|
+
const result = validateManifest(
|
|
103
|
+
buildManifest({ entrypoint: "/usr/bin/python3" }),
|
|
104
|
+
);
|
|
105
|
+
expect(result.valid).toBe(false);
|
|
106
|
+
expect(result.errors.some((e) => e.includes("python3"))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects node interpreter as entrypoint", () => {
|
|
110
|
+
const result = validateManifest(
|
|
111
|
+
buildManifest({ entrypoint: "/usr/local/bin/node" }),
|
|
112
|
+
);
|
|
113
|
+
expect(result.valid).toBe(false);
|
|
114
|
+
expect(result.errors.some((e) => e.includes("node"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("rejects bash shell as entrypoint", () => {
|
|
118
|
+
const result = validateManifest(
|
|
119
|
+
buildManifest({ entrypoint: "/bin/bash" }),
|
|
120
|
+
);
|
|
121
|
+
expect(result.valid).toBe(false);
|
|
122
|
+
expect(result.errors.some((e) => e.includes("bash"))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects sh shell as entrypoint", () => {
|
|
126
|
+
const result = validateManifest(
|
|
127
|
+
buildManifest({ entrypoint: "/bin/sh" }),
|
|
128
|
+
);
|
|
129
|
+
expect(result.valid).toBe(false);
|
|
130
|
+
expect(result.errors.some((e) => e.includes("sh"))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("rejects env trampoline as entrypoint", () => {
|
|
134
|
+
const result = validateManifest(
|
|
135
|
+
buildManifest({ entrypoint: "/usr/bin/env" }),
|
|
136
|
+
);
|
|
137
|
+
expect(result.valid).toBe(false);
|
|
138
|
+
expect(result.errors.some((e) => e.includes("env"))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects bundleId matching a denied binary", () => {
|
|
142
|
+
const result = validateManifest(
|
|
143
|
+
buildManifest({
|
|
144
|
+
entrypoint: "bin/my-curl-wrapper",
|
|
145
|
+
bundleId: "curl",
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
expect(result.valid).toBe(false);
|
|
149
|
+
expect(
|
|
150
|
+
result.errors.some(
|
|
151
|
+
(e) => e.includes("bundleId") && e.includes("curl"),
|
|
152
|
+
),
|
|
153
|
+
).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// -- Missing egress mode ---------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test("rejects manifest with missing egressMode", () => {
|
|
159
|
+
const manifest = buildManifest();
|
|
160
|
+
// @ts-expect-error testing runtime validation with missing field
|
|
161
|
+
delete manifest.egressMode;
|
|
162
|
+
const result = validateManifest(manifest);
|
|
163
|
+
expect(result.valid).toBe(false);
|
|
164
|
+
expect(result.errors.some((e) => e.includes("egressMode"))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects manifest with invalid egressMode", () => {
|
|
168
|
+
const result = validateManifest(
|
|
169
|
+
buildManifest({
|
|
170
|
+
// @ts-expect-error testing invalid value
|
|
171
|
+
egressMode: "direct",
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
expect(result.valid).toBe(false);
|
|
175
|
+
expect(
|
|
176
|
+
result.errors.some(
|
|
177
|
+
(e) => e.includes("egressMode") && e.includes("direct"),
|
|
178
|
+
),
|
|
179
|
+
).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -- Missing auth adapter ---------------------------------------------------
|
|
183
|
+
|
|
184
|
+
test("rejects manifest with missing authAdapter", () => {
|
|
185
|
+
const manifest = buildManifest();
|
|
186
|
+
// @ts-expect-error testing runtime validation with missing field
|
|
187
|
+
delete manifest.authAdapter;
|
|
188
|
+
const result = validateManifest(manifest);
|
|
189
|
+
expect(result.valid).toBe(false);
|
|
190
|
+
expect(result.errors.some((e) => e.includes("authAdapter"))).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// -- Empty command profiles -------------------------------------------------
|
|
194
|
+
|
|
195
|
+
test("rejects manifest with no command profiles", () => {
|
|
196
|
+
const result = validateManifest(
|
|
197
|
+
buildManifest({ commandProfiles: {} }),
|
|
198
|
+
);
|
|
199
|
+
expect(result.valid).toBe(false);
|
|
200
|
+
expect(
|
|
201
|
+
result.errors.some((e) =>
|
|
202
|
+
e.includes("At least one command profile"),
|
|
203
|
+
),
|
|
204
|
+
).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// -- Denied subcommands in profiles -----------------------------------------
|
|
208
|
+
|
|
209
|
+
test("rejects profile with empty allowed argv patterns", () => {
|
|
210
|
+
const result = validateManifest(
|
|
211
|
+
buildManifest({
|
|
212
|
+
commandProfiles: {
|
|
213
|
+
"empty-profile": {
|
|
214
|
+
description: "A profile with no patterns",
|
|
215
|
+
allowedArgvPatterns: [],
|
|
216
|
+
deniedSubcommands: [],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
expect(result.valid).toBe(false);
|
|
222
|
+
expect(
|
|
223
|
+
result.errors.some((e) => e.includes("allowedArgvPattern")),
|
|
224
|
+
).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// -- Overbroad patterns -----------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test("rejects overbroad argv pattern (single rest placeholder)", () => {
|
|
230
|
+
const result = validateManifest(
|
|
231
|
+
buildManifest({
|
|
232
|
+
commandProfiles: {
|
|
233
|
+
overbroad: {
|
|
234
|
+
description: "Matches anything",
|
|
235
|
+
allowedArgvPatterns: [
|
|
236
|
+
{ name: "everything", tokens: ["<args...>"] },
|
|
237
|
+
],
|
|
238
|
+
deniedSubcommands: [],
|
|
239
|
+
allowedNetworkTargets: [
|
|
240
|
+
{ hostPattern: "api.github.com", protocols: ["https"] },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
expect(result.valid).toBe(false);
|
|
247
|
+
expect(result.errors.some((e) => e.includes("too broad"))).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// -- proxy_required without network targets --------------------------------
|
|
251
|
+
|
|
252
|
+
test("rejects proxy_required profile without network targets", () => {
|
|
253
|
+
const result = validateManifest(
|
|
254
|
+
buildManifest({
|
|
255
|
+
egressMode: EgressMode.ProxyRequired,
|
|
256
|
+
commandProfiles: {
|
|
257
|
+
"no-targets": {
|
|
258
|
+
description: "Proxy but no targets",
|
|
259
|
+
allowedArgvPatterns: [
|
|
260
|
+
{ name: "run", tokens: ["run", "<task>"] },
|
|
261
|
+
],
|
|
262
|
+
deniedSubcommands: [],
|
|
263
|
+
// No allowedNetworkTargets
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
expect(result.valid).toBe(false);
|
|
269
|
+
expect(
|
|
270
|
+
result.errors.some(
|
|
271
|
+
(e) =>
|
|
272
|
+
e.includes("proxy_required") &&
|
|
273
|
+
e.includes("allowedNetworkTargets"),
|
|
274
|
+
),
|
|
275
|
+
).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// -- no_network with network targets (contradictory) -----------------------
|
|
279
|
+
|
|
280
|
+
test("rejects no_network profile with network targets", () => {
|
|
281
|
+
const result = validateManifest(
|
|
282
|
+
buildManifest({
|
|
283
|
+
egressMode: EgressMode.NoNetwork,
|
|
284
|
+
commandProfiles: {
|
|
285
|
+
"contradictory": {
|
|
286
|
+
description: "No network but has targets",
|
|
287
|
+
allowedArgvPatterns: [
|
|
288
|
+
{ name: "run", tokens: ["format", "<file>"] },
|
|
289
|
+
],
|
|
290
|
+
deniedSubcommands: [],
|
|
291
|
+
allowedNetworkTargets: [
|
|
292
|
+
{ hostPattern: "example.com", protocols: ["https"] },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
expect(result.valid).toBe(false);
|
|
299
|
+
expect(
|
|
300
|
+
result.errors.some(
|
|
301
|
+
(e) =>
|
|
302
|
+
e.includes("no_network") && e.includes("contradictory"),
|
|
303
|
+
),
|
|
304
|
+
).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// -- Valid no_network manifest ---------------------------------------------
|
|
308
|
+
|
|
309
|
+
test("accepts valid no_network manifest", () => {
|
|
310
|
+
const result = validateManifest(
|
|
311
|
+
buildManifest({
|
|
312
|
+
egressMode: EgressMode.NoNetwork,
|
|
313
|
+
commandProfiles: {
|
|
314
|
+
"format": {
|
|
315
|
+
description: "Format code files",
|
|
316
|
+
allowedArgvPatterns: [
|
|
317
|
+
{ name: "format-file", tokens: ["format", "<file>"] },
|
|
318
|
+
],
|
|
319
|
+
deniedSubcommands: [],
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
expect(result.valid).toBe(true);
|
|
325
|
+
expect(result.errors).toHaveLength(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// -- Rest placeholder not at end -------------------------------------------
|
|
329
|
+
|
|
330
|
+
test("rejects rest placeholder not at end of pattern", () => {
|
|
331
|
+
const result = validateManifest(
|
|
332
|
+
buildManifest({
|
|
333
|
+
commandProfiles: {
|
|
334
|
+
"bad-pattern": {
|
|
335
|
+
description: "Rest placeholder in wrong position",
|
|
336
|
+
allowedArgvPatterns: [
|
|
337
|
+
{
|
|
338
|
+
name: "bad",
|
|
339
|
+
tokens: ["cmd", "<args...>", "--flag"],
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
deniedSubcommands: [],
|
|
343
|
+
allowedNetworkTargets: [
|
|
344
|
+
{ hostPattern: "api.github.com", protocols: ["https"] },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
expect(result.valid).toBe(false);
|
|
351
|
+
expect(
|
|
352
|
+
result.errors.some((e) => e.includes("rest placeholder")),
|
|
353
|
+
).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// -- Auth adapter validation -----------------------------------------------
|
|
357
|
+
|
|
358
|
+
test("rejects auth adapter with empty envVarName", () => {
|
|
359
|
+
const result = validateManifest(
|
|
360
|
+
buildManifest({
|
|
361
|
+
authAdapter: {
|
|
362
|
+
type: AuthAdapterType.EnvVar,
|
|
363
|
+
envVarName: "",
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
expect(result.valid).toBe(false);
|
|
368
|
+
expect(
|
|
369
|
+
result.errors.some((e) => e.includes("envVarName")),
|
|
370
|
+
).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("rejects credential_process adapter with empty helperCommand", () => {
|
|
374
|
+
const result = validateManifest(
|
|
375
|
+
buildManifest({
|
|
376
|
+
authAdapter: {
|
|
377
|
+
type: AuthAdapterType.CredentialProcess,
|
|
378
|
+
helperCommand: "",
|
|
379
|
+
envVarName: "AWS_CREDENTIALS",
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
expect(result.valid).toBe(false);
|
|
384
|
+
expect(
|
|
385
|
+
result.errors.some((e) => e.includes("helperCommand")),
|
|
386
|
+
).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("accepts valid temp_file adapter", () => {
|
|
390
|
+
const result = validateManifest(
|
|
391
|
+
buildManifest({
|
|
392
|
+
authAdapter: {
|
|
393
|
+
type: AuthAdapterType.TempFile,
|
|
394
|
+
envVarName: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
395
|
+
fileExtension: ".json",
|
|
396
|
+
fileMode: 0o400,
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
expect(result.valid).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("rejects temp_file adapter with too permissive fileMode", () => {
|
|
404
|
+
const result = validateManifest(
|
|
405
|
+
buildManifest({
|
|
406
|
+
authAdapter: {
|
|
407
|
+
type: AuthAdapterType.TempFile,
|
|
408
|
+
envVarName: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
409
|
+
fileMode: 0o644,
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
expect(result.valid).toBe(false);
|
|
414
|
+
expect(result.errors.some((e) => e.includes("fileMode"))).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// -- Multiple errors reported exhaustively ----------------------------------
|
|
418
|
+
|
|
419
|
+
test("reports multiple errors exhaustively", () => {
|
|
420
|
+
const result = validateManifest(
|
|
421
|
+
buildManifest({
|
|
422
|
+
bundleDigest: "",
|
|
423
|
+
entrypoint: "/bin/bash",
|
|
424
|
+
commandProfiles: {},
|
|
425
|
+
// @ts-expect-error testing invalid value
|
|
426
|
+
egressMode: "unrestricted",
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
expect(result.valid).toBe(false);
|
|
430
|
+
// Should have at least 3 errors: bundleDigest, entrypoint, commandProfiles, egressMode
|
|
431
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// isDeniedBinary
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
describe("isDeniedBinary", () => {
|
|
440
|
+
test("denies curl by name", () => {
|
|
441
|
+
expect(isDeniedBinary("curl")).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("denies curl by full path", () => {
|
|
445
|
+
expect(isDeniedBinary("/usr/bin/curl")).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("denies wget", () => {
|
|
449
|
+
expect(isDeniedBinary("wget")).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("denies httpie variants", () => {
|
|
453
|
+
expect(isDeniedBinary("http")).toBe(true);
|
|
454
|
+
expect(isDeniedBinary("https")).toBe(true);
|
|
455
|
+
expect(isDeniedBinary("httpie")).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("denies interpreters", () => {
|
|
459
|
+
expect(isDeniedBinary("python")).toBe(true);
|
|
460
|
+
expect(isDeniedBinary("python3")).toBe(true);
|
|
461
|
+
expect(isDeniedBinary("node")).toBe(true);
|
|
462
|
+
expect(isDeniedBinary("bun")).toBe(true);
|
|
463
|
+
expect(isDeniedBinary("deno")).toBe(true);
|
|
464
|
+
expect(isDeniedBinary("ruby")).toBe(true);
|
|
465
|
+
expect(isDeniedBinary("perl")).toBe(true);
|
|
466
|
+
expect(isDeniedBinary("php")).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("denies shell trampolines", () => {
|
|
470
|
+
expect(isDeniedBinary("bash")).toBe(true);
|
|
471
|
+
expect(isDeniedBinary("sh")).toBe(true);
|
|
472
|
+
expect(isDeniedBinary("zsh")).toBe(true);
|
|
473
|
+
expect(isDeniedBinary("env")).toBe(true);
|
|
474
|
+
expect(isDeniedBinary("xargs")).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("allows legitimate CLIs", () => {
|
|
478
|
+
expect(isDeniedBinary("gh")).toBe(false);
|
|
479
|
+
expect(isDeniedBinary("aws")).toBe(false);
|
|
480
|
+
expect(isDeniedBinary("gcloud")).toBe(false);
|
|
481
|
+
expect(isDeniedBinary("terraform")).toBe(false);
|
|
482
|
+
expect(isDeniedBinary("kubectl")).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// matchesArgvPattern
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
describe("matchesArgvPattern", () => {
|
|
491
|
+
test("matches exact literal tokens", () => {
|
|
492
|
+
const pattern = { name: "list", tokens: ["repo", "list"] };
|
|
493
|
+
expect(matchesArgvPattern(["repo", "list"], pattern)).toBe(true);
|
|
494
|
+
expect(matchesArgvPattern(["repo", "create"], pattern)).toBe(false);
|
|
495
|
+
expect(matchesArgvPattern(["repo"], pattern)).toBe(false);
|
|
496
|
+
expect(matchesArgvPattern(["repo", "list", "extra"], pattern)).toBe(false);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("matches single placeholder", () => {
|
|
500
|
+
const pattern = {
|
|
501
|
+
name: "api-get",
|
|
502
|
+
tokens: ["api", "<endpoint>", "--method", "GET"],
|
|
503
|
+
};
|
|
504
|
+
expect(
|
|
505
|
+
matchesArgvPattern(["api", "/repos", "--method", "GET"], pattern),
|
|
506
|
+
).toBe(true);
|
|
507
|
+
expect(
|
|
508
|
+
matchesArgvPattern(["api", "/issues", "--method", "GET"], pattern),
|
|
509
|
+
).toBe(true);
|
|
510
|
+
expect(
|
|
511
|
+
matchesArgvPattern(["api", "/repos", "--method", "POST"], pattern),
|
|
512
|
+
).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("matches rest placeholder", () => {
|
|
516
|
+
const pattern = {
|
|
517
|
+
name: "run-args",
|
|
518
|
+
tokens: ["run", "<args...>"],
|
|
519
|
+
};
|
|
520
|
+
expect(matchesArgvPattern(["run", "build"], pattern)).toBe(true);
|
|
521
|
+
expect(matchesArgvPattern(["run", "build", "--watch"], pattern)).toBe(true);
|
|
522
|
+
// Rest requires at least one arg
|
|
523
|
+
expect(matchesArgvPattern(["run"], pattern)).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("empty argv never matches", () => {
|
|
527
|
+
const pattern = { name: "any", tokens: ["cmd"] };
|
|
528
|
+
expect(matchesArgvPattern([], pattern)).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// validateCommand
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
describe("validateCommand", () => {
|
|
537
|
+
const manifest = buildManifest();
|
|
538
|
+
|
|
539
|
+
test("allows command matching an allowed pattern", () => {
|
|
540
|
+
const result = validateCommand(manifest, [
|
|
541
|
+
"api",
|
|
542
|
+
"/repos/owner/repo",
|
|
543
|
+
"--method",
|
|
544
|
+
"GET",
|
|
545
|
+
]);
|
|
546
|
+
expect(result.allowed).toBe(true);
|
|
547
|
+
expect(result.matchedProfile).toBe("api-read");
|
|
548
|
+
expect(result.matchedPattern).toBe("api-get");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("denies command with denied subcommand", () => {
|
|
552
|
+
const result = validateCommand(manifest, ["auth", "login"]);
|
|
553
|
+
expect(result.allowed).toBe(false);
|
|
554
|
+
expect(result.reason).toContain("auth login");
|
|
555
|
+
expect(result.reason).toContain("denied");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("denies command with denied flag", () => {
|
|
559
|
+
const result = validateCommand(manifest, [
|
|
560
|
+
"api",
|
|
561
|
+
"/repos",
|
|
562
|
+
"--exec",
|
|
563
|
+
"GET",
|
|
564
|
+
]);
|
|
565
|
+
expect(result.allowed).toBe(false);
|
|
566
|
+
expect(result.reason).toContain("--exec");
|
|
567
|
+
expect(result.reason).toContain("denied");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("denies command matching no pattern", () => {
|
|
571
|
+
const result = validateCommand(manifest, [
|
|
572
|
+
"issue",
|
|
573
|
+
"create",
|
|
574
|
+
"--title",
|
|
575
|
+
"bug",
|
|
576
|
+
]);
|
|
577
|
+
expect(result.allowed).toBe(false);
|
|
578
|
+
expect(result.reason).toContain("does not match any allowed pattern");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("denies empty argv", () => {
|
|
582
|
+
const result = validateCommand(manifest, []);
|
|
583
|
+
expect(result.allowed).toBe(false);
|
|
584
|
+
expect(result.reason).toContain("Empty argv");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("denies undeclared flags even when pattern would match", () => {
|
|
588
|
+
// The argv without the denied flag would match, but the flag should cause rejection
|
|
589
|
+
const manifestWithDeniedFlags = buildManifest({
|
|
590
|
+
commandProfiles: {
|
|
591
|
+
"api-read": {
|
|
592
|
+
description: "Read-only GitHub API calls",
|
|
593
|
+
allowedArgvPatterns: [
|
|
594
|
+
{
|
|
595
|
+
name: "api-call",
|
|
596
|
+
tokens: ["api", "<endpoint>", "<args...>"],
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
deniedSubcommands: [],
|
|
600
|
+
deniedFlags: ["--unsafe-perm", "--exec"],
|
|
601
|
+
allowedNetworkTargets: [
|
|
602
|
+
{ hostPattern: "api.github.com", protocols: ["https"] },
|
|
603
|
+
],
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const result = validateCommand(manifestWithDeniedFlags, [
|
|
609
|
+
"api",
|
|
610
|
+
"/repos",
|
|
611
|
+
"--unsafe-perm",
|
|
612
|
+
]);
|
|
613
|
+
expect(result.allowed).toBe(false);
|
|
614
|
+
expect(result.reason).toContain("--unsafe-perm");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// -- Multi-profile matching ------------------------------------------------
|
|
618
|
+
|
|
619
|
+
test("matches across multiple profiles", () => {
|
|
620
|
+
const multiProfileManifest = buildManifest({
|
|
621
|
+
commandProfiles: {
|
|
622
|
+
read: {
|
|
623
|
+
description: "Read operations",
|
|
624
|
+
allowedArgvPatterns: [
|
|
625
|
+
{ name: "list", tokens: ["repo", "list"] },
|
|
626
|
+
],
|
|
627
|
+
deniedSubcommands: [],
|
|
628
|
+
allowedNetworkTargets: [
|
|
629
|
+
{ hostPattern: "api.github.com", protocols: ["https"] },
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
write: {
|
|
633
|
+
description: "Write operations",
|
|
634
|
+
allowedArgvPatterns: [
|
|
635
|
+
{ name: "create-issue", tokens: ["issue", "create", "<args...>"] },
|
|
636
|
+
],
|
|
637
|
+
deniedSubcommands: [],
|
|
638
|
+
allowedNetworkTargets: [
|
|
639
|
+
{ hostPattern: "api.github.com", protocols: ["https"] },
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const readResult = validateCommand(multiProfileManifest, [
|
|
646
|
+
"repo",
|
|
647
|
+
"list",
|
|
648
|
+
]);
|
|
649
|
+
expect(readResult.allowed).toBe(true);
|
|
650
|
+
expect(readResult.matchedProfile).toBe("read");
|
|
651
|
+
|
|
652
|
+
const writeResult = validateCommand(multiProfileManifest, [
|
|
653
|
+
"issue",
|
|
654
|
+
"create",
|
|
655
|
+
"--title",
|
|
656
|
+
"bug",
|
|
657
|
+
]);
|
|
658
|
+
expect(writeResult.allowed).toBe(true);
|
|
659
|
+
expect(writeResult.matchedProfile).toBe("write");
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Comprehensive denied binary coverage
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
describe("DENIED_BINARIES set", () => {
|
|
668
|
+
test("contains all expected generic HTTP clients", () => {
|
|
669
|
+
for (const binary of ["curl", "wget", "http", "https", "httpie"]) {
|
|
670
|
+
expect(DENIED_BINARIES.has(binary)).toBe(true);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("contains all expected interpreters", () => {
|
|
675
|
+
for (const binary of [
|
|
676
|
+
"python",
|
|
677
|
+
"python3",
|
|
678
|
+
"node",
|
|
679
|
+
"bun",
|
|
680
|
+
"deno",
|
|
681
|
+
"ruby",
|
|
682
|
+
"perl",
|
|
683
|
+
"lua",
|
|
684
|
+
"php",
|
|
685
|
+
]) {
|
|
686
|
+
expect(DENIED_BINARIES.has(binary)).toBe(true);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("contains all expected shell trampolines", () => {
|
|
691
|
+
for (const binary of [
|
|
692
|
+
"bash",
|
|
693
|
+
"sh",
|
|
694
|
+
"zsh",
|
|
695
|
+
"fish",
|
|
696
|
+
"dash",
|
|
697
|
+
"ksh",
|
|
698
|
+
"csh",
|
|
699
|
+
"tcsh",
|
|
700
|
+
"env",
|
|
701
|
+
"xargs",
|
|
702
|
+
"exec",
|
|
703
|
+
"nohup",
|
|
704
|
+
]) {
|
|
705
|
+
expect(DENIED_BINARIES.has(binary)).toBe(true);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
});
|