@cohortapp/agent-sdk 2.17.0 → 2.18.5

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 (531) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +18 -5
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/setup-wizard.md +1 -1
  9. package/docs/runbooks/fleet-rollout.md +156 -0
  10. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  11. package/lib/action-executor.js +19 -3
  12. package/lib/budget-guard.mjs +279 -3
  13. package/lib/channels/base-adapter.mjs +3 -1
  14. package/lib/channels/contract.mjs +2 -1
  15. package/lib/channels/inbox-item.mjs +8 -0
  16. package/lib/claude-bin.mjs +5 -6
  17. package/lib/cli/doctor-checks.mjs +141 -10
  18. package/lib/cli/global-setup-extras.mjs +5 -1
  19. package/lib/cli/inbox.mjs +100 -15
  20. package/lib/cli/seat-auth.mjs +463 -0
  21. package/lib/cli/session.mjs +80 -12
  22. package/lib/collective/capture-slots.mjs +234 -0
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/config.mjs +2 -0
  25. package/lib/collective/global-config.mjs +63 -1
  26. package/lib/collective/loop-guard.mjs +155 -0
  27. package/lib/collective/presence.mjs +142 -5
  28. package/lib/comms/send-gate.mjs +559 -1
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/failover.mjs +67 -16
  138. package/lib/model-router/llm-task.mjs +39 -3
  139. package/lib/model-router/resolve.mjs +89 -3
  140. package/lib/model-router/spawn.mjs +46 -47
  141. package/lib/model-router/taxonomy.mjs +126 -4
  142. package/lib/org/cost-sync.mjs +141 -11
  143. package/lib/org/inbound/broadcast.mjs +289 -0
  144. package/lib/org/inbound/collective.mjs +375 -0
  145. package/lib/org/inbound/directedness.mjs +96 -8
  146. package/lib/org/inbound/facts.mjs +78 -2
  147. package/lib/org/inbound/project.mjs +22 -0
  148. package/lib/org/inbound/surfaces.mjs +14 -0
  149. package/lib/org/llm-token.mjs +879 -0
  150. package/lib/org/mesh.mjs +61 -0
  151. package/lib/org/messaging.mjs +3 -1
  152. package/lib/org/protocol.checksum +1 -1
  153. package/lib/org/protocol.mjs +15 -0
  154. package/lib/org/quota.mjs +520 -0
  155. package/lib/org/tool-surface.mjs +104 -16
  156. package/lib/org/ui-parity.mjs +16 -1
  157. package/lib/org/work-ledger.mjs +37 -6
  158. package/lib/rate-guard.mjs +114 -1
  159. package/lib/resource-governor.mjs +41 -6
  160. package/lib/runtime/adapter.mjs +833 -0
  161. package/lib/runtime/child-env.mjs +191 -0
  162. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  163. package/lib/runtime/seat-engine.mjs +162 -0
  164. package/lib/session/ask-ledger.mjs +271 -0
  165. package/lib/session/current-work.mjs +676 -0
  166. package/lib/session/feed-core.mjs +40 -3
  167. package/lib/session/launch-args.mjs +56 -4
  168. package/lib/session/status-summary.mjs +26 -9
  169. package/lib/session/upgrade-notice.mjs +42 -0
  170. package/lib/setup/claude-probe.mjs +117 -13
  171. package/lib/setup/enrich.mjs +13 -10
  172. package/lib/setup/sections/model.mjs +39 -13
  173. package/lib/telemetry/collect.mjs +229 -11
  174. package/lib/upgrade/ignored-drift.mjs +105 -0
  175. package/lib/voice/post-call-brief.mjs +30 -17
  176. package/package.json +13 -3
  177. package/plugins/maestro-skills/skills/board-work.md +5 -0
  178. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  179. package/plugins/maestro-skills/skills/main-session.md +18 -7
  180. package/scaffold/config/collective.yaml +7 -0
  181. package/scripts/ci/check-durable-write-seam.mjs +3 -1
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +142 -19
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +51 -37
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +595 -149
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/maestro-daemon.mjs +11 -0
  198. package/scripts/daemon/prompt-builder.mjs +24 -0
  199. package/scripts/daemon/responder.mjs +246 -79
  200. package/scripts/daemon/sdk-version.mjs +98 -16
  201. package/scripts/eval/probe-gateway.mjs +635 -0
  202. package/scripts/eval/replay/extract.mjs +270 -0
  203. package/scripts/eval/replay/grade.mjs +260 -0
  204. package/scripts/eval/replay/lib/config.mjs +50 -0
  205. package/scripts/eval/replay/lib/effects.mjs +65 -0
  206. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  207. package/scripts/eval/replay/lib/judge.mjs +72 -0
  208. package/scripts/eval/replay/lib/redact.mjs +136 -0
  209. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  210. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  211. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  212. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  213. package/scripts/eval/replay/report.mjs +185 -0
  214. package/scripts/eval/replay/run.mjs +404 -0
  215. package/scripts/fleet/rollout.mjs +1151 -0
  216. package/scripts/hooks/pre-send-audit.sh +36 -245
  217. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  218. package/scripts/hooks/validate-state-yaml.sh +190 -0
  219. package/scripts/huddle/huddle-llm.mjs +361 -0
  220. package/scripts/huddle/huddle-server.mjs +46 -121
  221. package/scripts/local-triggers/autoupdate.sh +465 -81
  222. package/scripts/local-triggers/run-trigger.sh +13 -0
  223. package/scripts/maintenance/pin-integrity.mjs +364 -0
  224. package/scripts/poll-slack-events.sh +41 -9
  225. package/scripts/poller/slack-socket-mode.mjs +28 -3
  226. package/scripts/session/supervisor.mjs +80 -13
  227. package/scripts/spawn-session.sh +13 -0
  228. package/bin/maestro.test.mjs +0 -1574
  229. package/lib/action-executor.test.mjs +0 -871
  230. package/lib/archetype.test.mjs +0 -132
  231. package/lib/assurance/plan-note.test.mjs +0 -234
  232. package/lib/assurance/room-budget.test.mjs +0 -486
  233. package/lib/assurance/tier.test.mjs +0 -174
  234. package/lib/autonomy.test.mjs +0 -66
  235. package/lib/backlog.test.mjs +0 -302
  236. package/lib/backup/policy.test.mjs +0 -305
  237. package/lib/budget-escalate.test.mjs +0 -232
  238. package/lib/budget-guard.envelope.test.mjs +0 -476
  239. package/lib/budget-guard.test.mjs +0 -427
  240. package/lib/cadence-bus-requeue.test.mjs +0 -83
  241. package/lib/cadence-bus-schedule.test.mjs +0 -194
  242. package/lib/cadence-bus.test.mjs +0 -720
  243. package/lib/cadences.test.mjs +0 -230
  244. package/lib/capability/inventory.test.mjs +0 -232
  245. package/lib/capability.test.mjs +0 -78
  246. package/lib/channels/base-adapter.test.mjs +0 -590
  247. package/lib/channels/channels.test.mjs +0 -371
  248. package/lib/channels/contract.test.mjs +0 -162
  249. package/lib/channels/inbox-item.test.mjs +0 -368
  250. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  251. package/lib/channels/pairing.test.mjs +0 -270
  252. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  253. package/lib/channels/slack-adapter.test.mjs +0 -212
  254. package/lib/channels/telegram-adapter.test.mjs +0 -306
  255. package/lib/channels/voice/adapter.test.mjs +0 -278
  256. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  257. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  258. package/lib/charter.test.mjs +0 -89
  259. package/lib/claude-bin.test.mjs +0 -131
  260. package/lib/cli/board.test.mjs +0 -227
  261. package/lib/cli/design.test.mjs +0 -270
  262. package/lib/cli/doctor-checks.test.mjs +0 -336
  263. package/lib/cli/global-setup-extras.test.mjs +0 -462
  264. package/lib/cli/inbox.test.mjs +0 -230
  265. package/lib/cli/session-ack.test.mjs +0 -63
  266. package/lib/cli/session.test.mjs +0 -613
  267. package/lib/collective/capture.test.mjs +0 -121
  268. package/lib/collective/cards.test.mjs +0 -114
  269. package/lib/collective/config.test.mjs +0 -123
  270. package/lib/collective/global-config.test.mjs +0 -220
  271. package/lib/collective/global-skills.test.mjs +0 -126
  272. package/lib/collective/presence.test.mjs +0 -95
  273. package/lib/collective/recall.test.mjs +0 -116
  274. package/lib/collective/vendor-skills.test.mjs +0 -306
  275. package/lib/comms/send-gate.test.mjs +0 -770
  276. package/lib/comms.test.mjs +0 -41
  277. package/lib/context/budget.test.mjs +0 -252
  278. package/lib/context/history-scope.test.mjs +0 -79
  279. package/lib/cost/ledger-row.test.mjs +0 -183
  280. package/lib/design/design-md.test.mjs +0 -318
  281. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  282. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  283. package/lib/design/fixtures/foundation.json +0 -133
  284. package/lib/design/refresh-gate.test.mjs +0 -144
  285. package/lib/design/write.test.mjs +0 -241
  286. package/lib/diagnostics/alerts.test.mjs +0 -318
  287. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  288. package/lib/diagnostics/counters.test.mjs +0 -206
  289. package/lib/diagnostics/events.test.mjs +0 -290
  290. package/lib/diagnostics/otel.test.mjs +0 -196
  291. package/lib/diagnostics/trace.test.mjs +0 -251
  292. package/lib/env-compat.test.mjs +0 -104
  293. package/lib/execution/disposition.test.mjs +0 -553
  294. package/lib/execution/drive.test.mjs +0 -270
  295. package/lib/execution/effects.test.mjs +0 -344
  296. package/lib/execution/intake.test.mjs +0 -389
  297. package/lib/execution/journal.test.mjs +0 -261
  298. package/lib/execution/match.test.mjs +0 -235
  299. package/lib/execution/pipeline.test.mjs +0 -392
  300. package/lib/execution/route.test.mjs +0 -186
  301. package/lib/execution/surface-policy.test.mjs +0 -162
  302. package/lib/fs-atomic.test.mjs +0 -72
  303. package/lib/fs-ownership.test.mjs +0 -158
  304. package/lib/goals/admission.test.mjs +0 -164
  305. package/lib/goals/classify.test.mjs +0 -167
  306. package/lib/goals/collaborate.test.mjs +0 -336
  307. package/lib/goals/gaps.test.mjs +0 -284
  308. package/lib/goals/loop.test.mjs +0 -845
  309. package/lib/hooks/bus.test.mjs +0 -387
  310. package/lib/identity/persona.test.mjs +0 -142
  311. package/lib/kpi-sensors.test.mjs +0 -278
  312. package/lib/kpi.test.mjs +0 -244
  313. package/lib/learning/config.test.mjs +0 -75
  314. package/lib/learning/counters.test.mjs +0 -69
  315. package/lib/learning/curator-consolidate.test.mjs +0 -238
  316. package/lib/learning/curator.test.mjs +0 -106
  317. package/lib/learning/reflect.test.mjs +0 -0
  318. package/lib/learning/session-index.test.mjs +0 -125
  319. package/lib/learning/skill-writer.test.mjs +0 -210
  320. package/lib/mandate/audit.test.mjs +0 -195
  321. package/lib/mandate/contract.test.mjs +0 -185
  322. package/lib/mandate/derive.test.mjs +0 -274
  323. package/lib/mandate/model.test.mjs +0 -164
  324. package/lib/mandate/refresh.test.mjs +0 -389
  325. package/lib/mcp/server.test.mjs +0 -426
  326. package/lib/model-router/auth-profiles.test.mjs +0 -580
  327. package/lib/model-router/catalog.test.mjs +0 -385
  328. package/lib/model-router/economics.test.mjs +0 -438
  329. package/lib/model-router/failover.test.mjs +0 -439
  330. package/lib/model-router/health.test.mjs +0 -338
  331. package/lib/model-router/integration-coverage.test.mjs +0 -831
  332. package/lib/model-router/integration.test.mjs +0 -564
  333. package/lib/model-router/ledger.test.mjs +0 -415
  334. package/lib/model-router/llm-task.test.mjs +0 -392
  335. package/lib/model-router/org-credentials.test.mjs +0 -265
  336. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  337. package/lib/model-router/reconcile.test.mjs +0 -316
  338. package/lib/model-router/repair.test.mjs +0 -180
  339. package/lib/model-router/spawn.test.mjs +0 -446
  340. package/lib/model-router/taxonomy.test.mjs +0 -410
  341. package/lib/model-router.test.mjs +0 -1207
  342. package/lib/org/activity.test.mjs +0 -134
  343. package/lib/org/approvals.test.mjs +0 -216
  344. package/lib/org/awareness.test.mjs +0 -159
  345. package/lib/org/board-mine-cache.test.mjs +0 -53
  346. package/lib/org/board.test.mjs +0 -187
  347. package/lib/org/bootstrap-context.test.mjs +0 -153
  348. package/lib/org/client.test.mjs +0 -1206
  349. package/lib/org/cohort-client.test.mjs +0 -126
  350. package/lib/org/cost-sync.test.mjs +0 -153
  351. package/lib/org/doctor.test.mjs +0 -346
  352. package/lib/org/engagement-ledger.test.mjs +0 -112
  353. package/lib/org/engagement.test.mjs +0 -739
  354. package/lib/org/handoff.test.mjs +0 -269
  355. package/lib/org/inbound/directedness.test.mjs +0 -668
  356. package/lib/org/inbound/facts.test.mjs +0 -471
  357. package/lib/org/inbound/hydrate.test.mjs +0 -908
  358. package/lib/org/inbound/index.test.mjs +0 -429
  359. package/lib/org/inbound/project.test.mjs +0 -287
  360. package/lib/org/integration-tools.test.mjs +0 -160
  361. package/lib/org/keys.test.mjs +0 -92
  362. package/lib/org/knowledge.test.mjs +0 -326
  363. package/lib/org/leases.test.mjs +0 -235
  364. package/lib/org/mesh-directives.test.mjs +0 -110
  365. package/lib/org/mesh-integration.test.mjs +0 -127
  366. package/lib/org/mesh.test.mjs +0 -400
  367. package/lib/org/messaging.test.mjs +0 -471
  368. package/lib/org/param-contract.test.mjs +0 -477
  369. package/lib/org/policy.test.mjs +0 -237
  370. package/lib/org/protocol.checksum.test.mjs +0 -90
  371. package/lib/org/protocol.test.mjs +0 -323
  372. package/lib/org/push.test.mjs +0 -792
  373. package/lib/org/registry.test.mjs +0 -100
  374. package/lib/org/resource-tools.test.mjs +0 -361
  375. package/lib/org/tool-access.test.mjs +0 -144
  376. package/lib/org/tool-surface-integration.test.mjs +0 -120
  377. package/lib/org/tool-surface.test.mjs +0 -1268
  378. package/lib/org/typing.test.mjs +0 -291
  379. package/lib/org/ui-parity.test.mjs +0 -560
  380. package/lib/org/verify.test.mjs +0 -194
  381. package/lib/org/work-ledger.test.mjs +0 -273
  382. package/lib/plan/adoption-e2e.test.mjs +0 -366
  383. package/lib/plan/budget-enforcement.test.mjs +0 -400
  384. package/lib/plan/compile.test.mjs +0 -382
  385. package/lib/plan/emit.test.mjs +0 -269
  386. package/lib/plan/explain.test.mjs +0 -188
  387. package/lib/prompts/parallelism.test.mjs +0 -177
  388. package/lib/rag/rag.test.mjs +0 -505
  389. package/lib/rate-guard.test.mjs +0 -272
  390. package/lib/reactive-gate.test.mjs +0 -57
  391. package/lib/render.test.mjs +0 -68
  392. package/lib/resource-governor.test.mjs +0 -488
  393. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  394. package/lib/scheduling/jitter.test.mjs +0 -140
  395. package/lib/secrets/broker.test.mjs +0 -280
  396. package/lib/secrets/providers.test.mjs +0 -274
  397. package/lib/security/audit-engine.test.mjs +0 -424
  398. package/lib/security/coerce-args.test.mjs +0 -281
  399. package/lib/security/dangerous-tools.test.mjs +0 -68
  400. package/lib/security/external-content.test.mjs +0 -84
  401. package/lib/security/redact.test.mjs +0 -441
  402. package/lib/security/secret-equal.test.mjs +0 -55
  403. package/lib/session/config.test.mjs +0 -92
  404. package/lib/session/feed-core.test.mjs +0 -198
  405. package/lib/session/first-run.test.mjs +0 -121
  406. package/lib/session/frontdoor.test.mjs +0 -205
  407. package/lib/session/handoffs.test.mjs +0 -183
  408. package/lib/session/identity.test.mjs +0 -180
  409. package/lib/session/inbox-claims.test.mjs +0 -286
  410. package/lib/session/launch-args.test.mjs +0 -157
  411. package/lib/session/liveness.test.mjs +0 -100
  412. package/lib/session/status-summary.test.mjs +0 -118
  413. package/lib/session-permissions.test.mjs +0 -120
  414. package/lib/setup/claude-probe.test.mjs +0 -187
  415. package/lib/setup/completeness.test.mjs +0 -110
  416. package/lib/setup/context-pack.test.mjs +0 -89
  417. package/lib/setup/enrich.test.mjs +0 -115
  418. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  419. package/lib/setup/integration.test.mjs +0 -162
  420. package/lib/setup/io.test.mjs +0 -77
  421. package/lib/setup/runner.test.mjs +0 -132
  422. package/lib/setup/sections/identity.test.mjs +0 -234
  423. package/lib/setup/sections/inventory.test.mjs +0 -198
  424. package/lib/setup/sections/learning.test.mjs +0 -81
  425. package/lib/setup/sections/mandate.test.mjs +0 -388
  426. package/lib/setup/sections/messaging.test.mjs +0 -127
  427. package/lib/setup/sections/model.test.mjs +0 -240
  428. package/lib/setup/sections/org.test.mjs +0 -346
  429. package/lib/setup/sections/orgmail.test.mjs +0 -118
  430. package/lib/setup/sections/recovery.test.mjs +0 -98
  431. package/lib/setup/sections/subagents.test.mjs +0 -429
  432. package/lib/setup/sections/verify.test.mjs +0 -175
  433. package/lib/setup/sot.test.mjs +0 -81
  434. package/lib/setup/state.test.mjs +0 -115
  435. package/lib/singleton.test.mjs +0 -151
  436. package/lib/subagents/cli.test.mjs +0 -389
  437. package/lib/subagents/client.test.mjs +0 -309
  438. package/lib/subagents/gap.test.mjs +0 -234
  439. package/lib/subagents/lock.test.mjs +0 -248
  440. package/lib/subagents/manifest.test.mjs +0 -175
  441. package/lib/subagents/refs.test.mjs +0 -204
  442. package/lib/subagents/resolve.test.mjs +0 -422
  443. package/lib/subagents/schema.test.mjs +0 -328
  444. package/lib/telemetry/alerts.test.mjs +0 -109
  445. package/lib/telemetry/collect.test.mjs +0 -1274
  446. package/lib/tool-definitions-integration.test.mjs +0 -83
  447. package/lib/tool-definitions.test.mjs +0 -437
  448. package/lib/upgrade/global-refresh.test.mjs +0 -65
  449. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  450. package/lib/upgrade/post-steps.test.mjs +0 -200
  451. package/lib/upgrade/verify.test.mjs +0 -164
  452. package/lib/util/fetch-timeout.test.mjs +0 -202
  453. package/lib/util/reconnect.test.mjs +0 -369
  454. package/lib/util/unhandled.test.mjs +0 -216
  455. package/lib/voice/outbound.test.mjs +0 -69
  456. package/lib/voice/session-rotation.test.mjs +0 -114
  457. package/lib/voice/stt.test.mjs +0 -226
  458. package/lib/voice/voice.test.mjs +0 -990
  459. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  460. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  461. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  462. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  463. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  464. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  465. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  466. package/scripts/ci/check.test.mjs +0 -194
  467. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  468. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  469. package/scripts/collective/hook-runner.test.mjs +0 -173
  470. package/scripts/cost/fleet-digest.test.mjs +0 -207
  471. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  472. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  473. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  474. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  475. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  476. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  477. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  478. package/scripts/daemon/assurance.test.mjs +0 -1791
  479. package/scripts/daemon/board-mirror.test.mjs +0 -165
  480. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  481. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  482. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  483. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  484. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  485. package/scripts/daemon/classifier.test.mjs +0 -266
  486. package/scripts/daemon/classify-kind.test.mjs +0 -40
  487. package/scripts/daemon/context-compiler.test.mjs +0 -406
  488. package/scripts/daemon/deliver.test.mjs +0 -564
  489. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  490. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  491. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  492. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  493. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  494. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  495. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  496. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  497. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  498. package/scripts/daemon/integration.test.mjs +0 -149
  499. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  500. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  501. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  502. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  503. package/scripts/daemon/responder-cost.test.mjs +0 -68
  504. package/scripts/daemon/responder-history.test.mjs +0 -221
  505. package/scripts/daemon/sdk-version.test.mjs +0 -31
  506. package/scripts/daemon/session-lock.test.mjs +0 -252
  507. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  508. package/scripts/daemon/typing-registry.test.mjs +0 -102
  509. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  510. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  511. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  512. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  513. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  514. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  515. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  516. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  517. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  518. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  519. package/scripts/poller-launchd/install.test.mjs +0 -243
  520. package/scripts/restore-from-backup.test.mjs +0 -181
  521. package/scripts/session/feed.test.mjs +0 -196
  522. package/scripts/session/supervisor-sh.test.mjs +0 -218
  523. package/scripts/session/supervisor.test.mjs +0 -482
  524. package/scripts/setup/configure-macos.test.mjs +0 -306
  525. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  526. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  527. package/scripts/setup/generate-capability.test.mjs +0 -134
  528. package/scripts/setup/init-agent.test.mjs +0 -370
  529. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  530. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  531. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -0,0 +1,845 @@
