@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -1,702 +0,0 @@
1
- /**
2
- * Secure command manifest validator.
3
- *
4
- * Validates that a {@link SecureCommandManifest} meets the CES security
5
- * invariants before it can be registered. Validation is fail-closed: any
6
- * structural issue, missing field, or policy violation results in rejection.
7
- *
8
- * Invariants enforced:
9
- *
10
- * 1. The entrypoint and bundleId must not be a denied binary.
11
- * 2. At least one command profile must be declared (no empty manifests).
12
- * 3. Each profile must have at least one allowed argv pattern.
13
- * 4. Denied subcommands and denied flags lists are checked for consistency.
14
- * 5. Auth adapter config must be structurally valid.
15
- * 6. `egressMode` must be explicitly declared.
16
- * 7. When `egressMode` is `proxy_required`, each profile must declare at
17
- * least one allowed network target.
18
- * 8. When `egressMode` is `no_network`, profiles must not declare network
19
- * targets (contradictory).
20
- * 9. Overbroad patterns (e.g. a single `<param...>` that matches anything)
21
- * are rejected.
22
- */
23
-
24
- import {
25
- validateAuthAdapterConfig,
26
- AuthAdapterType,
27
- } from "./auth-adapters.js";
28
- import {
29
- type SecureCommandManifest,
30
- type CommandProfile,
31
- type AllowedArgvPattern,
32
- type AllowedNetworkTarget,
33
- MANIFEST_SCHEMA_VERSION,
34
- EGRESS_MODES,
35
- EgressMode,
36
- isDeniedBinary,
37
- pathBasename,
38
- } from "./profiles.js";
39
-
40
- // ---------------------------------------------------------------------------
41
- // Validation result
42
- // ---------------------------------------------------------------------------
43
-
44
- export interface ValidationResult {
45
- /** Whether the manifest passed all checks. */
46
- valid: boolean;
47
- /** List of human-readable error messages (empty when valid). */
48
- errors: string[];
49
- }
50
-
51
- // ---------------------------------------------------------------------------
52
- // Top-level validator
53
- // ---------------------------------------------------------------------------
54
-
55
- /**
56
- * Validate a secure command manifest against all CES security invariants.
57
- *
58
- * Returns a {@link ValidationResult} with `valid: false` and a list of
59
- * error messages if any check fails. Validation is exhaustive — all
60
- * violations are reported, not just the first.
61
- */
62
- export function validateManifest(
63
- manifest: SecureCommandManifest,
64
- ): ValidationResult {
65
- const errors: string[] = [];
66
-
67
- // -- Schema version
68
- if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
69
- errors.push(
70
- `Unsupported schema version "${manifest.schemaVersion}". Expected "${MANIFEST_SCHEMA_VERSION}".`,
71
- );
72
- }
73
-
74
- // -- Required string fields
75
- if (!manifest.bundleDigest || manifest.bundleDigest.trim().length === 0) {
76
- errors.push("bundleDigest is required and must be non-empty.");
77
- }
78
- if (!manifest.bundleId || manifest.bundleId.trim().length === 0) {
79
- errors.push("bundleId is required and must be non-empty.");
80
- }
81
- if (!manifest.version || manifest.version.trim().length === 0) {
82
- errors.push("version is required and must be non-empty.");
83
- }
84
- if (!manifest.entrypoint || manifest.entrypoint.trim().length === 0) {
85
- errors.push("entrypoint is required and must be non-empty.");
86
- }
87
-
88
- // -- Denied binary check (entrypoint basename and bundleId)
89
- if (manifest.entrypoint && isDeniedBinary(manifest.entrypoint)) {
90
- errors.push(
91
- `Entrypoint "${manifest.entrypoint}" (basename: "${pathBasename(manifest.entrypoint)}") is a structurally denied binary. ` +
92
- `Generic HTTP clients, interpreters, and shell trampolines cannot be secure command profiles.`,
93
- );
94
- }
95
- if (manifest.bundleId && isDeniedBinary(manifest.bundleId)) {
96
- errors.push(
97
- `bundleId "${manifest.bundleId}" matches a structurally denied binary name. ` +
98
- `Generic HTTP clients, interpreters, and shell trampolines cannot be secure command profiles.`,
99
- );
100
- }
101
-
102
- // -- Egress mode
103
- if (!manifest.egressMode) {
104
- errors.push(
105
- `egressMode is required. Valid values: ${EGRESS_MODES.join(", ")}.`,
106
- );
107
- } else if (!(EGRESS_MODES as readonly string[]).includes(manifest.egressMode)) {
108
- errors.push(
109
- `Invalid egressMode "${manifest.egressMode}". Valid values: ${EGRESS_MODES.join(", ")}.`,
110
- );
111
- }
112
-
113
- // -- Auth adapter
114
- if (!manifest.authAdapter) {
115
- errors.push("authAdapter is required.");
116
- } else {
117
- const adapterErrors = validateAuthAdapterConfig(manifest.authAdapter);
118
- for (const e of adapterErrors) {
119
- errors.push(`authAdapter: ${e}`);
120
- }
121
-
122
- // -- credential_process helperCommand denied binary check
123
- if (manifest.authAdapter.type === AuthAdapterType.CredentialProcess) {
124
- const helper = manifest.authAdapter.helperCommand;
125
- if (helper && helper.trim().length > 0) {
126
- // Reject shell metacharacters that could chain a denied binary
127
- // after an allowed one (e.g. "aws-vault exec ; curl ...").
128
- // Since helperCommand is executed via `sh -c`, these operators
129
- // allow arbitrary command chaining that bypasses the denylist.
130
- if (containsShellMetacharacters(helper)) {
131
- errors.push(
132
- `authAdapter: credential_process helperCommand contains shell metacharacters. ` +
133
- `Command chaining operators (;, &&, ||, |) and subshell expansion ($()) ` +
134
- `are not allowed in helperCommand because they can bypass the denied binary check.`,
135
- );
136
- }
137
-
138
- const firstWord = extractShellBinary(helper);
139
- const basename = pathBasename(firstWord);
140
- if (isDeniedBinary(firstWord)) {
141
- errors.push(
142
- `authAdapter: credential_process helperCommand starts with denied binary "${basename}". ` +
143
- `Generic HTTP clients, interpreters, and shell trampolines cannot be used as credential helpers.`,
144
- );
145
- }
146
- }
147
- }
148
- }
149
-
150
- // -- cleanConfigDirs key validation (defense-in-depth against path traversal)
151
- if (manifest.cleanConfigDirs) {
152
- for (const key of Object.keys(manifest.cleanConfigDirs)) {
153
- if (key.includes("..")) {
154
- errors.push(
155
- `cleanConfigDirs key "${key}" contains path traversal sequence "..". ` +
156
- `This is not allowed.`,
157
- );
158
- }
159
- if (key.trim().length === 0) {
160
- errors.push(
161
- `cleanConfigDirs contains an empty key.`,
162
- );
163
- }
164
- }
165
- }
166
-
167
- // -- Command profiles (must have at least one)
168
- if (
169
- !manifest.commandProfiles ||
170
- Object.keys(manifest.commandProfiles).length === 0
171
- ) {
172
- errors.push(
173
- "At least one command profile must be declared. " +
174
- "Secure command profiles cannot default to 'run any subcommand on this binary.'",
175
- );
176
- } else {
177
- for (const [profileName, profile] of Object.entries(
178
- manifest.commandProfiles,
179
- )) {
180
- const profileErrors = validateProfile(
181
- profileName,
182
- profile,
183
- manifest.egressMode,
184
- );
185
- errors.push(...profileErrors);
186
- }
187
- }
188
-
189
- return {
190
- valid: errors.length === 0,
191
- errors,
192
- };
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Profile-level validation
197
- // ---------------------------------------------------------------------------
198
-
199
- function validateProfile(
200
- profileName: string,
201
- profile: CommandProfile,
202
- egressMode: EgressMode | undefined,
203
- ): string[] {
204
- const errors: string[] = [];
205
- const prefix = `Profile "${profileName}"`;
206
-
207
- // -- Description
208
- if (!profile.description || profile.description.trim().length === 0) {
209
- errors.push(`${prefix}: description is required and must be non-empty.`);
210
- }
211
-
212
- // -- Allowed argv patterns (must have at least one)
213
- if (
214
- !profile.allowedArgvPatterns ||
215
- profile.allowedArgvPatterns.length === 0
216
- ) {
217
- errors.push(
218
- `${prefix}: at least one allowedArgvPattern is required. ` +
219
- "Profiles must explicitly declare what invocations are allowed.",
220
- );
221
- } else {
222
- for (const pattern of profile.allowedArgvPatterns) {
223
- const patternErrors = validateArgvPattern(prefix, pattern);
224
- errors.push(...patternErrors);
225
- }
226
- }
227
-
228
- // -- Denied subcommands (required — runtime iterates unconditionally)
229
- if (!profile.deniedSubcommands || !Array.isArray(profile.deniedSubcommands)) {
230
- errors.push(
231
- `${prefix}: deniedSubcommands is required and must be an array. ` +
232
- "Use an empty array if no subcommands need to be denied.",
233
- );
234
- } else {
235
- for (const sub of profile.deniedSubcommands) {
236
- if (!sub || sub.trim().length === 0) {
237
- errors.push(
238
- `${prefix}: deniedSubcommands contains an empty string.`,
239
- );
240
- }
241
- }
242
- }
243
-
244
- // -- Denied flags (optional)
245
- if (profile.deniedFlags) {
246
- for (const flag of profile.deniedFlags) {
247
- if (!flag || flag.trim().length === 0) {
248
- errors.push(`${prefix}: deniedFlags contains an empty string.`);
249
- }
250
- if (flag && !flag.startsWith("-")) {
251
- errors.push(
252
- `${prefix}: deniedFlags entry "${flag}" does not start with "-". ` +
253
- "Flags must start with a dash.",
254
- );
255
- }
256
- }
257
- }
258
-
259
- // -- Network targets vs egress mode consistency
260
- if (egressMode === EgressMode.ProxyRequired) {
261
- if (
262
- !profile.allowedNetworkTargets ||
263
- profile.allowedNetworkTargets.length === 0
264
- ) {
265
- errors.push(
266
- `${prefix}: egressMode is "proxy_required" but no allowedNetworkTargets are declared. ` +
267
- "Commands with network egress must declare their allowed network targets.",
268
- );
269
- } else {
270
- for (let i = 0; i < profile.allowedNetworkTargets.length; i++) {
271
- const target = profile.allowedNetworkTargets[i]!;
272
- const targetErrors = validateNetworkTarget(
273
- `${prefix}: allowedNetworkTargets[${i}]`,
274
- target,
275
- );
276
- errors.push(...targetErrors);
277
- }
278
- }
279
- }
280
-
281
- if (egressMode === EgressMode.NoNetwork) {
282
- if (
283
- profile.allowedNetworkTargets &&
284
- profile.allowedNetworkTargets.length > 0
285
- ) {
286
- errors.push(
287
- `${prefix}: egressMode is "no_network" but allowedNetworkTargets are declared. ` +
288
- "This is contradictory — remove network targets or change egressMode.",
289
- );
290
- }
291
- }
292
-
293
- return errors;
294
- }
295
-
296
- // ---------------------------------------------------------------------------
297
- // Network target validation
298
- // ---------------------------------------------------------------------------
299
-
300
- /**
301
- * Overbroad host patterns that effectively match everything.
302
- * These defeat the purpose of declaring allowed network targets.
303
- */
304
- const OVERBROAD_HOST_PATTERNS: ReadonlySet<string> = new Set([
305
- "*",
306
- "*.*",
307
- "*.*.*",
308
- "*.*.*.*",
309
- ]);
310
-
311
- /**
312
- * Validate a single {@link AllowedNetworkTarget} entry.
313
- *
314
- * Returns an array of error messages (empty if valid). Checks:
315
- * - `hostPattern` is non-empty
316
- * - `hostPattern` is not overbroad (e.g. `"*"`, `"*.*"`)
317
- * - `hostPattern` is either an exact hostname or a wildcard-subdomain pattern (`*.domain.tld`)
318
- * - `ports` (if specified) are valid (1–65535)
319
- * - `protocols` (if specified) are `"http"` or `"https"` only
320
- */
321
- function validateNetworkTarget(
322
- prefix: string,
323
- target: AllowedNetworkTarget,
324
- ): string[] {
325
- const errors: string[] = [];
326
-
327
- // -- hostPattern must be non-empty
328
- if (!target.hostPattern || target.hostPattern.trim().length === 0) {
329
- errors.push(`${prefix}: hostPattern is required and must be non-empty.`);
330
- return errors; // Can't validate further without a pattern
331
- }
332
-
333
- const pattern = target.hostPattern;
334
-
335
- // -- Reject overbroad patterns
336
- if (OVERBROAD_HOST_PATTERNS.has(pattern)) {
337
- errors.push(
338
- `${prefix}: hostPattern "${pattern}" is overbroad and matches effectively any host. ` +
339
- "Use exact hostnames (e.g. \"api.github.com\") or wildcard-subdomain patterns (e.g. \"*.github.com\").",
340
- );
341
- return errors;
342
- }
343
-
344
- // -- Validate pattern shape: exact hostname or *.domain.tld
345
- if (pattern.includes("*")) {
346
- // Only *.domain.tld form is allowed
347
- if (!pattern.startsWith("*.") || pattern.indexOf("*", 1) !== -1) {
348
- errors.push(
349
- `${prefix}: hostPattern "${pattern}" uses an unsupported wildcard format. ` +
350
- "Only wildcard-subdomain patterns (\"*.domain.tld\") are allowed. " +
351
- "Wildcards in the middle or end of a hostname are not supported.",
352
- );
353
- } else {
354
- // Ensure the domain part after *. is non-empty and looks like a domain
355
- const domain = pattern.slice(2);
356
- if (!domain || domain.trim().length === 0) {
357
- errors.push(
358
- `${prefix}: hostPattern "${pattern}" has an empty domain after the wildcard prefix.`,
359
- );
360
- }
361
- }
362
- }
363
-
364
- // -- Validate ports
365
- if (target.ports) {
366
- for (const port of target.ports) {
367
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
368
- errors.push(
369
- `${prefix}: port ${port} is invalid. Ports must be integers between 1 and 65535.`,
370
- );
371
- }
372
- }
373
- }
374
-
375
- // -- Validate protocols
376
- if (target.protocols) {
377
- const validProtocols = new Set(["http", "https"]);
378
- for (const proto of target.protocols) {
379
- if (!validProtocols.has(proto)) {
380
- errors.push(
381
- `${prefix}: protocol "${proto}" is invalid. Only "http" and "https" are allowed.`,
382
- );
383
- }
384
- }
385
- }
386
-
387
- return errors;
388
- }
389
-
390
- // ---------------------------------------------------------------------------
391
- // Argv pattern validation
392
- // ---------------------------------------------------------------------------
393
-
394
- function validateArgvPattern(
395
- profilePrefix: string,
396
- pattern: AllowedArgvPattern,
397
- ): string[] {
398
- const errors: string[] = [];
399
-
400
- if (!pattern.name || pattern.name.trim().length === 0) {
401
- errors.push(
402
- `${profilePrefix}: argv pattern has no name. Each pattern must be named for audit logging.`,
403
- );
404
- }
405
-
406
- if (!pattern.tokens || pattern.tokens.length === 0) {
407
- errors.push(
408
- `${profilePrefix}: argv pattern "${pattern.name}" has no tokens. ` +
409
- "Empty patterns would match any invocation.",
410
- );
411
- return errors;
412
- }
413
-
414
- // Check for overbroad patterns: a single rest placeholder matches anything
415
- if (
416
- pattern.tokens.length === 1 &&
417
- isRestPlaceholder(pattern.tokens[0]!)
418
- ) {
419
- errors.push(
420
- `${profilePrefix}: argv pattern "${pattern.name}" contains only a rest placeholder ` +
421
- `("${pattern.tokens[0]}"). This would match any invocation and is too broad.`,
422
- );
423
- }
424
-
425
- // Rest placeholder must be last token
426
- for (let i = 0; i < pattern.tokens.length; i++) {
427
- const token = pattern.tokens[i]!;
428
- if (isRestPlaceholder(token) && i < pattern.tokens.length - 1) {
429
- errors.push(
430
- `${profilePrefix}: argv pattern "${pattern.name}" has a rest placeholder ` +
431
- `("${token}") at position ${i}, but rest placeholders must be the last token.`,
432
- );
433
- }
434
- }
435
-
436
- // Only check denied binaries in executable positions — the first token
437
- // (index 0) is the subcommand position for multi-call umbrella binaries
438
- // (e.g. busybox wget). Tokens at other positions are argument values and
439
- // may legitimately use names that overlap with denied binaries (e.g.
440
- // "--scheme https" where "https" is an httpie alias in DENIED_BINARIES).
441
- const firstToken = pattern.tokens[0];
442
- if (firstToken && !isPlaceholder(firstToken) && !isRestPlaceholder(firstToken) && isDeniedBinary(firstToken)) {
443
- errors.push(
444
- `${profilePrefix}: argv pattern "${pattern.name}" token "${firstToken}" matches a denied binary. ` +
445
- `Multi-call umbrella binaries and shell trampolines cannot appear in executable argv positions.`,
446
- );
447
- }
448
-
449
- return errors;
450
- }
451
-
452
- // ---------------------------------------------------------------------------
453
- // Shell metacharacter detection (for helperCommand safety)
454
- // ---------------------------------------------------------------------------
455
-
456
- /**
457
- * Shell metacharacters that enable command chaining or subshell expansion.
458
- * Since helperCommand is executed via `sh -c`, these operators allow an
459
- * attacker to chain a denied binary after an allowed one, bypassing the
460
- * denylist check on the first token.
461
- *
462
- * Detected patterns:
463
- * - `;` — command separator
464
- * - `&&` — logical AND
465
- * - `||` — logical OR
466
- * - `|` — pipe (but not `||`)
467
- * - `$(` — command substitution
468
- * - `` ` `` — backtick command substitution
469
- * - `\n` — newline (POSIX command separator, equivalent to `;`)
470
- * - `\r` — carriage return
471
- */
472
- const SHELL_METACHAR_RE = /;|&&|\|\||(?<!\|)\|(?!\|)|\$\(|`|\n|\r/;
473
-
474
- /**
475
- * Returns true if the command string contains shell metacharacters that
476
- * could be used for command chaining or subshell expansion.
477
- */
478
- export function containsShellMetacharacters(command: string): boolean {
479
- return SHELL_METACHAR_RE.test(command);
480
- }
481
-
482
- // ---------------------------------------------------------------------------
483
- // Shell binary extraction (for helperCommand denylist checks)
484
- // ---------------------------------------------------------------------------
485
-
486
- /**
487
- * Regex matching shell variable assignments (KEY=VALUE) at the start of a
488
- * command. These are environment overrides and not the binary. Handles
489
- * bare values, single-quoted values, and double-quoted values.
490
- */
491
- const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|(?:\\.|[^\s])*)\s+/;
492
-
493
- /**
494
- * Extract the actual binary name from a shell command string, accounting for
495
- * leading env-var assignments (KEY=VALUE prefixes) and shell quoting around
496
- * the binary token. This is necessary because helperCommand is executed via
497
- * `sh -c`, so the shell resolves assignments and quotes before execution.
498
- *
499
- * Examples:
500
- * "curl https://..." → "curl"
501
- * "'curl' https://..." → "curl"
502
- * "AWS_PROFILE=x curl ..." → "curl"
503
- * "AWS_PROFILE=x FOO=bar curl ..." → "curl"
504
- * "/usr/bin/python3 script.py" → "/usr/bin/python3"
505
- */
506
- export function extractShellBinary(command: string): string {
507
- let remaining = command.trim();
508
-
509
- // Strip leading KEY=VALUE assignments
510
- let match: RegExpExecArray | null;
511
- while ((match = ENV_ASSIGNMENT_RE.exec(remaining)) !== null) {
512
- remaining = remaining.slice(match[0].length);
513
- }
514
-
515
- // Extract the first whitespace-delimited token
516
- const firstToken = remaining.split(/\s+/)[0] ?? remaining;
517
-
518
- // Strip surrounding quotes (single or double)
519
- return stripShellQuotes(firstToken);
520
- }
521
-
522
- /**
523
- * Remove surrounding single or double quotes from a token.
524
- * Only strips matching pairs at the boundaries (e.g., `'curl'` → `curl`).
525
- */
526
- function stripShellQuotes(token: string): string {
527
- if (token.length >= 2) {
528
- if (
529
- (token.startsWith("'") && token.endsWith("'")) ||
530
- (token.startsWith('"') && token.endsWith('"'))
531
- ) {
532
- return token.slice(1, -1);
533
- }
534
- }
535
- return token;
536
- }
537
-
538
- // ---------------------------------------------------------------------------
539
- // Argv matching (used by the runtime to check commands against profiles)
540
- // ---------------------------------------------------------------------------
541
-
542
- /**
543
- * Returns true if the token is a single-value placeholder like `<name>`.
544
- */
545
- function isPlaceholder(token: string): boolean {
546
- return token.startsWith("<") && token.endsWith(">") && !token.endsWith("...>");
547
- }
548
-
549
- /**
550
- * Returns true if the token is a rest placeholder like `<name...>`.
551
- */
552
- function isRestPlaceholder(token: string): boolean {
553
- return token.startsWith("<") && token.endsWith("...>");
554
- }
555
-
556
- /**
557
- * Check if a concrete argv array matches an allowed argv pattern.
558
- *
559
- * Matching rules:
560
- * - Literal tokens must match exactly.
561
- * - `<name>` matches exactly one argument.
562
- * - `<name...>` matches one or more remaining arguments (must be last token).
563
- */
564
- export function matchesArgvPattern(
565
- argv: readonly string[],
566
- pattern: AllowedArgvPattern,
567
- ): boolean {
568
- const { tokens } = pattern;
569
-
570
- for (let i = 0; i < tokens.length; i++) {
571
- const token = tokens[i]!;
572
-
573
- if (isRestPlaceholder(token)) {
574
- // Rest placeholder: must have at least one remaining arg
575
- return argv.length > i;
576
- }
577
-
578
- // No more args but still have pattern tokens
579
- if (i >= argv.length) return false;
580
-
581
- if (isPlaceholder(token)) {
582
- // Single placeholder: matches any single value
583
- continue;
584
- }
585
-
586
- // Literal: must match exactly
587
- if (argv[i] !== token) return false;
588
- }
589
-
590
- // All pattern tokens consumed — argv must also be fully consumed
591
- return argv.length === tokens.length;
592
- }
593
-
594
- // ---------------------------------------------------------------------------
595
- // Full command validation against a manifest
596
- // ---------------------------------------------------------------------------
597
-
598
- export interface CommandValidationResult {
599
- /** Whether the command is allowed. */
600
- allowed: boolean;
601
- /** The profile name that matched (undefined when rejected). */
602
- matchedProfile?: string;
603
- /** The pattern name that matched (undefined when rejected). */
604
- matchedPattern?: string;
605
- /** Human-readable reason for rejection (undefined when allowed). */
606
- reason?: string;
607
- }
608
-
609
- /**
610
- * Validate a concrete command invocation (argv array) against a manifest.
611
- *
612
- * Checks:
613
- * 1. The argv is non-empty.
614
- * 2. The argv does not contain any denied subcommands (across all profiles).
615
- * 3. The argv does not contain any denied flags (across all profiles).
616
- * 4. At least one profile's allowed argv patterns matches.
617
- *
618
- * This function does NOT re-validate the manifest itself — call
619
- * {@link validateManifest} separately during registration.
620
- */
621
- export function validateCommand(
622
- manifest: SecureCommandManifest,
623
- argv: readonly string[],
624
- ): CommandValidationResult {
625
- if (argv.length === 0) {
626
- return {
627
- allowed: false,
628
- reason: "Empty argv — no command to validate.",
629
- };
630
- }
631
-
632
- // Collect all denied subcommands and flags across profiles
633
- const allDeniedSubcommands = new Set<string>();
634
- const allDeniedFlags = new Set<string>();
635
-
636
- for (const profile of Object.values(manifest.commandProfiles)) {
637
- for (const sub of profile.deniedSubcommands) {
638
- allDeniedSubcommands.add(sub);
639
- }
640
- if (profile.deniedFlags) {
641
- for (const flag of profile.deniedFlags) {
642
- allDeniedFlags.add(flag);
643
- }
644
- }
645
- }
646
-
647
- // Check denied subcommands (match against first N tokens of argv)
648
- for (const denied of allDeniedSubcommands) {
649
- const deniedParts = denied.split(/\s+/);
650
- if (deniedParts.length <= argv.length) {
651
- const match = deniedParts.every((part, i) => argv[i] === part);
652
- if (match) {
653
- return {
654
- allowed: false,
655
- reason: `Subcommand "${denied}" is explicitly denied.`,
656
- };
657
- }
658
- }
659
- }
660
-
661
- // Check denied flags — also handle --flag=value combined tokens
662
- for (const arg of argv) {
663
- if (allDeniedFlags.has(arg)) {
664
- return {
665
- allowed: false,
666
- reason: `Flag "${arg}" is explicitly denied.`,
667
- };
668
- }
669
-
670
- // Handle --flag=value form: extract the flag prefix before '='
671
- if (arg.startsWith("-") && arg.includes("=")) {
672
- const flagPrefix = arg.slice(0, arg.indexOf("="));
673
- if (allDeniedFlags.has(flagPrefix)) {
674
- return {
675
- allowed: false,
676
- reason: `Flag "${flagPrefix}" is explicitly denied (via "${arg}").`,
677
- };
678
- }
679
- }
680
- }
681
-
682
- // Try to match against allowed argv patterns in each profile
683
- for (const [profileName, profile] of Object.entries(
684
- manifest.commandProfiles,
685
- )) {
686
- for (const pattern of profile.allowedArgvPatterns) {
687
- if (matchesArgvPattern(argv, pattern)) {
688
- return {
689
- allowed: true,
690
- matchedProfile: profileName,
691
- matchedPattern: pattern.name,
692
- };
693
- }
694
- }
695
- }
696
-
697
- return {
698
- allowed: false,
699
- reason:
700
- "Command argv does not match any allowed pattern in any profile.",
701
- };
702
- }