vellum 0.0.16 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (838) hide show
  1. package/.dockerignore +27 -0
  2. package/.env.example +22 -0
  3. package/Dockerfile +99 -0
  4. package/Dockerfile.sandbox +5 -0
  5. package/README.md +150 -3
  6. package/bun.lock +1768 -0
  7. package/bunfig.toml +2 -0
  8. package/docs/skills.md +158 -0
  9. package/drizzle/0000_dizzy_maggott.sql +301 -0
  10. package/drizzle/meta/0000_snapshot.json +1999 -0
  11. package/drizzle/meta/_journal.json +13 -0
  12. package/drizzle.config.ts +7 -0
  13. package/eslint.config.mjs +17 -0
  14. package/hook-templates/debug-prompt-logger/hook.json +7 -0
  15. package/hook-templates/debug-prompt-logger/run.sh +68 -0
  16. package/knip.json +9 -0
  17. package/package.json +60 -10
  18. package/scripts/ipc/check-contract-inventory.ts +104 -0
  19. package/scripts/ipc/check-swift-decoder-drift.ts +163 -0
  20. package/scripts/ipc/generate-swift.ts +492 -0
  21. package/scripts/test-filesystem-tools.sh +48 -0
  22. package/scripts/test.sh +122 -0
  23. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2079 -0
  24. package/src/__tests__/account-registry.test.ts +244 -0
  25. package/src/__tests__/active-skill-tools.test.ts +378 -0
  26. package/src/__tests__/agent-loop-thinking.test.ts +81 -0
  27. package/src/__tests__/agent-loop.test.ts +1135 -0
  28. package/src/__tests__/anthropic-provider.test.ts +778 -0
  29. package/src/__tests__/app-builder-tool-scripts.test.ts +290 -0
  30. package/src/__tests__/app-bundler.test.ts +313 -0
  31. package/src/__tests__/app-executors.test.ts +613 -0
  32. package/src/__tests__/app-open-proxy.test.ts +62 -0
  33. package/src/__tests__/asset-materialize-tool.test.ts +451 -0
  34. package/src/__tests__/asset-search-tool.test.ts +476 -0
  35. package/src/__tests__/assistant-attachment-directive.test.ts +401 -0
  36. package/src/__tests__/assistant-attachments.test.ts +437 -0
  37. package/src/__tests__/assistant-event-hub.test.ts +226 -0
  38. package/src/__tests__/assistant-event.test.ts +123 -0
  39. package/src/__tests__/attachments-store.test.ts +547 -0
  40. package/src/__tests__/attachments.test.ts +134 -0
  41. package/src/__tests__/audit-log-rotation.test.ts +154 -0
  42. package/src/__tests__/browser-fill-credential.test.ts +309 -0
  43. package/src/__tests__/browser-manager.test.ts +203 -0
  44. package/src/__tests__/browser-runtime-check.test.ts +55 -0
  45. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +67 -0
  46. package/src/__tests__/browser-skill-endstate.test.ts +198 -0
  47. package/src/__tests__/bundle-scanner.test.ts +313 -0
  48. package/src/__tests__/checker.test.ts +3856 -0
  49. package/src/__tests__/clarification-resolver.test.ts +159 -0
  50. package/src/__tests__/classifier.test.ts +67 -0
  51. package/src/__tests__/claude-code-skill-regression.test.ts +127 -0
  52. package/src/__tests__/claude-code-tool-profiles.test.ts +88 -0
  53. package/src/__tests__/cli-discover.test.ts +85 -0
  54. package/src/__tests__/cli.test.ts +81 -0
  55. package/src/__tests__/clipboard.test.ts +80 -0
  56. package/src/__tests__/commit-guarantee.test.ts +335 -0
  57. package/src/__tests__/computer-use-session-compaction.test.ts +132 -0
  58. package/src/__tests__/computer-use-session-lifecycle.test.ts +293 -0
  59. package/src/__tests__/computer-use-session-working-dir.test.ts +117 -0
  60. package/src/__tests__/computer-use-skill-baseline.test.ts +74 -0
  61. package/src/__tests__/computer-use-skill-endstate.test.ts +89 -0
  62. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +217 -0
  63. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +107 -0
  64. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +54 -0
  65. package/src/__tests__/config-schema.test.ts +720 -0
  66. package/src/__tests__/conflict-store.test.ts +329 -0
  67. package/src/__tests__/connection-policy.test.ts +102 -0
  68. package/src/__tests__/context-memory-e2e.test.ts +434 -0
  69. package/src/__tests__/context-token-estimator.test.ts +135 -0
  70. package/src/__tests__/context-window-manager.test.ts +376 -0
  71. package/src/__tests__/contradiction-checker.test.ts +216 -0
  72. package/src/__tests__/conversation-store.test.ts +614 -0
  73. package/src/__tests__/credential-broker-browser-fill.test.ts +517 -0
  74. package/src/__tests__/credential-broker-server-use.test.ts +554 -0
  75. package/src/__tests__/credential-broker.test.ts +167 -0
  76. package/src/__tests__/credential-host-pattern-match.test.ts +104 -0
  77. package/src/__tests__/credential-metadata-store.test.ts +779 -0
  78. package/src/__tests__/credential-policy-validate.test.ts +121 -0
  79. package/src/__tests__/credential-resolve.test.ts +328 -0
  80. package/src/__tests__/credential-security-e2e.test.ts +352 -0
  81. package/src/__tests__/credential-security-invariants.test.ts +563 -0
  82. package/src/__tests__/credential-selection.test.ts +354 -0
  83. package/src/__tests__/credential-vault.test.ts +852 -0
  84. package/src/__tests__/daemon-assistant-events.test.ts +164 -0
  85. package/src/__tests__/daemon-server-session-init.test.ts +522 -0
  86. package/src/__tests__/delete-managed-skill-tool.test.ts +97 -0
  87. package/src/__tests__/diff.test.ts +121 -0
  88. package/src/__tests__/domain-normalize.test.ts +112 -0
  89. package/src/__tests__/domain-policy.test.ts +124 -0
  90. package/src/__tests__/doordash-client.test.ts +186 -0
  91. package/src/__tests__/doordash-session.test.ts +143 -0
  92. package/src/__tests__/dynamic-page-surface.test.ts +91 -0
  93. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +132 -0
  94. package/src/__tests__/edit-engine.test.ts +180 -0
  95. package/src/__tests__/email-cli.test.ts +283 -0
  96. package/src/__tests__/encrypted-store.test.ts +332 -0
  97. package/src/__tests__/entity-extractor.test.ts +190 -0
  98. package/src/__tests__/ephemeral-permissions.test.ts +312 -0
  99. package/src/__tests__/evaluate-typescript-tool.test.ts +286 -0
  100. package/src/__tests__/event-bus.test.ts +222 -0
  101. package/src/__tests__/file-edit-tool.test.ts +122 -0
  102. package/src/__tests__/file-ops-service.test.ts +330 -0
  103. package/src/__tests__/file-read-tool.test.ts +75 -0
  104. package/src/__tests__/file-write-tool.test.ts +113 -0
  105. package/src/__tests__/fixtures/credential-security-fixtures.ts +181 -0
  106. package/src/__tests__/fixtures/media-reuse-fixtures.ts +126 -0
  107. package/src/__tests__/fixtures/mock-signup-server.ts +387 -0
  108. package/src/__tests__/fixtures/proxy-fixtures.ts +147 -0
  109. package/src/__tests__/fuzzy-match-property.test.ts +216 -0
  110. package/src/__tests__/fuzzy-match.test.ts +138 -0
  111. package/src/__tests__/gemini-image-service.test.ts +261 -0
  112. package/src/__tests__/gemini-provider.test.ts +651 -0
  113. package/src/__tests__/get-weather.test.ts +318 -0
  114. package/src/__tests__/gmail-integration.test.ts +73 -0
  115. package/src/__tests__/handlers-cu-observation-blob.test.ts +351 -0
  116. package/src/__tests__/handlers-ipc-blob-probe.test.ts +190 -0
  117. package/src/__tests__/handlers-slack-config.test.ts +199 -0
  118. package/src/__tests__/handlers-task-submit-slash.test.ts +38 -0
  119. package/src/__tests__/headless-browser-interactions.test.ts +536 -0
  120. package/src/__tests__/headless-browser-navigate.test.ts +211 -0
  121. package/src/__tests__/headless-browser-read-tools.test.ts +261 -0
  122. package/src/__tests__/headless-browser-snapshot.test.ts +185 -0
  123. package/src/__tests__/history-repair-observability.test.ts +56 -0
  124. package/src/__tests__/history-repair.test.ts +510 -0
  125. package/src/__tests__/home-base-bootstrap.test.ts +77 -0
  126. package/src/__tests__/hooks-blocking.test.ts +128 -0
  127. package/src/__tests__/hooks-cli.test.ts +144 -0
  128. package/src/__tests__/hooks-config.test.ts +93 -0
  129. package/src/__tests__/hooks-discovery.test.ts +199 -0
  130. package/src/__tests__/hooks-integration.test.ts +189 -0
  131. package/src/__tests__/hooks-manager.test.ts +187 -0
  132. package/src/__tests__/hooks-runner.test.ts +178 -0
  133. package/src/__tests__/hooks-settings.test.ts +154 -0
  134. package/src/__tests__/hooks-templates.test.ts +137 -0
  135. package/src/__tests__/hooks-ts-runner.test.ts +125 -0
  136. package/src/__tests__/hooks-watch.test.ts +100 -0
  137. package/src/__tests__/host-file-edit-tool.test.ts +104 -0
  138. package/src/__tests__/host-file-read-tool.test.ts +61 -0
  139. package/src/__tests__/host-file-write-tool.test.ts +77 -0
  140. package/src/__tests__/host-shell-tool.test.ts +311 -0
  141. package/src/__tests__/intent-routing.test.ts +255 -0
  142. package/src/__tests__/ipc-blob-store.test.ts +315 -0
  143. package/src/__tests__/ipc-contract-inventory.test.ts +54 -0
  144. package/src/__tests__/ipc-contract.test.ts +74 -0
  145. package/src/__tests__/ipc-protocol.test.ts +113 -0
  146. package/src/__tests__/ipc-snapshot.test.ts +1560 -0
  147. package/src/__tests__/ipc-validate.test.ts +357 -0
  148. package/src/__tests__/key-migration.test.ts +183 -0
  149. package/src/__tests__/keychain.test.ts +258 -0
  150. package/src/__tests__/llm-usage-store.test.ts +226 -0
  151. package/src/__tests__/managed-skill-lifecycle.test.ts +257 -0
  152. package/src/__tests__/managed-store.test.ts +608 -0
  153. package/src/__tests__/media-generate-image.test.ts +238 -0
  154. package/src/__tests__/media-reuse-story.e2e.test.ts +676 -0
  155. package/src/__tests__/media-visibility-policy.test.ts +141 -0
  156. package/src/__tests__/memory-context-benchmark.test.ts +235 -0
  157. package/src/__tests__/memory-lifecycle-e2e.test.ts +481 -0
  158. package/src/__tests__/memory-query-builder.test.ts +59 -0
  159. package/src/__tests__/memory-recall-quality.test.ts +846 -0
  160. package/src/__tests__/memory-regressions.experimental.test.ts +538 -0
  161. package/src/__tests__/memory-regressions.test.ts +4238 -0
  162. package/src/__tests__/memory-retrieval-budget.test.ts +49 -0
  163. package/src/__tests__/migration-cli-flows.test.ts +169 -0
  164. package/src/__tests__/migration-ordering.test.ts +249 -0
  165. package/src/__tests__/mock-signup-server.test.ts +528 -0
  166. package/src/__tests__/onboarding-starter-tasks.test.ts +166 -0
  167. package/src/__tests__/onboarding-template-contract.test.ts +58 -0
  168. package/src/__tests__/openai-provider.test.ts +753 -0
  169. package/src/__tests__/parser.test.ts +472 -0
  170. package/src/__tests__/path-classifier.test.ts +73 -0
  171. package/src/__tests__/path-policy.test.ts +435 -0
  172. package/src/__tests__/platform-move-helper.test.ts +99 -0
  173. package/src/__tests__/platform-socket-path.test.ts +52 -0
  174. package/src/__tests__/platform-workspace-migration.test.ts +1000 -0
  175. package/src/__tests__/platform.test.ts +131 -0
  176. package/src/__tests__/prebuilt-home-base-seed.test.ts +71 -0
  177. package/src/__tests__/pricing.test.ts +256 -0
  178. package/src/__tests__/profile-compiler.test.ts +373 -0
  179. package/src/__tests__/provider-registry-ollama.test.ts +16 -0
  180. package/src/__tests__/proxy-approval-callback.test.ts +601 -0
  181. package/src/__tests__/ratelimit.test.ts +297 -0
  182. package/src/__tests__/registry.test.ts +487 -0
  183. package/src/__tests__/reminder-store.test.ts +220 -0
  184. package/src/__tests__/reminder.test.ts +263 -0
  185. package/src/__tests__/request-file-tool.test.ts +158 -0
  186. package/src/__tests__/run-orchestrator.test.ts +200 -0
  187. package/src/__tests__/runtime-attachment-metadata.test.ts +190 -0
  188. package/src/__tests__/runtime-runs-http.test.ts +451 -0
  189. package/src/__tests__/runtime-runs.test.ts +273 -0
  190. package/src/__tests__/sandbox-diagnostics.test.ts +408 -0
  191. package/src/__tests__/sandbox-host-parity.test.ts +950 -0
  192. package/src/__tests__/scaffold-managed-skill-tool.test.ts +253 -0
  193. package/src/__tests__/script-proxy-certs.test.ts +90 -0
  194. package/src/__tests__/script-proxy-connect-tunnel.test.ts +177 -0
  195. package/src/__tests__/script-proxy-decision-trace.test.ts +156 -0
  196. package/src/__tests__/script-proxy-http-forwarder.test.ts +281 -0
  197. package/src/__tests__/script-proxy-injection-runtime.test.ts +401 -0
  198. package/src/__tests__/script-proxy-mitm-handler.test.ts +407 -0
  199. package/src/__tests__/script-proxy-policy-runtime.test.ts +287 -0
  200. package/src/__tests__/script-proxy-policy.test.ts +310 -0
  201. package/src/__tests__/script-proxy-rewrite-specificity.test.ts +135 -0
  202. package/src/__tests__/script-proxy-router.test.ts +180 -0
  203. package/src/__tests__/script-proxy-session-manager.test.ts +382 -0
  204. package/src/__tests__/script-proxy-session-runtime.test.ts +113 -0
  205. package/src/__tests__/secret-allowlist.test.ts +229 -0
  206. package/src/__tests__/secret-ingress-handler.test.ts +99 -0
  207. package/src/__tests__/secret-onetime-send.test.ts +130 -0
  208. package/src/__tests__/secret-prompt-log-hygiene.test.ts +106 -0
  209. package/src/__tests__/secret-response-routing.test.ts +93 -0
  210. package/src/__tests__/secret-scanner-executor.test.ts +348 -0
  211. package/src/__tests__/secret-scanner.test.ts +857 -0
  212. package/src/__tests__/secure-keys.test.ts +323 -0
  213. package/src/__tests__/server-history-render.test.ts +430 -0
  214. package/src/__tests__/session-abort-tool-results.test.ts +240 -0
  215. package/src/__tests__/session-conflict-gate.test.ts +697 -0
  216. package/src/__tests__/session-error.test.ts +341 -0
  217. package/src/__tests__/session-evictor.test.ts +188 -0
  218. package/src/__tests__/session-load-history-repair.test.ts +222 -0
  219. package/src/__tests__/session-pre-run-repair.test.ts +213 -0
  220. package/src/__tests__/session-profile-injection.test.ts +444 -0
  221. package/src/__tests__/session-provider-retry-repair.test.ts +306 -0
  222. package/src/__tests__/session-queue.test.ts +1462 -0
  223. package/src/__tests__/session-runtime-assembly.test.ts +315 -0
  224. package/src/__tests__/session-runtime-workspace.test.ts +183 -0
  225. package/src/__tests__/session-skill-tools.test.ts +2431 -0
  226. package/src/__tests__/session-slash-known.test.ts +368 -0
  227. package/src/__tests__/session-slash-queue.test.ts +288 -0
  228. package/src/__tests__/session-slash-unknown.test.ts +271 -0
  229. package/src/__tests__/session-tool-setup-app-refresh.test.ts +473 -0
  230. package/src/__tests__/session-tool-setup-memory-scope.test.ts +140 -0
  231. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +140 -0
  232. package/src/__tests__/session-undo.test.ts +75 -0
  233. package/src/__tests__/session-workspace-cache-state.test.ts +246 -0
  234. package/src/__tests__/session-workspace-injection.test.ts +327 -0
  235. package/src/__tests__/session-workspace-tool-tracking.test.ts +240 -0
  236. package/src/__tests__/shared-filesystem-errors.test.ts +78 -0
  237. package/src/__tests__/shell-credential-ref.test.ts +187 -0
  238. package/src/__tests__/shell-parser-fuzz.test.ts +544 -0
  239. package/src/__tests__/shell-parser-property.test.ts +433 -0
  240. package/src/__tests__/shell-tool-proxy-mode.test.ts +272 -0
  241. package/src/__tests__/signup-e2e.test.ts +352 -0
  242. package/src/__tests__/size-guard.test.ts +117 -0
  243. package/src/__tests__/skill-include-graph.test.ts +303 -0
  244. package/src/__tests__/skill-load-tool.test.ts +409 -0
  245. package/src/__tests__/skill-script-runner-host.test.ts +489 -0
  246. package/src/__tests__/skill-script-runner-sandbox.test.ts +349 -0
  247. package/src/__tests__/skill-tool-factory.test.ts +252 -0
  248. package/src/__tests__/skill-tool-manifest.test.ts +658 -0
  249. package/src/__tests__/skill-version-hash.test.ts +182 -0
  250. package/src/__tests__/skills.test.ts +597 -0
  251. package/src/__tests__/slash-commands-catalog.test.ts +86 -0
  252. package/src/__tests__/slash-commands-parser.test.ts +119 -0
  253. package/src/__tests__/slash-commands-resolver.test.ts +193 -0
  254. package/src/__tests__/slash-commands-rewrite.test.ts +39 -0
  255. package/src/__tests__/starter-bundle.test.ts +136 -0
  256. package/src/__tests__/starter-task-flow.test.ts +143 -0
  257. package/src/__tests__/subagent-manager-notify.test.ts +372 -0
  258. package/src/__tests__/subagent-tools.test.ts +118 -0
  259. package/src/__tests__/subagent-types.test.ts +78 -0
  260. package/src/__tests__/swarm-orchestrator.test.ts +428 -0
  261. package/src/__tests__/swarm-plan-validator.test.ts +330 -0
  262. package/src/__tests__/swarm-recursion.test.ts +165 -0
  263. package/src/__tests__/swarm-router-planner.test.ts +208 -0
  264. package/src/__tests__/swarm-session-integration.test.ts +274 -0
  265. package/src/__tests__/swarm-tool.test.ts +145 -0
  266. package/src/__tests__/swarm-worker-backend.test.ts +129 -0
  267. package/src/__tests__/swarm-worker-runner.test.ts +272 -0
  268. package/src/__tests__/system-prompt.test.ts +461 -0
  269. package/src/__tests__/task-compiler.test.ts +283 -0
  270. package/src/__tests__/task-runner.test.ts +215 -0
  271. package/src/__tests__/task-scheduler.test.ts +216 -0
  272. package/src/__tests__/task-tools.test.ts +602 -0
  273. package/src/__tests__/terminal-sandbox-docker.test.ts +1064 -0
  274. package/src/__tests__/terminal-sandbox.integration.test.ts +178 -0
  275. package/src/__tests__/terminal-sandbox.test.ts +202 -0
  276. package/src/__tests__/test-support/browser-skill-harness.ts +90 -0
  277. package/src/__tests__/test-support/computer-use-skill-harness.ts +45 -0
  278. package/src/__tests__/tool-audit-listener.test.ts +112 -0
  279. package/src/__tests__/tool-domain-event-publisher.test.ts +251 -0
  280. package/src/__tests__/tool-executor-lifecycle-events.test.ts +516 -0
  281. package/src/__tests__/tool-executor-redaction.test.ts +289 -0
  282. package/src/__tests__/tool-executor.test.ts +1971 -0
  283. package/src/__tests__/tool-metrics-listener.test.ts +225 -0
  284. package/src/__tests__/tool-notification-listener.test.ts +49 -0
  285. package/src/__tests__/tool-policy.test.ts +54 -0
  286. package/src/__tests__/tool-profiling-listener.test.ts +268 -0
  287. package/src/__tests__/tool-result-truncation.test.ts +217 -0
  288. package/src/__tests__/tool-trace-listener.test.ts +226 -0
  289. package/src/__tests__/top-level-renderer.test.ts +121 -0
  290. package/src/__tests__/top-level-scanner.test.ts +141 -0
  291. package/src/__tests__/trace-emitter.test.ts +173 -0
  292. package/src/__tests__/trust-store.test.ts +2030 -0
  293. package/src/__tests__/turn-commit.test.ts +219 -0
  294. package/src/__tests__/url-safety.test.ts +418 -0
  295. package/src/__tests__/weather-skill-regression.test.ts +225 -0
  296. package/src/__tests__/web-fetch.test.ts +869 -0
  297. package/src/__tests__/web-search.test.ts +584 -0
  298. package/src/__tests__/workspace-git-service.test.ts +750 -0
  299. package/src/__tests__/workspace-heartbeat-service.test.ts +347 -0
  300. package/src/__tests__/workspace-lifecycle.test.ts +292 -0
  301. package/src/agent/attachments.ts +35 -0
  302. package/src/agent/loop.ts +500 -0
  303. package/src/agent/message-types.ts +17 -0
  304. package/src/autonomy/autonomy-resolver.ts +60 -0
  305. package/src/autonomy/autonomy-store.ts +122 -0
  306. package/src/autonomy/disposition-mapper.ts +31 -0
  307. package/src/autonomy/index.ts +11 -0
  308. package/src/autonomy/types.ts +39 -0
  309. package/src/bundler/app-bundler.ts +274 -0
  310. package/src/bundler/bundle-scanner.ts +535 -0
  311. package/src/bundler/bundle-signer.ts +124 -0
  312. package/src/bundler/manifest.ts +21 -0
  313. package/src/bundler/signature-verifier.ts +184 -0
  314. package/src/cli/autonomy.ts +188 -0
  315. package/src/cli/contacts.ts +149 -0
  316. package/src/cli/doordash.ts +824 -0
  317. package/src/cli/email-guardrails.ts +200 -0
  318. package/src/cli/email.ts +405 -0
  319. package/src/cli/main-screen.tsx +155 -0
  320. package/src/cli.ts +935 -0
  321. package/src/config/bundled-skills/.gitkeep +0 -0
  322. package/src/config/bundled-skills/agentmail/SKILL.md +128 -0
  323. package/src/config/bundled-skills/agentmail/icon.svg +21 -0
  324. package/src/config/bundled-skills/app-builder/SKILL.md +1348 -0
  325. package/src/config/bundled-skills/app-builder/TOOLS.json +279 -0
  326. package/src/config/bundled-skills/app-builder/icon.svg +9 -0
  327. package/src/config/bundled-skills/app-builder/tools/app-create.ts +15 -0
  328. package/src/config/bundled-skills/app-builder/tools/app-delete.ts +10 -0
  329. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +11 -0
  330. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +10 -0
  331. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +18 -0
  332. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +11 -0
  333. package/src/config/bundled-skills/app-builder/tools/app-list.ts +10 -0
  334. package/src/config/bundled-skills/app-builder/tools/app-query.ts +10 -0
  335. package/src/config/bundled-skills/app-builder/tools/app-update.ts +20 -0
  336. package/src/config/bundled-skills/browser/SKILL.md +28 -0
  337. package/src/config/bundled-skills/browser/TOOLS.json +234 -0
  338. package/src/config/bundled-skills/browser/tools/browser-click.ts +9 -0
  339. package/src/config/bundled-skills/browser/tools/browser-close.ts +9 -0
  340. package/src/config/bundled-skills/browser/tools/browser-extract.ts +9 -0
  341. package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +9 -0
  342. package/src/config/bundled-skills/browser/tools/browser-navigate.ts +9 -0
  343. package/src/config/bundled-skills/browser/tools/browser-press-key.ts +9 -0
  344. package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +9 -0
  345. package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +9 -0
  346. package/src/config/bundled-skills/browser/tools/browser-type.ts +9 -0
  347. package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +9 -0
  348. package/src/config/bundled-skills/claude-code/SKILL.md +50 -0
  349. package/src/config/bundled-skills/claude-code/TOOLS.json +40 -0
  350. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +9 -0
  351. package/src/config/bundled-skills/computer-use/SKILL.md +17 -0
  352. package/src/config/bundled-skills/computer-use/TOOLS.json +326 -0
  353. package/src/config/bundled-skills/computer-use/tools/computer-use-click.ts +9 -0
  354. package/src/config/bundled-skills/computer-use/tools/computer-use-done.ts +9 -0
  355. package/src/config/bundled-skills/computer-use/tools/computer-use-double-click.ts +9 -0
  356. package/src/config/bundled-skills/computer-use/tools/computer-use-drag.ts +9 -0
  357. package/src/config/bundled-skills/computer-use/tools/computer-use-key.ts +9 -0
  358. package/src/config/bundled-skills/computer-use/tools/computer-use-open-app.ts +9 -0
  359. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +9 -0
  360. package/src/config/bundled-skills/computer-use/tools/computer-use-respond.ts +9 -0
  361. package/src/config/bundled-skills/computer-use/tools/computer-use-right-click.ts +9 -0
  362. package/src/config/bundled-skills/computer-use/tools/computer-use-run-applescript.ts +9 -0
  363. package/src/config/bundled-skills/computer-use/tools/computer-use-scroll.ts +9 -0
  364. package/src/config/bundled-skills/computer-use/tools/computer-use-type-text.ts +9 -0
  365. package/src/config/bundled-skills/computer-use/tools/computer-use-wait.ts +9 -0
  366. package/src/config/bundled-skills/google-calendar/SKILL.md +51 -0
  367. package/src/config/bundled-skills/google-calendar/TOOLS.json +108 -0
  368. package/src/config/bundled-skills/google-calendar/calendar-client.ts +165 -0
  369. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +21 -0
  370. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +42 -0
  371. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +13 -0
  372. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +30 -0
  373. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +41 -0
  374. package/src/config/bundled-skills/google-calendar/tools/shared.ts +18 -0
  375. package/src/config/bundled-skills/google-calendar/types.ts +97 -0
  376. package/src/config/bundled-skills/image-studio/SKILL.md +32 -0
  377. package/src/config/bundled-skills/image-studio/TOOLS.json +42 -0
  378. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +137 -0
  379. package/src/config/bundled-skills/messaging/SKILL.md +126 -0
  380. package/src/config/bundled-skills/messaging/TOOLS.json +357 -0
  381. package/src/config/bundled-skills/messaging/tools/gmail-archive.ts +23 -0
  382. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +23 -0
  383. package/src/config/bundled-skills/messaging/tools/gmail-batch-label.ts +25 -0
  384. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +26 -0
  385. package/src/config/bundled-skills/messaging/tools/gmail-label.ts +25 -0
  386. package/src/config/bundled-skills/messaging/tools/gmail-trash.ts +23 -0
  387. package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +84 -0
  388. package/src/config/bundled-skills/messaging/tools/messaging-analyze-activity.ts +18 -0
  389. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +124 -0
  390. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +16 -0
  391. package/src/config/bundled-skills/messaging/tools/messaging-draft.ts +49 -0
  392. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +21 -0
  393. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +25 -0
  394. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +28 -0
  395. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +29 -0
  396. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +22 -0
  397. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -0
  398. package/src/config/bundled-skills/messaging/tools/shared.ts +71 -0
  399. package/src/config/bundled-skills/messaging/tools/slack-add-reaction.ts +25 -0
  400. package/src/config/bundled-skills/messaging/tools/slack-leave-channel.ts +23 -0
  401. package/src/config/bundled-skills/self-upgrade/SKILL.md +74 -0
  402. package/src/config/bundled-skills/start-the-day/SKILL.md +70 -0
  403. package/src/config/bundled-skills/start-the-day/icon.svg +13 -0
  404. package/src/config/bundled-skills/weather/SKILL.md +37 -0
  405. package/src/config/bundled-skills/weather/TOOLS.json +32 -0
  406. package/src/config/bundled-skills/weather/icon.svg +24 -0
  407. package/src/config/bundled-skills/weather/tools/get-weather.ts +9 -0
  408. package/src/config/computer-use-prompt.ts +97 -0
  409. package/src/config/defaults.ts +186 -0
  410. package/src/config/loader.ts +336 -0
  411. package/src/config/schema.ts +1004 -0
  412. package/src/config/skill-state.ts +95 -0
  413. package/src/config/skills.ts +972 -0
  414. package/src/config/system-prompt.ts +927 -0
  415. package/src/config/templates/BOOTSTRAP.md +70 -0
  416. package/src/config/templates/IDENTITY.md +18 -0
  417. package/src/config/templates/LOOKS.md +25 -0
  418. package/src/config/templates/SOUL.md +37 -0
  419. package/src/config/templates/USER.md +19 -0
  420. package/src/config/types.ts +32 -0
  421. package/src/config/vellum-skills/deploy-fullstack-vercel/SKILL.md +179 -0
  422. package/src/config/vellum-skills/document-writer/SKILL.md +195 -0
  423. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +194 -0
  424. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +147 -0
  425. package/src/config/vellum-skills/telegram-setup/SKILL.md +105 -0
  426. package/src/contacts/contact-store.ts +410 -0
  427. package/src/contacts/index.ts +11 -0
  428. package/src/contacts/types.ts +28 -0
  429. package/src/context/token-estimator.ts +108 -0
  430. package/src/context/tool-result-truncation.ts +128 -0
  431. package/src/context/window-manager.ts +531 -0
  432. package/src/daemon/assistant-attachments.ts +679 -0
  433. package/src/daemon/classifier.ts +108 -0
  434. package/src/daemon/computer-use-session.ts +900 -0
  435. package/src/daemon/connection-policy.ts +41 -0
  436. package/src/daemon/handlers/apps.ts +446 -0
  437. package/src/daemon/handlers/computer-use.ts +181 -0
  438. package/src/daemon/handlers/config.ts +434 -0
  439. package/src/daemon/handlers/diagnostics.ts +334 -0
  440. package/src/daemon/handlers/documents.ts +184 -0
  441. package/src/daemon/handlers/home-base.ts +73 -0
  442. package/src/daemon/handlers/index.ts +355 -0
  443. package/src/daemon/handlers/misc.ts +323 -0
  444. package/src/daemon/handlers/open-bundle-handler.ts +80 -0
  445. package/src/daemon/handlers/publish.ts +182 -0
  446. package/src/daemon/handlers/sessions.ts +486 -0
  447. package/src/daemon/handlers/shared.ts +533 -0
  448. package/src/daemon/handlers/skills.ts +487 -0
  449. package/src/daemon/handlers/subagents.ts +122 -0
  450. package/src/daemon/handlers/work-items.ts +176 -0
  451. package/src/daemon/handlers.ts +17 -0
  452. package/src/daemon/history-repair.ts +214 -0
  453. package/src/daemon/ipc-blob-store.ts +231 -0
  454. package/src/daemon/ipc-contract-inventory.json +407 -0
  455. package/src/daemon/ipc-contract-inventory.ts +126 -0
  456. package/src/daemon/ipc-contract.ts +2102 -0
  457. package/src/daemon/ipc-protocol.ts +70 -0
  458. package/src/daemon/ipc-validate.ts +171 -0
  459. package/src/daemon/lifecycle.ts +503 -0
  460. package/src/daemon/main.ts +15 -0
  461. package/src/daemon/media-visibility-policy.ts +57 -0
  462. package/src/daemon/ride-shotgun-handler.ts +244 -0
  463. package/src/daemon/server.ts +1085 -0
  464. package/src/daemon/session-attachments.ts +173 -0
  465. package/src/daemon/session-conflict-gate.ts +219 -0
  466. package/src/daemon/session-dynamic-profile.ts +63 -0
  467. package/src/daemon/session-error.ts +269 -0
  468. package/src/daemon/session-evictor.ts +196 -0
  469. package/src/daemon/session-history.ts +437 -0
  470. package/src/daemon/session-memory.ts +212 -0
  471. package/src/daemon/session-process.ts +264 -0
  472. package/src/daemon/session-queue-manager.ts +81 -0
  473. package/src/daemon/session-runtime-assembly.ts +395 -0
  474. package/src/daemon/session-skill-tools.ts +237 -0
  475. package/src/daemon/session-slash.ts +302 -0
  476. package/src/daemon/session-surfaces.ts +624 -0
  477. package/src/daemon/session-tool-setup.ts +286 -0
  478. package/src/daemon/session-usage.ts +74 -0
  479. package/src/daemon/session-workspace.ts +19 -0
  480. package/src/daemon/session.ts +1651 -0
  481. package/src/daemon/trace-emitter.ts +82 -0
  482. package/src/daemon/watch-handler.ts +274 -0
  483. package/src/doordash/client.ts +905 -0
  484. package/src/doordash/queries.ts +1312 -0
  485. package/src/doordash/query-extractor.ts +93 -0
  486. package/src/doordash/session.ts +82 -0
  487. package/src/email/provider.ts +117 -0
  488. package/src/email/providers/agentmail.ts +317 -0
  489. package/src/email/providers/index.ts +58 -0
  490. package/src/email/service.ts +303 -0
  491. package/src/email/types.ts +126 -0
  492. package/src/events/bus.ts +157 -0
  493. package/src/events/domain-events.ts +83 -0
  494. package/src/events/index.ts +18 -0
  495. package/src/events/tool-audit-listener.ts +80 -0
  496. package/src/events/tool-domain-event-publisher.ts +111 -0
  497. package/src/events/tool-metrics-listener.ts +159 -0
  498. package/src/events/tool-notification-listener.ts +17 -0
  499. package/src/events/tool-profiling-listener.ts +158 -0
  500. package/src/events/tool-trace-listener.ts +75 -0
  501. package/src/export/formatter.ts +96 -0
  502. package/src/followups/followup-store.ts +166 -0
  503. package/src/followups/index.ts +10 -0
  504. package/src/followups/types.ts +23 -0
  505. package/src/gallery/default-gallery.ts +795 -0
  506. package/src/gallery/gallery-manifest.ts +24 -0
  507. package/src/home-base/app-link-store.ts +82 -0
  508. package/src/home-base/bootstrap.ts +66 -0
  509. package/src/home-base/prebuilt/index.html +662 -0
  510. package/src/home-base/prebuilt/seed-metadata.json +21 -0
  511. package/src/home-base/prebuilt/seed.ts +101 -0
  512. package/src/home-base/prebuilt-home-base-updater.ts +30 -0
  513. package/src/hooks/cli.ts +163 -0
  514. package/src/hooks/config.ts +88 -0
  515. package/src/hooks/discovery.ts +110 -0
  516. package/src/hooks/manager.ts +128 -0
  517. package/src/hooks/runner.ts +123 -0
  518. package/src/hooks/templates.ts +52 -0
  519. package/src/hooks/types.ts +72 -0
  520. package/src/index.ts +1194 -0
  521. package/src/instrument.ts +60 -0
  522. package/src/logfire.ts +99 -0
  523. package/src/media/gemini-image-service.ts +136 -0
  524. package/src/memory/account-store.ts +108 -0
  525. package/src/memory/admin.ts +211 -0
  526. package/src/memory/app-store.ts +556 -0
  527. package/src/memory/attachments-store.ts +453 -0
  528. package/src/memory/channel-delivery-store.ts +368 -0
  529. package/src/memory/checkpoints.ts +52 -0
  530. package/src/memory/clarification-resolver.ts +297 -0
  531. package/src/memory/conflict-store.ts +342 -0
  532. package/src/memory/contradiction-checker.ts +329 -0
  533. package/src/memory/conversation-key-store.ts +127 -0
  534. package/src/memory/conversation-store.ts +469 -0
  535. package/src/memory/db.ts +1105 -0
  536. package/src/memory/embedding-backend.ts +229 -0
  537. package/src/memory/embedding-gemini.ts +52 -0
  538. package/src/memory/embedding-local.ts +75 -0
  539. package/src/memory/embedding-ollama.ts +55 -0
  540. package/src/memory/embedding-openai.ts +25 -0
  541. package/src/memory/entity-extractor.ts +471 -0
  542. package/src/memory/fingerprint.ts +20 -0
  543. package/src/memory/indexer.ts +156 -0
  544. package/src/memory/items-extractor.ts +460 -0
  545. package/src/memory/job-handlers/backfill.ts +139 -0
  546. package/src/memory/job-handlers/cleanup.ts +58 -0
  547. package/src/memory/job-handlers/conflict.ts +99 -0
  548. package/src/memory/job-handlers/embedding.ts +61 -0
  549. package/src/memory/job-handlers/extraction.ts +123 -0
  550. package/src/memory/job-handlers/index-maintenance.ts +54 -0
  551. package/src/memory/job-handlers/summarization.ts +286 -0
  552. package/src/memory/job-utils.ts +170 -0
  553. package/src/memory/jobs-store.ts +400 -0
  554. package/src/memory/jobs-worker.ts +274 -0
  555. package/src/memory/llm-request-log-store.ts +45 -0
  556. package/src/memory/llm-usage-store.ts +62 -0
  557. package/src/memory/message-content.ts +54 -0
  558. package/src/memory/profile-compiler.ts +160 -0
  559. package/src/memory/published-pages-store.ts +137 -0
  560. package/src/memory/qdrant-client.ts +366 -0
  561. package/src/memory/qdrant-manager.ts +242 -0
  562. package/src/memory/query-builder.ts +45 -0
  563. package/src/memory/retrieval-budget.ts +30 -0
  564. package/src/memory/retriever.ts +653 -0
  565. package/src/memory/runs-store.ts +211 -0
  566. package/src/memory/schema.ts +529 -0
  567. package/src/memory/search/entity.ts +298 -0
  568. package/src/memory/search/formatting.ts +207 -0
  569. package/src/memory/search/lexical.ts +227 -0
  570. package/src/memory/search/ranking.ts +401 -0
  571. package/src/memory/search/semantic.ts +121 -0
  572. package/src/memory/search/types.ts +137 -0
  573. package/src/memory/segmenter.ts +68 -0
  574. package/src/memory/shared-app-links-store.ts +138 -0
  575. package/src/memory/tool-usage-store.ts +62 -0
  576. package/src/messaging/activity-analyzer.ts +76 -0
  577. package/src/messaging/draft-store.ts +88 -0
  578. package/src/messaging/index.ts +3 -0
  579. package/src/messaging/provider-types.ts +80 -0
  580. package/src/messaging/provider.ts +43 -0
  581. package/src/messaging/providers/gmail/adapter.ts +193 -0
  582. package/src/messaging/providers/gmail/client.ts +204 -0
  583. package/src/messaging/providers/gmail/types.ts +90 -0
  584. package/src/messaging/providers/slack/adapter.ts +202 -0
  585. package/src/messaging/providers/slack/client.ts +198 -0
  586. package/src/messaging/providers/slack/types.ts +119 -0
  587. package/src/messaging/registry.ts +34 -0
  588. package/src/messaging/style-analyzer.ts +158 -0
  589. package/src/messaging/thread-summarizer.ts +310 -0
  590. package/src/messaging/triage-engine.ts +321 -0
  591. package/src/messaging/types.ts +55 -0
  592. package/src/permissions/checker.ts +636 -0
  593. package/src/permissions/defaults.ts +243 -0
  594. package/src/permissions/prompter.ts +102 -0
  595. package/src/permissions/secret-prompter.ts +114 -0
  596. package/src/permissions/trust-store.ts +584 -0
  597. package/src/permissions/types.ts +62 -0
  598. package/src/playbooks/index.ts +2 -0
  599. package/src/playbooks/playbook-compiler.ts +90 -0
  600. package/src/playbooks/types.ts +55 -0
  601. package/src/providers/anthropic/client.ts +751 -0
  602. package/src/providers/failover.ts +129 -0
  603. package/src/providers/fireworks/client.ts +20 -0
  604. package/src/providers/gemini/client.ts +285 -0
  605. package/src/providers/ollama/client.ts +30 -0
  606. package/src/providers/openai/client.ts +337 -0
  607. package/src/providers/ratelimit.ts +93 -0
  608. package/src/providers/registry.ts +138 -0
  609. package/src/providers/retry.ts +106 -0
  610. package/src/providers/stream-timeout.ts +38 -0
  611. package/src/providers/types.ts +109 -0
  612. package/src/runtime/assistant-event-hub.ts +120 -0
  613. package/src/runtime/assistant-event.ts +82 -0
  614. package/src/runtime/http-server.ts +478 -0
  615. package/src/runtime/http-types.ts +68 -0
  616. package/src/runtime/routes/app-routes.ts +174 -0
  617. package/src/runtime/routes/attachment-routes.ts +134 -0
  618. package/src/runtime/routes/channel-routes.ts +342 -0
  619. package/src/runtime/routes/conversation-routes.ts +349 -0
  620. package/src/runtime/routes/run-routes.ts +223 -0
  621. package/src/runtime/routes/secret-routes.ts +76 -0
  622. package/src/runtime/run-orchestrator.ts +206 -0
  623. package/src/schedule/schedule-store.ts +452 -0
  624. package/src/schedule/scheduler.ts +168 -0
  625. package/src/security/encrypted-store.ts +238 -0
  626. package/src/security/keychain.ts +252 -0
  627. package/src/security/oauth2.ts +241 -0
  628. package/src/security/redaction.ts +89 -0
  629. package/src/security/secret-allowlist.ts +118 -0
  630. package/src/security/secret-ingress.ts +57 -0
  631. package/src/security/secret-scanner.ts +543 -0
  632. package/src/security/secure-keys.ts +180 -0
  633. package/src/security/token-manager.ts +141 -0
  634. package/src/services/published-app-updater.ts +69 -0
  635. package/src/services/vercel-deploy.ts +73 -0
  636. package/src/skills/active-skill-tools.ts +81 -0
  637. package/src/skills/clawhub.ts +414 -0
  638. package/src/skills/include-graph.ts +146 -0
  639. package/src/skills/managed-store.ts +233 -0
  640. package/src/skills/path-classifier.ts +128 -0
  641. package/src/skills/slash-commands.ts +174 -0
  642. package/src/skills/tool-manifest.ts +165 -0
  643. package/src/skills/version-hash.ts +110 -0
  644. package/src/slack/slack-webhook.ts +61 -0
  645. package/src/subagent/index.ts +19 -0
  646. package/src/subagent/manager.ts +477 -0
  647. package/src/subagent/types.ts +69 -0
  648. package/src/swarm/backend-claude-code.ts +90 -0
  649. package/src/swarm/index.ts +44 -0
  650. package/src/swarm/limits.ts +37 -0
  651. package/src/swarm/orchestrator.ts +279 -0
  652. package/src/swarm/plan-validator.ts +151 -0
  653. package/src/swarm/router-planner.ts +100 -0
  654. package/src/swarm/router-prompts.ts +36 -0
  655. package/src/swarm/synthesizer.ts +62 -0
  656. package/src/swarm/types.ts +62 -0
  657. package/src/swarm/worker-backend.ts +121 -0
  658. package/src/swarm/worker-prompts.ts +78 -0
  659. package/src/swarm/worker-runner.ts +164 -0
  660. package/src/tasks/SPEC.md +133 -0
  661. package/src/tasks/candidate-store.ts +86 -0
  662. package/src/tasks/ephemeral-permissions.ts +41 -0
  663. package/src/tasks/task-compiler.ts +198 -0
  664. package/src/tasks/task-runner.ts +85 -0
  665. package/src/tasks/task-scheduler.ts +20 -0
  666. package/src/tasks/task-store.ts +127 -0
  667. package/src/tools/apps/definitions.ts +59 -0
  668. package/src/tools/apps/executors.ts +313 -0
  669. package/src/tools/apps/open-proxy.ts +43 -0
  670. package/src/tools/apps/registry.ts +16 -0
  671. package/src/tools/assets/materialize.ts +218 -0
  672. package/src/tools/assets/search.ts +396 -0
  673. package/src/tools/browser/__tests__/auth-cache.test.ts +219 -0
  674. package/src/tools/browser/__tests__/auth-detector.test.ts +362 -0
  675. package/src/tools/browser/__tests__/jit-auth.test.ts +189 -0
  676. package/src/tools/browser/auth-cache.ts +149 -0
  677. package/src/tools/browser/auth-detector.ts +347 -0
  678. package/src/tools/browser/browser-execution.ts +979 -0
  679. package/src/tools/browser/browser-handoff.ts +79 -0
  680. package/src/tools/browser/browser-manager.ts +715 -0
  681. package/src/tools/browser/browser-screencast.ts +217 -0
  682. package/src/tools/browser/headless-browser.ts +450 -0
  683. package/src/tools/browser/jit-auth.ts +51 -0
  684. package/src/tools/browser/network-recorder.ts +348 -0
  685. package/src/tools/browser/network-recording-types.ts +49 -0
  686. package/src/tools/browser/recording-store.ts +49 -0
  687. package/src/tools/browser/runtime-check.ts +43 -0
  688. package/src/tools/claude-code/claude-code.ts +232 -0
  689. package/src/tools/computer-use/definitions.ts +443 -0
  690. package/src/tools/computer-use/registry.ts +22 -0
  691. package/src/tools/computer-use/request-computer-control.ts +53 -0
  692. package/src/tools/computer-use/skill-proxy-bridge.ts +28 -0
  693. package/src/tools/contacts/contact-merge.ts +87 -0
  694. package/src/tools/contacts/contact-search.ts +102 -0
  695. package/src/tools/contacts/contact-upsert.ts +137 -0
  696. package/src/tools/contacts/index.ts +4 -0
  697. package/src/tools/credentials/account-registry.ts +127 -0
  698. package/src/tools/credentials/broker-types.ts +107 -0
  699. package/src/tools/credentials/broker.ts +372 -0
  700. package/src/tools/credentials/domain-policy.ts +51 -0
  701. package/src/tools/credentials/host-pattern-match.ts +60 -0
  702. package/src/tools/credentials/metadata-store.ts +335 -0
  703. package/src/tools/credentials/policy-types.ts +52 -0
  704. package/src/tools/credentials/policy-validate.ts +80 -0
  705. package/src/tools/credentials/resolve.ts +122 -0
  706. package/src/tools/credentials/selection.ts +159 -0
  707. package/src/tools/credentials/tool-policy.ts +25 -0
  708. package/src/tools/credentials/vault.ts +641 -0
  709. package/src/tools/document/document-tool.ts +165 -0
  710. package/src/tools/document/editor-template.ts +237 -0
  711. package/src/tools/document/index.ts +5 -0
  712. package/src/tools/executor.ts +825 -0
  713. package/src/tools/filesystem/edit.ts +127 -0
  714. package/src/tools/filesystem/fuzzy-match.ts +202 -0
  715. package/src/tools/filesystem/read.ts +71 -0
  716. package/src/tools/filesystem/view-image.ts +199 -0
  717. package/src/tools/filesystem/write.ts +79 -0
  718. package/src/tools/followups/followup_create.ts +118 -0
  719. package/src/tools/followups/followup_list.ts +100 -0
  720. package/src/tools/followups/followup_resolve.ts +91 -0
  721. package/src/tools/followups/index.ts +3 -0
  722. package/src/tools/host-filesystem/edit.ts +125 -0
  723. package/src/tools/host-filesystem/read.ts +80 -0
  724. package/src/tools/host-filesystem/write.ts +76 -0
  725. package/src/tools/host-terminal/cli-discover.ts +179 -0
  726. package/src/tools/host-terminal/host-shell.ts +181 -0
  727. package/src/tools/memory/definitions.ts +69 -0
  728. package/src/tools/memory/handlers.ts +245 -0
  729. package/src/tools/memory/register.ts +66 -0
  730. package/src/tools/network/domain-normalize.ts +85 -0
  731. package/src/tools/network/script-proxy/certs.ts +237 -0
  732. package/src/tools/network/script-proxy/connect-tunnel.ts +82 -0
  733. package/src/tools/network/script-proxy/http-forwarder.ts +151 -0
  734. package/src/tools/network/script-proxy/index.ts +28 -0
  735. package/src/tools/network/script-proxy/logging.ts +196 -0
  736. package/src/tools/network/script-proxy/mitm-handler.ts +269 -0
  737. package/src/tools/network/script-proxy/policy.ts +152 -0
  738. package/src/tools/network/script-proxy/router.ts +60 -0
  739. package/src/tools/network/script-proxy/server.ts +136 -0
  740. package/src/tools/network/script-proxy/session-manager.ts +534 -0
  741. package/src/tools/network/script-proxy/types.ts +125 -0
  742. package/src/tools/network/url-safety.ts +227 -0
  743. package/src/tools/network/web-fetch.ts +701 -0
  744. package/src/tools/network/web-search.ts +319 -0
  745. package/src/tools/playbooks/index.ts +5 -0
  746. package/src/tools/playbooks/playbook-create.ts +140 -0
  747. package/src/tools/playbooks/playbook-delete.ts +76 -0
  748. package/src/tools/playbooks/playbook-list.ts +101 -0
  749. package/src/tools/playbooks/playbook-update.ts +159 -0
  750. package/src/tools/registry.ts +297 -0
  751. package/src/tools/reminder/reminder-store.ts +148 -0
  752. package/src/tools/reminder/reminder.ts +153 -0
  753. package/src/tools/schedule/create.ts +86 -0
  754. package/src/tools/schedule/delete.ts +54 -0
  755. package/src/tools/schedule/list.ts +88 -0
  756. package/src/tools/schedule/update.ts +97 -0
  757. package/src/tools/shared/filesystem/edit-engine.ts +56 -0
  758. package/src/tools/shared/filesystem/errors.ts +85 -0
  759. package/src/tools/shared/filesystem/file-ops-service.ts +215 -0
  760. package/src/tools/shared/filesystem/format-diff.ts +35 -0
  761. package/src/tools/shared/filesystem/path-policy.ts +125 -0
  762. package/src/tools/shared/filesystem/size-guard.ts +41 -0
  763. package/src/tools/shared/filesystem/types.ts +80 -0
  764. package/src/tools/shared/shell-output.ts +52 -0
  765. package/src/tools/skills/delete-managed.ts +60 -0
  766. package/src/tools/skills/load.ts +139 -0
  767. package/src/tools/skills/sandbox-runner.ts +279 -0
  768. package/src/tools/skills/scaffold-managed.ts +150 -0
  769. package/src/tools/skills/script-contract.ts +6 -0
  770. package/src/tools/skills/skill-script-runner.ts +86 -0
  771. package/src/tools/skills/skill-tool-factory.ts +64 -0
  772. package/src/tools/skills/vellum-catalog.ts +217 -0
  773. package/src/tools/subagent/abort.ts +62 -0
  774. package/src/tools/subagent/index.ts +5 -0
  775. package/src/tools/subagent/message.ts +72 -0
  776. package/src/tools/subagent/read.ts +98 -0
  777. package/src/tools/subagent/spawn.ts +85 -0
  778. package/src/tools/subagent/status.ts +74 -0
  779. package/src/tools/swarm/delegate.ts +182 -0
  780. package/src/tools/system/request-permission.ts +98 -0
  781. package/src/tools/tasks/index.ts +25 -0
  782. package/src/tools/tasks/task-delete.ts +69 -0
  783. package/src/tools/tasks/task-list.ts +65 -0
  784. package/src/tools/tasks/task-run.ts +125 -0
  785. package/src/tools/tasks/task-save.ts +79 -0
  786. package/src/tools/tasks/work-item-enqueue.ts +176 -0
  787. package/src/tools/tasks/work-item-list.ts +86 -0
  788. package/src/tools/terminal/backends/docker.ts +372 -0
  789. package/src/tools/terminal/backends/native.ts +188 -0
  790. package/src/tools/terminal/backends/types.ts +26 -0
  791. package/src/tools/terminal/evaluate-typescript.ts +275 -0
  792. package/src/tools/terminal/parser.ts +393 -0
  793. package/src/tools/terminal/safe-env.ts +37 -0
  794. package/src/tools/terminal/sandbox-diagnostics.ts +149 -0
  795. package/src/tools/terminal/sandbox.ts +44 -0
  796. package/src/tools/terminal/shell.ts +257 -0
  797. package/src/tools/tool-manifest.ts +250 -0
  798. package/src/tools/types.ts +177 -0
  799. package/src/tools/ui-surface/definitions.ts +232 -0
  800. package/src/tools/ui-surface/registry.ts +14 -0
  801. package/src/tools/watch/screen-watch.ts +128 -0
  802. package/src/tools/watch/watch-state.ts +119 -0
  803. package/src/tools/watcher/create.ts +110 -0
  804. package/src/tools/watcher/delete.ts +53 -0
  805. package/src/tools/watcher/digest.ts +84 -0
  806. package/src/tools/watcher/list.ts +90 -0
  807. package/src/tools/watcher/update.ts +102 -0
  808. package/src/tools/weather/service.ts +551 -0
  809. package/src/usage/actors.ts +24 -0
  810. package/src/usage/types.ts +38 -0
  811. package/src/util/clipboard.ts +33 -0
  812. package/src/util/content-id.ts +16 -0
  813. package/src/util/diff.ts +181 -0
  814. package/src/util/errors.ts +129 -0
  815. package/src/util/logger.ts +243 -0
  816. package/src/util/platform.ts +607 -0
  817. package/src/util/pricing.ts +150 -0
  818. package/src/util/spinner.ts +51 -0
  819. package/src/util/time.ts +16 -0
  820. package/src/util/xml.ts +4 -0
  821. package/src/version.ts +3 -0
  822. package/src/watcher/constants.ts +11 -0
  823. package/src/watcher/engine.ts +199 -0
  824. package/src/watcher/provider-registry.ts +15 -0
  825. package/src/watcher/provider-types.ts +48 -0
  826. package/src/watcher/providers/gmail.ts +198 -0
  827. package/src/watcher/providers/google-calendar.ts +228 -0
  828. package/src/watcher/providers/slack.ts +128 -0
  829. package/src/watcher/watcher-store.ts +418 -0
  830. package/src/work-items/work-item-store.ts +91 -0
  831. package/src/workspace/git-service.ts +620 -0
  832. package/src/workspace/heartbeat-service.ts +288 -0
  833. package/src/workspace/top-level-renderer.ts +19 -0
  834. package/src/workspace/top-level-scanner.ts +41 -0
  835. package/src/workspace/turn-commit.ts +122 -0
  836. package/tsconfig.json +21 -0
  837. package/LICENSE +0 -674
  838. package/dist/cli.js +0 -569