1
+ /**
2
+ * lib/engine/permissions.mjs — may this tool call run? (pure)
3
+ *
4
+ * Modes:
5
+ * default rules decide; anything not allowed needs approval
6
+ * acceptEdits also allows Write/Edit/NotebookEdit inside the working
7
+ * directory (and permissions.additionalDirectories)
8
+ * plan denies every tool that can change anything
9
+ * dontAsk anything not explicitly allowed is denied
10
+ * bypassPermissions allows everything except deny and ask rules
11
+ *
12
+ * Rules come from `permissions.allow / deny / ask` in the settings layers and
13
+ * from --allowedTools / --disallowedTools. Syntax (Claude-compatible):
14
+ *
15
+ * Bash every Bash call
16
+ * Bash(npm run test:*) a command starting with "npm run test"
17
+ * Bash(git log *) `*` wildcard anywhere; otherwise an exact command
18
+ * Read(./src/**) path globs, gitignore style: `//abs`, `~/home`,
19
+ * Edit(/docs/*.md) `/from-settings-root`, `./` or bare = from cwd;
20
+ * a pattern with no slash matches at any depth.
21
+ * Read rules also govern Glob and Grep; Edit rules
22
+ * govern Write, Edit and NotebookEdit.
23
+ * WebFetch(domain:example.com)
24
+ * mcp__cohort every tool of that server (= mcp__cohort__*)
25
+ * mcp__cohort__org_rpc one MCP tool
26
+ *
27
+ * Evaluation, first match wins in this order:
28
+ * 1. a deny rule → deny (holds in every mode)
29
+ * 2. plan mode and a tool not planSafe → deny (every MCP tool counts as
30
+ * mutating, whatever its hint;
31
+ * TOOL_POSTURE names the agent
32
+ * and session tools, CF-47)
33
+ * 3. an ask rule → ask (holds in every mode)
34
+ * 4. an allow rule → allow (for a write to engine config,
35
+ * only a rule naming a path)
36
+ * 5. a write to engine config → ask (see below; every mode; for
37
+ * Bash, bashEngineConfigWrite)
38
+ * 6. bypassPermissions → allow
39
+ * 7. acceptEdits and an edit in scope→ allow
40
+ * 8. a read-only built-in, or a TOOL_POSTURE autoAllow tool → allow
41
+ * 9. dontAsk → deny
42
+ * 10. otherwise → ask
43
+ *
44
+ * Engine config (`isEngineConfigPath`): anything under a `.claude` directory
45
+ * (settings, settings.local, skills, agents, commands, hooks, memory), any
46
+ * `.mcp.json` or `.claude.json`, the instruction files CLAUDE.md,
47
+ * CLAUDE.local.md, COHORT.md and AGENTS.md, and anything under a caller-named
48
+ * config directory ($CLAUDE_CONFIG_DIR). A write there could plant a hook, an
49
+ * MCP server, an allow rule or `defaultMode: bypassPermissions` for the next
50
+ * run, so no mode auto-allows it and neither does a whole-tool `Write`/`Edit`
51
+ * allow rule; only a path-specific allow rule does.
52
+ *
53
+ * The engine has no approval prompt: the caller turns `ask` into a denial with
54
+ * a message that says how to allow the call (`headlessAskDenial`), unless a
55
+ * PreToolUse hook decided it first.
56
+ *
57
+ * Compound shell commands are split on `&&`, `||`, `;`, `|`, `&` and newlines
58
+ * outside quotes. An allow rule must match EVERY part; a deny or ask rule
59
+ * matches when ANY part does. A command with command substitution (`$(…)`,
60
+ * backticks, `<(…)`) never matches a Bash allow rule that has a specifier.
61
+ *
62
+ * Deny and ask rules fail closed. Each part is also tested with common
63
+ * wrappers stripped (`sudo`, `env A=1`, `nohup`, `time`, `command`, `exec`,
64
+ * `nice`, `timeout 5`, `xargs`), quotes removed from the command word, and a
65
+ * path on it reduced to its basename (`/bin/rm` is `rm`). A command whose
66
+ * real text the rules cannot see — substitution, `eval`, `sh|bash|zsh -c`,
67
+ * `find -exec`, a command word that is a variable — matches EVERY Bash deny
68
+ * and ask rule that has a specifier.
69
+ *
70
+ * Symlinks (CF-19, W5-B). With a `resolvePath` (context/real-path.mjs; the
71
+ * guard and the CLI always pass one), a path is judged by where it really
72
+ * leads: a deny or ask path rule matches the name OR the real path, an allow
73
+ * rule must cover the real path (the rule's literal prefix resolved too, so
74
+ * `/tmp/**` still covers /private/tmp), the engine-config protection holds for
75
+ * a link to config, and acceptEdits allows only an edit whose real path is in
76
+ * scope. A new file is judged by its nearest existing ancestor's real path, a
77
+ * dangling link by the path it names; a loop keeps its name. A link swapped
78
+ * between this check and the tool's open is not caught.
79
+ *
80
+ * A Bash (or Monitor) command that names engine config in a redirection, tee,
81
+ * cp, mv, sed -i … (`bashEngineConfigWrite`) is an `ask` in every mode, like an
82
+ * Edit of config: a whole-tool `Bash` allow does not cover it, only specific
83
+ * rules naming the file. A conservative narrowing, not a boundary.
84
+ *
85
+ * No filesystem, clock or environment access: every input is a parameter.
86
+ *
87
+ * @module lib/engine/permissions
88
+ */
89
+
90
+ import path from "node:path";
91
+
92
+ export const PERMISSION_MODES = Object.freeze(["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"]);
93
+ export const EDIT_TOOLS = Object.freeze(new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]));
94
+ export const READ_TOOLS = Object.freeze(new Set(["Read", "Glob", "Grep", "LS", "NotebookRead"]));
95
+
96
+ /**
97
+ * CF-47 (W4-B): the permission posture of the agent, session and front-door
98
+ * tools, keyed by tool name. Rationale: docs/engine/posture.md.
99
+ *
100
+ * planSafe the tool runs in plan mode
101
+ * autoAllow the tool needs no allow rule in default and dontAsk modes
102
+ *
103
+ * A built-in named here is judged by this table whatever its own `readOnly`
104
+ * flag says; the flag stays the loop's parallel-batching hint. Tools not named
105
+ * here use their `readOnly` flag for both. MCP tools are never either.
106
+ *
107
+ * session-state reads or manages only this run's own state planSafe, autoAllow
108
+ * spawns-clamped starts a child held to this run's mode and rules autoAllow
109
+ * spawns-work starts work the run's guard does not hold neither
110
+ * outbound reaches a session with its own permissions neither
111
+ */
112
+ export const TOOL_POSTURE = Object.freeze({
113
+ TodoWrite: posture("session-state"),
114
+ TaskOutput: posture("session-state"),
115
+ TaskStop: posture("session-state"),
116
+ BashOutput: posture("session-state"),
117
+ KillShell: posture("session-state"),
118
+ ScheduleWakeup: posture("session-state"),
119
+ ListAgents: posture("session-state"),
120
+ WorkflowStatus: posture("session-state"),
121
+ Task: posture("spawns-clamped"),
122
+ Agent: posture("spawns-clamped"),
123
+ Workflow: posture("spawns-work"),
124
+ Monitor: posture("spawns-work"),
125
+ SendMessage: posture("outbound"),
126
+ });
127
+
128
+ /** @param {'session-state'|'spawns-clamped'|'spawns-work'|'outbound'} kind */
129
+ function posture(kind) {
130
+ return Object.freeze({ kind, planSafe: kind === "session-state", autoAllow: kind === "session-state" || kind === "spawns-clamped" });
131
+ }
132
+
133
+ /**
134
+ * The posture a call is judged by. Pure.
135
+ * @param {{toolName:string, readOnly:boolean, isMcp?:boolean}} p
136
+ * @returns {{kind:string, planSafe:boolean, autoAllow:boolean}}
137
+ */
138
+ export function toolPosture({ toolName, readOnly, isMcp = false }) {
139
+ if (isMcp) return { kind: "mcp", planSafe: false, autoAllow: false };
140
+ const named = Object.hasOwn(TOOL_POSTURE, toolName) ? TOOL_POSTURE[/** @type {keyof typeof TOOL_POSTURE} */ (toolName)] : null;
141
+ if (named) return named;
142
+ return readOnly ? { kind: "read-only", planSafe: true, autoAllow: true } : { kind: "mutating", planSafe: false, autoAllow: false };
143
+ }
144
+
145
+ /**
146
+ * @typedef {{raw:string, tool:string, specifier:string|null, source:string, baseDir:string}} PermissionRule
147
+ * @typedef {{allow:PermissionRule[], deny:PermissionRule[], ask:PermissionRule[]}} RuleSet
148
+ * @typedef {{behavior:'allow'|'deny'|'ask', reason:string, rule?:string}} PermissionDecision
149
+ */
150
+
151
+ /** @param {unknown} mode */
152
+ export function isPermissionMode(mode) {
153
+ return typeof mode === "string" && PERMISSION_MODES.includes(mode);
154
+ }
155
+
156
+ /**
157
+ * Split a --allowedTools / --disallowedTools value into rule strings: commas
158
+ * or whitespace separate rules, except inside parentheses.
159
+ * @param {string} value
160
+ */
161
+ export function splitRuleList(value) {
162
+ const out = [];
163
+ let depth = 0;
164
+ let cur = "";
165
+ for (const ch of String(value)) {
166
+ if (ch === "(") depth++;
167
+ if (ch === ")") depth = Math.max(0, depth - 1);
168
+ if (depth === 0 && (ch === "," || /\s/.test(ch))) {
169
+ if (cur.trim()) out.push(cur.trim());
170
+ cur = "";
171
+ continue;
172
+ }
173
+ cur += ch;
174
+ }
175
+ if (cur.trim()) out.push(cur.trim());
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * @param {string} text
181
+ * @param {{source?:string, baseDir?:string}} [o]
182
+ * @returns {{ok:true, rule:PermissionRule} | {ok:false, error:string}}
183
+ */
184
+ export function parseRule(text, o = {}) {
185
+ const raw = String(text ?? "").trim();
186
+ const m = /^([A-Za-z0-9_*-]+)(?:\(([\s\S]*)\))?$/.exec(raw);
187
+ if (!m) return { ok: false, error: `permission rule "${raw}" is not ToolName or ToolName(specifier)` };
188
+ const specifier = m[2] === undefined ? null : m[2].trim();
189
+ return { ok: true, rule: { raw, tool: m[1], specifier: specifier === "" ? null : specifier, source: o.source ?? "", baseDir: o.baseDir ?? "" } };
190
+ }
191
+
192
+ /**
193
+ * @param {{allow?:unknown, deny?:unknown, ask?:unknown}} lists
194
+ * @param {{source:string, baseDir:string}} o
195
+ * @returns {{rules:RuleSet, errors:string[]}}
196
+ */
197
+ export function compileRules(lists, o) {
198
+ /** @type {RuleSet} */
199
+ const rules = { allow: [], deny: [], ask: [] };
200
+ const errors = [];
201
+ for (const kind of /** @type {const} */ (["allow", "deny", "ask"])) {
202
+ const list = lists?.[kind];
203
+ if (list === undefined || list === null) continue;
204
+ if (!Array.isArray(list)) {
205
+ errors.push(`${o.source}: permissions.${kind} must be an array`);
206
+ continue;
207
+ }
208
+ for (const item of list) {
209
+ const r = parseRule(String(item), o);
210
+ if (r.ok) rules[kind].push(r.rule);
211
+ else errors.push(`${o.source}: ${r.error}`);
212
+ }
213
+ }
214
+ return { rules, errors };
215
+ }
216
+
217
+ /** @param {...RuleSet} sets @returns {RuleSet} */
218
+ export function mergeRuleSets(...sets) {
219
+ return {
220
+ allow: sets.flatMap((s) => s.allow),
221
+ deny: sets.flatMap((s) => s.deny),
222
+ ask: sets.flatMap((s) => s.ask),
223
+ };
224
+ }
225
+
226
+ /** @param {string} glob `*` = any run of characters */
227
+ function wildcardRegex(glob) {
228
+ return new RegExp(`^${glob.split("*").map(escapeRe).join("[\\s\\S]*")}$`);
229
+ }
230
+
231
+ /** @param {string} s */
232
+ function escapeRe(s) {
233
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
234
+ }
235
+
236
+ /**
237
+ * Does the rule's tool part cover this tool name?
238
+ * @param {string} ruleTool
239
+ * @param {string} toolName
240
+ */
241
+ export function ruleToolMatches(ruleTool, toolName) {
242
+ if (ruleTool.startsWith("mcp__")) {
243
+ const rest = ruleTool.slice("mcp__".length);
244
+ if (!rest.includes("__")) return toolName.startsWith(`mcp__${rest}__`); // server-level rule
245
+ return ruleTool.includes("*") ? wildcardRegex(ruleTool).test(toolName) : ruleTool === toolName;
246
+ }
247
+ if ((ruleTool === "Task" || ruleTool === "Agent") && (toolName === "Task" || toolName === "Agent")) return true; // one tool, two names
248
+ if (ruleTool === "Read") return READ_TOOLS.has(toolName);
249
+ if (ruleTool === "Edit") return EDIT_TOOLS.has(toolName);
250
+ return ruleTool.includes("*") ? wildcardRegex(ruleTool).test(toolName) : ruleTool === toolName;
251
+ }
252
+
253
+ /**
254
+ * Split a shell command into simple commands, outside quotes. Pure and
255
+ * conservative: it does not understand every shell construct, and callers
256
+ * treat what it cannot see through (`hasSubstitution`) as unmatched for allow.
257
+ * @param {string} command
258
+ * @returns {{parts:string[], hasSubstitution:boolean}}
259
+ */
260
+ export function splitShellCommand(command) {
261
+ const s = String(command);
262
+ const parts = [];
263
+ let cur = "";
264
+ let quote = /** @type {string|null} */ (null);
265
+ let hasSubstitution = false;
266
+ for (let i = 0; i < s.length; i++) {
267
+ const ch = s[i];
268
+ const next = s[i + 1];
269
+ if (quote) {
270
+ if (ch === "\\" && quote === '"' && next !== undefined) {
271
+ cur += ch + next;
272
+ i++;
273
+ continue;
274
+ }
275
+ if (quote === '"' && (ch === "`" || (ch === "$" && next === "("))) hasSubstitution = true;
276
+ if (ch === quote) quote = null;
277
+ cur += ch;
278
+ continue;
279
+ }
280
+ if (ch === "\\" && next !== undefined) {
281
+ cur += ch + next;
282
+ i++;
283
+ continue;
284
+ }
285
+ if (ch === "'" || ch === '"') {
286
+ quote = ch;
287
+ cur += ch;
288
+ continue;
289
+ }
290
+ if (ch === "`" || (ch === "$" && next === "(") || ((ch === "<" || ch === ">") && next === "(")) hasSubstitution = true;
291
+ const two = ch + (next ?? "");
292
+ if (two === "&&" || two === "||") {
293
+ parts.push(cur);
294
+ cur = "";
295
+ i++;
296
+ continue;
297
+ }
298
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
299
+ parts.push(cur);
300
+ cur = "";
301
+ continue;
302
+ }
303
+ cur += ch;
304
+ }
305
+ parts.push(cur);
306
+ return {
307
+ parts: parts.map((p) => p.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)+/, "").trim()).filter((p) => p !== ""),
308
+ hasSubstitution,
309
+ };
310
+ }
311
+
312
+ const WRAPPERS = new Set(["sudo", "env", "nohup", "time", "command", "builtin", "exec", "nice", "timeout", "xargs", "stdbuf", "doas"]);
313
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]);
314
+
315
+ /** @param {string} word quotes and backslashes removed, a path reduced to its basename */
316
+ function normaliseCommandWord(word) {
317
+ const unquoted = word.replace(/["'\\]/g, "");
318
+ return unquoted.includes("/") ? unquoted.slice(unquoted.lastIndexOf("/") + 1) : unquoted;
319
+ }
320
+
321
+ /**
322
+ * The texts a deny or ask rule is tested against for one simple command: the
323
+ * part as written, then with its command word normalised and each leading
324
+ * wrapper (and the wrapper's own options and assignments) stripped.
325
+ * @param {string} part
326
+ * @returns {{texts:string[], opaque:boolean}}
327
+ */
328
+ export function denyCandidates(part) {
329
+ const texts = [part];
330
+ let words = part.split(/\s+/).filter(Boolean);
331
+ let opaque = false;
332
+ for (let guard = 0; guard < 10 && words.length > 0; guard++) {
333
+ const cmd = normaliseCommandWord(words[0]);
334
+ if (words[0].startsWith("$")) opaque = true;
335
+ if (cmd === "eval") opaque = true;
336
+ if (SHELLS.has(cmd) && words.slice(1).some((w) => /^-[A-Za-z]*c[A-Za-z]*$/.test(w))) opaque = true;
337
+ if (cmd === "find" && words.some((w) => /^-(exec|execdir|ok|okdir)$/.test(w))) opaque = true;
338
+ texts.push([cmd, ...words.slice(1)].join(" "));
339
+ if (!WRAPPERS.has(cmd)) break;
340
+ // Drop the wrapper, then its options, assignments and a numeric argument (nice -n 5, timeout 10s).
341
+ let i = 1;
342
+ while (i < words.length && (/^-/.test(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^\d+[smhd]?$/.test(words[i]))) i++;
343
+ words = words.slice(i);
344
+ }
345
+ return { texts, opaque };
346
+ }
347
+
348
+ /** @param {string} spec @param {string} command */
349
+ function bashSpecMatches(spec, command) {
350
+ if (spec === "*") return true;
351
+ if (spec.endsWith(":*")) {
352
+ const prefix = spec.slice(0, -2);
353
+ return command === prefix || command.startsWith(`${prefix} `) || (prefix.endsWith(" ") && command.startsWith(prefix));
354
+ }
355
+ if (spec.includes("*")) return wildcardRegex(spec).test(command);
356
+ return command === spec;
357
+ }
358
+
359
+ /**
360
+ * Compile a gitignore-style path pattern to an absolute-path regex.
361
+ * @param {string} pattern
362
+ * @param {{cwd:string, homedir:string|null, baseDir:string}} where
363
+ */
364
+ export function pathPatternRegex(pattern, where) {
365
+ const { root, p } = patternParts(pattern, where);
366
+ return compilePathRegex(root, p);
367
+ }
368
+
369
+ /**
370
+ * CF-19: the same pattern with its literal prefix (root plus the segments before
371
+ * the first glob character) resolved through symlinks, so a resolved target is
372
+ * compared with a resolved rule — `Edit(/tmp/**)` still covers a file whose real
373
+ * path is /private/tmp/…, and `./**` still covers cwd when cwd is a symlink. Pure
374
+ * (the resolver is a parameter).
375
+ * @param {string} pattern
376
+ * @param {{cwd:string, homedir:string|null, baseDir:string}} where
377
+ * @param {(p:string)=>string|null} resolvePath
378
+ */
379
+ export function resolvedPathPatternRegex(pattern, where, resolvePath) {
380
+ const { root, p } = patternParts(pattern, where);
381
+ const segs = p === "" ? [] : p.split("/");
382
+ let i = 0;
383
+ while (i < segs.length && !/[*?[\]{}]/.test(segs[i])) i++;
384
+ const literalRoot = path.join(root, ...segs.slice(0, i));
385
+ const realRoot = resolvePath(literalRoot) ?? literalRoot;
386
+ const rest = segs.slice(i).join("/");
387
+ if (rest === "") return new RegExp(`^${escapeRe(realRoot)}(?:/.*)?$`);
388
+ return compilePathRegex(realRoot, rest);
389
+ }
390
+
391
+ /**
392
+ * The absolute root a pattern is anchored at and the glob body under it.
393
+ * @param {string} pattern
394
+ * @param {{cwd:string, homedir:string|null, baseDir:string}} where
395
+ */
396
+ function patternParts(pattern, { cwd, homedir, baseDir }) {
397
+ let p = pattern;
398
+ let root;
399
+ if (p.startsWith("//")) {
400
+ root = "/";
401
+ p = p.slice(2);
402
+ } else if (p.startsWith("~/")) {
403
+ root = homedir ?? "/nonexistent-home";
404
+ p = p.slice(2);
405
+ } else if (p.startsWith("/")) {
406
+ root = baseDir || cwd;
407
+ p = p.slice(1);
408
+ } else {
409
+ root = cwd;
410
+ if (p.startsWith("./")) p = p.slice(2);
411
+ else if (!p.includes("/")) p = `**/${p}`; // gitignore: no slash matches at any depth
412
+ }
413
+ // `dir/` and `dir/**` both name a directory; the suffix below covers its
414
+ // contents, and the directory itself matches too (a Grep rooted there).
415
+ if (p.endsWith("/**")) p = p.slice(0, -3);
416
+ else if (p.endsWith("/")) p = p.slice(0, -1);
417
+ return { root, p };
418
+ }
419
+
420
+ /** @param {string} root @param {string} p */
421
+ function compilePathRegex(root, p) {
422
+ let re = "";
423
+ for (let i = 0; i < p.length; i++) {
424
+ const ch = p[i];
425
+ if (ch === "*" && p[i + 1] === "*") {
426
+ if (p[i + 2] === "/") {
427
+ re += "(?:.*/)?";
428
+ i += 2;
429
+ } else {
430
+ re += ".*";
431
+ i += 1;
432
+ }
433
+ } else if (ch === "*") re += "[^/]*";
434
+ else if (ch === "?") re += "[^/]";
435
+ else re += escapeRe(ch);
436
+ }
437
+ const rootPrefix = root.endsWith("/") ? root : `${root}/`;
438
+ // A pattern naming a directory also covers everything under it.
439
+ return new RegExp(`^${escapeRe(rootPrefix)}${re}(?:/.*)?$`);
440
+ }
441
+
442
+ /**
443
+ * The path a file tool acts on. For Glob it is the directory the pattern's
444
+ * literal prefix reaches — `{pattern:"/etc/**"}` reads /etc, whatever `path` is.
445
+ * @param {string} toolName @param {any} input @param {string} cwd
446
+ */
447
+ export function targetPath(toolName, input, cwd) {
448
+ const raw = input?.file_path ?? input?.notebook_path ?? input?.path;
449
+ const base = typeof raw === "string" && raw !== "" ? path.resolve(cwd, raw) : READ_TOOLS.has(toolName) ? path.resolve(cwd) : null;
450
+ if (toolName === "Glob" && base && typeof input?.pattern === "string") {
451
+ const literal = [];
452
+ for (const seg of input.pattern.split("/")) {
453
+ if (/[*?[\]{}]/.test(seg)) break;
454
+ literal.push(seg);
455
+ }
456
+ const prefix = literal.join("/");
457
+ if (prefix !== "") return path.resolve(base, prefix);
458
+ if (input.pattern.startsWith("/")) return "/"; // "/**": the literal prefix is the root itself
459
+ }
460
+ return base;
461
+ }
462
+
463
+ export const INSTRUCTION_FILE_NAMES = Object.freeze(new Set(["CLAUDE.md", "CLAUDE.local.md", "COHORT.md", "AGENTS.md"]));
464
+
465
+ /**
466
+ * Is this absolute path part of the engine's own configuration or instructions?
467
+ * @param {string} target absolute
468
+ * @param {string[]} [configDirs] extra config roots ($CLAUDE_CONFIG_DIR)
469
+ */
470
+ export function isEngineConfigPath(target, configDirs = []) {
471
+ const segments = target.split("/").filter(Boolean);
472
+ const base = segments[segments.length - 1] ?? "";
473
+ if (segments.slice(0, -1).includes(".claude") || base === ".claude") return true;
474
+ if (base === ".mcp.json" || base === ".claude.json" || INSTRUCTION_FILE_NAMES.has(base)) return true;
475
+ return isWithin(target, configDirs);
476
+ }
477
+
478
+ /**
479
+ * @param {PermissionRule} rule
480
+ * @param {{toolName:string, input:any}} call
481
+ * @param {{cwd:string, homedir:string|null, resolvePath?:((p:string)=>string|null)|null}} where
482
+ * @param {'allow'|'deny'|'ask'} kind
483
+ */
484
+ export function ruleMatches(rule, call, where, kind) {
485
+ if (!ruleToolMatches(rule.tool, call.toolName)) return false;
486
+ if (rule.specifier === null || rule.specifier === "*") return true;
487
+ if (call.toolName.startsWith("mcp__")) return false; // MCP rules take no specifier
488
+
489
+ if (call.toolName === "Bash") {
490
+ const command = typeof call.input?.command === "string" ? call.input.command.trim() : "";
491
+ const { parts, hasSubstitution } = splitShellCommand(command);
492
+ if (kind === "allow") {
493
+ if (hasSubstitution || parts.length === 0) return false;
494
+ return parts.every((p) => bashSpecMatches(rule.specifier, p));
495
+ }
496
+ // deny / ask: fail closed on what the rule cannot see through.
497
+ if (hasSubstitution) return true;
498
+ if (bashSpecMatches(rule.specifier, command)) return true;
499
+ const spec = /** @type string */ (rule.specifier);
500
+ return parts.some((p) => {
501
+ const { texts, opaque } = denyCandidates(p);
502
+ return opaque || texts.some((t) => bashSpecMatches(spec, t));
503
+ });
504
+ }
505
+
506
+ if (READ_TOOLS.has(call.toolName) || EDIT_TOOLS.has(call.toolName)) {
507
+ const target = targetPath(call.toolName, call.input, where.cwd);
508
+ if (!target) return false;
509
+ const at = { cwd: where.cwd, homedir: where.homedir, baseDir: rule.baseDir };
510
+ const lexical = pathPatternRegex(rule.specifier, at);
511
+ if (!where.resolvePath) return lexical.test(target);
512
+ // CF-19: judged by where the path leads. Deny and ask match either the name or the real path
513
+ // (fail closed); an allow must cover the real path. An unresolvable path (a loop) keeps its name.
514
+ const real = where.resolvePath(target);
515
+ if (real === null) return lexical.test(target);
516
+ const realMatches = lexical.test(real) || resolvedPathPatternRegex(rule.specifier, at, where.resolvePath).test(real);
517
+ return kind === "allow" ? realMatches : realMatches || lexical.test(target);
518
+ }
519
+
520
+ if (call.toolName === "WebFetch" && rule.specifier.startsWith("domain:")) {
521
+ const domain = rule.specifier.slice("domain:".length).toLowerCase();
522
+ try {
523
+ const host = new URL(String(call.input?.url)).hostname.toLowerCase();
524
+ return host === domain || host.endsWith(`.${domain}`);
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
530
+ if (call.toolName === "Skill") return call.input?.skill === rule.specifier;
531
+ if (call.toolName === "SlashCommand") return slashCommandMatches(rule.specifier, call.input?.command); // SlashCommand(/review), SlashCommand(/kit:*)
532
+ if (call.toolName === "Task" || call.toolName === "Agent") return subagentTypeMatches(rule.specifier, call.input?.subagent_type, kind); // Task(code-reader)
533
+ return false;
534
+ }
535
+
536
+ /**
537
+ * Does `SlashCommand(<specifier>)` cover an invoked command name (W4-E2)? The
538
+ * leading `/` is optional on both sides. `/name` is that name exactly;
539
+ * `/kit:*` is `kit` and every `kit:<name>` (a plugin's commands and skills).
540
+ * Pure.
541
+ * @param {string} specifier @param {unknown} command
542
+ */
543
+ export function slashCommandMatches(specifier, command) {
544
+ if (typeof command !== "string") return false;
545
+ const name = command.trim().replace(/^\//, "");
546
+ const spec = specifier.trim().replace(/^\//, "");
547
+ if (spec.endsWith(":*")) {
548
+ const prefix = spec.slice(0, -2);
549
+ return name === prefix || name.startsWith(`${prefix}:`);
550
+ }
551
+ return name === spec;
552
+ }
553
+
554
+ /**
555
+ * Does `Task(<specifier>)` cover the subagent type the model asked for? The
556
+ * Task tool trims the name and accepts a plugin agent's unqualified name, so a
557
+ * rule must see the same name the tool will run:
558
+ * allow the trimmed name equals the specifier, exactly;
559
+ * deny / ask also when one side is unqualified and equals the other side's
560
+ * unqualified part (`Task(kit:helper)` refuses "helper", and
561
+ * `Task(helper)` refuses "kit:helper") — fail closed.
562
+ * The Task tool re-checks the resolved definition name as well (agents/runtime.mjs).
563
+ * @param {string} specifier @param {unknown} requested @param {'allow'|'deny'|'ask'} kind
564
+ */
565
+ export function subagentTypeMatches(specifier, requested, kind) {
566
+ if (typeof requested !== "string") return false;
567
+ const want = requested.trim();
568
+ const spec = specifier.trim();
569
+ if (want === spec) return true;
570
+ if (kind === "allow") return false;
571
+ const bare = (/** @type string */ n) => n.slice(n.lastIndexOf(":") + 1);
572
+ return (!want.includes(":") && want === bare(spec)) || (!spec.includes(":") && spec === bare(want));
573
+ }
574
+
575
+ /**
576
+ * The allow rule covering a Bash call: a rule with no specifier covers any
577
+ * command; otherwise EVERY part of the command must be covered by some
578
+ * specific rule (different parts may be covered by different rules), and a
579
+ * command with substitution is never covered. Returns the last rule used.
580
+ * A command that writes engine config (`configWrite`, CF-19) is covered only by
581
+ * specific rules, at least one of which names the file as the command does.
582
+ * @param {PermissionRule[]} allowRules @param {any} input
583
+ * @param {{target:string, word:string}|null} [configWrite]
584
+ * @returns {PermissionRule|undefined}
585
+ */
586
+ function bashAllowRule(allowRules, input, configWrite = null) {
587
+ const bashRules = allowRules.filter((r) => ruleToolMatches(r.tool, "Bash"));
588
+ const whole = bashRules.find((r) => r.specifier === null || r.specifier === "*");
589
+ if (whole && !configWrite) return whole;
590
+ const specific = bashRules.filter((r) => r.specifier !== null && r.specifier !== "*");
591
+ const command = typeof input?.command === "string" ? input.command.trim() : "";
592
+ const { parts, hasSubstitution } = splitShellCommand(command);
593
+ if (hasSubstitution || parts.length === 0) return undefined;
594
+ let last;
595
+ let namesTarget = false;
596
+ for (const part of parts) {
597
+ last = specific.find((r) => bashSpecMatches(/** @type string */ (r.specifier), part));
598
+ if (!last) return undefined;
599
+ if (configWrite && /** @type string */ (last.specifier).includes(configWrite.word)) namesTarget = true;
600
+ }
601
+ return configWrite && !namesTarget ? undefined : last;
602
+ }
603
+
604
+ /* ───────────────── CF-19: a shell command that writes engine config ───────────────── */
605
+
606
+ /** Commands whose every operand may be a destination. */
607
+ const COPY_WRITERS = new Set(["tee", "cp", "mv", "install", "ln", "rsync", "truncate", "touch"]);
608
+ /** A redirection and its target: `>f`, `>> f`, `2>f`, `&>f`, `>|f` (the target keeps its quotes). */
609
+ const REDIRECT_RE = /(?:^|[^<>&$\d])\d*(?:&?>>?|>\|)\s*((?:"[^"]*"|'[^']*'|[^\s;&|()<>"'])+)/g;
610
+
611
+ /** Split a simple command into words, removing quotes and backslashes. Pure. @param {string} text */
612
+ function shellWords(text) {
613
+ const out = [];
614
+ let cur = "";
615
+ let quoted = false;
616
+ /** @type {string|null} */
617
+ let q = null;
618
+ for (let i = 0; i < text.length; i++) {
619
+ const ch = text[i];
620
+ if (q) {
621
+ if (ch === q) q = null;
622
+ else if (ch === "\\" && q === '"' && i + 1 < text.length) cur += text[++i];
623
+ else cur += ch;
624
+ continue;
625
+ }
626
+ if (ch === "'" || ch === '"') {
627
+ q = ch;
628
+ quoted = true;
629
+ } else if (ch === "\\" && i + 1 < text.length) {
630
+ cur += text[++i];
631
+ } else if (/\s/.test(ch)) {
632
+ if (cur !== "" || quoted) out.push(cur);
633
+ cur = "";
634
+ quoted = false;
635
+ } else cur += ch;
636
+ }
637
+ if (cur !== "" || quoted) out.push(cur);
638
+ return out;
639
+ }
640
+
641
+ /**
642
+ * Does a Bash command name engine configuration as a place it writes? A
643
+ * conservative pre-check, because a shell command cannot be path-checked: it
644
+ * looks at redirection targets, the operands of `tee`, `cp`, `mv`, `install`,
645
+ * `ln`, `rsync`, `truncate`, `touch`, the files of `sed -i` / `perl -i`,
646
+ * `dd of=`, `curl -o` and `wget -O`, through wrappers (`sudo`, `env A=1`, …),
647
+ * `cd`/`pushd` (the directory later parts resolve against), `sh|bash|zsh -c`
648
+ * and `eval` scripts (to depth 3). `~` and `$HOME` expand to `homedir`; with a
649
+ * resolver, a name is also judged by the path it really leads to. It narrows,
650
+ * it is not a boundary: an interpreter (`python -c`, `node -e`), `git`,
651
+ * `patch`, a substituted or variable path, or a copy made under another name
652
+ * and renamed by a program is not seen. A `cp` FROM config is flagged too.
653
+ * Pure (the resolver is a parameter).
654
+ * @param {unknown} command
655
+ * @param {{cwd:string, homedir?:string|null, configDirs?:string[], resolvePath?:((p:string)=>string|null)|null}} where
656
+ * @param {number} [depth]
657
+ * @returns {{target:string, word:string}|null} the first config path it names, as resolved and as written
658
+ */
659
+ export function bashEngineConfigWrite(command, { cwd, homedir = null, configDirs = [], resolvePath = null }, depth = 0) {
660
+ const text = typeof command === "string" ? command : "";
661
+ if (text.trim() === "" || depth > 3) return null;
662
+ const realConfigDirs = resolvePath ? configDirs.map((d) => resolvePath(path.resolve(d)) ?? path.resolve(d)) : [];
663
+ const expandHome = (/** @type string */ w) => {
664
+ if (!homedir) return w;
665
+ if (w === "~" || w.startsWith("~/")) return homedir + w.slice(1);
666
+ return w.replace(/^\$\{?HOME\}?(?=\/|$)/, homedir);
667
+ };
668
+ /** @param {string} word @param {string} dir */
669
+ const configTarget = (word, dir) => {
670
+ const w = word.replace(/["']/g, "");
671
+ if (w === "" || w.startsWith("&") || w.startsWith("-")) return null;
672
+ const abs = path.resolve(dir, expandHome(w));
673
+ if (isEngineConfigPath(abs, configDirs)) return { target: abs, word: w };
674
+ const real = resolvePath ? resolvePath(abs) : null;
675
+ return real !== null && isEngineConfigPath(real, realConfigDirs) ? { target: real, word: w } : null;
676
+ };
677
+ let dir = cwd;
678
+ for (const part of splitShellCommand(text).parts) {
679
+ for (const m of part.matchAll(REDIRECT_RE)) {
680
+ const hit = configTarget(m[1], dir);
681
+ if (hit) return hit;
682
+ }
683
+ const words = shellWords(part);
684
+ let i = 0;
685
+ for (let guard = 0; guard < 10 && i < words.length; guard++) {
686
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
687
+ if (i >= words.length || !WRAPPERS.has(normaliseCommandWord(words[i]))) break;
688
+ i++;
689
+ while (i < words.length && (/^-/.test(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^\d+[smhd]?$/.test(words[i]))) i++;
690
+ }
691
+ if (i >= words.length) continue;
692
+ const cmd = normaliseCommandWord(words[i]);
693
+ const args = words.slice(i + 1).filter((a) => !/^\d*(?:&?>>?|>\||<)/.test(a));
694
+ /** @type {string[]} */
695
+ let candidates = [];
696
+ if (cmd === "cd" || cmd === "pushd") {
697
+ const d = args.find((a) => !a.startsWith("-"));
698
+ if (d) dir = path.resolve(dir, expandHome(d));
699
+ continue;
700
+ }
701
+ if (SHELLS.has(cmd) || cmd === "eval") {
702
+ const at = cmd === "eval" ? 0 : args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a)) + 1;
703
+ const script = cmd === "eval" ? args.join(" ") : at > 0 ? args[at] : undefined;
704
+ const inner = script === undefined ? null : bashEngineConfigWrite(script, { cwd: dir, homedir, configDirs, resolvePath }, depth + 1);
705
+ if (inner) return inner;
706
+ continue;
707
+ }
708
+ if (COPY_WRITERS.has(cmd)) candidates = args.map((a) => (a.startsWith("--target-directory=") ? a.slice("--target-directory=".length) : a));
709
+ else if ((cmd === "sed" && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) || (cmd === "perl" && args.some((a) => /^-[A-Za-z]*i/.test(a)))) candidates = args;
710
+ else if (cmd === "dd") candidates = args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3));
711
+ else if (cmd === "curl" || cmd === "wget") {
712
+ const letter = cmd === "curl" ? "o" : "O";
713
+ const long = cmd === "curl" ? "--output" : "--output-document";
714
+ args.forEach((a, k) => {
715
+ if ((a === long || new RegExp(`^-[A-Za-z]*${letter}$`).test(a)) && args[k + 1] !== undefined) candidates.push(args[k + 1]); // -o f, -so f
716
+ else if (a.startsWith(`${long}=`)) candidates.push(a.slice(long.length + 1));
717
+ else if (a.startsWith(`-${letter}`) && a.length > 2 && !a.startsWith("--")) candidates.push(a.slice(2)); // -of
718
+ });
719
+ }
720
+ for (const c of candidates) {
721
+ const hit = configTarget(c, dir);
722
+ if (hit) return hit;
723
+ }
724
+ }
725
+ return null;
726
+ }
727
+
728
+ /**
729
+ * Memoise a resolver for one decision, and make it never throw.
730
+ * @param {(p:string)=>string|null} fn
731
+ * @returns {(p:string)=>string|null}
732
+ */
733
+ function memoResolver(fn) {
734
+ /** @type {Map<string, string|null>} */
735
+ const cache = new Map();
736
+ return (p) => {
737
+ if (!cache.has(p)) {
738
+ let r = null;
739
+ try {
740
+ r = fn(p);
741
+ } catch {
742
+ r = null;
743
+ }
744
+ cache.set(p, typeof r === "string" ? r : null);
745
+ }
746
+ return cache.get(p) ?? null;
747
+ };
748
+ }
749
+
750
+ /** @param {string} file @param {string[]} dirs */
751
+ function isWithin(file, dirs) {
752
+ return dirs.some((d) => {
753
+ const rel = path.relative(path.resolve(d), file);
754
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
755
+ });
756
+ }
757
+
758
+ /**
759
+ * @param {object} p
760
+ * @param {string} p.toolName
761
+ * @param {any} p.input
762
+ * @param {boolean} p.readOnly the tool cannot change anything
763
+ * @param {boolean} [p.isMcp] MCP tools are never auto-allowed as read-only
764
+ * @param {string} p.mode
765
+ * @param {RuleSet} p.rules
766
+ * @param {string} p.cwd
767
+ * @param {string|null} [p.homedir]
768
+ * @param {string[]} [p.additionalDirectories]
769
+ * @param {string[]} [p.configDirs] extra engine config roots ($CLAUDE_CONFIG_DIR)
770
+ * @param {((p:string)=>string|null)|null} [p.resolvePath] CF-19: where a path really leads
771
+ * (context/real-path.mjs); null judges paths by name only
772
+ * @returns {PermissionDecision}
773
+ */
774
+ export function evaluatePermission({ toolName, input, readOnly, isMcp = false, mode, rules, cwd, homedir = null, additionalDirectories = [], configDirs = [], resolvePath = null }) {
775
+ const m = isPermissionMode(mode) ? mode : "default";
776
+ const call = { toolName, input };
777
+ const real = resolvePath ? memoResolver(resolvePath) : null;
778
+ const where = { cwd, homedir, resolvePath: real };
779
+ const find = (/** @type {'allow'|'deny'|'ask'} */ kind) => rules[kind].find((r) => ruleMatches(r, call, where, kind));
780
+
781
+ const pos = toolPosture({ toolName, readOnly, isMcp });
782
+ const deny = find("deny");
783
+ if (deny) return { behavior: "deny", reason: `denied by the rule ${deny.raw}${deny.source ? ` (${deny.source})` : ""}`, rule: deny.raw };
784
+ // An MCP server's readOnlyHint is its own claim: plan mode does not rely on it.
785
+ if (m === "plan" && !pos.planSafe) {
786
+ const reason = isMcp ? "plan mode does not run MCP tools" : pos.kind === "mutating" ? "plan mode allows only tools that change nothing" : `plan mode does not run ${toolName}: it ${POSTURE_REASON[pos.kind]}`;
787
+ return { behavior: "deny", reason };
788
+ }
789
+ const ask = find("ask");
790
+ if (ask) return { behavior: "ask", reason: `the rule ${ask.raw}${ask.source ? ` (${ask.source})` : ""} requires approval`, rule: ask.raw };
791
+ const editTarget = EDIT_TOOLS.has(toolName) ? targetPath(toolName, input, cwd) : null;
792
+ // CF-19: an edit is judged by the file it really reaches (a symlink's target) as well as by its name.
793
+ const realEdit = editTarget && real ? real(editTarget) : editTarget;
794
+ const realOf = (/** @type string */ d) => (real ? (real(path.resolve(d)) ?? path.resolve(d)) : d);
795
+ const protectedWrite = Boolean(editTarget && (isEngineConfigPath(editTarget, configDirs) || (realEdit !== null && realEdit !== editTarget && isEngineConfigPath(realEdit, configDirs.map(realOf)))));
796
+ const configWrite = toolName === "Bash" ? bashEngineConfigWrite(input?.command, { cwd, homedir, configDirs, resolvePath: real }) : null;
797
+ const allow = toolName === "Bash" ? bashAllowRule(rules.allow, input, configWrite) : protectedWrite ? rules.allow.find((r) => r.specifier !== null && r.specifier !== "*" && ruleMatches(r, call, where, "allow")) : find("allow");
798
+ if (allow) return { behavior: "allow", reason: `allowed by the rule ${allow.raw}`, rule: allow.raw };
799
+ if (protectedWrite) {
800
+ const via = realEdit && realEdit !== editTarget ? ` (a link to ${realEdit})` : "";
801
+ return { behavior: "ask", reason: `${editTarget}${via} is engine configuration or instructions, which no mode edits without approval` };
802
+ }
803
+ if (configWrite) {
804
+ return {
805
+ behavior: "ask",
806
+ reason: `the command writes to ${configWrite.target}, which is engine configuration or instructions; a shell command cannot be path-checked, so one that names such a file in a redirection, tee, cp, mv, sed -i or the like needs approval in every mode`,
807
+ };
808
+ }
809
+ if (m === "bypassPermissions") return { behavior: "allow", reason: "bypassPermissions mode" };
810
+ const editInScope = editTarget && (real ? realEdit !== null && isWithin(realEdit, [cwd, ...additionalDirectories].map(realOf)) : isWithin(editTarget, [cwd, ...additionalDirectories]));
811
+ if (m === "acceptEdits" && editInScope) {
812
+ return { behavior: "allow", reason: "acceptEdits mode: an edit inside the working directory" };
813
+ }
814
+ if (pos.autoAllow) return { behavior: "allow", reason: pos.kind === "read-only" ? "a read-only tool" : `${toolName} needs no rule: it ${POSTURE_REASON[pos.kind]}` };
815
+ if (m === "dontAsk") return { behavior: "deny", reason: "dontAsk mode denies anything not allowed by a rule" };
816
+ return { behavior: "ask", reason: "no rule allows this tool" };
817
+ }
818
+
819
+ /** Why a posture class is judged as it is (reason text for decisions). */
820
+ export const POSTURE_REASON = Object.freeze({
821
+ "session-state": "reads or manages only this run's own state",
822
+ "spawns-clamped": "starts a subagent held to this run's permission mode and rules",
823
+ "spawns-work": "starts work this run's permission checks do not cover",
824
+ outbound: "reaches another session, which acts with its own permissions",
825
+ });
826
+
827
+ /**
828
+ * Is the tool denied outright, whatever its input? Such tools are not offered
829
+ * to the model at all (a rule with no specifier, or a server-level MCP rule).
830
+ * @param {string} toolName @param {RuleSet} rules
831
+ */
832
+ export function isToolFullyDenied(toolName, rules) {
833
+ return rules.deny.some((r) => (r.specifier === null || r.specifier === "*") && ruleToolMatches(r.tool, toolName));
834
+ }
835
+
836
+ /**
837
+ * The tool_result text for an `ask` the engine cannot put to anyone.
838
+ * @param {string} toolName @param {PermissionDecision} decision
839
+ */
840
+ export function headlessAskDenial(toolName, decision) {
841
+ return (
842
+ `Permission to use ${toolName} was not granted: ${decision.reason}, and this run cannot ask for approval. ` +
843
+ `It can be allowed with --allowedTools "${toolName}" or a permissions.allow rule in settings. Do not retry the same call; continue without it or explain what is blocked.`
844
+ );
845
+ }