@galaxyproject/orbit 0.0.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (451) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +760 -5
  4. package/bin/legacy-mcp-config.js +363 -0
  5. package/bin/orbit.js +810 -0
  6. package/bin/pi-extension-path.d.ts +4 -0
  7. package/bin/pi-extension-path.js +44 -0
  8. package/bin/provider-auth.d.ts +22 -0
  9. package/bin/provider-auth.js +77 -0
  10. package/bin/thinking-pref.js +38 -0
  11. package/bin/update-check.d.ts +15 -0
  12. package/bin/update-check.js +137 -0
  13. package/extensions/cli-update/index.ts +32 -0
  14. package/extensions/orbit/activity-hooks.ts +141 -0
  15. package/extensions/orbit/activity.ts +116 -0
  16. package/extensions/orbit/agent-dir.ts +11 -0
  17. package/extensions/orbit/auto-resume.ts +352 -0
  18. package/extensions/orbit/config.ts +3 -0
  19. package/extensions/orbit/confusables.ts +1 -0
  20. package/extensions/orbit/context.ts +957 -0
  21. package/extensions/orbit/dashboard-commands.ts +191 -0
  22. package/extensions/orbit/dashboard-store.ts +473 -0
  23. package/extensions/orbit/dashboard-tools.ts +1185 -0
  24. package/extensions/orbit/empty-completion.ts +99 -0
  25. package/extensions/orbit/evidence-gate.ts +1276 -0
  26. package/extensions/orbit/evidence-override-command.ts +294 -0
  27. package/extensions/orbit/exec-guard/bash-risk.ts +1408 -0
  28. package/extensions/orbit/exec-guard/gate.ts +181 -0
  29. package/extensions/orbit/exec-guard/guardian-config.ts +54 -0
  30. package/extensions/orbit/exec-guard/index.ts +1 -0
  31. package/extensions/orbit/exec-guard/model-tier.ts +36 -0
  32. package/extensions/orbit/exec-guard/path-jail.ts +54 -0
  33. package/extensions/orbit/exec-guard/policy.ts +342 -0
  34. package/extensions/orbit/exec-guard/runtime-state.ts +15 -0
  35. package/extensions/orbit/exec-guard/sensitive-read.ts +525 -0
  36. package/extensions/orbit/exec-guard/types.ts +42 -0
  37. package/extensions/orbit/execution-commands.ts +134 -0
  38. package/extensions/orbit/feedback-command.ts +81 -0
  39. package/extensions/orbit/feedback.ts +96 -0
  40. package/extensions/orbit/galaxy-api.test.ts +122 -0
  41. package/extensions/orbit/galaxy-api.ts +606 -0
  42. package/extensions/orbit/galaxy-artifact-link-guidance.ts +5 -0
  43. package/extensions/orbit/galaxy-destructive-gate.ts +113 -0
  44. package/extensions/orbit/galaxy-enrich.ts +1108 -0
  45. package/extensions/orbit/galaxy-failure.ts +101 -0
  46. package/extensions/orbit/galaxy-fixture.ts +126 -0
  47. package/extensions/orbit/galaxy-history-guard.ts +233 -0
  48. package/extensions/orbit/galaxy-job-block.ts +347 -0
  49. package/extensions/orbit/galaxy-job-ops.ts +288 -0
  50. package/extensions/orbit/galaxy-live-source.ts +929 -0
  51. package/extensions/orbit/galaxy-markdown-adapter.ts +248 -0
  52. package/extensions/orbit/galaxy-op-policy.ts +332 -0
  53. package/extensions/orbit/galaxy-ops-tools.ts +326 -0
  54. package/extensions/orbit/galaxy-output-store.ts +80 -0
  55. package/extensions/orbit/galaxy-output.ts +346 -0
  56. package/extensions/orbit/galaxy-page-binding.ts +222 -0
  57. package/extensions/orbit/galaxy-page-markdown-guidance.ts +15 -0
  58. package/extensions/orbit/galaxy-page-ops.ts +159 -0
  59. package/extensions/orbit/galaxy-page-sync.test.ts +417 -0
  60. package/extensions/orbit/galaxy-page-sync.ts +318 -0
  61. package/extensions/orbit/galaxy-pages-api.ts +203 -0
  62. package/extensions/orbit/galaxy-pages-sync.ts +409 -0
  63. package/extensions/orbit/galaxy-poll-guard.ts +189 -0
  64. package/extensions/orbit/galaxy-poller.ts +706 -0
  65. package/extensions/orbit/galaxy-provenance.ts +623 -0
  66. package/extensions/orbit/galaxy-reconcile.ts +956 -0
  67. package/extensions/orbit/galaxy-recovery.ts +176 -0
  68. package/extensions/orbit/galaxy-repeat-guard.ts +55 -0
  69. package/extensions/orbit/galaxy-submission-capture.ts +707 -0
  70. package/extensions/orbit/galaxy-submission.ts +502 -0
  71. package/extensions/orbit/galaxy-tool-source.ts +78 -0
  72. package/extensions/orbit/galaxy-tool-wrap.ts +234 -0
  73. package/extensions/orbit/galaxy-udt-block.ts +127 -0
  74. package/extensions/orbit/galaxy-upload-tus.ts +334 -0
  75. package/extensions/orbit/galaxy-upload.ts +300 -0
  76. package/extensions/orbit/galaxy-version-capture.ts +65 -0
  77. package/extensions/orbit/git.ts +173 -0
  78. package/extensions/orbit/harness-block-fields.ts +597 -0
  79. package/extensions/orbit/index.ts +593 -0
  80. package/extensions/orbit/init-gate.ts +284 -0
  81. package/extensions/orbit/instructions-command.ts +136 -0
  82. package/extensions/orbit/invocation-failure-hint.ts +57 -0
  83. package/extensions/orbit/iwc-plan-check.ts +94 -0
  84. package/extensions/orbit/lesson-command.ts +303 -0
  85. package/extensions/orbit/lesson-hint.ts +245 -0
  86. package/extensions/orbit/lesson-nudge.ts +106 -0
  87. package/extensions/orbit/lessons/compose.ts +177 -0
  88. package/extensions/orbit/lessons/counters.ts +120 -0
  89. package/extensions/orbit/lessons/enabled.ts +116 -0
  90. package/extensions/orbit/lessons/matcher.ts +346 -0
  91. package/extensions/orbit/lessons/paths.ts +286 -0
  92. package/extensions/orbit/lessons/pi-event-contract.ts +105 -0
  93. package/extensions/orbit/lessons/propose-replay.ts +103 -0
  94. package/extensions/orbit/lessons/propose-tool.ts +222 -0
  95. package/extensions/orbit/lessons/propose.ts +502 -0
  96. package/extensions/orbit/lessons/replay.ts +89 -0
  97. package/extensions/orbit/lessons/reproduction-index.ts +83 -0
  98. package/extensions/orbit/lessons/search-tool.ts +99 -0
  99. package/extensions/orbit/lessons/search.ts +136 -0
  100. package/extensions/orbit/lessons/store.ts +415 -0
  101. package/extensions/orbit/lessons/text.ts +36 -0
  102. package/extensions/orbit/lessons/types.ts +69 -0
  103. package/extensions/orbit/lessons/user-dir.ts +24 -0
  104. package/extensions/orbit/lessons/user-lesson.ts +88 -0
  105. package/extensions/orbit/lessons/wrapper.ts +61 -0
  106. package/extensions/orbit/lessons-command.ts +147 -0
  107. package/extensions/orbit/local-exec.ts +39 -0
  108. package/extensions/orbit/mcp-output.ts +179 -0
  109. package/extensions/orbit/mcp-servers.test.ts +24 -0
  110. package/extensions/orbit/mcp-servers.ts +25 -0
  111. package/extensions/orbit/model-script.ts +351 -0
  112. package/extensions/orbit/notebook-anchors.ts +342 -0
  113. package/extensions/orbit/notebook-writer.ts +863 -0
  114. package/extensions/orbit/observation-allowlists.ts +1109 -0
  115. package/extensions/orbit/observation-replay.ts +148 -0
  116. package/extensions/orbit/observation-triggers.ts +563 -0
  117. package/extensions/orbit/observation-ui.ts +175 -0
  118. package/extensions/orbit/observations-command.ts +421 -0
  119. package/extensions/orbit/observations-config.ts +200 -0
  120. package/extensions/orbit/observations.ts +1381 -0
  121. package/extensions/orbit/profiles.ts +270 -0
  122. package/extensions/orbit/progress-updates.ts +95 -0
  123. package/extensions/orbit/proposal-commands.ts +882 -0
  124. package/extensions/orbit/provenance-fs-file.ts +59 -0
  125. package/extensions/orbit/provenance-fs.ts +32 -0
  126. package/extensions/orbit/provenance-memory-fs.ts +59 -0
  127. package/extensions/orbit/record-store-file.ts +177 -0
  128. package/extensions/orbit/record-store-page.ts +77 -0
  129. package/extensions/orbit/record-store-seam.ts +47 -0
  130. package/extensions/orbit/record-store.ts +149 -0
  131. package/extensions/orbit/registry-carrier.ts +107 -0
  132. package/extensions/orbit/registry-eligibility.ts +95 -0
  133. package/extensions/orbit/registry-evaluation.ts +403 -0
  134. package/extensions/orbit/registry-evaluator.ts +519 -0
  135. package/extensions/orbit/registry-import.ts +91 -0
  136. package/extensions/orbit/registry-lock.ts +221 -0
  137. package/extensions/orbit/registry-memory-fs.ts +42 -0
  138. package/extensions/orbit/registry-page-carrier.ts +103 -0
  139. package/extensions/orbit/registry-proposal.ts +1348 -0
  140. package/extensions/orbit/registry-runtime.ts +234 -0
  141. package/extensions/orbit/registry-schema.ts +735 -0
  142. package/extensions/orbit/registry-submitter.ts +59 -0
  143. package/extensions/orbit/registry-templates.ts +346 -0
  144. package/extensions/orbit/registry.ts +476 -0
  145. package/extensions/orbit/sandbox/index.ts +120 -0
  146. package/extensions/orbit/sandbox/sandbox-bash.ts +93 -0
  147. package/extensions/orbit/sandbox/sandbox-config.ts +87 -0
  148. package/extensions/orbit/sandbox/sandbox-init-message.ts +28 -0
  149. package/extensions/orbit/secret-redaction.ts +247 -0
  150. package/extensions/orbit/session-index/cwd.ts +18 -0
  151. package/extensions/orbit/session-index/db.ts +56 -0
  152. package/extensions/orbit/session-index/indexer.ts +154 -0
  153. package/extensions/orbit/session-index/is-enabled.ts +17 -0
  154. package/extensions/orbit/session-index/parse.ts +254 -0
  155. package/extensions/orbit/session-index/query.ts +209 -0
  156. package/extensions/orbit/session-index/schema.ts +72 -0
  157. package/extensions/orbit/session-index/tools.ts +152 -0
  158. package/extensions/orbit/session-lifecycle.ts +321 -0
  159. package/extensions/orbit/skill-triggers.ts +159 -0
  160. package/extensions/orbit/skills-command.ts +48 -0
  161. package/extensions/orbit/skills-discovery.ts +435 -0
  162. package/extensions/orbit/skills.ts +84 -0
  163. package/extensions/orbit/sra-import-gate.ts +77 -0
  164. package/extensions/orbit/state.ts +265 -0
  165. package/extensions/orbit/submission-replay.ts +147 -0
  166. package/extensions/orbit/sync-command.ts +157 -0
  167. package/extensions/orbit/teams/critic-parser.ts +93 -0
  168. package/extensions/orbit/teams/dispatcher.ts +210 -0
  169. package/extensions/orbit/teams/is-enabled.ts +22 -0
  170. package/extensions/orbit/teams/tool.ts +187 -0
  171. package/extensions/orbit/teams/types.ts +57 -0
  172. package/extensions/orbit/teams/validate.ts +42 -0
  173. package/extensions/orbit/tester-id-command.ts +133 -0
  174. package/extensions/orbit/tools-sync.ts +279 -0
  175. package/extensions/orbit/tools.ts +1300 -0
  176. package/extensions/orbit/types.ts +41 -0
  177. package/extensions/orbit/ui-bridge.ts +55 -0
  178. package/extensions/orbit/ulid.ts +60 -0
  179. package/extensions/orbit/user-instructions.ts +388 -0
  180. package/extensions/orbit/vendor/skills/_catalog.json +68 -0
  181. package/extensions/orbit/vendor/skills/_manifest.json +1106 -0
  182. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/SKILL.md +112 -0
  183. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_provenance.json +218 -0
  184. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_required_tools.json +33 -0
  185. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_verify.json +4 -0
  186. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/add.json +50 -0
  187. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-extract.json +51 -0
  188. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-next-step.json +42 -0
  189. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-validate.json +140 -0
  190. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/list.json +32 -0
  191. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/galaxy-tool-job-failure-reference.md +137 -0
  192. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/open-requirements-ledger.md +173 -0
  193. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-tool-summary.schema.json +175 -0
  194. package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
  195. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/SKILL.md +82 -0
  196. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_provenance.json +133 -0
  197. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_required_tools.json +16 -0
  198. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_verify.json +4 -0
  199. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/cli/draft-validate.json +140 -0
  200. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/galaxy-workflow-draft-format.md +135 -0
  201. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
  202. package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
  203. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/SKILL.md +84 -0
  204. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_provenance.json +166 -0
  205. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_verify.json +29 -0
  206. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/galaxy-workflow-testability-design.md +136 -0
  207. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
  208. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-test-data-conventions.md +308 -0
  209. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-asserts-idioms.md +246 -0
  210. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-workflow-test-architecture.md +151 -0
  211. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
  212. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/summary-galaxy-workflow.schema.json +687 -0
  213. package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/tests-format.schema.json +7328 -0
  214. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/SKILL.md +127 -0
  215. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_provenance.json +198 -0
  216. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_required_tools.json +16 -0
  217. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_verify.json +4 -0
  218. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/cli/convert.json +123 -0
  219. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/galaxy-data-flow-draft-contract.md +129 -0
  220. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
  221. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-test-data-conventions.md +308 -0
  222. package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/open-requirements-ledger.md +173 -0
  223. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/SKILL.md +73 -0
  224. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_provenance.json +160 -0
  225. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_verify.json +4 -0
  226. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.md +43 -0
  227. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.yml +1250 -0
  228. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-tool-job-failure-reference.md +137 -0
  229. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
  230. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
  231. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/nextflow-operators-to-galaxy-collection-recipes.md +98 -0
  232. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-asserts-idioms.md +246 -0
  233. package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-workflow-test-architecture.md +151 -0
  234. package/extensions/orbit/vendor/skills/discover-shed-tool/SKILL.md +174 -0
  235. package/extensions/orbit/vendor/skills/discover-shed-tool/_provenance.json +130 -0
  236. package/extensions/orbit/vendor/skills/discover-shed-tool/_required_tools.json +18 -0
  237. package/extensions/orbit/vendor/skills/discover-shed-tool/_verify.json +17 -0
  238. package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-revisions.json +48 -0
  239. package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-search.json +95 -0
  240. package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-versions.json +39 -0
  241. package/extensions/orbit/vendor/skills/discover-shed-tool/references/notes/component-tool-shed-search.md +230 -0
  242. package/extensions/orbit/vendor/skills/discover-shed-tool/references/schemas/galaxy-tool-discovery.schema.json +231 -0
  243. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/SKILL.md +93 -0
  244. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_provenance.json +201 -0
  245. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_required_tools.json +28 -0
  246. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_verify.json +17 -0
  247. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/planemo.md +44 -0
  248. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/validate-tests.json +40 -0
  249. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/galaxy-workflow-testability-design.md +136 -0
  250. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
  251. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-test-data-conventions.md +308 -0
  252. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
  253. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
  254. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
  255. package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/tests-format.schema.json +7328 -0
  256. package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/SKILL.md +81 -0
  257. package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_provenance.json +93 -0
  258. package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_verify.json +17 -0
  259. package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
  260. package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/schemas/summary-galaxy-workflow.schema.json +687 -0
  261. package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/SKILL.md +81 -0
  262. package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/_assembly.json +23 -0
  263. package/extensions/orbit/vendor/skills/run-workflow-test/SKILL.md +79 -0
  264. package/extensions/orbit/vendor/skills/run-workflow-test/_provenance.json +125 -0
  265. package/extensions/orbit/vendor/skills/run-workflow-test/_required_tools.json +28 -0
  266. package/extensions/orbit/vendor/skills/run-workflow-test/_verify.json +4 -0
  267. package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/planemo.md +44 -0
  268. package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/validate-tests.json +40 -0
  269. package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
  270. package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
  271. package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
  272. package/extensions/orbit/vendor/skills/run-workflow-test/references/schemas/tests-format.schema.json +7328 -0
  273. package/extensions/orbit/vendor/skills/skills/LICENSE +21 -0
  274. package/extensions/orbit/vendor/skills/skills/collection-manipulation/README.md +39 -0
  275. package/extensions/orbit/vendor/skills/skills/collection-manipulation/SKILL.md +892 -0
  276. package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/api-patterns.md +927 -0
  277. package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/apply-rules.md +1482 -0
  278. package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/test-patterns.md +996 -0
  279. package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/tools.md +609 -0
  280. package/extensions/orbit/vendor/skills/skills/galaxy-data-libraries/SKILL.md +170 -0
  281. package/extensions/orbit/vendor/skills/skills/galaxy-integration/README.md +162 -0
  282. package/extensions/orbit/vendor/skills/skills/galaxy-integration/SKILL.md +99 -0
  283. package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/README.md +119 -0
  284. package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/tool-checking.md +419 -0
  285. package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/workflow-testing.md +617 -0
  286. package/extensions/orbit/vendor/skills/skills/galaxy-integration/galaxy-integration.md +614 -0
  287. package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/SKILL.md +144 -0
  288. package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/average_col3.ipynb +68 -0
  289. package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/extract_sample_metadata.ipynb +190 -0
  290. package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/variant_annotation.ipynb +816 -0
  291. package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/vcp_variant_map.ipynb +443 -0
  292. package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/README.md +165 -0
  293. package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/galaxy_tool_checker.py +521 -0
  294. package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/SKILL.md +128 -0
  295. package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/gotchas.md +135 -0
  296. package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/history-access.md +132 -0
  297. package/extensions/orbit/vendor/skills/skills/reproduciblify/SKILL.md +146 -0
  298. package/extensions/orbit/vendor/skills/skills/reproduciblify/references/directives.yml +496 -0
  299. package/extensions/orbit/vendor/skills/skills/udt-authoring/SKILL.md +245 -0
  300. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/01-minimal-echo.yml +27 -0
  301. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/02-single-data-in-out.yml +27 -0
  302. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/03-multiple-data-js.yml +28 -0
  303. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/04-select-integer-boolean.yml +41 -0
  304. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/05-resource-requirements.yml +25 -0
  305. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/06-biocontainer-tool.yml +38 -0
  306. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/07-inline-python-script.yml +56 -0
  307. package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/08-configfile-script.yml +74 -0
  308. package/extensions/orbit/vendor/skills/skills/udt-authoring/references/common-mistakes.md +76 -0
  309. package/extensions/orbit/vendor/skills/skills/udt-authoring/references/schema-reference.md +155 -0
  310. package/extensions/orbit/vendor/skills/skills/udt-authoring/references/templating.md +196 -0
  311. package/extensions/orbit/vendor/skills/skills/udt-authoring/scripts/validate.py +109 -0
  312. package/extensions/orbit/vendor/skills/skills/workflow-reports/README.md +71 -0
  313. package/extensions/orbit/vendor/skills/skills/workflow-reports/SKILL.md +190 -0
  314. package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/histology-staining.md +145 -0
  315. package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/tissue-microarray-analysis.md +176 -0
  316. package/extensions/orbit/vendor/skills/skills/workflow-reports/references/directives.md +415 -0
  317. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/SKILL.md +98 -0
  318. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_provenance.json +93 -0
  319. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_required_tools.json +28 -0
  320. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_verify.json +17 -0
  321. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/convert.json +123 -0
  322. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/foundry.md +25 -0
  323. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/validate.json +142 -0
  324. package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/schemas/summary-galaxy-workflow.schema.json +687 -0
  325. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/SKILL.md +74 -0
  326. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_provenance.json +71 -0
  327. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_required_tools.json +16 -0
  328. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_verify.json +4 -0
  329. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/cli/validate.json +142 -0
  330. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
  331. package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-testability-design.md +136 -0
  332. package/extensions/orbit/vendor-skills.ts +283 -0
  333. package/extensions/orbit/workspace-state-dir.ts +46 -0
  334. package/extensions/orbit-handoff/index.ts +73 -0
  335. package/extensions/orbit-handoff/orbit-launcher.ts +142 -0
  336. package/extensions/whats-new/index.ts +98 -0
  337. package/lessons/LICENSE +17 -0
  338. package/lessons/README.md +117 -0
  339. package/lessons/SCHEMA.md +187 -0
  340. package/lessons/build-snapshot.mjs +311 -0
  341. package/lessons/data/downloaded-file-is-not-what-its-extension-says.md +61 -0
  342. package/lessons/galaxy-api/403-history-is-a-hard-stop.md +57 -0
  343. package/lessons/galaxy-api/collection-into-single-dataset-input.md +61 -0
  344. package/lessons/galaxy-api/connectedvalue-in-command-line.md +62 -0
  345. package/lessons/galaxy-api/hid-is-not-an-id.md +59 -0
  346. package/lessons/galaxy-api/invoke-workflow-inputs-not-params.md +60 -0
  347. package/lessons/galaxy-api/repeat-param-pipe-keys.md +63 -0
  348. package/lessons/galaxy-api/run-tool-returns-on-submit.md +61 -0
  349. package/lessons/galaxy-tools/reference-index-not-on-server.md +62 -0
  350. package/lessons/log.md +42 -0
  351. package/lessons/reproduction/input-population-mismatch.md +59 -0
  352. package/lessons/reproduction/methods-text-vs-executed-parameters.md +62 -0
  353. package/lessons/snapshot.json +1004 -0
  354. package/lessons/stats/de-contrast-direction-and-sample-labels.md +63 -0
  355. package/lessons/stats/na-coerced-to-zero-in-filters.md +59 -0
  356. package/lessons/validate.mjs +1219 -0
  357. package/package.json +103 -5
  358. package/shared/agent-prompt.d.ts +8 -0
  359. package/shared/agent-prompt.js +15 -0
  360. package/shared/approval-prompt.d.ts +4 -0
  361. package/shared/approval-prompt.js +61 -0
  362. package/shared/brain-env.d.ts +14 -0
  363. package/shared/brain-env.js +108 -0
  364. package/shared/brain-exit.d.ts +20 -0
  365. package/shared/brain-exit.js +149 -0
  366. package/shared/capability-profile.d.ts +16 -0
  367. package/shared/capability-profile.js +75 -0
  368. package/shared/config-source.d.ts +13 -0
  369. package/shared/config-source.js +17 -0
  370. package/shared/confusables.d.ts +3 -0
  371. package/shared/confusables.js +93 -0
  372. package/shared/custom-provider.d.ts +45 -0
  373. package/shared/custom-provider.js +169 -0
  374. package/shared/dashboard-contract.d.ts +123 -0
  375. package/shared/dashboard-contract.js +560 -0
  376. package/shared/dashboard-layout-store.d.ts +44 -0
  377. package/shared/dashboard-layout-store.js +281 -0
  378. package/shared/feedback-contract.d.ts +61 -0
  379. package/shared/feedback-contract.js +146 -0
  380. package/shared/galaxy-artifact-links.d.ts +30 -0
  381. package/shared/galaxy-artifact-links.js +166 -0
  382. package/shared/galaxy-credentials.d.ts +26 -0
  383. package/shared/galaxy-credentials.js +71 -0
  384. package/shared/galaxy-destructive.d.ts +21 -0
  385. package/shared/galaxy-destructive.js +269 -0
  386. package/shared/galaxy-guidance.d.ts +2 -0
  387. package/shared/galaxy-guidance.js +53 -0
  388. package/shared/galaxy-live-contract.d.ts +92 -0
  389. package/shared/galaxy-live-contract.js +180 -0
  390. package/shared/galaxy-page-cells.d.ts +2 -0
  391. package/shared/galaxy-page-cells.js +22 -0
  392. package/shared/galaxy-tool-family.d.ts +5 -0
  393. package/shared/galaxy-tool-family.js +27 -0
  394. package/shared/galaxy-url.d.ts +1 -0
  395. package/shared/galaxy-url.js +48 -0
  396. package/shared/gtn.d.ts +31 -0
  397. package/shared/gtn.js +253 -0
  398. package/shared/handled-prompt.d.ts +3 -0
  399. package/shared/handled-prompt.js +26 -0
  400. package/shared/known-provider.d.ts +11 -0
  401. package/shared/known-provider.js +89 -0
  402. package/shared/lesson-hint-marker.d.ts +7 -0
  403. package/shared/lesson-hint-marker.js +32 -0
  404. package/shared/lesson-rules.d.ts +150 -0
  405. package/shared/lesson-rules.js +203 -0
  406. package/shared/malformed-object-ids.d.ts +1 -0
  407. package/shared/malformed-object-ids.js +37 -0
  408. package/shared/markdown-sections.d.ts +10 -0
  409. package/shared/markdown-sections.js +118 -0
  410. package/shared/notebook-fences.d.ts +12 -0
  411. package/shared/notebook-fences.js +53 -0
  412. package/shared/observation-contract.d.ts +91 -0
  413. package/shared/observation-contract.js +544 -0
  414. package/shared/orbit-config.d.ts +226 -0
  415. package/shared/orbit-config.js +189 -0
  416. package/shared/orbit-env.d.ts +13 -0
  417. package/shared/orbit-env.js +124 -0
  418. package/shared/orbit-shell-contract.d.ts +74 -0
  419. package/shared/orbit-shell-contract.js +30 -0
  420. package/shared/page-content-hash.d.ts +1 -0
  421. package/shared/page-content-hash.js +13 -0
  422. package/shared/pi-default-model.d.ts +7 -0
  423. package/shared/pi-default-model.js +41 -0
  424. package/shared/prompt-blocks.d.ts +87 -0
  425. package/shared/prompt-blocks.js +898 -0
  426. package/shared/provider-auth-caps.d.ts +22 -0
  427. package/shared/provider-auth-caps.js +57 -0
  428. package/shared/random.d.ts +2 -0
  429. package/shared/random.js +15 -0
  430. package/shared/record-excerpt.d.ts +4 -0
  431. package/shared/record-excerpt.js +16 -0
  432. package/shared/redirect-guard.d.ts +36 -0
  433. package/shared/redirect-guard.js +235 -0
  434. package/shared/skills-pin.d.ts +8 -0
  435. package/shared/skills-pin.js +9 -0
  436. package/shared/skills-router.d.ts +17 -0
  437. package/shared/skills-router.js +63 -0
  438. package/shared/sra-import.d.ts +34 -0
  439. package/shared/sra-import.js +206 -0
  440. package/shared/state-dir.d.ts +21 -0
  441. package/shared/state-dir.js +240 -0
  442. package/shared/team-dispatch-contract.d.ts +28 -0
  443. package/shared/team-dispatch-contract.js +1 -0
  444. package/shared/truncate-middle.d.ts +11 -0
  445. package/shared/truncate-middle.js +68 -0
  446. package/shared/version-compare.d.ts +10 -0
  447. package/shared/version-compare.js +74 -0
  448. package/shared/whats-new.d.ts +31 -0
  449. package/shared/whats-new.js +83 -0
  450. package/shared/wsl.d.ts +10 -0
  451. package/shared/wsl.js +24 -0