@@ -0,0 +1,2030 @@
1
+ import { describe, test, expect, beforeEach, mock, spyOn } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+
7
+ // Create a temp directory for the trust file
8
+ const testDir = mkdtempSync(join(tmpdir(), 'trust-store-test-'));
9
+
10
+ // Mock platform module so trust-store writes to temp dir instead of ~/.vellum
11
+ mock.module('../util/platform.js', () => ({
12
+ getRootDir: () => testDir,
13
+ getDataDir: () => testDir,
14
+ isMacOS: () => process.platform === 'darwin',
15
+ isLinux: () => process.platform === 'linux',
16
+ isWindows: () => process.platform === 'win32',
17
+ getSocketPath: () => join(testDir, 'test.sock'),
18
+ getPidPath: () => join(testDir, 'test.pid'),
19
+ getDbPath: () => join(testDir, 'test.db'),
20
+ getLogPath: () => join(testDir, 'test.log'),
21
+ ensureDataDir: () => {},
22
+ }));
23
+
24
+ // Mock logger to suppress output during tests
25
+ mock.module('../util/logger.js', () => ({
26
+ getLogger: () => ({
27
+ info: () => {},
28
+ warn: () => {},
29
+ error: () => {},
30
+ debug: () => {},
31
+ trace: () => {},
32
+ fatal: () => {},
33
+ child: () => ({
34
+ info: () => {},
35
+ warn: () => {},
36
+ error: () => {},
37
+ debug: () => {},
38
+ }),
39
+ }),
40
+ }));
41
+
42
+ import { addRule, removeRule, updateRule, findMatchingRule, findDenyRule, findHighestPriorityRule, getAllRules, clearAllRules, clearCache } from '../permissions/trust-store.js';
43
+ import { getDefaultRuleTemplates } from '../permissions/defaults.js';
44
+
45
+ const trustPath = join(testDir, 'protected', 'trust.json');
46
+ const DEFAULT_TEMPLATES = getDefaultRuleTemplates();
47
+ const NUM_DEFAULTS = DEFAULT_TEMPLATES.length;
48
+ const DEFAULT_PRIORITY_BY_ID = new Map(DEFAULT_TEMPLATES.map((t) => [t.id, t.priority]));
49
+
50
+ describe('Trust Store', () => {
51
+ beforeEach(() => {
52
+ // Clear cached rules and remove the trust file between tests
53
+ clearCache();
54
+ try { rmSync(trustPath); } catch { /* may not exist */ }
55
+ });
56
+
57
+ // Intentionally do not remove `testDir` in afterAll.
58
+ // A late async log flush can still attempt to open `test.log` under this dir,
59
+ // which intermittently causes an unhandled ENOENT in CI if the dir is removed.
60
+ // ── addRule ─────────────────────────────────────────────────────
61
+
62
+ describe('addRule', () => {
63
+ test('adds a rule and returns it', () => {
64
+ const rule = addRule('bash', 'git *', '/home/user/project');
65
+ expect(rule.id).toBeDefined();
66
+ expect(rule.tool).toBe('bash');
67
+ expect(rule.pattern).toBe('git *');
68
+ expect(rule.scope).toBe('/home/user/project');
69
+ expect(rule.decision).toBe('allow');
70
+ expect(rule.priority).toBe(100);
71
+ expect(rule.createdAt).toBeGreaterThan(0);
72
+ });
73
+
74
+ test('assigns unique IDs to each rule', () => {
75
+ const rule1 = addRule('bash', 'npm *', '/tmp');
76
+ const rule2 = addRule('bash', 'bun *', '/tmp');
77
+ expect(rule1.id).not.toBe(rule2.id);
78
+ });
79
+
80
+ test('persists rule to disk', () => {
81
+ addRule('bash', 'git push', '/home/user');
82
+ const raw = readFileSync(trustPath, 'utf-8');
83
+ const data = JSON.parse(raw);
84
+ expect(data.version).toBe(3);
85
+ expect(data.rules).toHaveLength(1 + NUM_DEFAULTS);
86
+ const userRule = data.rules.find((r: { pattern: string }) => r.pattern === 'git push');
87
+ expect(userRule).toBeDefined();
88
+ expect(userRule.priority).toBe(100);
89
+ });
90
+
91
+ test('multiple rules accumulate', () => {
92
+ addRule('bash', 'git *', '/tmp');
93
+ addRule('file_write', '/tmp/*', '/tmp');
94
+ addRule('bash', 'npm *', '/tmp');
95
+ expect(getAllRules()).toHaveLength(3 + NUM_DEFAULTS);
96
+ });
97
+
98
+ test('default priority is 100', () => {
99
+ const rule = addRule('bash', 'git *', '/tmp');
100
+ expect(rule.priority).toBe(100);
101
+ });
102
+
103
+ test('custom priority is respected', () => {
104
+ const rule = addRule('bash', 'git *', '/tmp', 'allow', 5);
105
+ expect(rule.priority).toBe(5);
106
+ });
107
+
108
+ test('rules are sorted by priority descending in getAllRules', () => {
109
+ addRule('bash', 'low *', '/tmp', 'allow', 0);
110
+ addRule('bash', 'high *', '/tmp', 'allow', 2);
111
+ addRule('bash', 'med *', '/tmp', 'allow', 1);
112
+ const rules = getAllRules();
113
+ // Default ask rules have higher priority than user rules
114
+ const maxDefaultPriority = Math.max(...DEFAULT_TEMPLATES.map((t) => t.priority));
115
+ expect(rules[0].priority).toBe(maxDefaultPriority);
116
+ const userRules = rules.filter((r) => !r.id.startsWith('default:'));
117
+ expect(userRules[0].priority).toBe(2);
118
+ expect(userRules[1].priority).toBe(1);
119
+ expect(userRules[2].priority).toBe(0);
120
+ });
121
+
122
+ test('accepts allowHighRisk option and persists it', () => {
123
+ const rule = addRule('bash', 'sudo *', 'everywhere', 'allow', 100, { allowHighRisk: true });
124
+ expect(rule.allowHighRisk).toBe(true);
125
+ // Verify it persists to disk
126
+ clearCache();
127
+ const rules = getAllRules();
128
+ const found = rules.find((r) => r.id === rule.id);
129
+ expect(found).toBeDefined();
130
+ expect(found!.allowHighRisk).toBe(true);
131
+ });
132
+
133
+ test('addRule without allowHighRisk option does not set the field', () => {
134
+ const rule = addRule('bash', 'git *', '/tmp');
135
+ expect(rule.allowHighRisk).toBeUndefined();
136
+ // Verify on disk
137
+ const raw = JSON.parse(readFileSync(trustPath, 'utf-8'));
138
+ const diskRule = raw.rules.find((r: { id: string }) => r.id === rule.id);
139
+ expect(diskRule).toBeDefined();
140
+ expect(diskRule).not.toHaveProperty('allowHighRisk');
141
+ });
142
+
143
+ test('at same priority deny rules sort before allow rules', () => {
144
+ addRule('bash', 'allow *', '/tmp', 'allow', 100);
145
+ addRule('bash', 'deny *', '/tmp', 'deny', 100);
146
+ const userRules = getAllRules().filter((r) => !r.id.startsWith('default:'));
147
+ expect(userRules[0].decision).toBe('deny');
148
+ expect(userRules[1].decision).toBe('allow');
149
+ });
150
+
151
+ test('accepts principal and executionTarget options and persists them', () => {
152
+ const rule = addRule('skill_tool', 'skill_tool:*', '/tmp', 'allow', 100, {
153
+ principalKind: 'skill',
154
+ principalId: 'my-skill-42',
155
+ principalVersion: 'sha256-abc123',
156
+ executionTarget: 'sandbox',
157
+ });
158
+ expect(rule.principalKind).toBe('skill');
159
+ expect(rule.principalId).toBe('my-skill-42');
160
+ expect(rule.principalVersion).toBe('sha256-abc123');
161
+ expect(rule.executionTarget).toBe('sandbox');
162
+
163
+ // Verify persistence to disk
164
+ clearCache();
165
+ const rules = getAllRules();
166
+ const found = rules.find((r) => r.id === rule.id);
167
+ expect(found).toBeDefined();
168
+ expect(found!.principalKind).toBe('skill');
169
+ expect(found!.principalId).toBe('my-skill-42');
170
+ expect(found!.principalVersion).toBe('sha256-abc123');
171
+ expect(found!.executionTarget).toBe('sandbox');
172
+ });
173
+
174
+ test('accepts all contextual options together (principal, target, allowHighRisk)', () => {
175
+ const rule = addRule('risky_tool', 'risky_tool:*', 'everywhere', 'allow', 100, {
176
+ allowHighRisk: true,
177
+ principalKind: 'skill',
178
+ principalId: 'dangerous-skill',
179
+ principalVersion: 'sha256-deadbeef',
180
+ executionTarget: 'host',
181
+ });
182
+ expect(rule.allowHighRisk).toBe(true);
183
+ expect(rule.principalKind).toBe('skill');
184
+ expect(rule.principalId).toBe('dangerous-skill');
185
+ expect(rule.principalVersion).toBe('sha256-deadbeef');
186
+ expect(rule.executionTarget).toBe('host');
187
+
188
+ // Verify on disk
189
+ const raw = JSON.parse(readFileSync(trustPath, 'utf-8'));
190
+ const diskRule = raw.rules.find((r: { id: string }) => r.id === rule.id);
191
+ expect(diskRule).toBeDefined();
192
+ expect(diskRule.allowHighRisk).toBe(true);
193
+ expect(diskRule.principalKind).toBe('skill');
194
+ expect(diskRule.principalId).toBe('dangerous-skill');
195
+ expect(diskRule.principalVersion).toBe('sha256-deadbeef');
196
+ expect(diskRule.executionTarget).toBe('host');
197
+ });
198
+
199
+ test('addRule without principal options does not set principal fields', () => {
200
+ const rule = addRule('bash', 'echo *', '/tmp');
201
+ expect(rule.principalKind).toBeUndefined();
202
+ expect(rule.principalId).toBeUndefined();
203
+ expect(rule.principalVersion).toBeUndefined();
204
+ expect(rule.executionTarget).toBeUndefined();
205
+
206
+ // Verify on disk
207
+ const raw = JSON.parse(readFileSync(trustPath, 'utf-8'));
208
+ const diskRule = raw.rules.find((r: { id: string }) => r.id === rule.id);
209
+ expect(diskRule).toBeDefined();
210
+ expect(diskRule).not.toHaveProperty('principalKind');
211
+ expect(diskRule).not.toHaveProperty('principalId');
212
+ expect(diskRule).not.toHaveProperty('principalVersion');
213
+ expect(diskRule).not.toHaveProperty('executionTarget');
214
+ });
215
+ });
216
+
217
+ // ── removeRule ──────────────────────────────────────────────────
218
+
219
+ describe('removeRule', () => {
220
+ test('removes an existing rule', () => {
221
+ const rule = addRule('bash', 'git *', '/tmp');
222
+ expect(removeRule(rule.id)).toBe(true);
223
+ expect(getAllRules()).toHaveLength(NUM_DEFAULTS);
224
+ });
225
+
226
+ test('returns false for non-existent ID', () => {
227
+ expect(removeRule('non-existent-id')).toBe(false);
228
+ });
229
+
230
+ test('persists removal to disk', () => {
231
+ const rule = addRule('bash', 'npm *', '/tmp');
232
+ removeRule(rule.id);
233
+ // Reload from disk to verify
234
+ clearCache();
235
+ expect(getAllRules()).toHaveLength(NUM_DEFAULTS);
236
+ });
237
+
238
+ test('only removes the targeted rule', () => {
239
+ const rule1 = addRule('bash', 'git *', '/tmp');
240
+ const rule2 = addRule('bash', 'npm *', '/tmp');
241
+ removeRule(rule1.id);
242
+ const remaining = getAllRules();
243
+ expect(remaining).toHaveLength(1 + NUM_DEFAULTS);
244
+ expect(remaining.find((r) => r.id === rule2.id)).toBeDefined();
245
+ });
246
+ });
247
+
248
+ // ── updateRule ─────────────────────────────────────────────────
249
+
250
+ describe('updateRule', () => {
251
+ test('updates pattern on an existing rule', () => {
252
+ const rule = addRule('bash', 'git *', '/tmp');
253
+ const updated = updateRule(rule.id, { pattern: 'git push *' });
254
+ expect(updated.pattern).toBe('git push *');
255
+ expect(updated.id).toBe(rule.id);
256
+ expect(updated.tool).toBe('bash');
257
+ });
258
+
259
+ test('updates multiple fields at once', () => {
260
+ const rule = addRule('bash', 'npm *', '/tmp');
261
+ const updated = updateRule(rule.id, { tool: 'file_write', scope: '/home', decision: 'deny', priority: 50 });
262
+ expect(updated.tool).toBe('file_write');
263
+ expect(updated.scope).toBe('/home');
264
+ expect(updated.decision).toBe('deny');
265
+ expect(updated.priority).toBe(50);
266
+ });
267
+
268
+ test('throws for non-existent rule ID', () => {
269
+ expect(() => updateRule('non-existent-id', { pattern: 'test' })).toThrow('Trust rule not found: non-existent-id');
270
+ });
271
+
272
+ test('persists update to disk', () => {
273
+ const rule = addRule('bash', 'git *', '/tmp');
274
+ updateRule(rule.id, { pattern: 'git status' });
275
+ clearCache();
276
+ const rules = getAllRules();
277
+ const found = rules.find((r) => r.id === rule.id);
278
+ expect(found).toBeDefined();
279
+ expect(found!.pattern).toBe('git status');
280
+ });
281
+
282
+ test('re-sorts rules after priority change', () => {
283
+ const rule1 = addRule('bash', 'low *', '/tmp', 'allow', 10);
284
+ const rule2 = addRule('bash', 'high *', '/tmp', 'allow', 200);
285
+ // rule2 should be first (higher priority)
286
+ let userRules = getAllRules().filter((r) => !r.id.startsWith('default:'));
287
+ expect(userRules[0].id).toBe(rule2.id);
288
+ // Update rule1 to have higher priority
289
+ updateRule(rule1.id, { priority: 300 });
290
+ userRules = getAllRules().filter((r) => !r.id.startsWith('default:'));
291
+ expect(userRules[0].id).toBe(rule1.id);
292
+ });
293
+
294
+ test('leaves unchanged fields intact', () => {
295
+ const rule = addRule('bash', 'git *', '/home/user', 'allow', 100);
296
+ updateRule(rule.id, { pattern: 'git push *' });
297
+ const updated = getAllRules().find((r) => r.id === rule.id)!;
298
+ expect(updated.tool).toBe('bash');
299
+ expect(updated.scope).toBe('/home/user');
300
+ expect(updated.decision).toBe('allow');
301
+ expect(updated.priority).toBe(100);
302
+ expect(updated.createdAt).toBe(rule.createdAt);
303
+ });
304
+ });
305
+
306
+ // ── findMatchingRule ────────────────────────────────────────────
307
+
308
+ describe('findMatchingRule', () => {
309
+ test('finds exact match', () => {
310
+ addRule('bash', 'git push', '/tmp');
311
+ const match = findMatchingRule('bash', 'git push', '/tmp');
312
+ expect(match).not.toBeNull();
313
+ expect(match!.pattern).toBe('git push');
314
+ });
315
+
316
+ test('finds glob wildcard match', () => {
317
+ addRule('bash', 'git *', '/tmp');
318
+ const match = findMatchingRule('bash', 'git push origin main', '/tmp');
319
+ expect(match).not.toBeNull();
320
+ });
321
+
322
+ test('returns null when tool does not match', () => {
323
+ addRule('file_write', 'git *', '/tmp');
324
+ // host_bash default is 'ask' so findMatchingRule (allow-only) won't find it
325
+ const match = findMatchingRule('host_bash', 'git push', '/tmp');
326
+ expect(match).toBeNull();
327
+ });
328
+
329
+ test('returns null when pattern does not match', () => {
330
+ addRule('host_bash', 'git *', '/tmp');
331
+ const match = findMatchingRule('host_bash', 'npm install', '/tmp');
332
+ expect(match).toBeNull();
333
+ });
334
+
335
+ // Scope matching
336
+ describe('scope matching', () => {
337
+ test('matches when scope equals rule scope', () => {
338
+ addRule('bash', 'npm *', '/home/user/project');
339
+ const match = findMatchingRule('bash', 'npm install', '/home/user/project');
340
+ expect(match).not.toBeNull();
341
+ });
342
+
343
+ test('matches when scope is under rule scope (prefix)', () => {
344
+ addRule('bash', 'npm *', '/home/user');
345
+ const match = findMatchingRule('bash', 'npm install', '/home/user/project/sub');
346
+ expect(match).not.toBeNull();
347
+ });
348
+
349
+ test('does not match when scope is outside rule scope', () => {
350
+ addRule('host_bash', 'npm *', '/home/user/project');
351
+ const match = findMatchingRule('host_bash', 'npm install', '/home/other');
352
+ expect(match).toBeNull();
353
+ });
354
+
355
+ test('everywhere scope matches any directory', () => {
356
+ addRule('bash', 'git *', 'everywhere');
357
+ const match = findMatchingRule('bash', 'git status', '/any/random/path');
358
+ expect(match).not.toBeNull();
359
+ });
360
+
361
+ test('everywhere scope matches root', () => {
362
+ addRule('bash', 'ls', 'everywhere');
363
+ const match = findMatchingRule('bash', 'ls', '/');
364
+ expect(match).not.toBeNull();
365
+ });
366
+
367
+ test('does not match sibling path with shared prefix', () => {
368
+ addRule('host_bash', 'npm *', '/home/user/project');
369
+ const match = findMatchingRule('host_bash', 'npm install', '/home/user/project-evil');
370
+ expect(match).toBeNull();
371
+ });
372
+
373
+ test('matches exact scope with trailing slash on working dir', () => {
374
+ addRule('bash', 'npm *', '/home/user/project');
375
+ const match = findMatchingRule('bash', 'npm install', '/home/user/project/');
376
+ expect(match).not.toBeNull();
377
+ });
378
+
379
+ test('matches when rule scope has trailing slash', () => {
380
+ addRule('bash', 'npm *', '/home/user/project/');
381
+ const match = findMatchingRule('bash', 'npm install', '/home/user/project');
382
+ expect(match).not.toBeNull();
383
+ });
384
+
385
+ test('does not match sibling with glob-suffixed scope', () => {
386
+ addRule('host_bash', 'npm *', '/home/user/project*');
387
+ const match = findMatchingRule('host_bash', 'npm install', '/home/user/project-evil');
388
+ expect(match).toBeNull();
389
+ });
390
+ });
391
+
392
+ // Pattern matching with minimatch
393
+ describe('pattern matching', () => {
394
+ test('matches * wildcard', () => {
395
+ addRule('bash', 'npm *', '/tmp');
396
+ expect(findMatchingRule('bash', 'npm install', '/tmp')).not.toBeNull();
397
+ expect(findMatchingRule('bash', 'npm test', '/tmp')).not.toBeNull();
398
+ });
399
+
400
+ test('matches exact string', () => {
401
+ addRule('host_bash', 'git status', '/tmp');
402
+ expect(findMatchingRule('host_bash', 'git status', '/tmp')).not.toBeNull();
403
+ expect(findMatchingRule('host_bash', 'git push', '/tmp')).toBeNull();
404
+ });
405
+
406
+ test('matches file path pattern', () => {
407
+ addRule('file_write', '/tmp/*', '/tmp');
408
+ expect(findMatchingRule('file_write', '/tmp/file.txt', '/tmp')).not.toBeNull();
409
+ });
410
+
411
+ test('star pattern matches single-segment strings', () => {
412
+ addRule('file_write', '*', '/tmp');
413
+ // minimatch '*' matches strings without path separators
414
+ expect(findMatchingRule('file_write', 'file.txt', '/tmp')).not.toBeNull();
415
+ });
416
+
417
+ test('star pattern does not match paths with slashes', () => {
418
+ addRule('file_write', '*', '/tmp');
419
+ // minimatch '*' does not cross '/' boundaries
420
+ expect(findMatchingRule('file_write', '/any/path/file.txt', '/tmp')).toBeNull();
421
+ });
422
+ });
423
+ });
424
+
425
+ // ── findHighestPriorityRule ──────────────────────────────────────
426
+
427
+ describe('findHighestPriorityRule', () => {
428
+ test('returns highest priority matching rule', () => {
429
+ addRule('bash', 'rm *', '/tmp', 'allow', 0);
430
+ addRule('bash', 'rm *', '/tmp', 'deny', 100);
431
+ const match = findHighestPriorityRule('bash', ['rm file.txt'], '/tmp');
432
+ expect(match).not.toBeNull();
433
+ expect(match!.decision).toBe('deny');
434
+ expect(match!.priority).toBe(100);
435
+ });
436
+
437
+ test('higher priority allow beats lower priority deny', () => {
438
+ addRule('bash', 'rm *', '/tmp', 'deny', 0);
439
+ addRule('bash', 'rm *', '/tmp', 'allow', 100);
440
+ const match = findHighestPriorityRule('bash', ['rm file.txt'], '/tmp');
441
+ expect(match).not.toBeNull();
442
+ expect(match!.decision).toBe('allow');
443
+ });
444
+
445
+ test('same priority: deny beats allow', () => {
446
+ addRule('bash', 'rm *', '/tmp', 'allow', 100);
447
+ addRule('bash', 'rm *', '/tmp', 'deny', 100);
448
+ const match = findHighestPriorityRule('bash', ['rm file.txt'], '/tmp');
449
+ expect(match).not.toBeNull();
450
+ expect(match!.decision).toBe('deny');
451
+ });
452
+
453
+ test('checks multiple command candidates', () => {
454
+ addRule('web_fetch', 'web_fetch:https://example.com/*', '/tmp', 'allow');
455
+ const match = findHighestPriorityRule(
456
+ 'web_fetch',
457
+ ['web_fetch:https://example.com/page', 'web_fetch:https://example.com/*'],
458
+ '/tmp',
459
+ );
460
+ expect(match).not.toBeNull();
461
+ });
462
+
463
+ test('returns null when no rule matches', () => {
464
+ // Use file_read with a non-workspace path — file_read defaults only
465
+ // cover specific workspace files, so /tmp paths won't match any default.
466
+ addRule('file_read', 'file_read:/specific/*', '/tmp', 'allow');
467
+ const match = findHighestPriorityRule('file_read', ['file_read:/other/path'], '/tmp');
468
+ expect(match).toBeNull();
469
+ });
470
+
471
+ test('respects scope matching', () => {
472
+ // Use file_read — bash has a global default allow rule that matches everywhere.
473
+ addRule('file_read', 'file_read:/home/user/project/*', '/home/user/project', 'deny');
474
+ expect(findHighestPriorityRule('file_read', ['file_read:/home/user/project/file.txt'], '/home/user/project/sub')).not.toBeNull();
475
+ expect(findHighestPriorityRule('file_read', ['file_read:/home/user/project/file.txt'], '/home/other')).toBeNull();
476
+ });
477
+
478
+ test('everywhere scope matches any directory', () => {
479
+ addRule('bash', 'git *', 'everywhere', 'allow');
480
+ const match = findHighestPriorityRule('bash', ['git status'], '/any/random/path');
481
+ expect(match).not.toBeNull();
482
+ });
483
+ });
484
+
485
+ // ── getAllRules ─────────────────────────────────────────────────
486
+
487
+ describe('getAllRules', () => {
488
+ test('returns default rules when no user rules exist', () => {
489
+ const rules = getAllRules();
490
+ expect(rules).toHaveLength(NUM_DEFAULTS);
491
+ expect(rules.every((r) => r.id.startsWith('default:'))).toBe(true);
492
+ });
493
+
494
+ test('returns a copy (not the internal array)', () => {
495
+ addRule('bash', 'git *', '/tmp');
496
+ const rules1 = getAllRules();
497
+ const rules2 = getAllRules();
498
+ expect(rules1).toEqual(rules2);
499
+ expect(rules1).not.toBe(rules2); // different references
500
+ });
501
+ });
502
+
503
+ // ── clearCache ─────────────────────────────────────────────────
504
+
505
+ describe('clearCache', () => {
506
+ test('forces reload from disk on next access', () => {
507
+ addRule('bash', 'git *', '/tmp');
508
+ expect(getAllRules()).toHaveLength(1 + NUM_DEFAULTS);
509
+ clearCache();
510
+ // After clearing cache, rules are reloaded from disk
511
+ expect(getAllRules()).toHaveLength(1 + NUM_DEFAULTS);
512
+ });
513
+ });
514
+
515
+ // ── persistence ─────────────────────────────────────────────────
516
+
517
+ describe('persistence', () => {
518
+ test('rules survive cache clear (loaded from disk)', () => {
519
+ const rule = addRule('bash', 'npm *', '/tmp');
520
+ clearCache();
521
+ const rules = getAllRules();
522
+ expect(rules).toHaveLength(1 + NUM_DEFAULTS);
523
+ expect(rules.find((r) => r.id === rule.id)).toBeDefined();
524
+ });
525
+
526
+ test('trust file has correct structure', () => {
527
+ addRule('bash', 'git *', '/tmp');
528
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
529
+ expect(data).toHaveProperty('version', 3);
530
+ expect(data).toHaveProperty('rules');
531
+ expect(Array.isArray(data.rules)).toBe(true);
532
+ const userRule = data.rules.find((r: { pattern: string }) => r.pattern === 'git *');
533
+ expect(userRule).toHaveProperty('priority', 100);
534
+ });
535
+ });
536
+
537
+ // ── deny rules ─────────────────────────────────────────────────
538
+
539
+ describe('deny rules', () => {
540
+ test('addRule with deny decision creates a deny rule', () => {
541
+ const rule = addRule('bash', 'rm -rf *', '/tmp', 'deny');
542
+ expect(rule.decision).toBe('deny');
543
+ expect(rule.tool).toBe('bash');
544
+ expect(rule.pattern).toBe('rm -rf *');
545
+ });
546
+
547
+ test('deny rule persists to disk', () => {
548
+ addRule('bash', 'rm *', '/tmp', 'deny');
549
+ clearCache();
550
+ const rules = getAllRules();
551
+ expect(rules).toHaveLength(1 + NUM_DEFAULTS);
552
+ const userRule = rules.find((r) => r.pattern === 'rm *');
553
+ expect(userRule).toBeDefined();
554
+ expect(userRule!.decision).toBe('deny');
555
+ });
556
+
557
+ test('findDenyRule finds deny rules', () => {
558
+ addRule('bash', 'rm *', '/tmp', 'deny');
559
+ const match = findDenyRule('bash', 'rm file.txt', '/tmp');
560
+ expect(match).not.toBeNull();
561
+ expect(match!.decision).toBe('deny');
562
+ });
563
+
564
+ test('findDenyRule ignores allow rules', () => {
565
+ addRule('bash', 'rm *', '/tmp', 'allow');
566
+ const match = findDenyRule('bash', 'rm file.txt', '/tmp');
567
+ expect(match).toBeNull();
568
+ });
569
+
570
+ test('findMatchingRule ignores deny rules', () => {
571
+ // Use host_bash — bash has a default allow rule that would match.
572
+ addRule('host_bash', 'rm *', '/tmp', 'deny');
573
+ const match = findMatchingRule('host_bash', 'rm file.txt', '/tmp');
574
+ expect(match).toBeNull();
575
+ });
576
+
577
+ test('deny and allow rules coexist', () => {
578
+ addRule('bash', 'git *', '/tmp', 'allow');
579
+ addRule('bash', 'git push --force *', '/tmp', 'deny');
580
+ expect(findMatchingRule('bash', 'git status', '/tmp')).not.toBeNull();
581
+ expect(findDenyRule('bash', 'git push --force origin', '/tmp')).not.toBeNull();
582
+ });
583
+
584
+ test('deny rule with scope matching', () => {
585
+ addRule('bash', 'rm *', '/home/user/project', 'deny');
586
+ expect(findDenyRule('bash', 'rm file.txt', '/home/user/project/sub')).not.toBeNull();
587
+ expect(findDenyRule('bash', 'rm file.txt', '/home/other')).toBeNull();
588
+ });
589
+
590
+ test('deny rule with everywhere scope', () => {
591
+ addRule('bash', 'rm -rf *', 'everywhere', 'deny');
592
+ expect(findDenyRule('bash', 'rm -rf /', '/any/path')).not.toBeNull();
593
+ });
594
+
595
+ test('removeRule works for deny rules', () => {
596
+ const rule = addRule('bash', 'rm *', '/tmp', 'deny');
597
+ expect(removeRule(rule.id)).toBe(true);
598
+ expect(findDenyRule('bash', 'rm file.txt', '/tmp')).toBeNull();
599
+ });
600
+ });
601
+
602
+ // ── v1 migration ───────────────────────────────────────────────
603
+
604
+ describe('v1 migration', () => {
605
+ test('v1 rules get priority 100 on load', () => {
606
+ mkdirSync(dirname(trustPath), { recursive: true });
607
+ writeFileSync(trustPath, JSON.stringify({
608
+ version: 1,
609
+ rules: [{
610
+ id: 'test-v1-id',
611
+ tool: 'bash',
612
+ pattern: 'git *',
613
+ scope: '/tmp',
614
+ decision: 'allow',
615
+ createdAt: 1000,
616
+ }],
617
+ }));
618
+ clearCache();
619
+ const rules = getAllRules();
620
+ expect(rules).toHaveLength(1 + NUM_DEFAULTS);
621
+ const migratedRule = rules.find((r) => r.id === 'test-v1-id');
622
+ expect(migratedRule).toBeDefined();
623
+ expect(migratedRule!.priority).toBe(100);
624
+ });
625
+
626
+ test('v1 file is upgraded to v3 on disk', () => {
627
+ mkdirSync(dirname(trustPath), { recursive: true });
628
+ writeFileSync(trustPath, JSON.stringify({
629
+ version: 1,
630
+ rules: [{
631
+ id: 'migrate-me',
632
+ tool: 'bash',
633
+ pattern: 'npm *',
634
+ scope: 'everywhere',
635
+ decision: 'allow',
636
+ createdAt: 2000,
637
+ }],
638
+ }));
639
+ clearCache();
640
+ getAllRules(); // triggers load + migration
641
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
642
+ expect(data.version).toBe(3);
643
+ const migratedRule = data.rules.find((r: { id: string }) => r.id === 'migrate-me');
644
+ expect(migratedRule.priority).toBe(100);
645
+ });
646
+ });
647
+
648
+ // ── loadFromDisk resilience ─────────────────────────────────────
649
+
650
+ describe('loadFromDisk resilience', () => {
651
+ test('returns in-memory rules when saveToDisk fails during migration', () => {
652
+ // Write a v1 trust file that triggers needsSave on load
653
+ mkdirSync(dirname(trustPath), { recursive: true });
654
+ writeFileSync(trustPath, JSON.stringify({
655
+ version: 1,
656
+ rules: [{
657
+ id: 'v1-readonly',
658
+ tool: 'bash',
659
+ pattern: 'git *',
660
+ scope: '/tmp',
661
+ decision: 'allow' as const,
662
+ createdAt: 1000,
663
+ }],
664
+ }));
665
+
666
+ // Spy on writeFileSync to throw when saveToDisk is called during migration.
667
+ // This is deterministic regardless of user privileges (unlike chmod 0o555).
668
+ const spy = spyOn(fs, 'writeFileSync').mockImplementation(() => {
669
+ throw new Error('Simulated write failure');
670
+ });
671
+
672
+ try {
673
+ clearCache();
674
+ const rules = getAllRules();
675
+ // Should still return the migrated rules + defaults in-memory
676
+ expect(rules).toHaveLength(1 + NUM_DEFAULTS);
677
+ const migratedRule = rules.find((r) => r.id === 'v1-readonly');
678
+ expect(migratedRule).toBeDefined();
679
+ expect(migratedRule!.priority).toBe(100);
680
+ // Verify that saveToDisk was attempted (writeFileSync was called)
681
+ expect(spy).toHaveBeenCalled();
682
+ } finally {
683
+ spy.mockRestore();
684
+ }
685
+ });
686
+ });
687
+
688
+ // ── default rules ─────────────────────────────────────────────
689
+
690
+ describe('default rules', () => {
691
+ test('backfills default rules on first load', () => {
692
+ const rules = getAllRules();
693
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
694
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
695
+ for (const rule of defaults) {
696
+ expect(rule.priority).toBe(DEFAULT_PRIORITY_BY_ID.get(rule.id)!);
697
+ if (rule.id === 'default:allow-bash-rm-bootstrap') {
698
+ expect(rule.scope).toBe(join(testDir, 'workspace'));
699
+ } else {
700
+ expect(rule.scope).toBe('everywhere');
701
+ }
702
+ }
703
+
704
+ });
705
+
706
+ test('default rules cover file, host file, host shell, and workspace prompt tools', () => {
707
+ const rules = getAllRules();
708
+ const defaultTools = [...new Set(
709
+ rules
710
+ .filter((r) => r.id.startsWith('default:'))
711
+ .map((r) => r.tool),
712
+ )].sort();
713
+ expect(defaultTools).toEqual([
714
+ 'bash',
715
+ 'browser_click',
716
+ 'browser_close',
717
+ 'browser_extract',
718
+ 'browser_fill_credential',
719
+ 'browser_navigate',
720
+ 'browser_press_key',
721
+ 'browser_screenshot',
722
+ 'browser_snapshot',
723
+ 'browser_type',
724
+ 'browser_wait_for',
725
+ 'computer_use_click',
726
+ 'computer_use_double_click',
727
+ 'computer_use_drag',
728
+ 'computer_use_key',
729
+ 'computer_use_open_app',
730
+ 'computer_use_request_control',
731
+ 'computer_use_right_click',
732
+ 'computer_use_run_applescript',
733
+ 'computer_use_scroll',
734
+ 'computer_use_type_text',
735
+ 'computer_use_wait',
736
+ 'delete_managed_skill',
737
+ 'file_edit',
738
+ 'file_read',
739
+ 'file_write',
740
+ 'host_bash',
741
+ 'host_file_edit',
742
+ 'host_file_read',
743
+ 'host_file_write',
744
+ 'scaffold_managed_skill',
745
+ 'skill_load',
746
+ 'ui_dismiss',
747
+ 'ui_update',
748
+ 'view_image',
749
+ ]);
750
+ });
751
+
752
+ test('default rules are not duplicated on reload', () => {
753
+ getAllRules(); // first load
754
+ clearCache();
755
+ const rules = getAllRules(); // second load
756
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
757
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
758
+ });
759
+
760
+ test('default rules persist to disk', () => {
761
+ getAllRules(); // triggers backfill + save
762
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
763
+ const defaults = data.rules.filter((r: { id: string }) => r.id.startsWith('default:'));
764
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
765
+ });
766
+
767
+ test('default rules are backfilled alongside v1 migration', () => {
768
+ mkdirSync(dirname(trustPath), { recursive: true });
769
+ writeFileSync(trustPath, JSON.stringify({
770
+ version: 1,
771
+ rules: [{
772
+ id: 'v1-user-rule',
773
+ tool: 'bash',
774
+ pattern: 'git *',
775
+ scope: '/tmp',
776
+ decision: 'allow',
777
+ createdAt: 1000,
778
+ }],
779
+ }));
780
+ clearCache();
781
+ const rules = getAllRules();
782
+ expect(rules).toHaveLength(1 + NUM_DEFAULTS);
783
+ expect(rules.find((r) => r.id === 'v1-user-rule')!.priority).toBe(100);
784
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
785
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
786
+ expect(defaults.every((r) => r.priority === DEFAULT_PRIORITY_BY_ID.get(r.id))).toBe(true);
787
+ });
788
+
789
+ test('removed default rule is re-backfilled on next load', () => {
790
+ // First load backfills defaults
791
+ getAllRules();
792
+ // Remove one default rule by editing trust.json directly on disk
793
+ // (removeRule() throws for default rules, so we simulate external editing)
794
+ const raw = JSON.parse(readFileSync(trustPath, 'utf-8'));
795
+ raw.rules = raw.rules.filter((r: { id: string }) => r.id !== 'default:ask-host_file_read-global');
796
+ writeFileSync(trustPath, JSON.stringify(raw, null, 2));
797
+ // After reload, the rule is re-backfilled (defaults are always present)
798
+ clearCache();
799
+ const rules = getAllRules();
800
+ expect(rules.find((r) => r.id === 'default:ask-host_file_read-global')).toBeDefined();
801
+ });
802
+
803
+ test('findHighestPriorityRule matches default ask for host_file_read', () => {
804
+ const match = findHighestPriorityRule('host_file_read', ['host_file_read:/etc/hosts'], '/tmp');
805
+ expect(match).not.toBeNull();
806
+ expect(match!.id).toBe('default:ask-host_file_read-global');
807
+ expect(match!.decision).toBe('ask');
808
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_file_read-global')!);
809
+ });
810
+
811
+ test('findHighestPriorityRule matches default ask for host_file_write', () => {
812
+ const match = findHighestPriorityRule('host_file_write', ['host_file_write:/etc/hosts'], '/tmp');
813
+ expect(match).not.toBeNull();
814
+ expect(match!.id).toBe('default:ask-host_file_write-global');
815
+ expect(match!.decision).toBe('ask');
816
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_file_write-global')!);
817
+ });
818
+
819
+ test('findHighestPriorityRule matches default ask for host_file_edit', () => {
820
+ const match = findHighestPriorityRule('host_file_edit', ['host_file_edit:/etc/hosts'], '/tmp');
821
+ expect(match).not.toBeNull();
822
+ expect(match!.id).toBe('default:ask-host_file_edit-global');
823
+ expect(match!.decision).toBe('ask');
824
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_file_edit-global')!);
825
+ });
826
+
827
+ test('findHighestPriorityRule matches default ask for host_bash', () => {
828
+ const match = findHighestPriorityRule('host_bash', ['ls'], '/tmp');
829
+ expect(match).not.toBeNull();
830
+ expect(match!.id).toBe('default:ask-host_bash-global');
831
+ expect(match!.decision).toBe('ask');
832
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_bash-global')!);
833
+ });
834
+
835
+ test('findHighestPriorityRule matches default ask for computer_use_click', () => {
836
+ const match = findHighestPriorityRule('computer_use_click', ['computer_use_click:'], '/tmp');
837
+ expect(match).not.toBeNull();
838
+ expect(match!.id).toBe('default:ask-computer_use_click-global');
839
+ expect(match!.decision).toBe('ask');
840
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-computer_use_click-global')!);
841
+ });
842
+
843
+ test('findHighestPriorityRule matches default ask for computer_use_request_control', () => {
844
+ const match = findHighestPriorityRule('computer_use_request_control', ['computer_use_request_control:'], '/tmp');
845
+ expect(match).not.toBeNull();
846
+ expect(match!.id).toBe('default:ask-computer_use_request_control-global');
847
+ expect(match!.decision).toBe('ask');
848
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-computer_use_request_control-global')!);
849
+ });
850
+
851
+ test('bootstrap delete rule matches only when workingDir is the workspace dir', () => {
852
+ const workspaceDir = join(testDir, 'workspace');
853
+ // Should match when workingDir is the workspace directory — the bootstrap
854
+ // rule (priority 100) outranks the global default allow (priority 50).
855
+ const match = findHighestPriorityRule('bash', ['rm BOOTSTRAP.md'], workspaceDir);
856
+ expect(match).not.toBeNull();
857
+ expect(match!.id).toBe('default:allow-bash-rm-bootstrap');
858
+ expect(match!.decision).toBe('allow');
859
+ // Outside workspace, the bootstrap rule doesn't match — the global
860
+ // default:allow-bash-global rule matches instead (not the bootstrap rule).
861
+ const other = findHighestPriorityRule('bash', ['rm BOOTSTRAP.md'], '/tmp/other-project');
862
+ expect(other).not.toBeNull();
863
+ expect(other!.id).not.toBe('default:allow-bash-rm-bootstrap');
864
+ expect(other!.id).toBe('default:allow-bash-global');
865
+ });
866
+
867
+ test('default ask does not affect files outside protected directory', () => {
868
+ const safePath = join(testDir, 'data', 'assistant.db');
869
+ const match = findHighestPriorityRule('file_read', [`file_read:${safePath}`], '/tmp');
870
+ // Should not match a default deny rule
871
+ expect(match === null || !match.id.startsWith('default:')).toBe(true);
872
+ });
873
+
874
+ test('default rules are backfilled after malformed JSON in trust file', () => {
875
+ mkdirSync(dirname(trustPath), { recursive: true });
876
+ writeFileSync(trustPath, 'NOT VALID JSON {{{');
877
+ clearCache();
878
+ const rules = getAllRules();
879
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
880
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
881
+ });
882
+
883
+ test('default rules are backfilled in-memory after unknown file version without overwriting disk', () => {
884
+ mkdirSync(dirname(trustPath), { recursive: true });
885
+ const originalContent = JSON.stringify({ version: 9999, rules: [{ id: 'future-rule', tool: 'bash', pattern: 'future *', scope: 'everywhere', decision: 'allow', priority: 50, createdAt: 1000 }] });
886
+ writeFileSync(trustPath, originalContent);
887
+ clearCache();
888
+ const rules = getAllRules();
889
+ // Defaults should be present in-memory
890
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
891
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
892
+ // The on-disk file must NOT be overwritten — it preserves the unknown format
893
+ const diskContent = readFileSync(trustPath, 'utf-8');
894
+ expect(diskContent).toBe(originalContent);
895
+ });
896
+
897
+ test('clearAllRules preserves default rules', () => {
898
+ addRule('bash', 'git *', '/tmp');
899
+ clearAllRules();
900
+ const rules = getAllRules();
901
+ // User rules should be gone, but defaults should remain
902
+ expect(rules.filter((r) => !r.id.startsWith('default:'))).toHaveLength(0);
903
+ const defaults = rules.filter((r) => r.id.startsWith('default:'));
904
+ expect(defaults).toHaveLength(NUM_DEFAULTS);
905
+ });
906
+
907
+ // ── skill source mutation rules ────────────────────────────────
908
+
909
+ test('default rules include ask rules for file_write on skill source paths', () => {
910
+ const rules = getAllRules();
911
+ const managed = rules.find((r) => r.id === 'default:ask-file_write-managed-skills');
912
+ expect(managed).toBeDefined();
913
+ expect(managed!.tool).toBe('file_write');
914
+ expect(managed!.decision).toBe('ask');
915
+ expect(managed!.priority).toBe(50);
916
+ expect(managed!.pattern).toContain('workspace/skills/**');
917
+
918
+ const bundled = rules.find((r) => r.id === 'default:ask-file_write-bundled-skills');
919
+ expect(bundled).toBeDefined();
920
+ expect(bundled!.tool).toBe('file_write');
921
+ expect(bundled!.decision).toBe('ask');
922
+ expect(bundled!.priority).toBe(50);
923
+ });
924
+
925
+ test('default rules include ask rules for file_edit on skill source paths', () => {
926
+ const rules = getAllRules();
927
+ const managed = rules.find((r) => r.id === 'default:ask-file_edit-managed-skills');
928
+ expect(managed).toBeDefined();
929
+ expect(managed!.tool).toBe('file_edit');
930
+ expect(managed!.decision).toBe('ask');
931
+ expect(managed!.priority).toBe(50);
932
+ expect(managed!.pattern).toContain('workspace/skills/**');
933
+
934
+ const bundled = rules.find((r) => r.id === 'default:ask-file_edit-bundled-skills');
935
+ expect(bundled).toBeDefined();
936
+ expect(bundled!.tool).toBe('file_edit');
937
+ expect(bundled!.decision).toBe('ask');
938
+ expect(bundled!.priority).toBe(50);
939
+ });
940
+
941
+ // ── default allow: skill_load ────────────────────────────────
942
+
943
+ test('skill_load default allow rule exists in templates', () => {
944
+ const templates = getDefaultRuleTemplates();
945
+ const skillLoadRule = templates.find(t => t.id === 'default:allow-skill_load-global');
946
+ expect(skillLoadRule).toBeDefined();
947
+ expect(skillLoadRule!.tool).toBe('skill_load');
948
+ expect(skillLoadRule!.pattern).toBe('skill_load:*');
949
+ expect(skillLoadRule!.decision).toBe('allow');
950
+ expect(skillLoadRule!.scope).toBe('everywhere');
951
+ });
952
+
953
+ test('findHighestPriorityRule matches default allow for skill_load', () => {
954
+ const match = findHighestPriorityRule('skill_load', ['skill_load:browser'], '/tmp');
955
+ expect(match).not.toBeNull();
956
+ expect(match!.id).toBe('default:allow-skill_load-global');
957
+ expect(match!.decision).toBe('allow');
958
+ expect(match!.priority).toBe(100);
959
+ });
960
+
961
+ test('findHighestPriorityRule matches default allow for skill_load with any skill name', () => {
962
+ const match = findHighestPriorityRule('skill_load', ['skill_load:some-random-skill'], '/tmp');
963
+ expect(match).not.toBeNull();
964
+ expect(match!.id).toBe('default:allow-skill_load-global');
965
+ expect(match!.decision).toBe('allow');
966
+ });
967
+
968
+ // ── default allow: browser tools ────────────────────────────
969
+
970
+ test('all 10 browser tools have default allow rules', () => {
971
+ const templates = getDefaultRuleTemplates();
972
+ const browserTools = [
973
+ 'browser_navigate', 'browser_snapshot', 'browser_screenshot', 'browser_close',
974
+ 'browser_click', 'browser_type', 'browser_press_key', 'browser_wait_for',
975
+ 'browser_extract', 'browser_fill_credential',
976
+ ];
977
+
978
+ for (const tool of browserTools) {
979
+ const rule = templates.find(t => t.id === `default:allow-${tool}-global`);
980
+ expect(rule).toBeDefined();
981
+ expect(rule!.tool).toBe(tool);
982
+ // browser_navigate uses standalone "**" because its candidates
983
+ // contain URLs with "/" that single "*" cannot match.
984
+ const expectedPattern = tool === 'browser_navigate' ? '**' : `${tool}:*`;
985
+ expect(rule!.pattern).toBe(expectedPattern);
986
+ expect(rule!.decision).toBe('allow');
987
+ expect(rule!.scope).toBe('everywhere');
988
+ }
989
+ });
990
+
991
+ test('browser tool default rules match via findHighestPriorityRule', () => {
992
+ // Use a candidate without slashes so the `browser_snapshot:*` pattern
993
+ // matches (minimatch `*` does not cross `/` boundaries).
994
+ const result = findHighestPriorityRule('browser_snapshot', ['browser_snapshot:'], '/tmp');
995
+ expect(result).toBeDefined();
996
+ expect(result!.decision).toBe('allow');
997
+ });
998
+
999
+ test('no default ask rules exist for file_read on skill source paths', () => {
1000
+ const rules = getAllRules();
1001
+ // There should be no default rules with IDs matching file_read for skill sources
1002
+ const readManagedSkill = rules.find((r) => r.id === 'default:ask-file_read-managed-skills');
1003
+ const readBundledSkill = rules.find((r) => r.id === 'default:ask-file_read-bundled-skills');
1004
+ expect(readManagedSkill).toBeUndefined();
1005
+ expect(readBundledSkill).toBeUndefined();
1006
+ });
1007
+
1008
+ test('findHighestPriorityRule matches default ask for file_write on managed skill path', () => {
1009
+ const skillFile = join(testDir, 'workspace', 'skills', 'my-skill', 'SKILL.md');
1010
+ const match = findHighestPriorityRule('file_write', [`file_write:${skillFile}`], '/tmp');
1011
+ expect(match).not.toBeNull();
1012
+ expect(match!.id).toBe('default:ask-file_write-managed-skills');
1013
+ expect(match!.decision).toBe('ask');
1014
+ });
1015
+
1016
+ test('findHighestPriorityRule matches default ask for file_edit on managed skill path', () => {
1017
+ const skillFile = join(testDir, 'workspace', 'skills', 'my-skill', 'tools.ts');
1018
+ const match = findHighestPriorityRule('file_edit', [`file_edit:${skillFile}`], '/tmp');
1019
+ expect(match).not.toBeNull();
1020
+ expect(match!.id).toBe('default:ask-file_edit-managed-skills');
1021
+ expect(match!.decision).toBe('ask');
1022
+ });
1023
+ });
1024
+
1025
+ // ── trust rule schema v3 (PR 14) ──────────────────────────────
1026
+
1027
+ describe('trust rule schema v3 (PR 14)', () => {
1028
+ test('new rules can include principal fields', () => {
1029
+ const rule = addRule('bash', 'git *', '/tmp');
1030
+ // Manually set v3 principal fields on the rule and persist
1031
+ rule.principalKind = 'skill';
1032
+ rule.principalId = 'my-skill';
1033
+ rule.principalVersion = 'abc123';
1034
+ rule.executionTarget = '/usr/local/bin/node';
1035
+ rule.allowHighRisk = true;
1036
+ // Re-persist the updated rules
1037
+ const rules = getAllRules().map((r) =>
1038
+ r.id === rule.id ? rule : r,
1039
+ );
1040
+ // Write directly to verify round-trip
1041
+ const trustData = { version: 3, rules };
1042
+ writeFileSync(trustPath, JSON.stringify(trustData, null, 2));
1043
+ clearCache();
1044
+ const reloaded = getAllRules();
1045
+ const found = reloaded.find((r) => r.id === rule.id);
1046
+ expect(found).toBeDefined();
1047
+ expect(found!.principalKind).toBe('skill');
1048
+ expect(found!.principalId).toBe('my-skill');
1049
+ expect(found!.principalVersion).toBe('abc123');
1050
+ expect(found!.executionTarget).toBe('/usr/local/bin/node');
1051
+ expect(found!.allowHighRisk).toBe(true);
1052
+ });
1053
+
1054
+ test('v2 file is upgraded to v3 on disk', () => {
1055
+ mkdirSync(dirname(trustPath), { recursive: true });
1056
+ writeFileSync(trustPath, JSON.stringify({
1057
+ version: 2,
1058
+ rules: [{
1059
+ id: 'v2-rule',
1060
+ tool: 'bash',
1061
+ pattern: 'npm *',
1062
+ scope: 'everywhere',
1063
+ decision: 'allow',
1064
+ priority: 100,
1065
+ createdAt: 3000,
1066
+ }],
1067
+ }));
1068
+ clearCache();
1069
+ getAllRules(); // triggers load + migration
1070
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1071
+ expect(data.version).toBe(3);
1072
+ });
1073
+
1074
+ test('v2 rules survive v3 migration with no principal fields', () => {
1075
+ mkdirSync(dirname(trustPath), { recursive: true });
1076
+ writeFileSync(trustPath, JSON.stringify({
1077
+ version: 2,
1078
+ rules: [
1079
+ {
1080
+ id: 'user-v2-a',
1081
+ tool: 'bash',
1082
+ pattern: 'git *',
1083
+ scope: '/tmp',
1084
+ decision: 'allow',
1085
+ priority: 100,
1086
+ createdAt: 4000,
1087
+ },
1088
+ {
1089
+ id: 'user-v2-b',
1090
+ tool: 'file_write',
1091
+ pattern: '/tmp/*',
1092
+ scope: '/tmp',
1093
+ decision: 'deny',
1094
+ priority: 50,
1095
+ createdAt: 4001,
1096
+ },
1097
+ ],
1098
+ }));
1099
+ clearCache();
1100
+ const rules = getAllRules();
1101
+ const ruleA = rules.find((r) => r.id === 'user-v2-a');
1102
+ const ruleB = rules.find((r) => r.id === 'user-v2-b');
1103
+ expect(ruleA).toBeDefined();
1104
+ expect(ruleB).toBeDefined();
1105
+ expect(ruleA!.pattern).toBe('git *');
1106
+ expect(ruleB!.decision).toBe('deny');
1107
+ // No principal fields should be present
1108
+ expect(ruleA).not.toHaveProperty('principalKind');
1109
+ expect(ruleA).not.toHaveProperty('principalId');
1110
+ expect(ruleA).not.toHaveProperty('principalVersion');
1111
+ expect(ruleA).not.toHaveProperty('executionTarget');
1112
+ expect(ruleA).not.toHaveProperty('allowHighRisk');
1113
+ });
1114
+
1115
+ test('trust file persists with version 3', () => {
1116
+ addRule('bash', 'echo *', '/tmp');
1117
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1118
+ expect(data.version).toBe(3);
1119
+ });
1120
+ });
1121
+
1122
+ // ── v2 → v3 migration hardening (PR 15) ────────────────────────
1123
+
1124
+ describe('v2 → v3 migration hardening (PR 15)', () => {
1125
+ test('v2 rules with extra unknown fields survive migration cleanly', () => {
1126
+ mkdirSync(dirname(trustPath), { recursive: true });
1127
+ writeFileSync(trustPath, JSON.stringify({
1128
+ version: 2,
1129
+ rules: [{
1130
+ id: 'v2-extra-fields',
1131
+ tool: 'bash',
1132
+ pattern: 'git *',
1133
+ scope: '/tmp',
1134
+ decision: 'allow',
1135
+ priority: 100,
1136
+ createdAt: 5000,
1137
+ customField: 'should-survive',
1138
+ nested: { deep: true },
1139
+ }],
1140
+ }));
1141
+ clearCache();
1142
+ const rules = getAllRules();
1143
+ const rule = rules.find((r) => r.id === 'v2-extra-fields');
1144
+ expect(rule).toBeDefined();
1145
+ expect(rule!.tool).toBe('bash');
1146
+ expect(rule!.pattern).toBe('git *');
1147
+ // Extra fields pass through because the migration does not strip them
1148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- asserting extra fields pass through migration
1149
+ expect((rule as any).customField).toBe('should-survive');
1150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- asserting extra fields pass through migration
1151
+ expect((rule as any).nested).toEqual({ deep: true });
1152
+ });
1153
+
1154
+ test('v2 file with empty rules array migrates correctly', () => {
1155
+ mkdirSync(dirname(trustPath), { recursive: true });
1156
+ writeFileSync(trustPath, JSON.stringify({
1157
+ version: 2,
1158
+ rules: [],
1159
+ }));
1160
+ clearCache();
1161
+ const rules = getAllRules();
1162
+ // Should only have default rules, no user rules
1163
+ expect(rules).toHaveLength(NUM_DEFAULTS);
1164
+ expect(rules.every((r) => r.id.startsWith('default:'))).toBe(true);
1165
+ // File should be upgraded to v3 on disk
1166
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1167
+ expect(data.version).toBe(3);
1168
+ });
1169
+
1170
+ test('v2 file with no rules field at all migrates correctly', () => {
1171
+ mkdirSync(dirname(trustPath), { recursive: true });
1172
+ writeFileSync(trustPath, JSON.stringify({
1173
+ version: 2,
1174
+ }));
1175
+ clearCache();
1176
+ const rules = getAllRules();
1177
+ // rules defaults to [] so only defaults should appear
1178
+ expect(rules).toHaveLength(NUM_DEFAULTS);
1179
+ expect(rules.every((r) => r.id.startsWith('default:'))).toBe(true);
1180
+ // File should be upgraded to v3 on disk
1181
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1182
+ expect(data.version).toBe(3);
1183
+ });
1184
+
1185
+ test('malformed v2 file (rules is a string instead of array) is handled gracefully', () => {
1186
+ mkdirSync(dirname(trustPath), { recursive: true });
1187
+ writeFileSync(trustPath, JSON.stringify({
1188
+ version: 2,
1189
+ rules: 'not-an-array',
1190
+ }));
1191
+ clearCache();
1192
+ const rules = getAllRules();
1193
+ // Should fall back to empty rules and backfill defaults
1194
+ expect(rules).toHaveLength(NUM_DEFAULTS);
1195
+ expect(rules.every((r) => r.id.startsWith('default:'))).toBe(true);
1196
+ });
1197
+
1198
+ test('malformed v2 file (rules is an object instead of array) is handled gracefully', () => {
1199
+ mkdirSync(dirname(trustPath), { recursive: true });
1200
+ writeFileSync(trustPath, JSON.stringify({
1201
+ version: 2,
1202
+ rules: { notAnArray: true },
1203
+ }));
1204
+ clearCache();
1205
+ const rules = getAllRules();
1206
+ expect(rules).toHaveLength(NUM_DEFAULTS);
1207
+ expect(rules.every((r) => r.id.startsWith('default:'))).toBe(true);
1208
+ });
1209
+
1210
+ test('malformed file (valid JSON but null) is handled gracefully', () => {
1211
+ mkdirSync(dirname(trustPath), { recursive: true });
1212
+ writeFileSync(trustPath, 'null');
1213
+ clearCache();
1214
+ const rules = getAllRules();
1215
+ // Accessing null.version throws TypeError, caught by try/catch,
1216
+ // falls through to backfill defaults
1217
+ expect(rules).toHaveLength(NUM_DEFAULTS);
1218
+ });
1219
+
1220
+ test('concurrent v2 → v3 migration (loading twice in sequence) is idempotent', () => {
1221
+ mkdirSync(dirname(trustPath), { recursive: true });
1222
+ writeFileSync(trustPath, JSON.stringify({
1223
+ version: 2,
1224
+ rules: [{
1225
+ id: 'idempotent-rule',
1226
+ tool: 'bash',
1227
+ pattern: 'npm *',
1228
+ scope: 'everywhere',
1229
+ decision: 'allow',
1230
+ priority: 100,
1231
+ createdAt: 6000,
1232
+ }],
1233
+ }));
1234
+ // First load — triggers v2 → v3 migration
1235
+ clearCache();
1236
+ const rules1 = getAllRules();
1237
+ const rule1 = rules1.find((r) => r.id === 'idempotent-rule');
1238
+ expect(rule1).toBeDefined();
1239
+ expect(rule1!.pattern).toBe('npm *');
1240
+
1241
+ // Second load — should load the already-migrated v3 file without re-migrating
1242
+ clearCache();
1243
+ const rules2 = getAllRules();
1244
+ const rule2 = rules2.find((r) => r.id === 'idempotent-rule');
1245
+ expect(rule2).toBeDefined();
1246
+ expect(rule2!.pattern).toBe('npm *');
1247
+ expect(rule2!.priority).toBe(100);
1248
+
1249
+ // Verify file is still v3 and rule count is stable
1250
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1251
+ expect(data.version).toBe(3);
1252
+ const userRules = data.rules.filter((r: { id: string }) => !r.id.startsWith('default:'));
1253
+ expect(userRules).toHaveLength(1);
1254
+ });
1255
+
1256
+ test('v3 file with principal fields is loaded correctly without re-migration', () => {
1257
+ mkdirSync(dirname(trustPath), { recursive: true });
1258
+ const v3Rules = [
1259
+ {
1260
+ id: 'v3-with-principal',
1261
+ tool: 'bash',
1262
+ pattern: 'skill-cmd *',
1263
+ scope: '/tmp',
1264
+ decision: 'allow',
1265
+ priority: 100,
1266
+ createdAt: 7000,
1267
+ principalKind: 'skill',
1268
+ principalId: 'my-skill',
1269
+ principalVersion: 'sha256-abc',
1270
+ executionTarget: '/usr/bin/node',
1271
+ allowHighRisk: false,
1272
+ },
1273
+ {
1274
+ id: 'v3-without-principal',
1275
+ tool: 'bash',
1276
+ pattern: 'git *',
1277
+ scope: '/tmp',
1278
+ decision: 'allow',
1279
+ priority: 100,
1280
+ createdAt: 7001,
1281
+ },
1282
+ ];
1283
+ writeFileSync(trustPath, JSON.stringify({ version: 3, rules: v3Rules }));
1284
+ clearCache();
1285
+ const rules = getAllRules();
1286
+
1287
+ // Rule with principal fields should have them preserved
1288
+ const withPrincipal = rules.find((r) => r.id === 'v3-with-principal');
1289
+ expect(withPrincipal).toBeDefined();
1290
+ expect(withPrincipal!.principalKind).toBe('skill');
1291
+ expect(withPrincipal!.principalId).toBe('my-skill');
1292
+ expect(withPrincipal!.principalVersion).toBe('sha256-abc');
1293
+ expect(withPrincipal!.executionTarget).toBe('/usr/bin/node');
1294
+ expect(withPrincipal!.allowHighRisk).toBe(false);
1295
+
1296
+ // Rule without principal fields should remain without them
1297
+ const withoutPrincipal = rules.find((r) => r.id === 'v3-without-principal');
1298
+ expect(withoutPrincipal).toBeDefined();
1299
+ expect(withoutPrincipal).not.toHaveProperty('principalKind');
1300
+ expect(withoutPrincipal).not.toHaveProperty('principalId');
1301
+ });
1302
+
1303
+ test('v2 migration preserves rule meaning exactly — no default principal values added', () => {
1304
+ mkdirSync(dirname(trustPath), { recursive: true });
1305
+ const originalRules = [
1306
+ {
1307
+ id: 'preserve-a',
1308
+ tool: 'bash',
1309
+ pattern: 'git *',
1310
+ scope: '/home/user',
1311
+ decision: 'allow' as const,
1312
+ priority: 100,
1313
+ createdAt: 8000,
1314
+ },
1315
+ {
1316
+ id: 'preserve-b',
1317
+ tool: 'file_write',
1318
+ pattern: '/tmp/**',
1319
+ scope: 'everywhere',
1320
+ decision: 'deny' as const,
1321
+ priority: 50,
1322
+ createdAt: 8001,
1323
+ },
1324
+ ];
1325
+ writeFileSync(trustPath, JSON.stringify({ version: 2, rules: originalRules }));
1326
+ clearCache();
1327
+ const rules = getAllRules();
1328
+
1329
+ for (const original of originalRules) {
1330
+ const migrated = rules.find((r) => r.id === original.id);
1331
+ expect(migrated).toBeDefined();
1332
+ // Every original field is preserved exactly
1333
+ expect(migrated!.tool).toBe(original.tool);
1334
+ expect(migrated!.pattern).toBe(original.pattern);
1335
+ expect(migrated!.scope).toBe(original.scope);
1336
+ expect(migrated!.decision).toBe(original.decision);
1337
+ expect(migrated!.priority).toBe(original.priority);
1338
+ expect(migrated!.createdAt).toBe(original.createdAt);
1339
+ // No principal fields were injected by migration
1340
+ expect(migrated).not.toHaveProperty('principalKind');
1341
+ expect(migrated).not.toHaveProperty('principalId');
1342
+ expect(migrated).not.toHaveProperty('principalVersion');
1343
+ expect(migrated).not.toHaveProperty('executionTarget');
1344
+ expect(migrated).not.toHaveProperty('allowHighRisk');
1345
+ }
1346
+
1347
+ // Verify disk representation also has no principal fields on user rules
1348
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1349
+ for (const original of originalRules) {
1350
+ const diskRule = data.rules.find((r: { id: string }) => r.id === original.id);
1351
+ expect(diskRule).toBeDefined();
1352
+ expect(diskRule).not.toHaveProperty('principalKind');
1353
+ expect(diskRule).not.toHaveProperty('principalId');
1354
+ }
1355
+ });
1356
+
1357
+ test('v1 → v3 full migration preserves rules and adds priority', () => {
1358
+ mkdirSync(dirname(trustPath), { recursive: true });
1359
+ writeFileSync(trustPath, JSON.stringify({
1360
+ version: 1,
1361
+ rules: [{
1362
+ id: 'v1-full-migration',
1363
+ tool: 'bash',
1364
+ pattern: 'docker *',
1365
+ scope: '/srv',
1366
+ decision: 'allow',
1367
+ createdAt: 9000,
1368
+ }],
1369
+ }));
1370
+ clearCache();
1371
+ const rules = getAllRules();
1372
+ const rule = rules.find((r) => r.id === 'v1-full-migration');
1373
+ expect(rule).toBeDefined();
1374
+ // v1 → v2 adds priority 100
1375
+ expect(rule!.priority).toBe(100);
1376
+ // v2 → v3 adds no principal fields
1377
+ expect(rule).not.toHaveProperty('principalKind');
1378
+ expect(rule).not.toHaveProperty('principalId');
1379
+ // File should be v3 on disk
1380
+ const data = JSON.parse(readFileSync(trustPath, 'utf-8'));
1381
+ expect(data.version).toBe(3);
1382
+ });
1383
+ });
1384
+
1385
+ // ── backward compat: addRule without principal options (PR 2/40) ──
1386
+ // These tests verify that addRule() without explicit principal options
1387
+ // creates wildcard rules. The TrustRule schema *does* support principal
1388
+ // and version fields (since PR 14), but they are only set when explicitly
1389
+ // provided via the options parameter.
1390
+
1391
+ describe('backward compat: addRule without principal options (PR 2/40)', () => {
1392
+ test('addRule without principal options creates rules without principal fields', () => {
1393
+ const rule = addRule('skill_test_tool', 'skill_test_tool:*', '/tmp');
1394
+ expect(rule).not.toHaveProperty('principalKind');
1395
+ expect(rule).not.toHaveProperty('principalId');
1396
+ expect(rule).not.toHaveProperty('principalVersion');
1397
+ expect(rule).not.toHaveProperty('executionTarget');
1398
+ expect(rule).not.toHaveProperty('allowHighRisk');
1399
+ });
1400
+
1401
+ test('findHighestPriorityRule matches without policy context (backward compat)', () => {
1402
+ addRule('skill_test_tool', 'skill_test_tool:*', '/tmp', 'allow', 200);
1403
+ // Calling without the optional 4th ctx parameter still matches wildcard rules
1404
+ const match = findHighestPriorityRule('skill_test_tool', ['skill_test_tool:do-thing'], '/tmp');
1405
+ expect(match).not.toBeNull();
1406
+ expect(match!.decision).toBe('allow');
1407
+ });
1408
+
1409
+ test('trust file schema is v3 (rules created without principal fields)', () => {
1410
+ addRule('skill_test_tool', 'skill_test_tool:*', '/tmp');
1411
+ const raw = JSON.parse(readFileSync(trustPath, 'utf-8'));
1412
+ expect(raw.version).toBe(3);
1413
+ const userRule = raw.rules.find((r: { pattern: string }) => r.pattern === 'skill_test_tool:*');
1414
+ expect(userRule).toBeDefined();
1415
+ // addRule without principal options doesn't set principal fields
1416
+ expect(userRule).not.toHaveProperty('principalVersion');
1417
+ expect(userRule).not.toHaveProperty('principalKind');
1418
+ });
1419
+ });
1420
+
1421
+ // ── principal-aware rule matching (PR 16) ──────────────────────
1422
+
1423
+ describe('principal-aware rule matching (PR 16)', () => {
1424
+ /**
1425
+ * Helper: write a v3 trust file with the given rules directly to disk,
1426
+ * then clear the cache so the next getRules() call picks them up.
1427
+ */
1428
+ function seedRules(rules: Array<Record<string, unknown>>): void {
1429
+ mkdirSync(dirname(trustPath), { recursive: true });
1430
+ writeFileSync(trustPath, JSON.stringify({ version: 3, rules }));
1431
+ clearCache();
1432
+ }
1433
+
1434
+ // ── wildcard semantics (no principal fields on rule) ──────────
1435
+
1436
+ describe('wildcard semantics — rules without principal fields', () => {
1437
+ test('rule with no principal fields matches when no context is provided', () => {
1438
+ addRule('bash', 'git *', '/tmp', 'allow', 200);
1439
+ const match = findHighestPriorityRule('bash', ['git status'], '/tmp');
1440
+ expect(match).not.toBeNull();
1441
+ expect(match!.decision).toBe('allow');
1442
+ });
1443
+
1444
+ test('rule with no principal fields matches any principal context', () => {
1445
+ addRule('bash', 'git *', '/tmp', 'allow', 200);
1446
+ const match = findHighestPriorityRule('bash', ['git status'], '/tmp', {
1447
+ principal: { kind: 'skill', id: 'my-skill', version: 'v1' },
1448
+ });
1449
+ expect(match).not.toBeNull();
1450
+ expect(match!.decision).toBe('allow');
1451
+ });
1452
+
1453
+ test('rule with no principal fields matches any execution target', () => {
1454
+ addRule('bash', 'git *', '/tmp', 'allow', 200);
1455
+ const match = findHighestPriorityRule('bash', ['git status'], '/tmp', {
1456
+ executionTarget: '/usr/bin/node',
1457
+ });
1458
+ expect(match).not.toBeNull();
1459
+ expect(match!.decision).toBe('allow');
1460
+ });
1461
+
1462
+ test('rule with no principal fields matches context with both principal and target', () => {
1463
+ addRule('bash', 'npm *', '/tmp', 'allow', 200);
1464
+ const match = findHighestPriorityRule('bash', ['npm install'], '/tmp', {
1465
+ principal: { kind: 'skill', id: 'builder', version: 'sha256-xyz' },
1466
+ executionTarget: '/usr/local/bin/bun',
1467
+ });
1468
+ expect(match).not.toBeNull();
1469
+ });
1470
+ });
1471
+
1472
+ // ── principalKind matching ────────────────────────────────────
1473
+
1474
+ describe('principalKind matching', () => {
1475
+ test('rule with principalKind matches when context kind matches', () => {
1476
+ seedRules([{
1477
+ id: 'pk-match',
1478
+ tool: 'bash',
1479
+ pattern: 'echo *',
1480
+ scope: 'everywhere',
1481
+ decision: 'allow',
1482
+ priority: 200,
1483
+ createdAt: Date.now(),
1484
+ principalKind: 'skill',
1485
+ }]);
1486
+ const match = findHighestPriorityRule('bash', ['echo hello'], '/tmp', {
1487
+ principal: { kind: 'skill' },
1488
+ });
1489
+ expect(match).not.toBeNull();
1490
+ expect(match!.id).toBe('pk-match');
1491
+ });
1492
+
1493
+ test('rule with principalKind does NOT match when context kind differs', () => {
1494
+ seedRules([{
1495
+ id: 'pk-mismatch',
1496
+ tool: 'bash',
1497
+ pattern: 'echo *',
1498
+ scope: 'everywhere',
1499
+ decision: 'allow',
1500
+ priority: 200,
1501
+ createdAt: Date.now(),
1502
+ principalKind: 'skill',
1503
+ }]);
1504
+ const match = findHighestPriorityRule('bash', ['echo hello'], '/tmp', {
1505
+ principal: { kind: 'core' },
1506
+ });
1507
+ // Should not match the pk-mismatch rule; may still match a default rule
1508
+ expect(match === null || match.id !== 'pk-mismatch').toBe(true);
1509
+ });
1510
+
1511
+ test('rule with principalKind does NOT match when no context is provided', () => {
1512
+ seedRules([{
1513
+ id: 'pk-no-ctx',
1514
+ tool: 'bash',
1515
+ pattern: 'echo *',
1516
+ scope: 'everywhere',
1517
+ decision: 'allow',
1518
+ priority: 200,
1519
+ createdAt: Date.now(),
1520
+ principalKind: 'skill',
1521
+ }]);
1522
+ const match = findHighestPriorityRule('bash', ['echo hello'], '/tmp');
1523
+ expect(match === null || match.id !== 'pk-no-ctx').toBe(true);
1524
+ });
1525
+ });
1526
+
1527
+ // ── principalId matching ──────────────────────────────────────
1528
+
1529
+ describe('principalId matching', () => {
1530
+ test('rule with principalKind + principalId matches exact principal', () => {
1531
+ seedRules([{
1532
+ id: 'pid-exact',
1533
+ tool: 'bash',
1534
+ pattern: 'deploy *',
1535
+ scope: 'everywhere',
1536
+ decision: 'allow',
1537
+ priority: 200,
1538
+ createdAt: Date.now(),
1539
+ principalKind: 'skill',
1540
+ principalId: 'deployer',
1541
+ }]);
1542
+ const match = findHighestPriorityRule('bash', ['deploy prod'], '/tmp', {
1543
+ principal: { kind: 'skill', id: 'deployer' },
1544
+ });
1545
+ expect(match).not.toBeNull();
1546
+ expect(match!.id).toBe('pid-exact');
1547
+ });
1548
+
1549
+ test('rule with principalId does NOT match different id', () => {
1550
+ seedRules([{
1551
+ id: 'pid-diff',
1552
+ tool: 'bash',
1553
+ pattern: 'deploy *',
1554
+ scope: 'everywhere',
1555
+ decision: 'allow',
1556
+ priority: 200,
1557
+ createdAt: Date.now(),
1558
+ principalKind: 'skill',
1559
+ principalId: 'deployer',
1560
+ }]);
1561
+ const match = findHighestPriorityRule('bash', ['deploy prod'], '/tmp', {
1562
+ principal: { kind: 'skill', id: 'other-skill' },
1563
+ });
1564
+ expect(match === null || match.id !== 'pid-diff').toBe(true);
1565
+ });
1566
+ });
1567
+
1568
+ // ── principalVersion matching ─────────────────────────────────
1569
+
1570
+ describe('principalVersion matching', () => {
1571
+ test('rule with principalVersion matches exact version', () => {
1572
+ seedRules([{
1573
+ id: 'pv-exact',
1574
+ tool: 'bash',
1575
+ pattern: 'build *',
1576
+ scope: 'everywhere',
1577
+ decision: 'allow',
1578
+ priority: 200,
1579
+ createdAt: Date.now(),
1580
+ principalKind: 'skill',
1581
+ principalId: 'builder',
1582
+ principalVersion: 'sha256-abc123',
1583
+ }]);
1584
+ const match = findHighestPriorityRule('bash', ['build all'], '/tmp', {
1585
+ principal: { kind: 'skill', id: 'builder', version: 'sha256-abc123' },
1586
+ });
1587
+ expect(match).not.toBeNull();
1588
+ expect(match!.id).toBe('pv-exact');
1589
+ });
1590
+
1591
+ test('rule with principalVersion does NOT match different version', () => {
1592
+ seedRules([{
1593
+ id: 'pv-diff',
1594
+ tool: 'bash',
1595
+ pattern: 'build *',
1596
+ scope: 'everywhere',
1597
+ decision: 'allow',
1598
+ priority: 200,
1599
+ createdAt: Date.now(),
1600
+ principalKind: 'skill',
1601
+ principalId: 'builder',
1602
+ principalVersion: 'sha256-abc123',
1603
+ }]);
1604
+ const match = findHighestPriorityRule('bash', ['build all'], '/tmp', {
1605
+ principal: { kind: 'skill', id: 'builder', version: 'sha256-DIFFERENT' },
1606
+ });
1607
+ expect(match === null || match.id !== 'pv-diff').toBe(true);
1608
+ });
1609
+
1610
+ test('rule WITHOUT principalVersion matches any version (wildcard)', () => {
1611
+ seedRules([{
1612
+ id: 'pv-wildcard',
1613
+ tool: 'bash',
1614
+ pattern: 'build *',
1615
+ scope: 'everywhere',
1616
+ decision: 'allow',
1617
+ priority: 200,
1618
+ createdAt: Date.now(),
1619
+ principalKind: 'skill',
1620
+ principalId: 'builder',
1621
+ // no principalVersion — should match any version
1622
+ }]);
1623
+ const matchV1 = findHighestPriorityRule('bash', ['build all'], '/tmp', {
1624
+ principal: { kind: 'skill', id: 'builder', version: 'v1' },
1625
+ });
1626
+ expect(matchV1).not.toBeNull();
1627
+ expect(matchV1!.id).toBe('pv-wildcard');
1628
+
1629
+ const matchV2 = findHighestPriorityRule('bash', ['build all'], '/tmp', {
1630
+ principal: { kind: 'skill', id: 'builder', version: 'v2' },
1631
+ });
1632
+ expect(matchV2).not.toBeNull();
1633
+ expect(matchV2!.id).toBe('pv-wildcard');
1634
+
1635
+ const matchNoVersion = findHighestPriorityRule('bash', ['build all'], '/tmp', {
1636
+ principal: { kind: 'skill', id: 'builder' },
1637
+ });
1638
+ expect(matchNoVersion).not.toBeNull();
1639
+ expect(matchNoVersion!.id).toBe('pv-wildcard');
1640
+ });
1641
+ });
1642
+
1643
+ // ── executionTarget matching ──────────────────────────────────
1644
+
1645
+ describe('executionTarget matching', () => {
1646
+ test('rule with executionTarget matches exact target', () => {
1647
+ seedRules([{
1648
+ id: 'et-exact',
1649
+ tool: 'bash',
1650
+ pattern: 'run *',
1651
+ scope: 'everywhere',
1652
+ decision: 'allow',
1653
+ priority: 200,
1654
+ createdAt: Date.now(),
1655
+ executionTarget: '/usr/local/bin/node',
1656
+ }]);
1657
+ const match = findHighestPriorityRule('bash', ['run script.js'], '/tmp', {
1658
+ executionTarget: '/usr/local/bin/node',
1659
+ });
1660
+ expect(match).not.toBeNull();
1661
+ expect(match!.id).toBe('et-exact');
1662
+ });
1663
+
1664
+ test('rule with executionTarget does NOT match different target', () => {
1665
+ seedRules([{
1666
+ id: 'et-diff',
1667
+ tool: 'bash',
1668
+ pattern: 'run *',
1669
+ scope: 'everywhere',
1670
+ decision: 'allow',
1671
+ priority: 200,
1672
+ createdAt: Date.now(),
1673
+ executionTarget: '/usr/local/bin/node',
1674
+ }]);
1675
+ const match = findHighestPriorityRule('bash', ['run script.js'], '/tmp', {
1676
+ executionTarget: '/usr/local/bin/bun',
1677
+ });
1678
+ expect(match === null || match.id !== 'et-diff').toBe(true);
1679
+ });
1680
+
1681
+ test('rule with executionTarget does NOT match when no target in context', () => {
1682
+ seedRules([{
1683
+ id: 'et-no-ctx',
1684
+ tool: 'bash',
1685
+ pattern: 'run *',
1686
+ scope: 'everywhere',
1687
+ decision: 'allow',
1688
+ priority: 200,
1689
+ createdAt: Date.now(),
1690
+ executionTarget: '/usr/local/bin/node',
1691
+ }]);
1692
+ const match = findHighestPriorityRule('bash', ['run script.js'], '/tmp', {});
1693
+ expect(match === null || match.id !== 'et-no-ctx').toBe(true);
1694
+ });
1695
+
1696
+ test('rule WITHOUT executionTarget matches any target (wildcard)', () => {
1697
+ addRule('bash', 'run *', '/tmp', 'allow', 200);
1698
+ const match = findHighestPriorityRule('bash', ['run script.js'], '/tmp', {
1699
+ executionTarget: '/any/path/to/runtime',
1700
+ });
1701
+ expect(match).not.toBeNull();
1702
+ expect(match!.pattern).toBe('run *');
1703
+ });
1704
+ });
1705
+
1706
+ // ── combined principal + executionTarget ───────────────────────
1707
+
1708
+ describe('combined principal + executionTarget matching', () => {
1709
+ test('rule with both principal and executionTarget matches when all fields match', () => {
1710
+ seedRules([{
1711
+ id: 'combo-match',
1712
+ tool: 'bash',
1713
+ pattern: 'deploy *',
1714
+ scope: 'everywhere',
1715
+ decision: 'allow',
1716
+ priority: 200,
1717
+ createdAt: Date.now(),
1718
+ principalKind: 'skill',
1719
+ principalId: 'deployer',
1720
+ principalVersion: 'sha256-abc',
1721
+ executionTarget: '/usr/bin/node',
1722
+ }]);
1723
+ const match = findHighestPriorityRule('bash', ['deploy prod'], '/tmp', {
1724
+ principal: { kind: 'skill', id: 'deployer', version: 'sha256-abc' },
1725
+ executionTarget: '/usr/bin/node',
1726
+ });
1727
+ expect(match).not.toBeNull();
1728
+ expect(match!.id).toBe('combo-match');
1729
+ });
1730
+
1731
+ test('rule with both principal and executionTarget fails if principal mismatches', () => {
1732
+ seedRules([{
1733
+ id: 'combo-bad-principal',
1734
+ tool: 'bash',
1735
+ pattern: 'deploy *',
1736
+ scope: 'everywhere',
1737
+ decision: 'allow',
1738
+ priority: 200,
1739
+ createdAt: Date.now(),
1740
+ principalKind: 'skill',
1741
+ principalId: 'deployer',
1742
+ executionTarget: '/usr/bin/node',
1743
+ }]);
1744
+ const match = findHighestPriorityRule('bash', ['deploy prod'], '/tmp', {
1745
+ principal: { kind: 'skill', id: 'other-skill' },
1746
+ executionTarget: '/usr/bin/node',
1747
+ });
1748
+ expect(match === null || match.id !== 'combo-bad-principal').toBe(true);
1749
+ });
1750
+
1751
+ test('rule with both principal and executionTarget fails if target mismatches', () => {
1752
+ seedRules([{
1753
+ id: 'combo-bad-target',
1754
+ tool: 'bash',
1755
+ pattern: 'deploy *',
1756
+ scope: 'everywhere',
1757
+ decision: 'allow',
1758
+ priority: 200,
1759
+ createdAt: Date.now(),
1760
+ principalKind: 'skill',
1761
+ principalId: 'deployer',
1762
+ executionTarget: '/usr/bin/node',
1763
+ }]);
1764
+ const match = findHighestPriorityRule('bash', ['deploy prod'], '/tmp', {
1765
+ principal: { kind: 'skill', id: 'deployer' },
1766
+ executionTarget: '/usr/bin/bun',
1767
+ });
1768
+ expect(match === null || match.id !== 'combo-bad-target').toBe(true);
1769
+ });
1770
+ });
1771
+
1772
+ // ── priority interaction with principal filtering ──────────────
1773
+
1774
+ describe('priority interaction with principal filtering', () => {
1775
+ test('higher-priority principal-specific rule wins over lower-priority wildcard', () => {
1776
+ seedRules([
1777
+ {
1778
+ id: 'wildcard-low',
1779
+ tool: 'bash',
1780
+ pattern: 'test *',
1781
+ scope: 'everywhere',
1782
+ decision: 'deny',
1783
+ priority: 50,
1784
+ createdAt: Date.now(),
1785
+ },
1786
+ {
1787
+ id: 'specific-high',
1788
+ tool: 'bash',
1789
+ pattern: 'test *',
1790
+ scope: 'everywhere',
1791
+ decision: 'allow',
1792
+ priority: 200,
1793
+ createdAt: Date.now(),
1794
+ principalKind: 'skill',
1795
+ principalId: 'tester',
1796
+ },
1797
+ ]);
1798
+ const match = findHighestPriorityRule('bash', ['test unit'], '/tmp', {
1799
+ principal: { kind: 'skill', id: 'tester' },
1800
+ });
1801
+ expect(match).not.toBeNull();
1802
+ expect(match!.id).toBe('specific-high');
1803
+ expect(match!.decision).toBe('allow');
1804
+ });
1805
+
1806
+ test('non-matching principal rule is skipped, falling through to wildcard rule', () => {
1807
+ seedRules([
1808
+ {
1809
+ id: 'specific-high',
1810
+ tool: 'bash',
1811
+ pattern: 'test *',
1812
+ scope: 'everywhere',
1813
+ decision: 'allow',
1814
+ priority: 200,
1815
+ createdAt: Date.now(),
1816
+ principalKind: 'skill',
1817
+ principalId: 'deployer',
1818
+ },
1819
+ {
1820
+ id: 'wildcard-low',
1821
+ tool: 'bash',
1822
+ pattern: 'test *',
1823
+ scope: 'everywhere',
1824
+ decision: 'deny',
1825
+ priority: 50,
1826
+ createdAt: Date.now(),
1827
+ },
1828
+ ]);
1829
+ // Context has kind=skill, id=tester — doesn't match 'deployer'
1830
+ const match = findHighestPriorityRule('bash', ['test unit'], '/tmp', {
1831
+ principal: { kind: 'skill', id: 'tester' },
1832
+ });
1833
+ expect(match).not.toBeNull();
1834
+ expect(match!.id).toBe('wildcard-low');
1835
+ expect(match!.decision).toBe('deny');
1836
+ });
1837
+ });
1838
+
1839
+ // ── backward compatibility ────────────────────────────────────
1840
+
1841
+ describe('backward compatibility', () => {
1842
+ test('existing callers without ctx parameter still work', () => {
1843
+ addRule('bash', 'git *', '/tmp', 'allow', 200);
1844
+ // Calling without the 4th argument — must still match
1845
+ const match = findHighestPriorityRule('bash', ['git status'], '/tmp');
1846
+ expect(match).not.toBeNull();
1847
+ expect(match!.pattern).toBe('git *');
1848
+ });
1849
+
1850
+ test('existing default rules (no principal fields) match with any context', () => {
1851
+ // Default rules have no principal fields and should match regardless of context.
1852
+ // Use host_file_read which has a default ask rule (default:ask-host_file_read-global).
1853
+ const match = findHighestPriorityRule(
1854
+ 'host_file_read',
1855
+ ['host_file_read:/etc/hosts'],
1856
+ '/tmp',
1857
+ { principal: { kind: 'skill', id: 'random-skill', version: 'v99' } },
1858
+ );
1859
+ expect(match).not.toBeNull();
1860
+ expect(match!.decision).toBe('ask');
1861
+ });
1862
+
1863
+ test('empty PolicyContext object behaves the same as no context', () => {
1864
+ addRule('bash', 'ls *', '/tmp', 'allow', 200);
1865
+ const matchNoCtx = findHighestPriorityRule('bash', ['ls -la'], '/tmp');
1866
+ const matchEmptyCtx = findHighestPriorityRule('bash', ['ls -la'], '/tmp', {});
1867
+ expect(matchNoCtx).not.toBeNull();
1868
+ expect(matchEmptyCtx).not.toBeNull();
1869
+ expect(matchNoCtx!.id).toBe(matchEmptyCtx!.id);
1870
+ });
1871
+ });
1872
+ });
1873
+
1874
+ // ── network_request trust rule matching ────────────────────────
1875
+
1876
+ describe('network_request trust rules', () => {
1877
+ test('exact origin rule matches network_request candidates', () => {
1878
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere');
1879
+ const rule = findHighestPriorityRule(
1880
+ 'network_request',
1881
+ ['network_request:https://api.example.com/v1/data', 'network_request:https://api.example.com/*'],
1882
+ '/tmp',
1883
+ );
1884
+ expect(rule).not.toBeNull();
1885
+ expect(rule!.decision).toBe('allow');
1886
+ });
1887
+
1888
+ test('exact url rule matches only that url candidate', () => {
1889
+ addRule('network_request', 'network_request:https://api.example.com/v1/data', 'everywhere');
1890
+ const match = findHighestPriorityRule(
1891
+ 'network_request',
1892
+ ['network_request:https://api.example.com/v1/data', 'network_request:https://api.example.com/*'],
1893
+ '/tmp',
1894
+ );
1895
+ expect(match).not.toBeNull();
1896
+
1897
+ const noMatch = findHighestPriorityRule(
1898
+ 'network_request',
1899
+ ['network_request:https://api.example.com/v2/other'],
1900
+ '/tmp',
1901
+ );
1902
+ expect(noMatch).toBeNull();
1903
+ });
1904
+
1905
+ test('globstar rule matches any network_request candidate', () => {
1906
+ // minimatch treats standalone "**" as globstar (matching "/"), but
1907
+ // "network_request:*" uses single "*" which doesn't cross slashes.
1908
+ // The tool field is already filtered by findHighestPriorityRule, so
1909
+ // "**" is the correct catch-all pattern.
1910
+ addRule('network_request', '**', 'everywhere');
1911
+ const rule = findHighestPriorityRule(
1912
+ 'network_request',
1913
+ ['network_request:https://any-host.example.org/path'],
1914
+ '/tmp',
1915
+ );
1916
+ expect(rule).not.toBeNull();
1917
+ });
1918
+
1919
+ test('single-star wildcard matches flat candidates only', () => {
1920
+ // "network_request:*" won't match URLs with slashes — consistent
1921
+ // with the behavior of web_fetch:* and browser_navigate:* patterns.
1922
+ addRule('network_request', 'network_request:*', 'everywhere');
1923
+ const noSlashMatch = findHighestPriorityRule(
1924
+ 'network_request',
1925
+ ['network_request:flat-target'],
1926
+ '/tmp',
1927
+ );
1928
+ expect(noSlashMatch).not.toBeNull();
1929
+
1930
+ const slashNoMatch = findHighestPriorityRule(
1931
+ 'network_request',
1932
+ ['network_request:https://example.com/path'],
1933
+ '/tmp',
1934
+ );
1935
+ // Single "*" does not match "/" so this URL candidate won't match.
1936
+ expect(slashNoMatch).toBeNull();
1937
+ });
1938
+
1939
+ test('network_request rule does not match web_fetch tool', () => {
1940
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere');
1941
+ const rule = findHighestPriorityRule(
1942
+ 'web_fetch',
1943
+ ['web_fetch:https://api.example.com/v1/data', 'web_fetch:https://api.example.com/*'],
1944
+ '/tmp',
1945
+ );
1946
+ expect(rule).toBeNull();
1947
+ });
1948
+
1949
+ test('web_fetch rule does not match network_request tool', () => {
1950
+ addRule('web_fetch', 'web_fetch:https://api.example.com/*', 'everywhere');
1951
+ const rule = findHighestPriorityRule(
1952
+ 'network_request',
1953
+ ['network_request:https://api.example.com/v1/data', 'network_request:https://api.example.com/*'],
1954
+ '/tmp',
1955
+ );
1956
+ expect(rule).toBeNull();
1957
+ });
1958
+
1959
+ test('deny rule takes precedence over allow at same priority', () => {
1960
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere', 'allow', 100);
1961
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere', 'deny', 100);
1962
+ const rule = findHighestPriorityRule(
1963
+ 'network_request',
1964
+ ['network_request:https://api.example.com/v1/data', 'network_request:https://api.example.com/*'],
1965
+ '/tmp',
1966
+ );
1967
+ expect(rule).not.toBeNull();
1968
+ expect(rule!.decision).toBe('deny');
1969
+ });
1970
+
1971
+ test('higher-priority allow overrides lower-priority deny', () => {
1972
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere', 'deny', 50);
1973
+ addRule('network_request', 'network_request:https://api.example.com/*', 'everywhere', 'allow', 100);
1974
+ const rule = findHighestPriorityRule(
1975
+ 'network_request',
1976
+ ['network_request:https://api.example.com/v1/data', 'network_request:https://api.example.com/*'],
1977
+ '/tmp',
1978
+ );
1979
+ expect(rule).not.toBeNull();
1980
+ expect(rule!.decision).toBe('allow');
1981
+ });
1982
+
1983
+ test('scope restricts network_request rule matching', () => {
1984
+ addRule('network_request', 'network_request:https://api.example.com/*', '/home/user/project');
1985
+ const inScope = findHighestPriorityRule(
1986
+ 'network_request',
1987
+ ['network_request:https://api.example.com/*'],
1988
+ '/home/user/project',
1989
+ );
1990
+ expect(inScope).not.toBeNull();
1991
+
1992
+ const outOfScope = findHighestPriorityRule(
1993
+ 'network_request',
1994
+ ['network_request:https://api.example.com/*'],
1995
+ '/tmp/other',
1996
+ );
1997
+ expect(outOfScope).toBeNull();
1998
+ });
1999
+ });
2000
+ });
2001
+
2002
+ describe('computer-use tool trust rule matching', () => {
2003
+ test('actionable CU tools have default ask trust rules', () => {
2004
+ // Actionable CU tools (those that perform screen interactions) should
2005
+ // have default "ask" rules so strict mode prompts before use.
2006
+ const actionableCuTools = [
2007
+ 'computer_use_click',
2008
+ 'computer_use_type_text',
2009
+ 'computer_use_request_control',
2010
+ ];
2011
+
2012
+ for (const name of actionableCuTools) {
2013
+ const rule = findHighestPriorityRule(name, [name], '/tmp/test');
2014
+ expect(rule).not.toBeNull();
2015
+ expect(rule!.decision).toBe('ask');
2016
+ }
2017
+ });
2018
+
2019
+ test('terminal CU tools (done/respond) have no default trust rules', () => {
2020
+ // computer_use_done and computer_use_respond are terminal signal tools
2021
+ // with RiskLevel.Low — they should not have ask rules since they don't
2022
+ // perform any screen action.
2023
+ const terminalCuTools = ['computer_use_done', 'computer_use_respond'];
2024
+
2025
+ for (const name of terminalCuTools) {
2026
+ const defaultRule = DEFAULT_TEMPLATES.find((t) => t.tool === name);
2027
+ expect(defaultRule).toBeUndefined();
2028
+ }
2029
+ });
2030
+ });