vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -5,10 +5,21 @@ import { resolveSkillSelector } from '../config/skills.js';
5
5
  import { computeSkillVersionHash } from '../skills/version-hash.js';
6
6
  import { getTool } from '../tools/registry.js';
7
7
  import { getConfig } from '../config/loader.js';
8
+ import { getLogger } from '../util/logger.js';
8
9
  import { dirname, resolve } from 'node:path';
9
10
  import { homedir } from 'node:os';
10
11
  import { looksLikeHostPortShorthand, looksLikePathOnlyInput } from '../tools/network/url-safety.js';
11
12
  import { normalizeFilePath, isSkillSourcePath } from '../skills/path-classifier.js';
13
+ import { isWorkspaceScopedInvocation } from './workspace-policy.js';
14
+ import { buildShellCommandCandidates, buildShellAllowlistOptions, type ParsedCommand } from './shell-identity.js';
15
+
16
+ // Ensures the legacy mode deprecation warning fires at most once per process.
17
+ let _legacyDeprecationWarned = false;
18
+
19
+ /** @internal — exposed only for tests to reset the one-time warning flag. */
20
+ export function _resetLegacyDeprecationWarning(): void {
21
+ _legacyDeprecationWarned = false;
22
+ }
12
23
 
13
24
  // Low-risk shell programs that are read-only / informational