@@ -0,0 +1,1408 @@
1
+ import * as path from "path";
2
+ import { WORKSPACE_STATE_DIR_NAMES } from "../workspace-state-dir";
3
+ import { isCredentialStore, isOrbitStatePath } from "./sensitive-read";
4
+
5
+ export interface BashClass {
6
+ kind: "safe" | "catastrophic" | "unknown";
7
+ reason: string;
8
+ /** Path-like args to read-style commands, for the policy layer to run through
9
+ * sensitive-read + jail. Best-effort; empty when not confidently parseable. */
10
+ readPaths: string[];
11
+ /** Content-read targets surfaced from EVERY shell segment, so the sensitive-read
12
+ * floor still fires when a pipe/compound forces kind="unknown" (closes the
13
+ * `cat secret | tool` evasion in #183). Unlike readPaths, this is computed even
14
+ * for compound commands; the policy layer applies only the sensitive floor to
15
+ * it (never the workspace-jail floor, so compound jail semantics are unchanged). */
16
+ sensitiveReadPaths: string[];
17
+ /** `.loom/`/`.orbit/` write targets this classifier judged to be ordinary work
18
+ * product in an Orbit analysis workspace. It only sees the command string, so the policy
19
+ * layer realpaths each one and re-applies isOrbitStatePath -- a symlink under
20
+ * the analyses tree pointing at Orbit's own state is still Orbit's own state.
21
+ * Empty unless a write verb aimed at a state dir was carved out. */
22
+ stateDirWriteTargets: string[];
23
+ /** A command runs after a `cd`/`pushd` into Orbit's own state or a credential
24
+ * store. The classifier cannot tell what an unrecognized command does there,
25
+ * so the policy layer must not auto-allow it. */
26
+ guardedCwd: boolean;
27
+ /** Read targets resolved against a directory a `cd` reached. Kept apart from
28
+ * sensitiveReadPaths so a command with no cd is judged exactly as before. */
29
+ cdReadPaths: string[];
30
+ /** The line names the analysis record or the harness's registry/provenance.
31
+ * An interpreter (`python3 -c`, `node -e`, a script) can write those in ways
32
+ * this classifier doesn't model, so such a line is never auto-allowed. */
33
+ namesRecord: boolean;
34
+ }
35
+
36
+ // Never-legitimate, irreversible-system-damage patterns. Order matters; first match wins.
37
+ // `sudo` allows an absolute/relative path prefix (/usr/bin/sudo), and the
38
+ // pipe-to-interpreter rule covers path-prefixed and env-wrapped interpreters
39
+ // beyond bare POSIX shells (python/perl/node/...).
40
+ const CATASTROPHIC: Array<[RegExp, string]> = [
41
+ [/(^|[\s;&|])(\S*\/)?sudo\b/, "privilege escalation (sudo)"],
42
+ [/:\s*\(\s*\)\s*\{.*:\|:.*\}/, "fork bomb"],
43
+ [/\bdd\b[^\n]*\bof=\/dev\//, "dd to a device"],
44
+ [/\bmkfs(\.[a-z0-9]+)?\b/, "filesystem format"],
45
+ [
46
+ /(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(env\s+)?(\S*\/)?(sh|bash|zsh|dash|ksh|fish|python[0-9.]*|perl|ruby|node|php)\b/,
47
+ "pipe remote content to an interpreter",
48
+ ],
49
+ [/\bchmod\s+-R\s+777\s+\//, "world-writable recursive chmod on /"],
50
+ [/>\s*\/dev\/(sd|nvme|disk)/, "redirect to a raw device"],
51
+ // Self-disabling: editing the gate's own config is how an agent would try to
52
+ // flip bypass on. The bypass key with an assignment is one signal; a write verb
53
+ // aimed at Orbit's own state is the other, and it needs a per-target decision, so
54
+ // it lives in isCatastrophicLoomWrite below rather than in this table. Reads of
55
+ // the config stay an `ask` via the sensitive-read floor (not caught here).
56
+ [/dangerouslyBypassPermissions['"\]\s]*[:=]/, "attempt to enable the permissions bypass"],
57
+ ];
58
+
59
+ // Every state-dir spelling, whichever one the workspace uses, as a regex
60
+ // alternation (`loom|orbit`).
61
+ const STATE_DIR_ALT = WORKSPACE_STATE_DIR_NAMES.map((n) => n.slice(1)).join("|");
62
+
63
+ // A write verb aimed at something under a `.loom/` or `.orbit/` directory. Only
64
+ // the trigger: whether it is really Orbit state is decided per target below,
65
+ // because Orbit's own workspaces live under $HOME/.loom/analyses/<name>/.
66
+ const STATE_DIR_WRITE = new RegExp(
67
+ String.raw`(?:>>?|\btee\b|\bsed\b[^\n]*-i|\bcp\b|\bmv\b|\bdd\b)[^\n]*\.(?:${STATE_DIR_ALT})\/`,
68
+ "i",
69
+ );
70
+
71
+ // Command wrappers that delegate to a real command. We strip them so a
72
+ // catastrophic command can't hide behind `env`, `conda run`, `nice`, etc.
73
+ const WRAPPER_CMDS = new Set([
74
+ "nohup",
75
+ "setsid",
76
+ "time",
77
+ "nice",
78
+ "ionice",
79
+ "stdbuf",
80
+ "timeout",
81
+ "caffeinate",
82
+ "command",
83
+ "exec",
84
+ "builtin",
85
+ ]);
86
+
87
+ function stripQuotes(s: string): string {
88
+ return s.replace(/^['"]+|['"]+$/g, "");
89
+ }
90
+
91
+ // Peel leading wrapper commands (env VAR=val, conda run -p PATH, nice -n N, ...)
92
+ // off a token list so the real verb is exposed to the catastrophic check.
93
+ function unwrap(tokens: string[]): string[] {
94
+ let t = tokens;
95
+ for (;;) {
96
+ if (t.length === 0) return t;
97
+ const head = t[0];
98
+ if (head === "env") {
99
+ t = t.slice(1);
100
+ while (t.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0])) t = t.slice(1);
101
+ continue;
102
+ }
103
+ if (head === "conda" && t[1] === "run") {
104
+ t = t.slice(2);
105
+ while (t.length && t[0].startsWith("-")) {
106
+ const takesArg = ["-p", "--prefix", "-n", "--name"].includes(t[0]);
107
+ t = t.slice(takesArg ? 2 : 1);
108
+ }
109
+ continue;
110
+ }
111
+ if (WRAPPER_CMDS.has(head)) {
112
+ t = t.slice(1);
113
+ while (t.length && t[0].startsWith("-")) t = t.slice(1);
114
+ if (t.length && /^\d+[a-z]?$/i.test(t[0])) t = t.slice(1); // `timeout 5`, `nice 10`
115
+ continue;
116
+ }
117
+ return t;
118
+ }
119
+ }
120
+
121
+ // Unquoted characters that end a shell word. Quotes deliberately do NOT: bash
122
+ // concatenates adjacent quoted and unquoted fragments into one word, so reading
123
+ // only as far as a quote would hand back `$HOME/.loom/analyses/` for
124
+ // `"$HOME/.loom/analyses/"../config.json` -- the carved-out prefix of a target
125
+ // that walks straight back out of the tree. `=` is not a boundary either; it is
126
+ // an ordinary character in a pathname. Only space, tab and newline split a word:
127
+ // JS `\s` also matches U+00A0 and a carriage return, both of which bash keeps
128
+ // inside the word.
129
+ const WORD_BREAK = /[ \t\n;&|<>()]/;
130
+
131
+ /** A run of characters that shared one quoting context, in word order. Quoting
132
+ * has to survive parsing: bash decides each expansion from how the fragment
133
+ * that carries it was quoted, so `"$"HOME/x` is a literal `$HOME/x` and
134
+ * `~"/x"` keeps its tilde. Reading the concatenated text back would invent
135
+ * expansions the shell never performs. An empty fragment is kept -- `''~/x` is
136
+ * a word that does not begin with a tilde, so bash leaves the tilde alone. */
137
+ interface WordFragment {
138
+ text: string;
139
+ quote: "'" | '"' | null;
140
+ }
141
+
142
+ // Backslash escapes bash honours inside double quotes; elsewhere it escapes
143
+ // whatever follows.
144
+ const DQ_ESCAPABLE = '$`"\\\n';
145
+
146
+ /** A shell word, or a run of unquoted operator characters (`&&`, `>>`, `2>&`
147
+ * arrives as a word `2` then `>&`). Parentheses and newlines are always an
148
+ * operator of their own. */
149
+ type ShellToken = { word: WordFragment[] } | { op: string } | { heredoc: string; quoted: boolean };
150
+
151
+ // Index of the `)` closing the `$(` whose `(` sits at `open`; the end of the
152
+ // string when it never closes. The body is shell code in its own right, so a
153
+ // `)` inside a comment, a quoted string, or a nested substitution inside a
154
+ // quoted string does not close it.
155
+ function substitutionEnd(s: string, open: number): number {
156
+ let depth = 0;
157
+ let wordStart = true;
158
+ for (let j = open; j < s.length; j++) {
159
+ const c = s[j];
160
+ if (c === "\\") {
161
+ j++;
162
+ wordStart = false;
163
+ } else if (c === "'") {
164
+ const k = s.indexOf("'", j + 1);
165
+ j = k === -1 ? s.length : k;
166
+ wordStart = false;
167
+ } else if (c === '"') {
168
+ j = doubleQuoteEnd(s, j);
169
+ wordStart = false;
170
+ } else if (c === "`") {
171
+ j = backtickEnd(s, j);
172
+ wordStart = false;
173
+ } else if (c === "$" && s[j + 1] === "(") {
174
+ j = substitutionEnd(s, j + 1);
175
+ wordStart = false;
176
+ } else if (c === "#" && wordStart) {
177
+ const nl = s.indexOf("\n", j);
178
+ if (nl === -1) return s.length - 1;
179
+ j = nl - 1;
180
+ } else if (c === "(") {
181
+ depth++;
182
+ wordStart = true;
183
+ } else if (c === ")") {
184
+ if (--depth === 0) return j;
185
+ wordStart = true;
186
+ } else {
187
+ wordStart = /[ \t\n;&|<>]/.test(c);
188
+ }
189
+ }
190
+ return s.length - 1;
191
+ }
192
+
193
+ function doubleQuoteEnd(s: string, open: number): number {
194
+ for (let j = open + 1; j < s.length; j++) {
195
+ const c = s[j];
196
+ if (c === "\\") j++;
197
+ else if (c === "$" && s[j + 1] === "(") j = substitutionEnd(s, j + 1);
198
+ else if (c === "`") j = backtickEnd(s, j);
199
+ else if (c === '"') return j;
200
+ }
201
+ return s.length - 1;
202
+ }
203
+
204
+ function backtickEnd(s: string, open: number): number {
205
+ for (let j = open + 1; j < s.length; j++) {
206
+ if (s[j] === "\\") j++;
207
+ else if (s[j] === "`") return j;
208
+ }
209
+ return s.length - 1;
210
+ }
211
+
212
+ // Split a command into shell words and operators, tracking the quoting of each
213
+ // fragment. Models bash's word splitting, escaping and comments, not its
214
+ // expansions -- anything that would need expanding is rejected by the resolvers
215
+ // below.
216
+ //
217
+ // `structure` is for the cd tracker, which needs to know what actually runs:
218
+ // an unquoted `$(...)` or backtick span stays inside its word instead of being
219
+ // split on its parentheses, and a heredoc body comes back as one `heredoc`
220
+ // token rather than as lines of commands. The path scan keeps the flat view,
221
+ // where a heredoc body's words are still examined.
222
+ function shellTokens(command: string, structure = false): ShellToken[] {
223
+ const tokens: ShellToken[] = [];
224
+ let word: WordFragment[] = [];
225
+ let opEnd = -2;
226
+ const heredocs: { delim: string; strip: boolean; quoted: boolean }[] = [];
227
+ let delimStrip: boolean | null = null;
228
+ let text = "";
229
+ let quote: "'" | '"' | null = null;
230
+ let started = false;
231
+ const endFragment = (keepEmpty: boolean) => {
232
+ if (text || keepEmpty) word.push({ text, quote });
233
+ text = "";
234
+ };
235
+ const endWord = () => {
236
+ endFragment(false);
237
+ if (word.length) {
238
+ if (delimStrip !== null) {
239
+ // A quoted delimiter makes the body literal; otherwise bash expands
240
+ // `$(...)` and backticks in it.
241
+ const quoted = word.some((f) => f.quote !== null);
242
+ heredocs.push({ delim: wordText(word), strip: delimStrip, quoted });
243
+ }
244
+ delimStrip = null;
245
+ tokens.push({ word });
246
+ }
247
+ word = [];
248
+ started = false;
249
+ };
250
+ // An escaped character is literal, exactly like a single-quoted one -- and it
251
+ // has to be recorded that way, or `\~/x` and `\$HOME/x` would be read back as
252
+ // expansions the shell already refused to perform.
253
+ const pushEscaped = (chr: string) => {
254
+ endFragment(false);
255
+ word.push({ text: chr, quote: "'" });
256
+ started = true;
257
+ };
258
+ for (let i = 0; i < command.length; i++) {
259
+ const ch = command[i];
260
+ if (quote === "'") {
261
+ if (ch === "'") {
262
+ endFragment(true);
263
+ quote = null;
264
+ } else {
265
+ text += ch;
266
+ }
267
+ continue;
268
+ }
269
+ if (quote === '"') {
270
+ if (ch === '"') {
271
+ endFragment(true);
272
+ quote = null;
273
+ } else if (ch === "\\" && i + 1 < command.length && DQ_ESCAPABLE.includes(command[i + 1])) {
274
+ i++;
275
+ if (command[i] !== "\n") pushEscaped(command[i]);
276
+ } else if (structure && ((ch === "$" && command[i + 1] === "(") || ch === "`")) {
277
+ // Quotes inside a substitution start afresh; the next `"` does not
278
+ // close the string around it.
279
+ const end = ch === "`" ? backtickEnd(command, i) : substitutionEnd(command, i + 1);
280
+ text += command.slice(i, end + 1);
281
+ i = end;
282
+ } else {
283
+ text += ch;
284
+ }
285
+ continue;
286
+ }
287
+ if (ch === "\\") {
288
+ if (i + 1 >= command.length) {
289
+ text += ch;
290
+ started = true;
291
+ continue;
292
+ }
293
+ i++;
294
+ // A line continuation contributes nothing -- not even the fact that a word
295
+ // began, or the `#` on the joined line would stop being a comment.
296
+ if (command[i] !== "\n") pushEscaped(command[i]);
297
+ continue;
298
+ }
299
+ if (structure && ((ch === "$" && command[i + 1] === "(") || ch === "`")) {
300
+ const end = ch === "`" ? backtickEnd(command, i) : substitutionEnd(command, i + 1);
301
+ text += command.slice(i, end + 1);
302
+ started = true;
303
+ i = end;
304
+ continue;
305
+ }
306
+ // Arithmetic -- `$[...]` and a `((...))` command -- is one opaque word, so
307
+ // the shift in `1<<2` is not taken for a heredoc.
308
+ if (structure && ch === "$" && command[i + 1] === "[") {
309
+ const close = command.indexOf("]", i);
310
+ const end = close === -1 ? command.length - 1 : close;
311
+ text += command.slice(i, end + 1);
312
+ started = true;
313
+ i = end;
314
+ continue;
315
+ }
316
+ if (structure && ch === "(" && command[i + 1] === "(" && !started) {
317
+ endWord();
318
+ const end = substitutionEnd(command, i);
319
+ // Double-quoted, not single: the expression is not split, but a `$(...)`
320
+ // inside it still runs.
321
+ tokens.push({ word: [{ text: command.slice(i, end + 1), quote: '"' }] });
322
+ i = end;
323
+ continue;
324
+ }
325
+ if (ch === "'" || ch === '"') {
326
+ endFragment(false);
327
+ quote = ch;
328
+ started = true;
329
+ continue;
330
+ }
331
+ // `#` starts a comment only where a word has not started; inside one it is
332
+ // an ordinary character, and treating it as a comment there would discard
333
+ // the rest of the line -- including whatever runs after the next `;`.
334
+ if (ch === "#" && !started) {
335
+ const nl = command.indexOf("\n", i);
336
+ if (nl === -1) break;
337
+ // Stop short of the newline: it still separates the next command.
338
+ i = nl - 1;
339
+ continue;
340
+ }
341
+ if (WORD_BREAK.test(ch)) {
342
+ endWord();
343
+ if (ch !== " " && ch !== "\t") {
344
+ const prev = tokens[tokens.length - 1];
345
+ const joinable = /[;&|<>]/.test(ch) && opEnd === i - 1;
346
+ let op: string;
347
+ if (joinable && prev && "op" in prev && /^[;&|<>]+$/.test(prev.op)) op = prev.op += ch;
348
+ else tokens.push({ op: (op = ch) });
349
+ opEnd = i;
350
+ if (structure && op === "<<") {
351
+ delimStrip = command[i + 1] === "-";
352
+ if (delimStrip) i++;
353
+ } else if (structure && op === "<<<") {
354
+ delimStrip = null;
355
+ }
356
+ if (structure && ch === "\n" && heredocs.length) i = readHeredocs(i + 1) - 1;
357
+ }
358
+ continue;
359
+ }
360
+ started = true;
361
+ text += ch;
362
+ }
363
+ endWord();
364
+ return tokens;
365
+
366
+ // Consume the bodies queued on the line that just ended; returns the index
367
+ // of the newline that ends the last delimiter line.
368
+ function readHeredocs(pos: number): number {
369
+ for (const h of heredocs) {
370
+ let body = "";
371
+ while (pos < command.length) {
372
+ const nl = command.indexOf("\n", pos) === -1 ? command.length : command.indexOf("\n", pos);
373
+ const line = command.slice(pos, nl);
374
+ if ((h.strip ? line.replace(/^\t+/, "") : line) === h.delim) {
375
+ pos = nl;
376
+ break;
377
+ }
378
+ body += line + "\n";
379
+ pos = nl + 1;
380
+ }
381
+ tokens.push({ heredoc: body, quoted: h.quoted });
382
+ if (pos < command.length && command[pos] === "\n" && h !== heredocs[heredocs.length - 1])
383
+ pos++;
384
+ }
385
+ heredocs.length = 0;
386
+ return pos;
387
+ }
388
+ }
389
+
390
+ function shellWords(command: string): WordFragment[][] {
391
+ const words: WordFragment[][] = [];
392
+ for (const t of shellTokens(command)) if ("word" in t) words.push(t.word);
393
+ return words;
394
+ }
395
+
396
+ function wordText(fragments: WordFragment[]): string {
397
+ return fragments.map((f) => f.text).join("");
398
+ }
399
+
400
+ // `.loom`/`.orbit` is matched case-insensitively because the carve-out below folds case
401
+ // too (macOS resolves ~/.LOOM and ~/.loom to the same directory), and against a
402
+ // backslash-stripped copy so a quoted `.\loom/` -- where the backslash survives
403
+ // as a literal -- is still examined rather than skipped.
404
+ const STATE_DIR_SEGMENT = new RegExp(String.raw`\.(?:${STATE_DIR_ALT})\/`, "i");
405
+ function mentionsStateDir(word: WordFragment[]): boolean {
406
+ return STATE_DIR_SEGMENT.test(wordText(word).replace(/\\/g, ""));
407
+ }
408
+
409
+ // Resolve a `.loom/` word to the absolute path the shell would act on, or null
410
+ // when we cannot say -- and null keeps the line denied. Conservative by
411
+ // construction: a leading `~/` expands only when the word begins with it
412
+ // unquoted, and `$HOME`/`${HOME}` only when the fragment carrying the variable
413
+ // is not single-quoted. Anything unresolvable afterwards -- a relative path
414
+ // (classifyBash has no cwd), another user's home, an unexpanded variable, a glob
415
+ // or bracket expression, a brace expansion, a surviving backslash, a command
416
+ // substitution, or any `..` segment (the resolver collapses those lexically
417
+ // before it realpaths, so a `..` after a symlink would never be inspected) --
418
+ // comes back null. What survives is an absolute path the caller can compare
419
+ // against the analyses tree the write tool already allows (isProtectedWritePath).
420
+ function resolveStateDirWord(word: WordFragment[], home: string): string | null {
421
+ if (!home) return null;
422
+ const fragments = word.filter((f) => f.text.length > 0);
423
+ if (fragments.length === 0) return null;
424
+ let text = wordText(fragments);
425
+ const head = fragments[0];
426
+ if (word[0].quote === null && word[0].text.startsWith("~/")) {
427
+ text = home + text.slice(1);
428
+ } else if (head.quote !== "'") {
429
+ for (const v of ["$HOME", "${HOME}"]) {
430
+ // The variable has to sit whole in the leading fragment; `$HO"ME"` and
431
+ // `"$"HOME` are two fragments and bash expands neither. The slash may
432
+ // arrive in the next one, as in `"$HOME"/x`.
433
+ if (head.text === v || head.text.startsWith(v + "/")) {
434
+ const rest = text.slice(v.length);
435
+ if (rest.startsWith("/")) {
436
+ text = home + rest;
437
+ break;
438
+ }
439
+ }
440
+ }
441
+ }
442
+ if (text.startsWith("~") || /[*?$`\\{}[\]]/.test(text)) return null;
443
+ if (!path.isAbsolute(text)) return null;
444
+ if (text.split("/").includes("..")) return null;
445
+ return path.normalize(text);
446
+ }
447
+
448
+ // Editing Orbit's own state from the shell is how an agent would disable the gate
449
+ // (the write TOOL into .loom is gated by isProtectedWritePath). The rule used to
450
+ // be a single regex, which also caught every ordinary write into an Orbit
451
+ // analysis workspace -- Orbit's DEFAULT_CWD is ~/.orbit/analyses -- and denied it
452
+ // outright while the same write through the file tool was allowed. Now a matched
453
+ // line is catastrophic only if some `.loom/` target on it is really Orbit state;
454
+ // the rest are handed to the policy layer, which can realpath them.
455
+ function scanStateDirWrite(
456
+ command: string,
457
+ home: string,
458
+ ): { catastrophic: boolean; targets: string[] } {
459
+ // Backslashes are stripped for the trigger too: bash drops them, so
460
+ // `.lo\om/` names the same directory and must not skip the per-word check.
461
+ if (!STATE_DIR_WRITE.test(command) && !STATE_DIR_WRITE.test(command.replace(/\\/g, ""))) {
462
+ return { catastrophic: false, targets: [] };
463
+ }
464
+ const words = shellWords(command).filter(mentionsStateDir);
465
+ if (words.length === 0) return { catastrophic: true, targets: [] };
466
+ const targets: string[] = [];
467
+ for (const w of words) {
468
+ const resolved = resolveStateDirWord(w, home);
469
+ if (resolved === null || isOrbitStatePath(resolved, home)) {
470
+ return { catastrophic: true, targets: [] };
471
+ }
472
+ targets.push(resolved);
473
+ }
474
+ return { catastrophic: false, targets };
475
+ }
476
+
477
+ // The analysis record: the notebook the evidence gate adjudicates and the
478
+ // activity log every gate writes to. The file tools reach both through hooks
479
+ // that see the write; a shell redirect or `sed -i` reaches them through
480
+ // nothing, so on the desktop the shell may not write them at all. Matched by
481
+ // basename, case-folded (macOS), wherever the path points: refusing a write to
482
+ // some other analysis's notebook costs nothing an honest command needs.
483
+ const RECORD_FILES = new Set(["notebook.md", "activity.jsonl"]);
484
+ const RECORD_MENTION = /notebook\.md|activity\.jsonl/i;
485
+ // A target whose text the shell would still expand -- a variable, a glob, a
486
+ // brace, a substitution. Unresolvable, so it is presumed to be the record when
487
+ // the line names one anywhere (`NB=notebook.md; echo x >> $NB`).
488
+ const EXPANDS = /[$`*?[{]/;
489
+ // The record by name, including the harness's own record stores.
490
+ const NAMES_RECORD = new RegExp(
491
+ String.raw`notebook\.md|activity\.jsonl|\.(?:${STATE_DIR_ALT})\/+(?:state|provenance)(?![\w.-])`,
492
+ "i",
493
+ );
494
+ const SEGMENT_OPS = new Set([";", "&&", "||", "|", "&", "(", ")", "\n", "|&", ";;"]);
495
+ // Every argument is a write target (the source of a `mv` is removed).
496
+ const ALL_ARGS_WRITE = new Set(["tee", "mv", "rm", "unlink", "truncate", "shred"]);
497
+ // Only the last argument is (`cp notebook.md backup.md` reads the notebook).
498
+ const LAST_ARG_WRITE = new Set(["cp", "install", "ln", "rsync"]);
499
+
500
+ /** A plain glob (`notebook.m*`, `*.jsonl`) whose last segment could name the record. */
501
+ function globCouldMatchRecord(text: string): boolean {
502
+ const base = text.replace(/\\/g, "").split("/").pop() ?? "";
503
+ if (!/[*?[]/.test(base) || /[$`{]/.test(base)) return false;
504
+ const re = new RegExp(
505
+ "^" +
506
+ base
507
+ .replace(/[.+^()|\\]/g, "\\$&")
508
+ .replace(/\*/g, ".*")
509
+ .replace(/\?/g, ".") +
510
+ "$",
511
+ "i",
512
+ );
513
+ try {
514
+ return [...RECORD_FILES].some((name) => re.test(name));
515
+ } catch {
516
+ return true;
517
+ }
518
+ }
519
+
520
+ function isRecordTarget(text: string): boolean {
521
+ const clean = text.replace(/\\/g, "");
522
+ const base = clean.split("/").pop()?.toLowerCase() ?? "";
523
+ return RECORD_FILES.has(base);
524
+ }
525
+
526
+ /**
527
+ * Write targets on this line that are, or may be, the analysis record. A
528
+ * model of the common shapes -- redirections, `tee`, in-place `sed`/`perl`,
529
+ * `cp`/`mv`/`rm`/`truncate`, `dd of=` -- not of every program that can open a
530
+ * file: a script it runs is out of reach, as it is for every check here.
531
+ */
532
+ export function scanRecordWrite(command: string): { targets: string[]; uncertain: boolean } {
533
+ const mentioned = RECORD_MENTION.test(command.replace(/\\/g, ""));
534
+ const targets: string[] = [];
535
+ let uncertain = false;
536
+ const consider = (text: string) => {
537
+ if (isRecordTarget(text)) targets.push(text);
538
+ else if (globCouldMatchRecord(text)) uncertain = true;
539
+ else if (mentioned && EXPANDS.test(text)) uncertain = true;
540
+ };
541
+ let segment: string[] = [];
542
+ const flush = () => {
543
+ const words = unwrap(segment.filter((w, i) => i > 0 || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)));
544
+ segment = [];
545
+ if (words.length === 0) return;
546
+ const verb = words[0].split("/").pop() ?? "";
547
+ const args = words.slice(1).filter((w) => !w.startsWith("-"));
548
+ if (ALL_ARGS_WRITE.has(verb)) args.forEach(consider);
549
+ else if (LAST_ARG_WRITE.has(verb) && args.length > 0) consider(args[args.length - 1]);
550
+ else if (
551
+ (verb === "sed" || verb === "perl") &&
552
+ words.some((w) => /^-[^-]*i|^--in-place/.test(w))
553
+ ) {
554
+ args.forEach(consider);
555
+ } else if (verb === "dd") {
556
+ for (const w of words) if (w.startsWith("of=")) consider(w.slice(3));
557
+ } else if (verb === "git" && (args[0] === "checkout" || args[0] === "restore")) {
558
+ // Puts an older notebook back over the one the gate has been judging.
559
+ args.slice(1).forEach(consider);
560
+ }
561
+ };
562
+ const tokens = shellTokens(command);
563
+ for (let i = 0; i < tokens.length; i++) {
564
+ const t = tokens[i];
565
+ if ("op" in t) {
566
+ // `>`, `>>`, `>|`, `&>`, `<>`: the next word is opened for writing. `2>&1`
567
+ // duplicates a descriptor and names no file.
568
+ if (t.op.includes(">") && !/>&$/.test(t.op)) {
569
+ const next = tokens[i + 1];
570
+ if (next && "word" in next) {
571
+ consider(wordText(next.word));
572
+ i++;
573
+ }
574
+ continue;
575
+ }
576
+ if (SEGMENT_OPS.has(t.op) || /^[;&|]+$/.test(t.op)) flush();
577
+ continue;
578
+ }
579
+ if ("word" in t) segment.push(wordText(t.word));
580
+ }
581
+ flush();
582
+ return { targets, uncertain };
583
+ }
584
+
585
+ // The checks above judge each path as written, so `cd ~/.loom && echo x > a`
586
+ // carried no `.loom/` after the write verb and sailed through. What follows
587
+ // tracks the working directory across one command line -- `cd`, `pushd`,
588
+ // `popd`, `cd -`, subshells, command substitutions, heredocs fed to a shell,
589
+ // `sh -c` wherever it appears in the argv, and `eval` -- and resolves relative
590
+ // operands against it, so a hop into Orbit's own state or a credential store gets
591
+ // the verdict the direct path would.
592
+ //
593
+ // It is a model of the shell, not the shell. Loops, functions, aliases,
594
+ // variables it cannot expand and `env -C`/`git -C` are out of reach. Where that
595
+ // matters it errs toward asking: a cd target it cannot resolve keeps the
596
+ // protection of the directory it left, a cd may always have failed, and any
597
+ // `cd`/`pushd` whose target visibly names Orbit state (even inside a python -c
598
+ // string) is never auto-allowed.
599
+
600
+ // Everything below models bash, so its paths are POSIX strings whatever the
601
+ // host. On a Windows host Node's own path module would inject a drive and
602
+ // backslashes into some results and not others, and the membership checks would
603
+ // compare strings that never match.
604
+ const P = path.posix;
605
+
606
+ // A Windows host hands over `C:\Users\me`; carry the drive as a leading
607
+ // segment (`/C:/Users/me`) so posix joins treat it as absolute.
608
+ function toShellPath(p: string): string {
609
+ if (!p) return p;
610
+ const f = p.replace(/\\/g, "/");
611
+ return /^[A-Za-z]:(\/|$)/.test(f) ? "/" + f : f;
612
+ }
613
+
614
+ // ...and back again for the host-path checks in sensitive-read, which already
615
+ // accept forward slashes on every platform.
616
+ function toHostPath(p: string): string {
617
+ return p.replace(/^\/([A-Za-z]:)(?=\/|$)/, "$1");
618
+ }
619
+
620
+ interface CwdState {
621
+ /** Absolute directory, or null once a `cd` target could not be resolved. */
622
+ dir: string | null;
623
+ /** Writes here land in Orbit's own state. */
624
+ state: boolean;
625
+ /** Under a state-dir segment at all, the analyses carve-out included: a target
626
+ * we cannot resolve from here may well land in real state. */
627
+ nearState: boolean;
628
+ /** Orbit state or a credential store: no unrecognized command runs here
629
+ * silently. */
630
+ guarded: boolean;
631
+ }
632
+
633
+ /** Every directory the shell might be standing in. More than one when a cd
634
+ * could have failed and the next command runs anyway. */
635
+ type Where = CwdState[];
636
+
637
+ interface CdScan {
638
+ catastrophic: boolean;
639
+ readPaths: string[];
640
+ writeTargets: string[];
641
+ guarded: boolean;
642
+ }
643
+
644
+ const STATE_DIR_SET = new Set<string>(WORKSPACE_STATE_DIR_NAMES);
645
+ function hasStateSegment(p: string): boolean {
646
+ return p.split("/").some((s) => STATE_DIR_SET.has(s.toLowerCase()));
647
+ }
648
+
649
+ // An unexpandable `cd` target that still visibly points at Orbit state: a
650
+ // state-dir name, a LOOM_/ORBIT_ variable, a glob or brace inside a dot-dir
651
+ // segment (`~/.l*`), or $OLDPWD from a shell we never saw.
652
+ const SUSPECT_CD = new RegExp(
653
+ String.raw`\.(?:${STATE_DIR_ALT})(?![\w.-])|\$\{?(?:LOOM|ORBIT)_|\$\{?OLDPWD\b|(?:^|\/)\.[^/]*[*?[{]`,
654
+ "i",
655
+ );
656
+
657
+ // A cd inside text the walker hands to something it cannot model (python -c,
658
+ // awk, a heredoc fed to perl), for the backstop in runSimple.
659
+ const RAW_CD = /(?:^|[^\w-])(?:cd|pushd)\s+("[^"]*"|'[^']*'|[^\s;&|()`'"]+)/g;
660
+
661
+ // Verbs whose arguments are data, not code: a cd quoted in a commit message or
662
+ // a grep pattern runs nowhere.
663
+ const INERT_VERBS = new Set([
664
+ "echo",
665
+ "printf",
666
+ "git",
667
+ "grep",
668
+ "rg",
669
+ "cat",
670
+ "head",
671
+ "tail",
672
+ "less",
673
+ "more",
674
+ "ls",
675
+ "wc",
676
+ "tee",
677
+ "touch",
678
+ "mkdir",
679
+ ]);
680
+
681
+ // Somewhere on the line a protected directory is named outright. A cd target
682
+ // we cannot expand (`D=~/.loom; cd $D`, `cd "$1"` under find -exec) is then
683
+ // presumed to be it. Orbit's analyses tree is work product, so it is cut out
684
+ // before the test.
685
+ const NAMES_STATE = new RegExp(String.raw`\.(?:${STATE_DIR_ALT})(?![\w.-])`, "i");
686
+ const NAMES_CREDENTIALS =
687
+ /\.(?:ssh|aws|gnupg|kube|docker|netrc|pgpass|npmrc)(?![\w.-])|\.config\/gcloud|Library\/Keychains/i;
688
+ const ANALYSES_TREE = new RegExp(
689
+ String.raw`\.(?:${STATE_DIR_ALT})\/+analyses(?:\/+[^/\s'"]+)?`,
690
+ "gi",
691
+ );
692
+
693
+ // More candidate directories than this and the tracker stops telling them
694
+ // apart; the cost of judging each one grows with every cd that might fail.
695
+ const MAX_CANDIDATES = 8;
696
+ // Past this many directory changes in one line the tracker stops following
697
+ // paths and keeps only the worst protection seen; otherwise a long run of
698
+ // `cd a&&` costs time proportional to the square of its length.
699
+ const MAX_CDS = 64;
700
+
701
+ // Words that open or close a compound command and sit in front of the real verb.
702
+ const LEADING_KEYWORDS = new Set([
703
+ "{",
704
+ "}",
705
+ "!",
706
+ "if",
707
+ "then",
708
+ "else",
709
+ "elif",
710
+ "do",
711
+ "while",
712
+ "until",
713
+ ]);
714
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
715
+ const DECLARE_VERBS = new Set(["export", "declare", "typeset", "local", "readonly"]);
716
+ // Variables that change where a later `cd` lands without changing its text.
717
+ const CD_STEERING = /^(?:HOME|CDPATH)=/;
718
+ const UNKNOWN: CwdState = { dir: null, state: false, nearState: false, guarded: false };
719
+ const LOST: CwdState = { dir: null, state: true, nearState: true, guarded: true };
720
+
721
+ // Resolve a word to the absolute path the shell would use, or null. Same
722
+ // expansion rules as resolveStateDirWord, plus a relative path against `base` and
723
+ // `..`, collapsed lexically -- which is what bash's default logical cd does. A
724
+ // write through a symlinked `..` is caught by the caller handing the prefix to
725
+ // the policy layer to realpath.
726
+ function resolveWordFrom(word: WordFragment[], home: string, base: string | null): string | null {
727
+ const fragments = word.filter((f) => f.text.length > 0);
728
+ if (fragments.length === 0) return null;
729
+ let text = wordText(fragments);
730
+ const head = fragments[0];
731
+ if (home && word[0].quote === null && (word[0].text === "~" || word[0].text.startsWith("~/"))) {
732
+ text = home + text.slice(1);
733
+ } else if (home && head.quote !== "'") {
734
+ for (const v of ["$HOME", "${HOME}"]) {
735
+ if (head.text === v || head.text.startsWith(v + "/")) {
736
+ const rest = text.slice(v.length);
737
+ if (rest === "" || rest.startsWith("/")) {
738
+ text = home + rest;
739
+ break;
740
+ }
741
+ }
742
+ }
743
+ }
744
+ if (text.startsWith("~") || /[*?$`\\{}[\]]/.test(text)) return null;
745
+ if (P.isAbsolute(text)) return P.resolve(text);
746
+ return base ? P.resolve(base, text) : null;
747
+ }
748
+
749
+ // The `$(...)` and backtick bodies inside a word, wherever bash would run them.
750
+ function substitutions(word: WordFragment[]): string[] {
751
+ const out: string[] = [];
752
+ for (const f of word) {
753
+ if (f.quote === "'") continue;
754
+ const t = f.text;
755
+ for (let j = 0; j < t.length; j++) {
756
+ if (t[j] === "$" && t[j + 1] === "(") {
757
+ const end = substitutionEnd(t, j + 1);
758
+ out.push(t.slice(j + 2, end));
759
+ j = end;
760
+ } else if (t[j] === "`") {
761
+ const end = backtickEnd(t, j);
762
+ out.push(t.slice(j + 1, end));
763
+ j = end;
764
+ }
765
+ }
766
+ }
767
+ return out;
768
+ }
769
+
770
+ function sameState(a: CwdState, b: CwdState): boolean {
771
+ return (
772
+ a.dir === b.dir && a.state === b.state && a.nearState === b.nearState && a.guarded === b.guarded
773
+ );
774
+ }
775
+ function union(...wheres: Where[]): Where {
776
+ const out: Where = [];
777
+ for (const w of wheres) for (const s of w) if (!out.some((o) => sameState(o, s))) out.push(s);
778
+ return out.length > MAX_CANDIDATES ? [worst(out)] : out;
779
+ }
780
+ function worst(where: Where): CwdState {
781
+ return {
782
+ dir: null,
783
+ state: where.some((d) => d.state),
784
+ nearState: where.some((d) => d.nearState),
785
+ guarded: where.some((d) => d.guarded),
786
+ };
787
+ }
788
+
789
+ interface Shell {
790
+ cur: Where;
791
+ oldpwd: Where | null;
792
+ stack: Where[];
793
+ /** A cd has happened, so relative operands no longer mean the request's cwd. */
794
+ moved: boolean;
795
+ /** HOME or CDPATH was reassigned, so a bare or relative cd lands who knows where. */
796
+ steered: boolean;
797
+ /** The code being walked names Orbit state (or a credential store) outright,
798
+ * so a cd target it cannot expand is presumed to be it. */
799
+ presumeState: boolean;
800
+ presumeGuarded: boolean;
801
+ }
802
+
803
+ interface Segment {
804
+ words: WordFragment[][];
805
+ outs: WordFragment[][];
806
+ ins: WordFragment[][];
807
+ hereStrings: WordFragment[][];
808
+ heredocs: number;
809
+ }
810
+
811
+ function scanCdContext(command: string, hostHome: string, hostCwd: string): CdScan {
812
+ const home = toShellPath(hostHome);
813
+ const cwd = toShellPath(hostCwd);
814
+ const isState = (p: string) => isOrbitStatePath(toHostPath(p), hostHome);
815
+ const out: CdScan = { catastrophic: false, readPaths: [], writeTargets: [], guarded: false };
816
+ // isCredentialStore realpaths a handful of files; a script that cds back
817
+ // and forth asks about the same few directories over and over.
818
+ const seen = new Map<string, CwdState>();
819
+ const stateFor = (dir: string): CwdState => {
820
+ const hit = seen.get(dir);
821
+ if (hit) return hit;
822
+ const state = isState(dir);
823
+ const v = {
824
+ dir,
825
+ state,
826
+ nearState: hasStateSegment(dir),
827
+ guarded: state || (!!home && isCredentialStore(toHostPath(dir), hostHome)),
828
+ };
829
+ seen.set(dir, v);
830
+ return v;
831
+ };
832
+ let cdCount = 0;
833
+ const backstop = (text: string) => {
834
+ for (const m of text.replace(/\\/g, "").matchAll(RAW_CD)) {
835
+ const raw = m[1].replace(/^["']|["']$/g, "");
836
+ const abs = resolveWordFrom([{ text: raw, quote: null }], home, null);
837
+ if (abs ? stateFor(abs).guarded : SUSPECT_CD.test(raw)) out.guarded = true;
838
+ }
839
+ };
840
+ const start: CwdState = cwd && P.isAbsolute(cwd) ? { ...UNKNOWN, dir: cwd } : UNKNOWN;
841
+ walk(
842
+ command,
843
+ {
844
+ cur: [start],
845
+ oldpwd: null,
846
+ stack: [],
847
+ moved: false,
848
+ steered: false,
849
+ presumeState: false,
850
+ presumeGuarded: false,
851
+ },
852
+ 0,
853
+ );
854
+
855
+ return out;
856
+
857
+ function walk(script: string, sh: Shell, depth: number): Shell {
858
+ if (depth > 4) {
859
+ // Nested deeper than any honest command: refuse to judge it as harmless.
860
+ out.guarded = true;
861
+ if (sh.cur.some((d) => d.nearState)) out.catastrophic = true;
862
+ return sh;
863
+ }
864
+ const tokens = shellTokens(script, true);
865
+ // Only words that run count -- a comment that mentions ~/.loom is not a
866
+ // reason to suspect the line. Orbit's analyses tree is work product.
867
+ const named = tokens
868
+ .map((t) => ("word" in t ? wordText(t.word) : ""))
869
+ .join(" ")
870
+ .replace(/\\/g, "")
871
+ .replace(ANALYSES_TREE, "");
872
+ const namesState = NAMES_STATE.test(named);
873
+ sh = {
874
+ ...sh,
875
+ presumeState: sh.presumeState || namesState,
876
+ presumeGuarded: sh.presumeGuarded || namesState || NAMES_CREDENTIALS.test(named),
877
+ };
878
+ const hasOr = script.includes("||");
879
+ const saved: Shell[] = [];
880
+ let inPipe = false;
881
+ // Heredoc bodies arrive after the line that asked for them; each waits here
882
+ // with the state its command started in.
883
+ const hosts: { shell: boolean; inert: boolean; sh: Shell }[] = [];
884
+ let seg: Segment = { words: [], outs: [], ins: [], hereStrings: [], heredocs: 0 };
885
+ let pending: "out" | "in" | "dup" | "skip" | "here" | null = null;
886
+ const flush = (sep: string) => {
887
+ const pipe = sep === "|" || sep === "|&";
888
+ if (seg.words.length || seg.outs.length || seg.ins.length) {
889
+ const before = sh;
890
+ const r = runSimple(seg, sh, depth);
891
+ sh = r.sh;
892
+ for (let k = 0; k < seg.heredocs; k++) {
893
+ hosts.push({ shell: r.shell, inert: r.inert, sh: before });
894
+ }
895
+ if (pipe || inPipe) {
896
+ // Every stage of a pipeline is a subshell: its cd moves nobody else.
897
+ sh = before;
898
+ } else if (r.cd && (sep !== "&&" || hasOr)) {
899
+ // The cd may have failed. `&&` would skip what follows; anything
900
+ // else runs it in the old directory.
901
+ sh = { ...sh, cur: union(sh.cur, before.cur) };
902
+ }
903
+ }
904
+ inPipe = pipe;
905
+ seg = { words: [], outs: [], ins: [], hereStrings: [], heredocs: 0 };
906
+ pending = null;
907
+ };
908
+ for (const t of tokens) {
909
+ if ("heredoc" in t) {
910
+ const host = hosts.shift();
911
+ // An unquoted body is expanded before anyone reads it, whoever the host.
912
+ if (!t.quoted && host) {
913
+ for (const inner of substitutions([{ text: t.heredoc, quote: '"' }])) {
914
+ walk(inner, copy(host.sh), depth + 1);
915
+ }
916
+ }
917
+ if (host?.shell) walk(t.heredoc, copy(host.sh), depth + 1);
918
+ else if (host && !host.inert) backstop(t.heredoc);
919
+ continue;
920
+ }
921
+ if ("word" in t) {
922
+ const txt = wordText(t.word);
923
+ if (pending === "out" || (pending === "dup" && !/^(\d+|-)$/.test(txt)))
924
+ seg.outs.push(t.word);
925
+ else if (pending === "in") seg.ins.push(t.word);
926
+ else if (pending === "here") seg.hereStrings.push(t.word);
927
+ else if (pending === null) seg.words.push(t.word);
928
+ pending = null;
929
+ continue;
930
+ }
931
+ const op = t.op;
932
+ if (op === "<<") {
933
+ seg.heredocs++;
934
+ pending = "skip";
935
+ } else if (op === "<<<") pending = "here";
936
+ else if (op === "<&") pending = "skip";
937
+ else if (op === "<") pending = "in";
938
+ else if (op.includes(">")) pending = op.endsWith("&") ? "dup" : "out";
939
+ else {
940
+ flush(op);
941
+ if (op === "(") {
942
+ saved.push(copy(sh));
943
+ } else if (op === ")") {
944
+ // A subshell's cd does not outlive it.
945
+ const back = saved.pop();
946
+ if (back) sh = back;
947
+ }
948
+ }
949
+ }
950
+ flush("");
951
+ return sh;
952
+ }
953
+
954
+ function copy(sh: Shell): Shell {
955
+ return { ...sh, stack: [...sh.stack] };
956
+ }
957
+
958
+ function runSimple(
959
+ seg: Segment,
960
+ sh: Shell,
961
+ depth: number,
962
+ ): { sh: Shell; cd: boolean; shell: boolean; inert: boolean } {
963
+ const all = [...seg.words, ...seg.outs, ...seg.ins, ...seg.hereStrings];
964
+ // Command substitutions run in a subshell that starts where this command stands.
965
+ for (const w of all) for (const inner of substitutions(w)) walk(inner, copy(sh), depth + 1);
966
+
967
+ let i = 0;
968
+ while (i < seg.words.length) {
969
+ const t = wordText(seg.words[i]);
970
+ if (LEADING_KEYWORDS.has(t)) i++;
971
+ else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
972
+ if (CD_STEERING.test(t)) sh = { ...sh, steered: true };
973
+ sh = setOldpwd(seg.words[i], sh);
974
+ i++;
975
+ } else break;
976
+ }
977
+ const rest = seg.words.slice(i);
978
+ const texts = rest.map(wordText);
979
+ const offset = texts.length - unwrap(texts).length;
980
+ const argv = rest.slice(offset);
981
+ const args = argv.slice(1);
982
+ const argTexts = args.map(wordText);
983
+ const verb = argv.length ? wordText(argv[0]).split("/").pop() : undefined;
984
+
985
+ if (verb && DECLARE_VERBS.has(verb)) {
986
+ if (argTexts.some((a) => CD_STEERING.test(a))) sh = { ...sh, steered: true };
987
+ for (const w of args) sh = setOldpwd(w, sh);
988
+ }
989
+ if (verb === "cd" || verb === "pushd" || verb === "popd" || verb === "chdir") {
990
+ // The redirections are opened before the builtin runs, in the old
991
+ // directory: `cd . > a.md` truncates a.md where the shell stands now.
992
+ if (sh.cur.some((d) => d.guarded) && (seg.outs.length || seg.ins.length)) out.guarded = true;
993
+ if (sh.moved) for (const where of sh.cur) judgeOperands(where, undefined, [], [], seg);
994
+ return { sh: changeDir(verb, args, sh), cd: true, shell: false, inert: false };
995
+ }
996
+ if (sh.cur.some((d) => d.guarded) && (argv.length || seg.outs.length)) out.guarded = true;
997
+
998
+ if (sh.moved) for (const where of sh.cur) judgeOperands(where, verb, args, argTexts, seg);
999
+
1000
+ // An inner shell starts where this one stands, wherever it sits in the argv
1001
+ // (`find -exec sh -c`, `xargs bash -c`, `nice sh -c`); `eval` runs in this one.
1002
+ const texts2 = argv.map(wordText);
1003
+ const inert = verb !== undefined && INERT_VERBS.has(verb);
1004
+ if (!inert) backstop(texts2.slice(1).join(" "));
1005
+ // `echo bash -c '...'` only prints, so an inert verb's arguments are not searched.
1006
+ const shellAt = inert ? -1 : texts2.findIndex((t) => SHELLS.has(t.split("/").pop() ?? ""));
1007
+ let shell = false;
1008
+ if (shellAt !== -1) {
1009
+ shell = true;
1010
+ const c = texts2.findIndex((t, k) => k > shellAt && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(t));
1011
+ if (c !== -1) {
1012
+ // bash takes the first operand after its options as the script; `--` ends them.
1013
+ const script = texts2.slice(c + 1).find((t) => !/^[-+]/.test(t));
1014
+ if (script !== undefined) walk(script, copy(sh), depth + 1);
1015
+ }
1016
+ for (const w of seg.hereStrings) walk(wordText(w), copy(sh), depth + 1);
1017
+ } else if (verb === "eval") {
1018
+ return { sh: walk(argTexts.join(" "), sh, depth + 1), cd: false, shell, inert };
1019
+ }
1020
+ return { sh, cd: false, shell, inert };
1021
+ }
1022
+
1023
+ function judgeOperands(
1024
+ where: CwdState,
1025
+ verb: string | undefined,
1026
+ args: WordFragment[][],
1027
+ argTexts: string[],
1028
+ seg: Segment,
1029
+ ) {
1030
+ const base = where.dir;
1031
+ const reads: WordFragment[][] = [...seg.ins];
1032
+ if (verb && READ_LIKE.has(verb)) {
1033
+ args.forEach((w, k) => {
1034
+ if (!argTexts[k].startsWith("-")) reads.push(w);
1035
+ });
1036
+ }
1037
+ for (const w of reads) {
1038
+ const abs = resolveWordFrom(w, home, base);
1039
+ if (abs) {
1040
+ out.readPaths.push(toHostPath(abs));
1041
+ } else if (base === null && where.state && home) {
1042
+ // Somewhere in Orbit state we could not pin down: judge a relative read
1043
+ // as if it were in either config dir, so `config.json` still denies.
1044
+ const rel = wordText(w);
1045
+ if (!P.isAbsolute(rel) && !rel.split("/").includes("..")) {
1046
+ for (const name of WORKSPACE_STATE_DIR_NAMES)
1047
+ out.readPaths.push(toHostPath(P.join(home, name, rel)));
1048
+ }
1049
+ }
1050
+ }
1051
+ const writes = [...seg.outs];
1052
+ const inPlaceSed =
1053
+ verb === "sed" && argTexts.some((a) => a === "--in-place" || /^-[a-zA-Z]*i/.test(a));
1054
+ if (verb === "tee" || verb === "cp" || verb === "mv" || inPlaceSed) {
1055
+ args.forEach((w, k) => {
1056
+ if (!argTexts[k].startsWith("-")) writes.push(w);
1057
+ });
1058
+ } else if (verb === "dd") {
1059
+ for (const w of args) {
1060
+ if (w[0]?.quote === null && w[0].text.startsWith("of=")) {
1061
+ writes.push([{ ...w[0], text: w[0].text.slice(3) }, ...w.slice(1)]);
1062
+ }
1063
+ }
1064
+ }
1065
+ for (const w of writes) {
1066
+ const text = wordText(w);
1067
+ const abs = resolveWordFrom(w, home, base);
1068
+ if (abs !== null) {
1069
+ if (isState(abs)) out.catastrophic = true;
1070
+ else if (hasStateSegment(abs)) out.writeTargets.push(toHostPath(abs));
1071
+ // `..` is collapsed lexically, but the kernel walks it physically: hand
1072
+ // the directory it climbs out of to the policy layer to realpath.
1073
+ const segs = text.split("/");
1074
+ const up = segs.indexOf("..");
1075
+ if (up !== -1) {
1076
+ const prefix = resolveWordFrom(
1077
+ [{ text: segs.slice(0, up).join("/") || ".", quote: null }],
1078
+ home,
1079
+ base,
1080
+ );
1081
+ if (prefix && hasStateSegment(prefix)) out.writeTargets.push(toHostPath(prefix));
1082
+ if (base && hasStateSegment(base)) out.writeTargets.push(toHostPath(base));
1083
+ }
1084
+ } else if (where.state) {
1085
+ out.catastrophic = true;
1086
+ } else if (where.nearState || where.guarded) {
1087
+ // Near state but not in it -- an Orbit analysis. A target we cannot
1088
+ // place gets the verdict it would get without the cd (none), unless it
1089
+ // climbs with `..`, names a state dir, or we have lost track entirely.
1090
+ const plain =
1091
+ base &&
1092
+ !/[$`~\\]/.test(text) &&
1093
+ !P.isAbsolute(text) &&
1094
+ !text.split("/").some((p) => p === ".." || /^\.[^/]*[*?[{]/.test(p));
1095
+ if (plain && isState(P.resolve(base, text))) {
1096
+ out.catastrophic = true;
1097
+ } else if (
1098
+ !plain &&
1099
+ (base === null || text.split("/").includes("..") || SUSPECT_CD.test(text))
1100
+ ) {
1101
+ out.guarded = true;
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ // `OLDPWD=...` decides where a later `cd -` goes.
1108
+ function setOldpwd(word: WordFragment[], sh: Shell): Shell {
1109
+ const head = word[0];
1110
+ if (!head || head.quote !== null || !head.text.startsWith("OLDPWD=")) return sh;
1111
+ const value = [{ ...head, text: head.text.slice("OLDPWD=".length) }, ...word.slice(1)];
1112
+ return { ...sh, oldpwd: targetStates(value, sh) };
1113
+ }
1114
+
1115
+ // Where a cd to `target` could land, from every directory the shell might be in.
1116
+ function targetStates(target: WordFragment[], sh: Shell): Where {
1117
+ const text = wordText(target);
1118
+ return union(
1119
+ sh.cur.map((where) => {
1120
+ const abs = resolveWordFrom(target, home, where.dir);
1121
+ if (abs) return stateFor(abs);
1122
+ // Unresolvable: it might be right here (`cd "$PWD"`), so keep this
1123
+ // directory's protection, plus whatever the text itself gives away.
1124
+ const suspect =
1125
+ sh.presumeState ||
1126
+ SUSPECT_CD.test(text.replace(/\\/g, "")) ||
1127
+ substitutions(target).some((body) =>
1128
+ shellWords(body).some((w) => SUSPECT_CD.test(wordText(w))),
1129
+ );
1130
+ return {
1131
+ dir: null,
1132
+ state: suspect || where.state,
1133
+ nearState: suspect || where.nearState,
1134
+ guarded: suspect || sh.presumeGuarded || where.guarded,
1135
+ };
1136
+ }),
1137
+ );
1138
+ }
1139
+
1140
+ function changeDir(verb: string, args: WordFragment[][], sh: Shell): Shell {
1141
+ let k = 0;
1142
+ while (k < args.length) {
1143
+ const t = wordText(args[k]);
1144
+ if (t === "--") {
1145
+ k++;
1146
+ break;
1147
+ }
1148
+ if (t.startsWith("-") && t !== "-" && !/^-\d+$/.test(t)) k++;
1149
+ else break;
1150
+ }
1151
+ const target = args[k];
1152
+ const stack = [...sh.stack];
1153
+ let next: Where;
1154
+ if (verb === "popd") {
1155
+ next = stack.pop() ?? sh.cur;
1156
+ } else if (verb === "pushd" && !target) {
1157
+ const top = stack.pop();
1158
+ if (!top) return sh;
1159
+ stack.push(sh.cur);
1160
+ next = top;
1161
+ } else if (!target) {
1162
+ next = [sh.steered ? LOST : home ? stateFor(home) : UNKNOWN];
1163
+ } else {
1164
+ const text = wordText(target);
1165
+ if (text === "-" && verb !== "pushd") {
1166
+ // A fresh shell has no OLDPWD, and a failed cd leaves the cwd alone.
1167
+ if (!sh.oldpwd) return sh;
1168
+ next = sh.oldpwd;
1169
+ } else if (/^[+-]\d+$/.test(text)) {
1170
+ // A pushd/popd stack rotation we do not model: assume the worst entry.
1171
+ next = [worst(union(sh.cur, ...stack))];
1172
+ } else if (sh.steered && !P.isAbsolute(text) && !text.startsWith("$")) {
1173
+ next = [LOST];
1174
+ } else {
1175
+ next = targetStates(target, sh);
1176
+ }
1177
+ if (verb === "pushd") stack.push(sh.cur);
1178
+ }
1179
+ if (++cdCount > MAX_CDS) next = [worst(next)];
1180
+ return { ...sh, cur: next, oldpwd: sh.cur, stack, moved: true };
1181
+ }
1182
+ }
1183
+
1184
+ // Roots whose recursive force-deletion is catastrophic. Quotes are stripped
1185
+ // first, so `"$HOME"` and `'/'` are caught; the home dir is passed in so an
1186
+ // explicit absolute home path (`rm -rf /Users/me`) is caught too.
1187
+ const SYSTEM_ROOTS = new Set([
1188
+ "/",
1189
+ "/usr",
1190
+ "/bin",
1191
+ "/sbin",
1192
+ "/lib",
1193
+ "/lib64",
1194
+ "/etc",
1195
+ "/var",
1196
+ "/boot",
1197
+ "/dev",
1198
+ "/opt",
1199
+ "/sys",
1200
+ "/proc",
1201
+ "/root",
1202
+ "/System",
1203
+ "/Library",
1204
+ "/Applications",
1205
+ ]);
1206
+
1207
+ function isFilesystemRoot(arg: string, home: string): boolean {
1208
+ const t = stripQuotes(arg);
1209
+ if (["/", "~", "~/", "~/*", "$HOME", "${HOME}", "$HOME/", "$HOME/*", "${HOME}/*"].includes(t)) {
1210
+ return true;
1211
+ }
1212
+ if (/^\/+$/.test(t)) return true;
1213
+ const noGlob = t.replace(/\/\*+$/, "");
1214
+ if (SYSTEM_ROOTS.has(noGlob)) return true;
1215
+ if (home && (noGlob === home || t === home + "/" || t === home)) return true;
1216
+ return false;
1217
+ }
1218
+
1219
+ // `rm` with BOTH a recursive and a force flag pointed at a filesystem root.
1220
+ // Token-based so it handles short/bundled/long flags in any order
1221
+ // (`-rf`, `-r -f`, `--recursive --force`), quoted targets, and wrapper prefixes
1222
+ // (`env rm`, `conda run rm`, `nice -n 10 rm`). A routine `rm -rf build` is NOT
1223
+ // caught (target isn't a root); it stays "unknown" and still prompts. Each shell
1224
+ // segment -- split on `;`, `&`, `|`, and NEWLINES -- is checked so it fires
1225
+ // inside a compound or multi-line command too.
1226
+ function isCatastrophicRm(command: string, home: string): boolean {
1227
+ for (const segment of command.split(/[;&|\n\r]+/)) {
1228
+ const tokens = unwrap(segment.trim().split(/\s+/).filter(Boolean).map(stripQuotes));
1229
+ if (tokens.length === 0) continue;
1230
+ const verb = tokens[0].split("/").pop(); // basename: /bin/rm -> rm
1231
+ if (verb !== "rm") continue;
1232
+ const flags = tokens.slice(1).filter((t) => t.startsWith("-"));
1233
+ const targets = tokens.slice(1).filter((t) => !t.startsWith("-"));
1234
+ const recursive = flags.some((f) => f === "--recursive" || /^-[a-zA-Z]*[rR][a-zA-Z]*$/.test(f));
1235
+ const force = flags.some((f) => f === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(f));
1236
+ if (recursive && force && targets.some((t) => isFilesystemRoot(t, home))) return true;
1237
+ }
1238
+ return false;
1239
+ }
1240
+
1241
+ // Single read-only/analysis commands we auto-allow when the line is "simple".
1242
+ // Deliberately excludes command wrappers (`env`, `conda run`, `bash -c`, ...):
1243
+ // those execute an arbitrary inner command, so they are never auto-safe -- they
1244
+ // fall through to `unknown` and prompt.
1245
+ const SAFE_COMMANDS = new Set([
1246
+ "ls",
1247
+ "cat",
1248
+ "head",
1249
+ "tail",
1250
+ "wc",
1251
+ "pwd",
1252
+ "echo",
1253
+ "grep",
1254
+ "rg",
1255
+ "fd",
1256
+ "find",
1257
+ "file",
1258
+ "stat",
1259
+ "du",
1260
+ "df",
1261
+ "which",
1262
+ "date",
1263
+ "whoami",
1264
+ "uname",
1265
+ ]);
1266
+ // Multi-token safe prefixes (exact leading tokens).
1267
+ const SAFE_PREFIXES = [
1268
+ ["git", "status"],
1269
+ ["git", "diff"],
1270
+ ["git", "log"],
1271
+ ["git", "show"],
1272
+ ];
1273
+
1274
+ // Any of these mean "we can't reason about this as a single safe command."
1275
+ // Bare newlines count: the shell runs them as separate commands, so a line that
1276
+ // starts with a safe verb but continues onto another line is NOT safe.
1277
+ const SHELL_META = /[;&|`\n\r]|\$\(|\$\{|<\(|>>?|<|\\\n/;
1278
+
1279
+ const READ_LIKE = new Set(["cat", "head", "tail", "less", "more", "grep", "rg"]);
1280
+
1281
+ // Safe commands whose path operands the policy layer runs through the workspace
1282
+ // jail. Superset of READ_LIKE: the content readers above plus the enumeration /
1283
+ // metadata commands, which reveal the structure, filenames, sizes, or contents
1284
+ // of their target. A bare `ls`/`find` on the safe allowlist was previously
1285
+ // auto-allowed regardless of where it pointed, so `ls ~/Desktop` silently
1286
+ // inspected outside the workspace while the equivalent `ls` *tool* prompted
1287
+ // (#224). `df <path>` is here too: it reveals existence + the mount/capacity of
1288
+ // its argument. The remaining safe commands (echo/pwd/which/date/whoami/uname)
1289
+ // take no file-path operand, so they are deliberately excluded -- collecting
1290
+ // their args would manufacture spurious out-of-workspace prompts. Unlike
1291
+ // READ_LIKE, this set does NOT feed the sensitive-read pipe floor
1292
+ // (extractReadTargets): `ls ~/.ssh` lists names, it does not dump key contents,
1293
+ // so the jail's escape-ask is the right response, not the credential-store deny.
1294
+ const PATH_READING = new Set([...READ_LIKE, "ls", "find", "fd", "file", "stat", "du", "df", "wc"]);
1295
+
1296
+ // Content-read targets across EVERY shell segment (split on the same separators
1297
+ // as the catastrophic-rm scan). For any segment whose verb is a content reader,
1298
+ // collect its non-flag args. This is what closes the pipe evasion: `cat secret |
1299
+ // tool` is "unknown" as a whole, but its first segment still reads `secret`. A
1300
+ // path that is only an auth arg to a non-reading command (`ssh -i key`) is NOT
1301
+ // collected -- only verbs that dump file contents to stdout.
1302
+ function extractReadTargets(command: string): string[] {
1303
+ const out: string[] = [];
1304
+ for (const segment of command.split(/[;&|\n\r]+/)) {
1305
+ const tokens = unwrap(segment.trim().split(/\s+/).filter(Boolean).map(stripQuotes));
1306
+ if (tokens.length === 0) continue;
1307
+ const verb = tokens[0].split("/").pop(); // basename: /bin/cat -> cat
1308
+ if (!verb || !READ_LIKE.has(verb)) continue;
1309
+ for (const t of tokens.slice(1)) if (!t.startsWith("-")) out.push(t);
1310
+ }
1311
+ return out;
1312
+ }
1313
+
1314
+ export function classifyBash(commandRaw: string, home = "", cwd = ""): BashClass {
1315
+ const command = commandRaw.trim();
1316
+ const cd = scanCdContext(command, home, cwd);
1317
+ // Computed for every kind (incl. compound/unknown) so the policy layer's
1318
+ // sensitive-read floor fires through a pipe; see BashClass.sensitiveReadPaths.
1319
+ const sensitiveReadPaths = extractReadTargets(command);
1320
+ const stateDir = scanStateDirWrite(command, home);
1321
+ const base = {
1322
+ sensitiveReadPaths,
1323
+ stateDirWriteTargets: [...stateDir.targets, ...cd.writeTargets],
1324
+ guardedCwd: cd.guarded,
1325
+ cdReadPaths: cd.readPaths,
1326
+ namesRecord: NAMES_RECORD.test(command.replace(/\\/g, "")),
1327
+ };
1328
+ for (const [re, why] of CATASTROPHIC) {
1329
+ if (re.test(command)) return { kind: "catastrophic", reason: why, readPaths: [], ...base };
1330
+ }
1331
+ const record = scanRecordWrite(command);
1332
+ if (record.targets.length > 0 || record.uncertain) {
1333
+ return {
1334
+ kind: "catastrophic",
1335
+ reason:
1336
+ "write to the analysis record (notebook.md or activity.jsonl) from the shell; " +
1337
+ "edit the notebook with the edit/write tools instead",
1338
+ readPaths: [],
1339
+ ...base,
1340
+ };
1341
+ }
1342
+ if (stateDir.catastrophic || cd.catastrophic) {
1343
+ return {
1344
+ kind: "catastrophic",
1345
+ reason: "write to the Orbit config directory",
1346
+ readPaths: [],
1347
+ ...base,
1348
+ };
1349
+ }
1350
+ if (isCatastrophicRm(command, home)) {
1351
+ return {
1352
+ kind: "catastrophic",
1353
+ reason: "recursive force-delete of / or home",
1354
+ readPaths: [],
1355
+ ...base,
1356
+ };
1357
+ }
1358
+ if (cd.guarded) {
1359
+ return {
1360
+ kind: "unknown",
1361
+ reason: "command may run inside Orbit's state or a credential store",
1362
+ readPaths: [],
1363
+ ...base,
1364
+ };
1365
+ }
1366
+ if (SHELL_META.test(command)) {
1367
+ return {
1368
+ kind: "unknown",
1369
+ reason: "compound or redirected command",
1370
+ readPaths: [],
1371
+ ...base,
1372
+ };
1373
+ }
1374
+ const tokens = command.split(/\s+/).filter(Boolean);
1375
+ if (tokens.length === 0)
1376
+ return { kind: "unknown", reason: "empty command", readPaths: [], ...base };
1377
+ const cmd = tokens[0];
1378
+
1379
+ const prefixHit = SAFE_PREFIXES.some((p) => p.every((t, i) => tokens[i] === t));
1380
+ const isSafeCmd = SAFE_COMMANDS.has(cmd) || prefixHit;
1381
+ if (!isSafeCmd) {
1382
+ return {
1383
+ kind: "unknown",
1384
+ reason: `'${cmd}' is not on the safe allowlist`,
1385
+ readPaths: [],
1386
+ ...base,
1387
+ };
1388
+ }
1389
+
1390
+ // Collect path-like args for read/enumerate commands so the policy layer can
1391
+ // apply the workspace jail (a "safe" cat/ls/find must still not reach outside
1392
+ // the workspace silently). See PATH_READING for why the set is broader than
1393
+ // READ_LIKE and which safe commands are deliberately left out. Quotes are
1394
+ // stripped first (mirroring extractReadTargets): without it `ls "/external"`
1395
+ // keeps its quotes, resolves as a cwd-relative path, and silently auto-allows.
1396
+ const readPaths = PATH_READING.has(cmd)
1397
+ ? tokens
1398
+ .slice(1)
1399
+ .map(stripQuotes)
1400
+ .filter((t) => t.length > 0 && !t.startsWith("-"))
1401
+ : [];
1402
+ return {
1403
+ kind: "safe",
1404
+ reason: `read-only/analysis command '${cmd}'`,
1405
+ readPaths,
1406
+ ...base,
1407
+ };
1408
+ }