@cohortapp/agent-sdk 2.17.0 → 2.18.4

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 (526) 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.mjs +8 -6
  23. package/lib/collective/global-config.mjs +63 -1
  24. package/lib/collective/presence.mjs +142 -5
  25. package/lib/comms/send-gate.mjs +559 -1
  26. package/lib/diagnostics/alerts.mjs +49 -0
  27. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  28. package/lib/engine/agents/definitions.mjs +343 -0
  29. package/lib/engine/agents/persist.mjs +275 -0
  30. package/lib/engine/agents/runtime.mjs +748 -0
  31. package/lib/engine/agents/usage.mjs +95 -0
  32. package/lib/engine/auth-status.mjs +139 -0
  33. package/lib/engine/budget.mjs +194 -0
  34. package/lib/engine/cli.mjs +1204 -0
  35. package/lib/engine/commands/index.mjs +269 -0
  36. package/lib/engine/context/budget.mjs +219 -0
  37. package/lib/engine/context/cache.mjs +125 -0
  38. package/lib/engine/context/child-env.mjs +215 -0
  39. package/lib/engine/context/compaction.mjs +342 -0
  40. package/lib/engine/context/images.mjs +90 -0
  41. package/lib/engine/context/instructions.mjs +327 -0
  42. package/lib/engine/context/lazy-instructions.mjs +169 -0
  43. package/lib/engine/context/manager.mjs +182 -0
  44. package/lib/engine/context/real-path.mjs +91 -0
  45. package/lib/engine/context/secret-values.mjs +163 -0
  46. package/lib/engine/context/settings.mjs +274 -0
  47. package/lib/engine/context/stream-input.mjs +159 -0
  48. package/lib/engine/guard.mjs +152 -0
  49. package/lib/engine/hooks.mjs +713 -0
  50. package/lib/engine/loop.mjs +560 -0
  51. package/lib/engine/mcp/client.mjs +254 -0
  52. package/lib/engine/mcp/config.mjs +301 -0
  53. package/lib/engine/mcp/http.mjs +201 -0
  54. package/lib/engine/mcp/index.mjs +146 -0
  55. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  56. package/lib/engine/mcp/naming.mjs +66 -0
  57. package/lib/engine/mcp/resources.mjs +89 -0
  58. package/lib/engine/mcp/results.mjs +133 -0
  59. package/lib/engine/mcp/stdio.mjs +137 -0
  60. package/lib/engine/mcp/supervisor.mjs +116 -0
  61. package/lib/engine/messages.mjs +104 -0
  62. package/lib/engine/output/json.mjs +164 -0
  63. package/lib/engine/output/stream-json.mjs +266 -0
  64. package/lib/engine/permissions.mjs +845 -0
  65. package/lib/engine/process-identity.mjs +164 -0
  66. package/lib/engine/process-tree.mjs +551 -0
  67. package/lib/engine/prompt.mjs +60 -0
  68. package/lib/engine/session/store.mjs +299 -0
  69. package/lib/engine/session-runtime/args.mjs +97 -0
  70. package/lib/engine/session-runtime/host.mjs +143 -0
  71. package/lib/engine/session-runtime/inbox.mjs +122 -0
  72. package/lib/engine/session-runtime/notifications.mjs +129 -0
  73. package/lib/engine/session-runtime/registry.mjs +328 -0
  74. package/lib/engine/session-runtime/runner.mjs +344 -0
  75. package/lib/engine/session-runtime/socket.mjs +212 -0
  76. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  77. package/lib/engine/skills/index.mjs +321 -0
  78. package/lib/engine/tools/bash-background.mjs +533 -0
  79. package/lib/engine/tools/bash.mjs +216 -0
  80. package/lib/engine/tools/edit.mjs +97 -0
  81. package/lib/engine/tools/glob.mjs +81 -0
  82. package/lib/engine/tools/grep.mjs +224 -0
  83. package/lib/engine/tools/index.mjs +84 -0
  84. package/lib/engine/tools/list-agents.mjs +32 -0
  85. package/lib/engine/tools/ls.mjs +127 -0
  86. package/lib/engine/tools/monitor.mjs +82 -0
  87. package/lib/engine/tools/notebook-edit.mjs +218 -0
  88. package/lib/engine/tools/read.mjs +103 -0
  89. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  90. package/lib/engine/tools/schema.mjs +144 -0
  91. package/lib/engine/tools/send-message.mjs +77 -0
  92. package/lib/engine/tools/session.mjs +70 -0
  93. package/lib/engine/tools/todo.mjs +144 -0
  94. package/lib/engine/tools/toolsearch.mjs +217 -0
  95. package/lib/engine/tools/walk.mjs +193 -0
  96. package/lib/engine/tools/web-switch.mjs +31 -0
  97. package/lib/engine/tools/webfetch-html.mjs +387 -0
  98. package/lib/engine/tools/webfetch-net.mjs +340 -0
  99. package/lib/engine/tools/webfetch.mjs +198 -0
  100. package/lib/engine/tools/websearch.mjs +91 -0
  101. package/lib/engine/tools/workflow.mjs +95 -0
  102. package/lib/engine/tools/write.mjs +76 -0
  103. package/lib/engine/tui/line-editor.mjs +137 -0
  104. package/lib/engine/tui/render.mjs +86 -0
  105. package/lib/engine/tui/tui.mjs +274 -0
  106. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  107. package/lib/engine/wire/effort.mjs +36 -0
  108. package/lib/engine/wire/errors.mjs +496 -0
  109. package/lib/engine/wire/http.mjs +441 -0
  110. package/lib/engine/wire/index.mjs +76 -0
  111. package/lib/engine/wire/openai-chat.mjs +332 -0
  112. package/lib/engine/wire/prompt-cache.mjs +79 -0
  113. package/lib/engine/wire/search.mjs +140 -0
  114. package/lib/engine/wire/sse.mjs +114 -0
  115. package/lib/engine/wire/stall.mjs +349 -0
  116. package/lib/engine/wire/token-provider.mjs +175 -0
  117. package/lib/engine/wire/usage.mjs +192 -0
  118. package/lib/engine/workflow/host.mjs +524 -0
  119. package/lib/engine/workflow/journal.mjs +188 -0
  120. package/lib/engine/workflow/json-schema.mjs +171 -0
  121. package/lib/engine/workflow/meta.mjs +329 -0
  122. package/lib/engine/workflow/notifications.mjs +52 -0
  123. package/lib/engine/workflow/runtime.mjs +447 -0
  124. package/lib/engine/workflow/sandbox.mjs +534 -0
  125. package/lib/engine/workflow/worker.mjs +141 -0
  126. package/lib/engine/workflow/worktree.mjs +74 -0
  127. package/lib/execution/disposition.mjs +1 -1
  128. package/lib/execution/intake.mjs +10 -0
  129. package/lib/execution/surface-policy.mjs +15 -0
  130. package/lib/learning/curator.mjs +8 -6
  131. package/lib/learning/reflect.mjs +8 -6
  132. package/lib/model-router/catalog/cohort.yaml +137 -0
  133. package/lib/model-router/catalog.mjs +118 -1
  134. package/lib/model-router/failover.mjs +67 -16
  135. package/lib/model-router/llm-task.mjs +39 -3
  136. package/lib/model-router/resolve.mjs +89 -3
  137. package/lib/model-router/spawn.mjs +46 -47
  138. package/lib/model-router/taxonomy.mjs +126 -4
  139. package/lib/org/cost-sync.mjs +141 -11
  140. package/lib/org/inbound/broadcast.mjs +289 -0
  141. package/lib/org/inbound/collective.mjs +375 -0
  142. package/lib/org/inbound/directedness.mjs +96 -8
  143. package/lib/org/inbound/facts.mjs +78 -2
  144. package/lib/org/inbound/project.mjs +22 -0
  145. package/lib/org/inbound/surfaces.mjs +14 -0
  146. package/lib/org/llm-token.mjs +879 -0
  147. package/lib/org/mesh.mjs +61 -0
  148. package/lib/org/messaging.mjs +3 -1
  149. package/lib/org/protocol.checksum +1 -1
  150. package/lib/org/protocol.mjs +15 -0
  151. package/lib/org/quota.mjs +520 -0
  152. package/lib/org/tool-surface.mjs +104 -16
  153. package/lib/org/ui-parity.mjs +16 -1
  154. package/lib/org/work-ledger.mjs +37 -6
  155. package/lib/rate-guard.mjs +114 -1
  156. package/lib/resource-governor.mjs +41 -6
  157. package/lib/runtime/adapter.mjs +823 -0
  158. package/lib/runtime/child-env.mjs +191 -0
  159. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  160. package/lib/runtime/seat-engine.mjs +162 -0
  161. package/lib/session/ask-ledger.mjs +271 -0
  162. package/lib/session/current-work.mjs +676 -0
  163. package/lib/session/feed-core.mjs +40 -3
  164. package/lib/session/launch-args.mjs +56 -4
  165. package/lib/session/status-summary.mjs +26 -9
  166. package/lib/session/upgrade-notice.mjs +42 -0
  167. package/lib/setup/claude-probe.mjs +117 -13
  168. package/lib/setup/enrich.mjs +13 -10
  169. package/lib/setup/sections/model.mjs +39 -13
  170. package/lib/telemetry/collect.mjs +208 -9
  171. package/lib/upgrade/ignored-drift.mjs +105 -0
  172. package/lib/voice/post-call-brief.mjs +30 -17
  173. package/package.json +13 -3
  174. package/plugins/maestro-skills/skills/board-work.md +5 -0
  175. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  176. package/plugins/maestro-skills/skills/main-session.md +18 -7
  177. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  178. package/scripts/ci/run-tests.mjs +47 -19
  179. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  180. package/scripts/collective/hook-runner.mjs +29 -2
  181. package/scripts/continuous-monitor.sh +13 -0
  182. package/scripts/cost/track-claude-usage.mjs +15 -0
  183. package/scripts/daemon/agent-daemon.mjs +408 -20
  184. package/scripts/daemon/assurance.mjs +48 -12
  185. package/scripts/daemon/cadence-consumer.mjs +218 -68
  186. package/scripts/daemon/cadence-handlers.mjs +73 -4
  187. package/scripts/daemon/classifier.mjs +75 -26
  188. package/scripts/daemon/context-compiler.mjs +51 -37
  189. package/scripts/daemon/deliver.mjs +30 -1
  190. package/scripts/daemon/dispatcher.mjs +595 -149
  191. package/scripts/daemon/health.mjs +14 -1
  192. package/scripts/daemon/maestro-daemon.mjs +11 -0
  193. package/scripts/daemon/prompt-builder.mjs +24 -0
  194. package/scripts/daemon/responder.mjs +246 -79
  195. package/scripts/daemon/sdk-version.mjs +98 -16
  196. package/scripts/eval/probe-gateway.mjs +635 -0
  197. package/scripts/eval/replay/extract.mjs +270 -0
  198. package/scripts/eval/replay/grade.mjs +260 -0
  199. package/scripts/eval/replay/lib/config.mjs +50 -0
  200. package/scripts/eval/replay/lib/effects.mjs +65 -0
  201. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  202. package/scripts/eval/replay/lib/judge.mjs +72 -0
  203. package/scripts/eval/replay/lib/redact.mjs +136 -0
  204. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  205. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  206. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  207. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  208. package/scripts/eval/replay/report.mjs +185 -0
  209. package/scripts/eval/replay/run.mjs +404 -0
  210. package/scripts/fleet/rollout.mjs +1094 -0
  211. package/scripts/hooks/pre-send-audit.sh +36 -245
  212. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  213. package/scripts/hooks/validate-state-yaml.sh +190 -0
  214. package/scripts/huddle/huddle-llm.mjs +361 -0
  215. package/scripts/huddle/huddle-server.mjs +46 -121
  216. package/scripts/local-triggers/autoupdate.sh +448 -78
  217. package/scripts/local-triggers/run-trigger.sh +13 -0
  218. package/scripts/maintenance/pin-integrity.mjs +364 -0
  219. package/scripts/poll-slack-events.sh +41 -9
  220. package/scripts/poller/slack-socket-mode.mjs +28 -3
  221. package/scripts/session/supervisor.mjs +80 -13
  222. package/scripts/spawn-session.sh +13 -0
  223. package/bin/maestro.test.mjs +0 -1574
  224. package/lib/action-executor.test.mjs +0 -871
  225. package/lib/archetype.test.mjs +0 -132
  226. package/lib/assurance/plan-note.test.mjs +0 -234
  227. package/lib/assurance/room-budget.test.mjs +0 -486
  228. package/lib/assurance/tier.test.mjs +0 -174
  229. package/lib/autonomy.test.mjs +0 -66
  230. package/lib/backlog.test.mjs +0 -302
  231. package/lib/backup/policy.test.mjs +0 -305
  232. package/lib/budget-escalate.test.mjs +0 -232
  233. package/lib/budget-guard.envelope.test.mjs +0 -476
  234. package/lib/budget-guard.test.mjs +0 -427
  235. package/lib/cadence-bus-requeue.test.mjs +0 -83
  236. package/lib/cadence-bus-schedule.test.mjs +0 -194
  237. package/lib/cadence-bus.test.mjs +0 -720
  238. package/lib/cadences.test.mjs +0 -230
  239. package/lib/capability/inventory.test.mjs +0 -232
  240. package/lib/capability.test.mjs +0 -78
  241. package/lib/channels/base-adapter.test.mjs +0 -590
  242. package/lib/channels/channels.test.mjs +0 -371
  243. package/lib/channels/contract.test.mjs +0 -162
  244. package/lib/channels/inbox-item.test.mjs +0 -368
  245. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  246. package/lib/channels/pairing.test.mjs +0 -270
  247. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  248. package/lib/channels/slack-adapter.test.mjs +0 -212
  249. package/lib/channels/telegram-adapter.test.mjs +0 -306
  250. package/lib/channels/voice/adapter.test.mjs +0 -278
  251. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  252. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  253. package/lib/charter.test.mjs +0 -89
  254. package/lib/claude-bin.test.mjs +0 -131
  255. package/lib/cli/board.test.mjs +0 -227
  256. package/lib/cli/design.test.mjs +0 -270
  257. package/lib/cli/doctor-checks.test.mjs +0 -336
  258. package/lib/cli/global-setup-extras.test.mjs +0 -462
  259. package/lib/cli/inbox.test.mjs +0 -230
  260. package/lib/cli/session-ack.test.mjs +0 -63
  261. package/lib/cli/session.test.mjs +0 -613
  262. package/lib/collective/capture.test.mjs +0 -121
  263. package/lib/collective/cards.test.mjs +0 -114
  264. package/lib/collective/config.test.mjs +0 -123
  265. package/lib/collective/global-config.test.mjs +0 -220
  266. package/lib/collective/global-skills.test.mjs +0 -126
  267. package/lib/collective/presence.test.mjs +0 -95
  268. package/lib/collective/recall.test.mjs +0 -116
  269. package/lib/collective/vendor-skills.test.mjs +0 -306
  270. package/lib/comms/send-gate.test.mjs +0 -770
  271. package/lib/comms.test.mjs +0 -41
  272. package/lib/context/budget.test.mjs +0 -252
  273. package/lib/context/history-scope.test.mjs +0 -79
  274. package/lib/cost/ledger-row.test.mjs +0 -183
  275. package/lib/design/design-md.test.mjs +0 -318
  276. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  277. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  278. package/lib/design/fixtures/foundation.json +0 -133
  279. package/lib/design/refresh-gate.test.mjs +0 -144
  280. package/lib/design/write.test.mjs +0 -241
  281. package/lib/diagnostics/alerts.test.mjs +0 -318
  282. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  283. package/lib/diagnostics/counters.test.mjs +0 -206
  284. package/lib/diagnostics/events.test.mjs +0 -290
  285. package/lib/diagnostics/otel.test.mjs +0 -196
  286. package/lib/diagnostics/trace.test.mjs +0 -251
  287. package/lib/env-compat.test.mjs +0 -104
  288. package/lib/execution/disposition.test.mjs +0 -553
  289. package/lib/execution/drive.test.mjs +0 -270
  290. package/lib/execution/effects.test.mjs +0 -344
  291. package/lib/execution/intake.test.mjs +0 -389
  292. package/lib/execution/journal.test.mjs +0 -261
  293. package/lib/execution/match.test.mjs +0 -235
  294. package/lib/execution/pipeline.test.mjs +0 -392
  295. package/lib/execution/route.test.mjs +0 -186
  296. package/lib/execution/surface-policy.test.mjs +0 -162
  297. package/lib/fs-atomic.test.mjs +0 -72
  298. package/lib/fs-ownership.test.mjs +0 -158
  299. package/lib/goals/admission.test.mjs +0 -164
  300. package/lib/goals/classify.test.mjs +0 -167
  301. package/lib/goals/collaborate.test.mjs +0 -336
  302. package/lib/goals/gaps.test.mjs +0 -284
  303. package/lib/goals/loop.test.mjs +0 -845
  304. package/lib/hooks/bus.test.mjs +0 -387
  305. package/lib/identity/persona.test.mjs +0 -142
  306. package/lib/kpi-sensors.test.mjs +0 -278
  307. package/lib/kpi.test.mjs +0 -244
  308. package/lib/learning/config.test.mjs +0 -75
  309. package/lib/learning/counters.test.mjs +0 -69
  310. package/lib/learning/curator-consolidate.test.mjs +0 -238
  311. package/lib/learning/curator.test.mjs +0 -106
  312. package/lib/learning/reflect.test.mjs +0 -0
  313. package/lib/learning/session-index.test.mjs +0 -125
  314. package/lib/learning/skill-writer.test.mjs +0 -210
  315. package/lib/mandate/audit.test.mjs +0 -195
  316. package/lib/mandate/contract.test.mjs +0 -185
  317. package/lib/mandate/derive.test.mjs +0 -274
  318. package/lib/mandate/model.test.mjs +0 -164
  319. package/lib/mandate/refresh.test.mjs +0 -389
  320. package/lib/mcp/server.test.mjs +0 -426
  321. package/lib/model-router/auth-profiles.test.mjs +0 -580
  322. package/lib/model-router/catalog.test.mjs +0 -385
  323. package/lib/model-router/economics.test.mjs +0 -438
  324. package/lib/model-router/failover.test.mjs +0 -439
  325. package/lib/model-router/health.test.mjs +0 -338
  326. package/lib/model-router/integration-coverage.test.mjs +0 -831
  327. package/lib/model-router/integration.test.mjs +0 -564
  328. package/lib/model-router/ledger.test.mjs +0 -415
  329. package/lib/model-router/llm-task.test.mjs +0 -392
  330. package/lib/model-router/org-credentials.test.mjs +0 -265
  331. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  332. package/lib/model-router/reconcile.test.mjs +0 -316
  333. package/lib/model-router/repair.test.mjs +0 -180
  334. package/lib/model-router/spawn.test.mjs +0 -446
  335. package/lib/model-router/taxonomy.test.mjs +0 -410
  336. package/lib/model-router.test.mjs +0 -1207
  337. package/lib/org/activity.test.mjs +0 -134
  338. package/lib/org/approvals.test.mjs +0 -216
  339. package/lib/org/awareness.test.mjs +0 -159
  340. package/lib/org/board-mine-cache.test.mjs +0 -53
  341. package/lib/org/board.test.mjs +0 -187
  342. package/lib/org/bootstrap-context.test.mjs +0 -153
  343. package/lib/org/client.test.mjs +0 -1206
  344. package/lib/org/cohort-client.test.mjs +0 -126
  345. package/lib/org/cost-sync.test.mjs +0 -153
  346. package/lib/org/doctor.test.mjs +0 -346
  347. package/lib/org/engagement-ledger.test.mjs +0 -112
  348. package/lib/org/engagement.test.mjs +0 -739
  349. package/lib/org/handoff.test.mjs +0 -269
  350. package/lib/org/inbound/directedness.test.mjs +0 -668
  351. package/lib/org/inbound/facts.test.mjs +0 -471
  352. package/lib/org/inbound/hydrate.test.mjs +0 -908
  353. package/lib/org/inbound/index.test.mjs +0 -429
  354. package/lib/org/inbound/project.test.mjs +0 -287
  355. package/lib/org/integration-tools.test.mjs +0 -160
  356. package/lib/org/keys.test.mjs +0 -92
  357. package/lib/org/knowledge.test.mjs +0 -326
  358. package/lib/org/leases.test.mjs +0 -235
  359. package/lib/org/mesh-directives.test.mjs +0 -110
  360. package/lib/org/mesh-integration.test.mjs +0 -127
  361. package/lib/org/mesh.test.mjs +0 -400
  362. package/lib/org/messaging.test.mjs +0 -471
  363. package/lib/org/param-contract.test.mjs +0 -477
  364. package/lib/org/policy.test.mjs +0 -237
  365. package/lib/org/protocol.checksum.test.mjs +0 -90
  366. package/lib/org/protocol.test.mjs +0 -323
  367. package/lib/org/push.test.mjs +0 -792
  368. package/lib/org/registry.test.mjs +0 -100
  369. package/lib/org/resource-tools.test.mjs +0 -361
  370. package/lib/org/tool-access.test.mjs +0 -144
  371. package/lib/org/tool-surface-integration.test.mjs +0 -120
  372. package/lib/org/tool-surface.test.mjs +0 -1268
  373. package/lib/org/typing.test.mjs +0 -291
  374. package/lib/org/ui-parity.test.mjs +0 -560
  375. package/lib/org/verify.test.mjs +0 -194
  376. package/lib/org/work-ledger.test.mjs +0 -273
  377. package/lib/plan/adoption-e2e.test.mjs +0 -366
  378. package/lib/plan/budget-enforcement.test.mjs +0 -400
  379. package/lib/plan/compile.test.mjs +0 -382
  380. package/lib/plan/emit.test.mjs +0 -269
  381. package/lib/plan/explain.test.mjs +0 -188
  382. package/lib/prompts/parallelism.test.mjs +0 -177
  383. package/lib/rag/rag.test.mjs +0 -505
  384. package/lib/rate-guard.test.mjs +0 -272
  385. package/lib/reactive-gate.test.mjs +0 -57
  386. package/lib/render.test.mjs +0 -68
  387. package/lib/resource-governor.test.mjs +0 -488
  388. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  389. package/lib/scheduling/jitter.test.mjs +0 -140
  390. package/lib/secrets/broker.test.mjs +0 -280
  391. package/lib/secrets/providers.test.mjs +0 -274
  392. package/lib/security/audit-engine.test.mjs +0 -424
  393. package/lib/security/coerce-args.test.mjs +0 -281
  394. package/lib/security/dangerous-tools.test.mjs +0 -68
  395. package/lib/security/external-content.test.mjs +0 -84
  396. package/lib/security/redact.test.mjs +0 -441
  397. package/lib/security/secret-equal.test.mjs +0 -55
  398. package/lib/session/config.test.mjs +0 -92
  399. package/lib/session/feed-core.test.mjs +0 -198
  400. package/lib/session/first-run.test.mjs +0 -121
  401. package/lib/session/frontdoor.test.mjs +0 -205
  402. package/lib/session/handoffs.test.mjs +0 -183
  403. package/lib/session/identity.test.mjs +0 -180
  404. package/lib/session/inbox-claims.test.mjs +0 -286
  405. package/lib/session/launch-args.test.mjs +0 -157
  406. package/lib/session/liveness.test.mjs +0 -100
  407. package/lib/session/status-summary.test.mjs +0 -118
  408. package/lib/session-permissions.test.mjs +0 -120
  409. package/lib/setup/claude-probe.test.mjs +0 -187
  410. package/lib/setup/completeness.test.mjs +0 -110
  411. package/lib/setup/context-pack.test.mjs +0 -89
  412. package/lib/setup/enrich.test.mjs +0 -115
  413. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  414. package/lib/setup/integration.test.mjs +0 -162
  415. package/lib/setup/io.test.mjs +0 -77
  416. package/lib/setup/runner.test.mjs +0 -132
  417. package/lib/setup/sections/identity.test.mjs +0 -234
  418. package/lib/setup/sections/inventory.test.mjs +0 -198
  419. package/lib/setup/sections/learning.test.mjs +0 -81
  420. package/lib/setup/sections/mandate.test.mjs +0 -388
  421. package/lib/setup/sections/messaging.test.mjs +0 -127
  422. package/lib/setup/sections/model.test.mjs +0 -240
  423. package/lib/setup/sections/org.test.mjs +0 -346
  424. package/lib/setup/sections/orgmail.test.mjs +0 -118
  425. package/lib/setup/sections/recovery.test.mjs +0 -98
  426. package/lib/setup/sections/subagents.test.mjs +0 -429
  427. package/lib/setup/sections/verify.test.mjs +0 -175
  428. package/lib/setup/sot.test.mjs +0 -81
  429. package/lib/setup/state.test.mjs +0 -115
  430. package/lib/singleton.test.mjs +0 -151
  431. package/lib/subagents/cli.test.mjs +0 -389
  432. package/lib/subagents/client.test.mjs +0 -309
  433. package/lib/subagents/gap.test.mjs +0 -234
  434. package/lib/subagents/lock.test.mjs +0 -248
  435. package/lib/subagents/manifest.test.mjs +0 -175
  436. package/lib/subagents/refs.test.mjs +0 -204
  437. package/lib/subagents/resolve.test.mjs +0 -422
  438. package/lib/subagents/schema.test.mjs +0 -328
  439. package/lib/telemetry/alerts.test.mjs +0 -109
  440. package/lib/telemetry/collect.test.mjs +0 -1274
  441. package/lib/tool-definitions-integration.test.mjs +0 -83
  442. package/lib/tool-definitions.test.mjs +0 -437
  443. package/lib/upgrade/global-refresh.test.mjs +0 -65
  444. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  445. package/lib/upgrade/post-steps.test.mjs +0 -200
  446. package/lib/upgrade/verify.test.mjs +0 -164
  447. package/lib/util/fetch-timeout.test.mjs +0 -202
  448. package/lib/util/reconnect.test.mjs +0 -369
  449. package/lib/util/unhandled.test.mjs +0 -216
  450. package/lib/voice/outbound.test.mjs +0 -69
  451. package/lib/voice/session-rotation.test.mjs +0 -114
  452. package/lib/voice/stt.test.mjs +0 -226
  453. package/lib/voice/voice.test.mjs +0 -990
  454. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  455. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  456. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  457. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  458. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  459. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  460. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  461. package/scripts/ci/check.test.mjs +0 -194
  462. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  463. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  464. package/scripts/collective/hook-runner.test.mjs +0 -173
  465. package/scripts/cost/fleet-digest.test.mjs +0 -207
  466. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  467. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  468. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  469. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  470. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  471. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  472. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  473. package/scripts/daemon/assurance.test.mjs +0 -1791
  474. package/scripts/daemon/board-mirror.test.mjs +0 -165
  475. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  476. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  477. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  478. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  479. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  480. package/scripts/daemon/classifier.test.mjs +0 -266
  481. package/scripts/daemon/classify-kind.test.mjs +0 -40
  482. package/scripts/daemon/context-compiler.test.mjs +0 -406
  483. package/scripts/daemon/deliver.test.mjs +0 -564
  484. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  485. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  486. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  487. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  488. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  489. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  490. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  491. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  492. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  493. package/scripts/daemon/integration.test.mjs +0 -149
  494. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  495. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  496. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  497. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  498. package/scripts/daemon/responder-cost.test.mjs +0 -68
  499. package/scripts/daemon/responder-history.test.mjs +0 -221
  500. package/scripts/daemon/sdk-version.test.mjs +0 -31
  501. package/scripts/daemon/session-lock.test.mjs +0 -252
  502. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  503. package/scripts/daemon/typing-registry.test.mjs +0 -102
  504. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  505. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  506. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  507. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  508. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  509. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  510. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  511. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  512. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  513. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  514. package/scripts/poller-launchd/install.test.mjs +0 -243
  515. package/scripts/restore-from-backup.test.mjs +0 -181
  516. package/scripts/session/feed.test.mjs +0 -196
  517. package/scripts/session/supervisor-sh.test.mjs +0 -218
  518. package/scripts/session/supervisor.test.mjs +0 -482
  519. package/scripts/setup/configure-macos.test.mjs +0 -306
  520. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  521. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  522. package/scripts/setup/generate-capability.test.mjs +0 -134
  523. package/scripts/setup/init-agent.test.mjs +0 -370
  524. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  525. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  526. 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
+ }