14
25
  const LOW_RISK_PROGRAMS = new Set([
@@ -143,9 +154,9 @@ function escapeMinimatchLiteral(value: string): string {
143
154
  return value.replace(/([\\*?[\]{}()!+@|])/g, '\\$1');
144
155
  }
145
156
 
146
- function buildCommandCandidates(toolName: string, input: Record<string, unknown>, workingDir: string): string[] {
157
+ async function buildCommandCandidates(toolName: string, input: Record<string, unknown>, workingDir: string, preParsed?: ParsedCommand): Promise<string[]> {
147
158
  if (toolName === 'bash' || toolName === 'host_bash') {
148
- return [getStringField(input, 'command')];
159
+ return buildShellCommandCandidates(getStringField(input, 'command'), preParsed);
149
160
  }
150
161
 
151
162
  if (toolName === 'skill_load') {
@@ -233,7 +244,7 @@ function buildCommandCandidates(toolName: string, input: Record<string, unknown>
233
244
  return [...new Set(candidates)];
234
245
  }
235
246
 
236
- export async function classifyRisk(toolName: string, input: Record<string, unknown>, workingDir?: string): Promise<RiskLevel> {
247
+ export async function classifyRisk(toolName: string, input: Record<string, unknown>, workingDir?: string, preParsed?: ParsedCommand): Promise<RiskLevel> {
237
248
  if (toolName === 'file_read') return RiskLevel.Low;
238
249
  if (toolName === 'file_write' || toolName === 'file_edit') {
239
250
  const filePath = getStringField(input, 'path', 'file_path');
@@ -273,7 +284,7 @@ export async function classifyRisk(toolName: string, input: Record<string, unkno
273
284
  const command = (input.command as string) ?? '';
274
285
  if (!command.trim()) return RiskLevel.Low;
275
286
 
276
- const parsed = await parse(command);
287
+ const parsed = preParsed ?? await parse(command);
277
288
 
278
289
  // Dangerous patterns → High
279
290
  if (parsed.dangerousPatterns.length > 0) return RiskLevel.High;
@@ -341,10 +352,19 @@ export async function check(
341
352
  workingDir: string,
342
353
  policyContext?: PolicyContext,
343
354
  ): Promise<PermissionCheckResult> {
344
- const risk = await classifyRisk(toolName, input, workingDir);
355
+ // For shell tools, parse once and share the result to avoid duplicate tree-sitter work.
356
+ let shellParsed: ParsedCommand | undefined;
357
+ if (toolName === 'bash' || toolName === 'host_bash') {
358
+ const command = ((input.command as string) ?? '').trim();
359
+ if (command) {
360
+ shellParsed = await parse(command);
361
+ }
362
+ }
363
+
364
+ const risk = await classifyRisk(toolName, input, workingDir, shellParsed);
345
365
 
346
366
  // Build command string candidates for rule matching
347
- const commandCandidates = buildCommandCandidates(toolName, input, workingDir);
367
+ const commandCandidates = await buildCommandCandidates(toolName, input, workingDir, shellParsed);
348
368
 
349
369
  // Find the highest-priority matching rule across all candidates
350
370
  const matchedRule = findHighestPriorityRule(toolName, commandCandidates, workingDir, policyContext);
@@ -399,10 +419,28 @@ export async function check(
399
419
  // agent new capabilities, so in strict mode users must approve each
400
420
  // skill load via an exact-version or wildcard trust rule.
401
421
  const permissionsMode = getConfig().permissions.mode;
422
+
423
+ if (permissionsMode === 'legacy' && !_legacyDeprecationWarned) {
424
+ _legacyDeprecationWarned = true;
425
+ getLogger('checker').warn('Permissions mode "legacy" is deprecated and will be removed in a future release. Switch to "workspace" (default) or "strict".');
426
+ }
427
+
402
428
  if (permissionsMode === 'strict' && !matchedRule) {
403
429
  return { decision: 'prompt', reason: `Strict mode: no matching rule, requires approval` };
404
430
  }
405
431
 
432
+ // Workspace mode: auto-allow workspace-scoped operations that don't have
433
+ // an explicit rule. Non-workspace operations fall through to risk-based policy.
434
+ if (permissionsMode === 'workspace' && !matchedRule) {
435
+ // When sandbox is disabled, bash runs on the host — don't auto-allow
436
+ const sandboxEnabled = getConfig().sandbox.enabled;
437
+ if (toolName === 'bash' && !sandboxEnabled) {
438
+ // Fall through to risk-based policy below
439
+ } else if (isWorkspaceScopedInvocation(toolName, input, workingDir)) {
440
+ return { decision: 'allow', reason: 'Workspace mode: workspace-scoped operation auto-allowed' };
441
+ }
442
+ }
443
+
406
444
  // Auto-allow low-risk bundled skill tools even without explicit trust rules.
407
445
  // These are first-party tools with a vetted risk declaration — applying the
408
446
  // same policy as the per-tool default allow rules for browser tools, but
@@ -448,38 +486,10 @@ function friendlyHostname(url: URL): string {
448
486
  return url.hostname.replace(/^www\./, '');
449
487
  }
450
488
 
451
- export function generateAllowlistOptions(toolName: string, input: Record<string, unknown>): AllowlistOption[] {
489
+ export async function generateAllowlistOptions(toolName: string, input: Record<string, unknown>): Promise<AllowlistOption[]> {
452
490
  if (toolName === 'bash' || toolName === 'host_bash') {
453
491
  const command = ((input.command as string) ?? '').trim();
454
- const parts = command.split(/\s+/);
455
- const program = parts[0] ?? command;
456
- const options: AllowlistOption[] = [];
457
-
458
- // Exact match
459
- options.push({ label: command, description: 'This exact command', pattern: command });
460
-
461
- if (parts.length >= 2) {
462
- // Subcommand wildcard: "npm install *"
463
- const sub = parts.slice(0, -1).join(' ');
464
- options.push({
465
- label: `${sub} *`,
466
- description: `Any "${sub}" command`,
467
- pattern: `${sub} *`,
468
- });
469
- }
470
-
471
- if (parts.length >= 1) {
472
- // Program wildcard: "npm *"
473
- options.push({ label: `${program} *`, description: `Any ${program} command`, pattern: `${program} *` });
474
- }
475
-
476
- // Deduplicate
477
- const seen = new Set<string>();
478
- return options.filter((o) => {
479
- if (seen.has(o.pattern)) return false;
480
- seen.add(o.pattern);
481
- return true;
482
- });
492
+ return buildShellAllowlistOptions(command);
483
493
  }
484
494
 
485
495
  if (
@@ -604,7 +614,7 @@ export function generateAllowlistOptions(toolName: string, input: Record<string,
604
614
  return [{ label: '*', description: 'Everything', pattern: '*' }];
605
615
  }
606
616
 
607
- export function generateScopeOptions(workingDir: string, toolName?: string): ScopeOption[] {
617
+ export function generateScopeOptions(workingDir: string, _toolName?: string): ScopeOption[] {
608
618
  const home = homedir();
609
619
  const options: ScopeOption[] = [];
610
620
 
@@ -626,11 +636,5 @@ export function generateScopeOptions(workingDir: string, toolName?: string): Sco
626
636
  // Everywhere
627
637
  options.push({ label: 'everywhere', scope: 'everywhere' });
628
638
 
629
- if (!toolName?.startsWith('host_')) {
630
- return options;
631
- }
632
-
633
- const everywhere = options.find((option) => option.scope === 'everywhere');
634
- const scoped = options.filter((option) => option.scope !== 'everywhere');
635
- return everywhere ? [everywhere, ...scoped] : options;
639
+ return options;
636
640
  }
@@ -225,6 +225,16 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
225
225
  priority: 100,
226
226
  };
227
227
 
228
+ // memory_search is a read-only tool — always allow without prompting.
229
+ const memorySearchRule: DefaultRuleTemplate = {
230
+ id: 'default:allow-memory_search-global',
231
+ tool: 'memory_search',
232
+ pattern: 'memory_search:*',
233
+ scope: 'everywhere',
234
+ decision: 'allow',
235
+ priority: 100,
236
+ };
237
+
228
238
  return [
229
239
  ...hostFileRules,
230
240
  hostShellRule,
@@ -239,5 +249,6 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
239
249
  ...browserToolRules,
240
250
  ...uiSurfaceRules,
241
251
  viewImageRule,
252
+ memorySearchRule,
242
253
  ];
243
254
  }
@@ -37,7 +37,6 @@ export class PermissionPrompter {
37
37
  sandboxed?: boolean,
38
38
  sessionId?: string,
39
39
  executionTarget?: ExecutionTarget,
40
- principal?: { kind?: string; id?: string; version?: string },
41
40
  persistentDecisionsAllowed?: boolean,
42
41
  ): Promise<{ decision: UserDecision; selectedPattern?: string; selectedScope?: string }> {
43
42
  const requestId = uuid();
@@ -64,9 +63,6 @@ export class PermissionPrompter {
64
63
  sandboxed,
65
64
  sessionId,
66
65
  executionTarget,
67
- principalKind: principal?.kind,
68
- principalId: principal?.id,
69
- principalVersion: principal?.version,
70
66
  persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
71
67
  });
72
68
  });
@@ -0,0 +1,227 @@
1
+ import { parse, type ParsedCommand, type CommandSegment, type DangerousPattern } from '../tools/terminal/parser.js';
2
+ import type { AllowlistOption } from './types.js';
3
+
4
+ export type { ParsedCommand };
5
+
6
+ export interface ShellActionKey {
7
+ /** e.g. "action:gh", "action:gh pr", "action:gh pr view" */
8
+ key: string;
9
+ /** How many tokens deep this key goes */
10
+ depth: number;
11
+ }
12
+
13
+ export interface ShellIdentityAnalysis {
14
+ /** The parsed segments from the shell parser */
15
+ segments: CommandSegment[];
16
+ /** The operator sequence between segments (e.g. ['&&', '|']) */
17
+ operators: string[];
18
+ /** Whether the command contains opaque constructs (eval, heredocs, etc.) */
19
+ hasOpaqueConstructs: boolean;
20
+ /** Dangerous patterns detected by the parser */
21
+ dangerousPatterns: DangerousPattern[];
22
+ }
23
+
24
+ export interface ActionKeyResult {
25
+ /** The derived action keys from narrowest to broadest */
26
+ keys: ShellActionKey[];
27
+ /** Whether this command has a "simple action" shape (setup prefix + single action) */
28
+ isSimpleAction: boolean;
29
+ /** The primary action segment (the non-setup-prefix action command) */
30
+ primarySegment?: CommandSegment;
31
+ }
32
+
33
+ /** Programs that are considered setup prefixes (not the main action) */
34
+ const SETUP_PREFIX_PROGRAMS = new Set(['cd', 'pushd', 'export', 'unset', 'set']);
35
+
36
+ const MAX_ACTION_KEY_DEPTH = 3;
37
+
38
+ /**
39
+ * Analyze a shell command using the tree-sitter parser to extract
40
+ * identity information for permission decisions.
41
+ */
42
+ export async function analyzeShellCommand(command: string, preParsed?: ParsedCommand): Promise<ShellIdentityAnalysis> {
43
+ const parsed = preParsed ?? await parse(command);
44
+
45
+ const operators: string[] = [];
46
+ for (const seg of parsed.segments) {
47
+ if (seg.operator) {
48
+ operators.push(seg.operator);
49
+ }
50
+ }
51
+
52
+ return {
53
+ segments: parsed.segments,
54
+ operators,
55
+ hasOpaqueConstructs: parsed.hasOpaqueConstructs,
56
+ dangerousPatterns: parsed.dangerousPatterns,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Derive canonical action keys from a shell command analysis.
62
+ *
63
+ * Action keys identify the "family" of a command for allowlist purposes.
64
+ * For example, `cd repo && gh pr view 5525 --json title` derives:
65
+ * - action:gh pr view
66
+ * - action:gh pr
67
+ * - action:gh
68
+ *
69
+ * Only "simple action" commands (optional setup prefix + one action) get
70
+ * action keys. Pipelines and complex chains are marked non-simple.
71
+ */
72
+ export function deriveShellActionKeys(analysis: ShellIdentityAnalysis): ActionKeyResult {
73
+ const { segments } = analysis;
74
+
75
+ if (segments.length === 0) {
76
+ return { keys: [], isSimpleAction: false };
77
+ }
78
+
79
+ // For multi-segment commands, only allow simple-action classification if
80
+ // ALL inter-segment operators are explicitly &&. Any other operator (|, ||,
81
+ // ;, &, empty/missing) means the separator is unknown or unsafe.
82
+ // This safely handles cases where the parser doesn't capture certain
83
+ // separators (;, newline, &) and leaves them as empty operators.
84
+ if (segments.length > 1) {
85
+ for (const seg of segments) {
86
+ const op = seg.operator;
87
+ // Non-empty operator that isn't && → definitely complex
88
+ if (op && op !== '&&') {
89
+ return { keys: [], isSimpleAction: false };
90
+ }
91
+ }
92
+ // Also check: if there are multiple segments but no operators at all
93
+ // between them (e.g. newline-separated), that's suspicious.
94
+ // The first segment always has operator '' (no preceding operator).
95
+ // If any non-first segment also has operator '', the separator was
96
+ // not captured — treat as complex for safety.
97
+ for (let i = 1; i < segments.length; i++) {
98
+ if (!segments[i].operator) {
99
+ return { keys: [], isSimpleAction: false };
100
+ }
101
+ }
102
+ }
103
+
104
+ // Separate setup-prefix segments from action segments
105
+ const actionSegments: CommandSegment[] = [];
106
+ let foundNonPrefix = false;
107
+
108
+ for (const seg of segments) {
109
+ if (!foundNonPrefix && SETUP_PREFIX_PROGRAMS.has(seg.program)) {
110
+ continue;
111
+ }
112
+ foundNonPrefix = true;
113
+ actionSegments.push(seg);
114
+ }
115
+
116
+ // Simple action: exactly one non-prefix action segment
117
+ if (actionSegments.length !== 1) {
118
+ return { keys: [], isSimpleAction: false };
119
+ }
120
+
121
+ const primarySegment = actionSegments[0];
122
+ const tokens: string[] = [primarySegment.program];
123
+
124
+ // Add non-flag, non-path stable subcommand tokens (up to MAX_ACTION_KEY_DEPTH)
125
+ for (const arg of primarySegment.args) {
126
+ if (tokens.length >= MAX_ACTION_KEY_DEPTH) break;
127
+ if (arg.startsWith('-')) continue;
128
+ if (arg.includes('/') || arg.startsWith('.')) continue;
129
+ if (/^\d+$/.test(arg)) continue;
130
+ if (arg.includes('$') || arg.includes('"') || arg.includes("'")) continue;
131
+ tokens.push(arg);
132
+ }
133
+
134
+ // Build action keys from narrowest to broadest
135
+ const keys: ShellActionKey[] = [];
136
+ for (let depth = tokens.length; depth >= 1; depth--) {
137
+ keys.push({
138
+ key: `action:${tokens.slice(0, depth).join(' ')}`,
139
+ depth,
140
+ });
141
+ }
142
+
143
+ return { keys, isSimpleAction: true, primarySegment };
144
+ }
145
+
146
+ /**
147
+ * Build an ordered list of command candidates for trust-rule matching.
148
+ *
149
+ * Candidate ordering:
150
+ * 1. Raw command (backward compatibility — existing rules match as before)
151
+ * 2. Canonical primary command (if simple action) — the full primary segment text
152
+ * 3. Action keys from narrowest to broadest (if simple action)
153
+ *
154
+ * Complex commands (pipelines, multi-action chains) only return the raw candidate.
155
+ */
156
+ export async function buildShellCommandCandidates(command: string, preParsed?: ParsedCommand): Promise<string[]> {
157
+ const trimmed = command.trim();
158
+ if (!trimmed) return [trimmed];
159
+
160
+ const analysis = await analyzeShellCommand(trimmed, preParsed);
161
+ const actionResult = deriveShellActionKeys(analysis);
162
+
163
+ const candidates: string[] = [trimmed];
164
+
165
+ if (actionResult.isSimpleAction && actionResult.primarySegment) {
166
+ // Add canonical primary command text (the actual segment, not the full command with setup prefixes)
167
+ const canonical = actionResult.primarySegment.command;
168
+ if (canonical !== trimmed) {
169
+ candidates.push(canonical);
170
+ }
171
+
172
+ // Add action keys
173
+ for (const actionKey of actionResult.keys) {
174
+ candidates.push(actionKey.key);
175
+ }
176
+ }
177
+
178
+ // Deduplicate while preserving order
179
+ return [...new Set(candidates)];
180
+ }
181
+
182
+ /**
183
+ * Build allowlist options for shell commands using parser-derived identity.
184
+ *
185
+ * For simple actions (optional setup prefix + one action), options are:
186
+ * 1. Exact canonical primary command
187
+ * 2. Deepest action key (e.g. "action:gh pr view")
188
+ * 3. Broader action keys (e.g. "action:gh pr", "action:gh")
189
+ *
190
+ * For complex commands (pipelines, multi-action chains), only the exact
191
+ * command is offered (no broad options).
192
+ */
193
+ export async function buildShellAllowlistOptions(command: string): Promise<AllowlistOption[]> {
194
+ const trimmed = command.trim();
195
+ if (!trimmed) return [];
196
+
197
+ const analysis = await analyzeShellCommand(trimmed);
198
+ const actionResult = deriveShellActionKeys(analysis);
199
+
200
+ if (!actionResult.isSimpleAction || !actionResult.primarySegment) {
201
+ // Complex command — exact only
202
+ return [{ label: trimmed, description: 'This exact compound command', pattern: trimmed }];
203
+ }
204
+
205
+ const options: AllowlistOption[] = [];
206
+
207
+ // Full original command text — "this exact command" means exactly what the user approved
208
+ options.push({ label: trimmed, description: 'This exact command', pattern: trimmed });
209
+
210
+ // Action keys from narrowest to broadest
211
+ for (const actionKey of actionResult.keys) {
212
+ const keyTokens = actionKey.key.replace(/^action:/, '');
213
+ options.push({
214
+ label: `${keyTokens} *`,
215
+ description: `Any "${keyTokens}" command`,
216
+ pattern: actionKey.key,
217
+ });
218
+ }
219
+
220
+ // Deduplicate by pattern
221
+ const seen = new Set<string>();
222
+ return options.filter((o) => {
223
+ if (seen.has(o.pattern)) return false;
224
+ seen.add(o.pattern);
225
+ return true;
226
+ });
227
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { v4 as uuid } from 'uuid';
4
- import { minimatch } from 'minimatch';
4
+ import { Minimatch } from 'minimatch';
5
5
  import { getRootDir } from '../util/platform.js';
6
6
  import { getLogger } from '../util/logger.js';
7
7
  import { getDefaultRuleTemplates } from './defaults.js';
@@ -21,6 +21,50 @@ interface TrustFile {
21
21
  let cachedRules: TrustRule[] | null = null;
22
22
  let cachedStarterBundleAccepted: boolean | null = null;
23
23
 
24
+ /**
25
+ * Cache of pre-compiled Minimatch objects keyed by pattern string.
26
+ * Rebuilt whenever cachedRules changes. Avoids re-parsing glob patterns
27
+ * on every tool-call permission check.
28
+ */
29
+ const compiledPatterns = new Map<string, Minimatch>();
30
+
31
+ /** Get or compile a Minimatch object for the given pattern. Returns null if the pattern is invalid. */
32
+ function getCompiledPattern(pattern: string): Minimatch | null {
33
+ let compiled = compiledPatterns.get(pattern);
34
+ if (!compiled) {
35
+ if (typeof pattern !== 'string') {
36
+ log.warn({ pattern }, 'Cannot compile non-string pattern');
37
+ return null;
38
+ }
39
+ try {
40
+ compiled = new Minimatch(pattern);
41
+ compiledPatterns.set(pattern, compiled);
42
+ } catch (err) {
43
+ log.warn({ pattern, err }, 'Failed to compile pattern');
44
+ return null;
45
+ }
46
+ }
47
+ return compiled;
48
+ }
49
+
50
+ /** Rebuild the compiled pattern cache from the current rule set. */
51
+ function rebuildPatternCache(rules: TrustRule[]): void {
52
+ compiledPatterns.clear();
53
+ for (const rule of rules) {
54
+ if (typeof rule.pattern !== 'string') {
55
+ log.warn({ ruleId: rule.id, pattern: rule.pattern }, 'Skipping rule with non-string pattern during cache rebuild');
56
+ continue;
57
+ }
58
+ if (!compiledPatterns.has(rule.pattern)) {
59
+ try {
60
+ compiledPatterns.set(rule.pattern, new Minimatch(rule.pattern));
61
+ } catch (err) {
62
+ log.warn({ ruleId: rule.id, pattern: rule.pattern, err }, 'Skipping rule with invalid pattern during cache rebuild');
63
+ }
64
+ }
65
+ }
66
+ }
67
+
24
68
  function getTrustPath(): string {
25
69
  return join(getRootDir(), 'protected', 'trust.json');
26
70
  }
@@ -201,6 +245,22 @@ function loadFromDisk(): TrustRule[] {
201
245
  log.info({ ruleCount: rules.length }, 'Migrated v2 trust rules to v3 (principal fields)');
202
246
  } else if (data.version === TRUST_FILE_VERSION) {
203
247
  rules = rawRules;
248
+
249
+ // Strip legacy principal-scoped fields from persisted v3 rules.
250
+ // Before the principal concept was removed, rules could carry
251
+ // principalKind/principalId/principalVersion which acted as scope
252
+ // constraints. Now that matching ignores those fields, leaving them
253
+ // on loaded rules would silently widen their scope to global
254
+ // wildcards. Stripping them and re-saving prevents scope escalation.
255
+ for (const rule of rules) {
256
+ const r = rule as unknown as Record<string, unknown>;
257
+ if ('principalKind' in r || 'principalId' in r || 'principalVersion' in r) {
258
+ delete r.principalKind;
259
+ delete r.principalId;
260
+ delete r.principalVersion;
261
+ needsSave = true;
262
+ }
263
+ }
204
264
  } else if (data.version !== 1) {
205
265
  log.warn({ version: data.version }, 'Unknown trust file version, applying defaults in-memory only');
206
266
  // Apply default deny rules in-memory so the assistant is still
@@ -262,6 +322,7 @@ function saveToDisk(rules: TrustRule[]): void {
262
322
  function getRules(): TrustRule[] {
263
323
  if (cachedRules === null) {
264
324
  cachedRules = loadFromDisk();
325
+ rebuildPatternCache(cachedRules);
265
326
  }
266
327
  return cachedRules;
267
328
  }
@@ -274,9 +335,6 @@ export function addRule(
274
335
  priority: number = 100,
275
336
  options?: {
276
337
  allowHighRisk?: boolean;
277
- principalKind?: string;
278
- principalId?: string;
279
- principalVersion?: string;
280
338
  executionTarget?: string;
281
339
  },
282
340
  ): TrustRule {
@@ -296,21 +354,13 @@ export function addRule(
296
354
  if (options?.allowHighRisk != null) {
297
355
  rule.allowHighRisk = options.allowHighRisk;
298
356
  }
299
- if (options?.principalKind != null) {
300
- rule.principalKind = options.principalKind;
301
- }
302
- if (options?.principalId != null) {
303
- rule.principalId = options.principalId;
304
- }
305
- if (options?.principalVersion != null) {
306
- rule.principalVersion = options.principalVersion;
307
- }
308
357
  if (options?.executionTarget != null) {
309
358
  rule.executionTarget = options.executionTarget;
310
359
  }
311
360
  rules.push(rule);
312
361
  rules.sort(ruleOrder);
313
362
  cachedRules = rules;
363
+ rebuildPatternCache(rules);
314
364
  saveToDisk(rules);
315
365
  log.info({ rule }, 'Added trust rule');
316
366
  return rule;
@@ -337,6 +387,7 @@ export function updateRule(
337
387
  rules[index] = rule;
338
388
  rules.sort(ruleOrder);
339
389
  cachedRules = rules;
390
+ rebuildPatternCache(rules);
340
391
  saveToDisk(rules);
341
392
  log.info({ rule }, 'Updated trust rule');
342
393
  return rule;
@@ -353,6 +404,7 @@ export function removeRule(id: string): boolean {
353
404
  if (index === -1) return false;
354
405
  rules.splice(index, 1);
355
406
  cachedRules = rules;
407
+ rebuildPatternCache(rules);
356
408
  saveToDisk(rules);
357
409
  log.info({ id }, 'Removed trust rule');
358
410
  return true;
@@ -372,47 +424,14 @@ function findRuleByDecision(tool: string, command: string, scope: string, decisi
372
424
  for (const rule of rules) {
373
425
  if (rule.tool !== tool) continue;
374
426
  if (rule.decision !== decision) continue;
375
- if (!minimatch(command, rule.pattern)) continue;
427
+ const compiled = getCompiledPattern(rule.pattern);
428
+ if (!compiled || !compiled.match(command)) continue;
376
429
  if (!matchesScope(rule.scope, scope)) continue;
377
430
  return rule;
378
431
  }
379
432
  return null;
380
433
  }
381
434
 
382
- /**
383
- * Check whether a rule's principal constraints match the given policy context.
384
- *
385
- * A missing field on the rule acts as a wildcard — it matches any value
386
- * (or absence) in the context. When a rule specifies a principal field,
387
- * the context must provide a matching value for the rule to apply.
388
- */
389
- function matchesPrincipal(rule: TrustRule, ctx?: PolicyContext): boolean {
390
- // If the rule has no principal constraints it matches everything (wildcard).
391
- if (rule.principalKind == null && rule.principalId == null && rule.principalVersion == null) {
392
- return true;
393
- }
394
-
395
- const principal = ctx?.principal;
396
-
397
- // Rule specifies a principalKind — context must supply one that matches.
398
- if (rule.principalKind != null) {
399
- if (principal?.kind !== rule.principalKind) return false;
400
- }
401
-
402
- // Rule specifies a principalId — context must supply one that matches.
403
- if (rule.principalId != null) {
404
- if (principal?.id !== rule.principalId) return false;
405
- }
406
-
407
- // Rule specifies a principalVersion — context must supply one that matches.
408
- // If the rule omits principalVersion, any version (or none) is accepted.
409
- if (rule.principalVersion != null) {
410
- if (principal?.version !== rule.principalVersion) return false;
411
- }
412
-
413
- return true;
414
- }
415
-
416
435
  /**
417
436
  * Check whether a rule's executionTarget constraint matches the context.
418
437
  *
@@ -428,9 +447,9 @@ function matchesExecutionTarget(rule: TrustRule, ctx?: PolicyContext): boolean {
428
447
  * Find the highest-priority rule that matches any of the command candidates.
429
448
  * Rules are pre-sorted by priority descending, so the first match wins.
430
449
  *
431
- * When a `PolicyContext` is provided, rules that specify principal or
432
- * executionTarget constraints are filtered accordingly. Rules without
433
- * those constraints act as wildcards and match any context.
450
+ * When a `PolicyContext` is provided, rules that specify executionTarget
451
+ * constraints are filtered accordingly. Rules without those constraints
452
+ * act as wildcards and match any context.
434
453
  */
435
454
  export function findHighestPriorityRule(tool: string, commands: string[], scope: string, ctx?: PolicyContext): TrustRule | null {
436
455
  // Check ephemeral (task-scoped) rules first — they take precedence over
@@ -449,10 +468,11 @@ export function findHighestPriorityRule(tool: string, commands: string[], scope:
449
468
  for (const rule of allRules) {
450
469
  if (rule.tool !== tool) continue;
451
470
  if (!matchesScope(rule.scope, scope)) continue;
452
- if (!matchesPrincipal(rule, ctx)) continue;
453
471
  if (!matchesExecutionTarget(rule, ctx)) continue;
472
+ const compiled = getCompiledPattern(rule.pattern);
473
+ if (!compiled) continue;
454
474
  for (const command of commands) {
455
- if (minimatch(command, rule.pattern)) {
475
+ if (compiled.match(command)) {
456
476
  return rule;
457
477
  }
458
478
  }
@@ -480,6 +500,7 @@ export function clearAllRules(): void {
480
500
  backfillDefaults(rules);
481
501
  rules.sort(ruleOrder);
482
502
  cachedRules = rules;
503
+ rebuildPatternCache(rules);
483
504
  saveToDisk(rules);
484
505
  log.info('Cleared all user trust rules (default rules preserved)');
485
506
  }
@@ -487,6 +508,7 @@ export function clearAllRules(): void {
487
508
  export function clearCache(): void {
488
509
  cachedRules = null;
489
510
  cachedStarterBundleAccepted = null;
511
+ compiledPatterns.clear();
490
512
  }
491
513
 
492
514
  // ─── Starter approval bundle ────────────────────────────────────────────────
@@ -577,6 +599,7 @@ export function acceptStarterBundle(): AcceptStarterBundleResult {
577
599
  cachedStarterBundleAccepted = true;
578
600
  rules.sort(ruleOrder);
579
601
  cachedRules = rules;
602
+ rebuildPatternCache(rules);
580
603
  saveToDisk(rules);
581
604
  log.info({ rulesAdded: added }, 'Starter approval bundle accepted');
582
605