rubik 0.1.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 (523) hide show
  1. checksums.yaml +7 -0
  2. data/ATTRIBUTION.md +139 -0
  3. data/CHANGELOG.md +236 -0
  4. data/LICENSE +21 -0
  5. data/README.md +269 -0
  6. data/bin/rubik +82 -0
  7. data/lib/rubik/adapter/base.rb +195 -0
  8. data/lib/rubik/adapter/c.rb +36 -0
  9. data/lib/rubik/adapter/crystal.rb +78 -0
  10. data/lib/rubik/adapter/generic.rb +86 -0
  11. data/lib/rubik/adapter/go.rb +36 -0
  12. data/lib/rubik/adapter/node.rb +146 -0
  13. data/lib/rubik/adapter/process.rb +78 -0
  14. data/lib/rubik/adapter/python.rb +30 -0
  15. data/lib/rubik/adapter/registry.rb +81 -0
  16. data/lib/rubik/adapter/ruby.rb +30 -0
  17. data/lib/rubik/adapter/rust.rb +36 -0
  18. data/lib/rubik/adapter/zig.rb +36 -0
  19. data/lib/rubik/adapter.rb +18 -0
  20. data/lib/rubik/ast/body_loader.rb +19 -0
  21. data/lib/rubik/ast/c_extractor.rb +114 -0
  22. data/lib/rubik/ast/cache_store.rb +191 -0
  23. data/lib/rubik/ast/extractor.rb +59 -0
  24. data/lib/rubik/ast/fallback_cleaner.rb +145 -0
  25. data/lib/rubik/ast/go_extractor.rb +148 -0
  26. data/lib/rubik/ast/graph.rb +272 -0
  27. data/lib/rubik/ast/python_extractor.rb +120 -0
  28. data/lib/rubik/ast/ruby_extractor.rb +260 -0
  29. data/lib/rubik/ast/rust_extractor.rb +127 -0
  30. data/lib/rubik/ast/symbol.rb +103 -0
  31. data/lib/rubik/ast/treesitter/base_walker.rb +109 -0
  32. data/lib/rubik/ast/treesitter/c_walker.rb +122 -0
  33. data/lib/rubik/ast/treesitter/go_walker.rb +120 -0
  34. data/lib/rubik/ast/treesitter/python_walker.rb +107 -0
  35. data/lib/rubik/ast/treesitter/rust_walker.rb +130 -0
  36. data/lib/rubik/ast/treesitter/typescript_walker.rb +182 -0
  37. data/lib/rubik/ast/treesitter/zig_walker.rb +109 -0
  38. data/lib/rubik/ast/treesitter_extractor.rb +93 -0
  39. data/lib/rubik/ast/typescript_extractor.rb +283 -0
  40. data/lib/rubik/ast/zig_extractor.rb +116 -0
  41. data/lib/rubik/ast.rb +13 -0
  42. data/lib/rubik/benchmark/runner.rb +224 -0
  43. data/lib/rubik/benchmark/synth_repo.rb +59 -0
  44. data/lib/rubik/benchmark.rb +9 -0
  45. data/lib/rubik/buffer/diff.rb +43 -0
  46. data/lib/rubik/buffer/manager.rb +427 -0
  47. data/lib/rubik/buffer/path_validator.rb +60 -0
  48. data/lib/rubik/buffer/snapshot.rb +99 -0
  49. data/lib/rubik/buffer.rb +10 -0
  50. data/lib/rubik/coeffects.rb +129 -0
  51. data/lib/rubik/concurrency/boundary.rb +74 -0
  52. data/lib/rubik/concurrency/dns_pool.rb +68 -0
  53. data/lib/rubik/concurrency/errors.rb +17 -0
  54. data/lib/rubik/concurrency/event_loop.rb +441 -0
  55. data/lib/rubik/concurrency/join.rb +55 -0
  56. data/lib/rubik/concurrency/process_waiter.rb +55 -0
  57. data/lib/rubik/concurrency/scheduler.rb +84 -0
  58. data/lib/rubik/concurrency/timers.rb +109 -0
  59. data/lib/rubik/concurrency.rb +132 -0
  60. data/lib/rubik/config/deep_ops.rb +80 -0
  61. data/lib/rubik/config/defaults.rb +248 -0
  62. data/lib/rubik/config/model_catalog.rb +93 -0
  63. data/lib/rubik/config/store.rb +155 -0
  64. data/lib/rubik/config.rb +216 -0
  65. data/lib/rubik/context/git_info.rb +84 -0
  66. data/lib/rubik/context/git_lister.rb +37 -0
  67. data/lib/rubik/context/index.rb +134 -0
  68. data/lib/rubik/context/repo_context_reader.rb +121 -0
  69. data/lib/rubik/context/scanner.rb +439 -0
  70. data/lib/rubik/context.rb +11 -0
  71. data/lib/rubik/diagnostics/collector.rb +200 -0
  72. data/lib/rubik/diagnostics/entry.rb +60 -0
  73. data/lib/rubik/diagnostics/parser.rb +107 -0
  74. data/lib/rubik/diagnostics.rb +10 -0
  75. data/lib/rubik/event_bus.rb +56 -0
  76. data/lib/rubik/kernel/dispatcher.rb +171 -0
  77. data/lib/rubik/kernel/dmesg.rb +116 -0
  78. data/lib/rubik/kernel/init.rb +177 -0
  79. data/lib/rubik/kernel/record.rb +85 -0
  80. data/lib/rubik/kernel/unit.rb +160 -0
  81. data/lib/rubik/llm/anthropic.rb +202 -0
  82. data/lib/rubik/llm/chunk_assembler.rb +285 -0
  83. data/lib/rubik/llm/client.rb +526 -0
  84. data/lib/rubik/llm/formatter.rb +450 -0
  85. data/lib/rubik/llm/message_sanitizer.rb +87 -0
  86. data/lib/rubik/llm/ollama.rb +167 -0
  87. data/lib/rubik/llm/openai.rb +21 -0
  88. data/lib/rubik/llm/openai_compatible.rb +216 -0
  89. data/lib/rubik/llm/provider.rb +125 -0
  90. data/lib/rubik/llm/registry.rb +336 -0
  91. data/lib/rubik/llm/router.rb +100 -0
  92. data/lib/rubik/llm.rb +18 -0
  93. data/lib/rubik/log.rb +38 -0
  94. data/lib/rubik/lsp/client.rb +475 -0
  95. data/lib/rubik/lsp/manager.rb +366 -0
  96. data/lib/rubik/lsp.rb +9 -0
  97. data/lib/rubik/mcp/boot_lifecycle.rb +114 -0
  98. data/lib/rubik/mcp/client.rb +169 -0
  99. data/lib/rubik/mcp/errors.rb +11 -0
  100. data/lib/rubik/mcp/manager.rb +619 -0
  101. data/lib/rubik/mcp/proxy_tool.rb +104 -0
  102. data/lib/rubik/mcp/transport/stdio.rb +255 -0
  103. data/lib/rubik/mcp.rb +12 -0
  104. data/lib/rubik/orchestrator/activity_log.rb +75 -0
  105. data/lib/rubik/orchestrator/auto_apply.rb +166 -0
  106. data/lib/rubik/orchestrator/budget_manager.rb +97 -0
  107. data/lib/rubik/orchestrator/continuation_manager.rb +161 -0
  108. data/lib/rubik/orchestrator/finish_message_selector.rb +55 -0
  109. data/lib/rubik/orchestrator/goal_ledger/drift.rb +71 -0
  110. data/lib/rubik/orchestrator/goal_ledger/ledger.rb +399 -0
  111. data/lib/rubik/orchestrator/goal_ledger/observation_projection.rb +179 -0
  112. data/lib/rubik/orchestrator/goal_ledger/patch.rb +268 -0
  113. data/lib/rubik/orchestrator/goal_ledger/schema.rb +123 -0
  114. data/lib/rubik/orchestrator/goal_ledger/validator.rb +361 -0
  115. data/lib/rubik/orchestrator/goal_ledger/verifier.rb +129 -0
  116. data/lib/rubik/orchestrator/goal_ledger/verify_spec.rb +50 -0
  117. data/lib/rubik/orchestrator/goal_ledger.rb +65 -0
  118. data/lib/rubik/orchestrator/goal_state.rb +184 -0
  119. data/lib/rubik/orchestrator/goal_wait.rb +125 -0
  120. data/lib/rubik/orchestrator/loop.rb +388 -0
  121. data/lib/rubik/orchestrator/lsp_feedback.rb +55 -0
  122. data/lib/rubik/orchestrator/maestro_gate.rb +65 -0
  123. data/lib/rubik/orchestrator/provider_recovery.rb +90 -0
  124. data/lib/rubik/orchestrator/provider_resolver.rb +69 -0
  125. data/lib/rubik/orchestrator/shell_deps.rb +85 -0
  126. data/lib/rubik/orchestrator/step_executor.rb +328 -0
  127. data/lib/rubik/orchestrator/stream_manager.rb +118 -0
  128. data/lib/rubik/orchestrator/subagent/activation.rb +65 -0
  129. data/lib/rubik/orchestrator/subagent/budget.rb +108 -0
  130. data/lib/rubik/orchestrator/subagent/completion.rb +61 -0
  131. data/lib/rubik/orchestrator/subagent/concurrency_gate.rb +37 -0
  132. data/lib/rubik/orchestrator/subagent/conversation_id.rb +67 -0
  133. data/lib/rubik/orchestrator/subagent/dependency.rb +398 -0
  134. data/lib/rubik/orchestrator/subagent/instance.rb +384 -0
  135. data/lib/rubik/orchestrator/subagent/lifecycle.rb +118 -0
  136. data/lib/rubik/orchestrator/subagent/messaging.rb +61 -0
  137. data/lib/rubik/orchestrator/subagent/narration.rb +125 -0
  138. data/lib/rubik/orchestrator/subagent/notices.rb +209 -0
  139. data/lib/rubik/orchestrator/subagent/questioner.rb +80 -0
  140. data/lib/rubik/orchestrator/subagent/reaper.rb +98 -0
  141. data/lib/rubik/orchestrator/subagent/resilience.rb +70 -0
  142. data/lib/rubik/orchestrator/subagent/reuse.rb +127 -0
  143. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +79 -0
  144. data/lib/rubik/orchestrator/subagent/step_runner.rb +240 -0
  145. data/lib/rubik/orchestrator/subagent/storage_id.rb +26 -0
  146. data/lib/rubik/orchestrator/subagent/supervisor.rb +327 -0
  147. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +141 -0
  148. data/lib/rubik/orchestrator/subagent/type_registry.rb +199 -0
  149. data/lib/rubik/orchestrator/subagent_notice_inbox.rb +80 -0
  150. data/lib/rubik/orchestrator/tool_call_id.rb +47 -0
  151. data/lib/rubik/orchestrator/tool_circuit_breaker.rb +91 -0
  152. data/lib/rubik/orchestrator/tool_history.rb +39 -0
  153. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +102 -0
  154. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +172 -0
  155. data/lib/rubik/orchestrator/turn_executor.rb +376 -0
  156. data/lib/rubik/orchestrator.rb +11 -0
  157. data/lib/rubik/platform/filesystem.rb +64 -0
  158. data/lib/rubik/platform/io_drain.rb +53 -0
  159. data/lib/rubik/platform/path.rb +210 -0
  160. data/lib/rubik/platform/process.rb +38 -0
  161. data/lib/rubik/platform/sanitize.rb +157 -0
  162. data/lib/rubik/platform.rb +113 -0
  163. data/lib/rubik/plugin/dsl.rb +51 -0
  164. data/lib/rubik/plugin/manager.rb +247 -0
  165. data/lib/rubik/plugin/storage.rb +195 -0
  166. data/lib/rubik/plugin.rb +10 -0
  167. data/lib/rubik/prompt/context_builder.rb +377 -0
  168. data/lib/rubik/prompt/context_compactor.rb +326 -0
  169. data/lib/rubik/prompt/conversation_rows.rb +74 -0
  170. data/lib/rubik/prompt/doctrine.rb +160 -0
  171. data/lib/rubik/prompt/emergency_compactor.rb +81 -0
  172. data/lib/rubik/prompt/services.rb +60 -0
  173. data/lib/rubik/prompt/token_estimator.rb +63 -0
  174. data/lib/rubik/prompt/tool_teachings.rb +78 -0
  175. data/lib/rubik/prompt.rb +13 -0
  176. data/lib/rubik/repair/loop.rb +95 -0
  177. data/lib/rubik/repair.rb +8 -0
  178. data/lib/rubik/repl/ast_warmup.rb +129 -0
  179. data/lib/rubik/repl/command_context.rb +214 -0
  180. data/lib/rubik/repl/command_parser.rb +48 -0
  181. data/lib/rubik/repl/commands/agent.rb +30 -0
  182. data/lib/rubik/repl/commands/agent_context.rb +289 -0
  183. data/lib/rubik/repl/commands/agent_execution.rb +342 -0
  184. data/lib/rubik/repl/commands/agent_models.rb +212 -0
  185. data/lib/rubik/repl/commands/agent_providers.rb +232 -0
  186. data/lib/rubik/repl/commands/base.rb +55 -0
  187. data/lib/rubik/repl/commands/buffer.rb +124 -0
  188. data/lib/rubik/repl/commands/dmesg.rb +87 -0
  189. data/lib/rubik/repl/commands/lsp.rb +235 -0
  190. data/lib/rubik/repl/commands/mcp.rb +194 -0
  191. data/lib/rubik/repl/commands/plugins.rb +208 -0
  192. data/lib/rubik/repl/commands/pruner.rb +71 -0
  193. data/lib/rubik/repl/commands/session.rb +183 -0
  194. data/lib/rubik/repl/commands/skills.rb +111 -0
  195. data/lib/rubik/repl/commands/storage.rb +85 -0
  196. data/lib/rubik/repl/commands/subagents.rb +195 -0
  197. data/lib/rubik/repl/commands/system.rb +85 -0
  198. data/lib/rubik/repl/commands/tasks.rb +120 -0
  199. data/lib/rubik/repl/commands/workspace.rb +443 -0
  200. data/lib/rubik/repl/commands.rb +44 -0
  201. data/lib/rubik/repl/composition.rb +32 -0
  202. data/lib/rubik/repl/config_wiring.rb +142 -0
  203. data/lib/rubik/repl/confirmations.rb +56 -0
  204. data/lib/rubik/repl/hooks.rb +47 -0
  205. data/lib/rubik/repl/input_execution.rb +323 -0
  206. data/lib/rubik/repl/input_loop.rb +213 -0
  207. data/lib/rubik/repl/interrupt_handler.rb +217 -0
  208. data/lib/rubik/repl/lifecycle.rb +410 -0
  209. data/lib/rubik/repl/paste_support.rb +88 -0
  210. data/lib/rubik/repl/pending_notice_queue.rb +60 -0
  211. data/lib/rubik/repl/presentation.rb +132 -0
  212. data/lib/rubik/repl/pruner/brief.rb +101 -0
  213. data/lib/rubik/repl/pruner/gate.rb +279 -0
  214. data/lib/rubik/repl/pruner/harness.rb +236 -0
  215. data/lib/rubik/repl/pruner/rules.rb +316 -0
  216. data/lib/rubik/repl/pruner/service.rb +82 -0
  217. data/lib/rubik/repl/pruner.rb +39 -0
  218. data/lib/rubik/repl/service_catalog.rb +374 -0
  219. data/lib/rubik/repl/setup_factories.rb +66 -0
  220. data/lib/rubik/repl/shell.rb +231 -0
  221. data/lib/rubik/repl/step_formatter.rb +324 -0
  222. data/lib/rubik/repl/tee_io.rb +105 -0
  223. data/lib/rubik/repl/terminal_guard.rb +157 -0
  224. data/lib/rubik/repl/turn_recorder.rb +234 -0
  225. data/lib/rubik/repl.rb +14 -0
  226. data/lib/rubik/runtime/ast_service.rb +78 -0
  227. data/lib/rubik/runtime/context.rb +335 -0
  228. data/lib/rubik/runtime/diagnostic_engine.rb +188 -0
  229. data/lib/rubik/runtime/discovery.rb +342 -0
  230. data/lib/rubik/runtime/fiber.rb +142 -0
  231. data/lib/rubik/runtime/fiber_ordering.rb +52 -0
  232. data/lib/rubik/runtime/fiber_retry_gate.rb +84 -0
  233. data/lib/rubik/runtime/index_service.rb +58 -0
  234. data/lib/rubik/runtime/loop_service.rb +73 -0
  235. data/lib/rubik/runtime/manager.rb +241 -0
  236. data/lib/rubik/runtime/overlay_storage.rb +481 -0
  237. data/lib/rubik/runtime/service_definition.rb +54 -0
  238. data/lib/rubik/runtime/service_registration.rb +33 -0
  239. data/lib/rubik/runtime/task_manager.rb +302 -0
  240. data/lib/rubik/runtime.rb +19 -0
  241. data/lib/rubik/sandbox/driver/base.rb +30 -0
  242. data/lib/rubik/sandbox/driver/bubblewrap.rb +111 -0
  243. data/lib/rubik/sandbox/driver/docker.rb +113 -0
  244. data/lib/rubik/sandbox/driver/podman.rb +98 -0
  245. data/lib/rubik/sandbox/driver/process.rb +190 -0
  246. data/lib/rubik/sandbox/driver.rb +91 -0
  247. data/lib/rubik/sandbox/runner.rb +218 -0
  248. data/lib/rubik/sandbox/shell_resolver.rb +178 -0
  249. data/lib/rubik/sandbox.rb +8 -0
  250. data/lib/rubik/security/blocked_guidance.rb +187 -0
  251. data/lib/rubik/security/classifier/classifier.rb +440 -0
  252. data/lib/rubik/security/classifier/commands/agent-browser.yaml +9 -0
  253. data/lib/rubik/security/classifier/commands/apt.yaml +25 -0
  254. data/lib/rubik/security/classifier/commands/awk.yaml +10 -0
  255. data/lib/rubik/security/classifier/commands/basename.yaml +7 -0
  256. data/lib/rubik/security/classifier/commands/bash-classify.yaml +8 -0
  257. data/lib/rubik/security/classifier/commands/bash.yaml +20 -0
  258. data/lib/rubik/security/classifier/commands/black.yaml +12 -0
  259. data/lib/rubik/security/classifier/commands/brew.yaml +31 -0
  260. data/lib/rubik/security/classifier/commands/builtin.yaml +8 -0
  261. data/lib/rubik/security/classifier/commands/cargo.yaml +114 -0
  262. data/lib/rubik/security/classifier/commands/cat.yaml +7 -0
  263. data/lib/rubik/security/classifier/commands/chgrp.yaml +10 -0
  264. data/lib/rubik/security/classifier/commands/chmod.yaml +10 -0
  265. data/lib/rubik/security/classifier/commands/chown.yaml +10 -0
  266. data/lib/rubik/security/classifier/commands/column.yaml +7 -0
  267. data/lib/rubik/security/classifier/commands/comm.yaml +7 -0
  268. data/lib/rubik/security/classifier/commands/command.yaml +12 -0
  269. data/lib/rubik/security/classifier/commands/cp.yaml +7 -0
  270. data/lib/rubik/security/classifier/commands/crystal.yaml +63 -0
  271. data/lib/rubik/security/classifier/commands/curl.yaml +30 -0
  272. data/lib/rubik/security/classifier/commands/cut.yaml +7 -0
  273. data/lib/rubik/security/classifier/commands/date.yaml +10 -0
  274. data/lib/rubik/security/classifier/commands/df.yaml +7 -0
  275. data/lib/rubik/security/classifier/commands/diff.yaml +7 -0
  276. data/lib/rubik/security/classifier/commands/dig.yaml +7 -0
  277. data/lib/rubik/security/classifier/commands/dirname.yaml +7 -0
  278. data/lib/rubik/security/classifier/commands/docker.yaml +177 -0
  279. data/lib/rubik/security/classifier/commands/du.yaml +7 -0
  280. data/lib/rubik/security/classifier/commands/echo.yaml +7 -0
  281. data/lib/rubik/security/classifier/commands/env.yaml +15 -0
  282. data/lib/rubik/security/classifier/commands/eslint.yaml +12 -0
  283. data/lib/rubik/security/classifier/commands/eval.yaml +8 -0
  284. data/lib/rubik/security/classifier/commands/exec.yaml +12 -0
  285. data/lib/rubik/security/classifier/commands/expand.yaml +7 -0
  286. data/lib/rubik/security/classifier/commands/false.yaml +7 -0
  287. data/lib/rubik/security/classifier/commands/file.yaml +7 -0
  288. data/lib/rubik/security/classifier/commands/find.yaml +25 -0
  289. data/lib/rubik/security/classifier/commands/fmt.yaml +7 -0
  290. data/lib/rubik/security/classifier/commands/fold.yaml +7 -0
  291. data/lib/rubik/security/classifier/commands/free.yaml +7 -0
  292. data/lib/rubik/security/classifier/commands/gawk.yaml +10 -0
  293. data/lib/rubik/security/classifier/commands/gcloud.yaml +979 -0
  294. data/lib/rubik/security/classifier/commands/gh.yaml +615 -0
  295. data/lib/rubik/security/classifier/commands/git.yaml +524 -0
  296. data/lib/rubik/security/classifier/commands/glab-discussion.yaml +21 -0
  297. data/lib/rubik/security/classifier/commands/glab.yaml +807 -0
  298. data/lib/rubik/security/classifier/commands/go.yaml +44 -0
  299. data/lib/rubik/security/classifier/commands/gog.yaml +983 -0
  300. data/lib/rubik/security/classifier/commands/gradle.yaml +92 -0
  301. data/lib/rubik/security/classifier/commands/gradlew.yaml +92 -0
  302. data/lib/rubik/security/classifier/commands/grep.yaml +7 -0
  303. data/lib/rubik/security/classifier/commands/groups.yaml +7 -0
  304. data/lib/rubik/security/classifier/commands/gunzip.yaml +8 -0
  305. data/lib/rubik/security/classifier/commands/gws.yaml +211 -0
  306. data/lib/rubik/security/classifier/commands/gzip.yaml +7 -0
  307. data/lib/rubik/security/classifier/commands/hadolint.yaml +9 -0
  308. data/lib/rubik/security/classifier/commands/halt.yaml +8 -0
  309. data/lib/rubik/security/classifier/commands/head.yaml +7 -0
  310. data/lib/rubik/security/classifier/commands/helm.yaml +129 -0
  311. data/lib/rubik/security/classifier/commands/hostname.yaml +7 -0
  312. data/lib/rubik/security/classifier/commands/id.yaml +7 -0
  313. data/lib/rubik/security/classifier/commands/install.yaml +7 -0
  314. data/lib/rubik/security/classifier/commands/isort.yaml +14 -0
  315. data/lib/rubik/security/classifier/commands/java.yaml +35 -0
  316. data/lib/rubik/security/classifier/commands/javac.yaml +39 -0
  317. data/lib/rubik/security/classifier/commands/join.yaml +7 -0
  318. data/lib/rubik/security/classifier/commands/journalctl.yaml +14 -0
  319. data/lib/rubik/security/classifier/commands/jq.yaml +7 -0
  320. data/lib/rubik/security/classifier/commands/kill.yaml +7 -0
  321. data/lib/rubik/security/classifier/commands/killall.yaml +7 -0
  322. data/lib/rubik/security/classifier/commands/kubectl.yaml +211 -0
  323. data/lib/rubik/security/classifier/commands/less.yaml +7 -0
  324. data/lib/rubik/security/classifier/commands/ln.yaml +7 -0
  325. data/lib/rubik/security/classifier/commands/ls.yaml +7 -0
  326. data/lib/rubik/security/classifier/commands/make.yaml +21 -0
  327. data/lib/rubik/security/classifier/commands/md5sum.yaml +7 -0
  328. data/lib/rubik/security/classifier/commands/mise.yaml +579 -0
  329. data/lib/rubik/security/classifier/commands/mkdir.yaml +8 -0
  330. data/lib/rubik/security/classifier/commands/more.yaml +7 -0
  331. data/lib/rubik/security/classifier/commands/mv.yaml +7 -0
  332. data/lib/rubik/security/classifier/commands/mvn.yaml +83 -0
  333. data/lib/rubik/security/classifier/commands/mvnw.yaml +83 -0
  334. data/lib/rubik/security/classifier/commands/mypy.yaml +9 -0
  335. data/lib/rubik/security/classifier/commands/netstat.yaml +7 -0
  336. data/lib/rubik/security/classifier/commands/nice.yaml +11 -0
  337. data/lib/rubik/security/classifier/commands/nl.yaml +7 -0
  338. data/lib/rubik/security/classifier/commands/node.yaml +20 -0
  339. data/lib/rubik/security/classifier/commands/nohup.yaml +9 -0
  340. data/lib/rubik/security/classifier/commands/npm.yaml +71 -0
  341. data/lib/rubik/security/classifier/commands/npx.yaml +42 -0
  342. data/lib/rubik/security/classifier/commands/nslookup.yaml +7 -0
  343. data/lib/rubik/security/classifier/commands/paste.yaml +7 -0
  344. data/lib/rubik/security/classifier/commands/ping.yaml +7 -0
  345. data/lib/rubik/security/classifier/commands/pip.yaml +33 -0
  346. data/lib/rubik/security/classifier/commands/pip3.yaml +33 -0
  347. data/lib/rubik/security/classifier/commands/pipx.yaml +33 -0
  348. data/lib/rubik/security/classifier/commands/pkill.yaml +7 -0
  349. data/lib/rubik/security/classifier/commands/pnpm.yaml +346 -0
  350. data/lib/rubik/security/classifier/commands/poetry.yaml +97 -0
  351. data/lib/rubik/security/classifier/commands/poweroff.yaml +8 -0
  352. data/lib/rubik/security/classifier/commands/prettier.yaml +13 -0
  353. data/lib/rubik/security/classifier/commands/printenv.yaml +7 -0
  354. data/lib/rubik/security/classifier/commands/printf.yaml +7 -0
  355. data/lib/rubik/security/classifier/commands/ps.yaml +7 -0
  356. data/lib/rubik/security/classifier/commands/pup.yaml +882 -0
  357. data/lib/rubik/security/classifier/commands/pwd.yaml +7 -0
  358. data/lib/rubik/security/classifier/commands/pytest.yaml +7 -0
  359. data/lib/rubik/security/classifier/commands/python.yaml +9 -0
  360. data/lib/rubik/security/classifier/commands/python3.yaml +9 -0
  361. data/lib/rubik/security/classifier/commands/read.yaml +7 -0
  362. data/lib/rubik/security/classifier/commands/readlink.yaml +7 -0
  363. data/lib/rubik/security/classifier/commands/realpath.yaml +7 -0
  364. data/lib/rubik/security/classifier/commands/reboot.yaml +8 -0
  365. data/lib/rubik/security/classifier/commands/rev.yaml +7 -0
  366. data/lib/rubik/security/classifier/commands/rg.yaml +7 -0
  367. data/lib/rubik/security/classifier/commands/rm.yaml +8 -0
  368. data/lib/rubik/security/classifier/commands/rsync.yaml +12 -0
  369. data/lib/rubik/security/classifier/commands/rtk.yaml +140 -0
  370. data/lib/rubik/security/classifier/commands/ruff.yaml +29 -0
  371. data/lib/rubik/security/classifier/commands/rustfmt.yaml +14 -0
  372. data/lib/rubik/security/classifier/commands/scp.yaml +7 -0
  373. data/lib/rubik/security/classifier/commands/sed.yaml +14 -0
  374. data/lib/rubik/security/classifier/commands/seq.yaml +7 -0
  375. data/lib/rubik/security/classifier/commands/sh.yaml +14 -0
  376. data/lib/rubik/security/classifier/commands/sha1sum.yaml +7 -0
  377. data/lib/rubik/security/classifier/commands/sha256sum.yaml +7 -0
  378. data/lib/rubik/security/classifier/commands/shellcheck.yaml +10 -0
  379. data/lib/rubik/security/classifier/commands/shutdown.yaml +8 -0
  380. data/lib/rubik/security/classifier/commands/slack.yaml +113 -0
  381. data/lib/rubik/security/classifier/commands/sleep.yaml +8 -0
  382. data/lib/rubik/security/classifier/commands/snip.yaml +79 -0
  383. data/lib/rubik/security/classifier/commands/sort.yaml +10 -0
  384. data/lib/rubik/security/classifier/commands/ss.yaml +7 -0
  385. data/lib/rubik/security/classifier/commands/ssh.yaml +41 -0
  386. data/lib/rubik/security/classifier/commands/stat.yaml +7 -0
  387. data/lib/rubik/security/classifier/commands/stdbuf.yaml +15 -0
  388. data/lib/rubik/security/classifier/commands/strace.yaml +20 -0
  389. data/lib/rubik/security/classifier/commands/strings.yaml +11 -0
  390. data/lib/rubik/security/classifier/commands/sudo.yaml +24 -0
  391. data/lib/rubik/security/classifier/commands/systemctl.yaml +58 -0
  392. data/lib/rubik/security/classifier/commands/systemd-run.yaml +30 -0
  393. data/lib/rubik/security/classifier/commands/tac.yaml +7 -0
  394. data/lib/rubik/security/classifier/commands/tail.yaml +7 -0
  395. data/lib/rubik/security/classifier/commands/tar.yaml +25 -0
  396. data/lib/rubik/security/classifier/commands/tee.yaml +8 -0
  397. data/lib/rubik/security/classifier/commands/terraform.yaml +70 -0
  398. data/lib/rubik/security/classifier/commands/test.yaml +7 -0
  399. data/lib/rubik/security/classifier/commands/time.yaml +8 -0
  400. data/lib/rubik/security/classifier/commands/timeout.yaml +18 -0
  401. data/lib/rubik/security/classifier/commands/top.yaml +7 -0
  402. data/lib/rubik/security/classifier/commands/touch.yaml +8 -0
  403. data/lib/rubik/security/classifier/commands/tr.yaml +7 -0
  404. data/lib/rubik/security/classifier/commands/traceroute.yaml +7 -0
  405. data/lib/rubik/security/classifier/commands/true.yaml +7 -0
  406. data/lib/rubik/security/classifier/commands/tsc.yaml +16 -0
  407. data/lib/rubik/security/classifier/commands/type.yaml +7 -0
  408. data/lib/rubik/security/classifier/commands/uname.yaml +7 -0
  409. data/lib/rubik/security/classifier/commands/unexpand.yaml +7 -0
  410. data/lib/rubik/security/classifier/commands/uniq.yaml +7 -0
  411. data/lib/rubik/security/classifier/commands/unzip.yaml +10 -0
  412. data/lib/rubik/security/classifier/commands/uptime.yaml +7 -0
  413. data/lib/rubik/security/classifier/commands/uv.yaml +156 -0
  414. data/lib/rubik/security/classifier/commands/wait.yaml +11 -0
  415. data/lib/rubik/security/classifier/commands/wc.yaml +7 -0
  416. data/lib/rubik/security/classifier/commands/wget.yaml +7 -0
  417. data/lib/rubik/security/classifier/commands/whereis.yaml +7 -0
  418. data/lib/rubik/security/classifier/commands/which.yaml +7 -0
  419. data/lib/rubik/security/classifier/commands/whoami.yaml +7 -0
  420. data/lib/rubik/security/classifier/commands/xargs.yaml +26 -0
  421. data/lib/rubik/security/classifier/commands/xmllint.yaml +7 -0
  422. data/lib/rubik/security/classifier/commands/yarn.yaml +72 -0
  423. data/lib/rubik/security/classifier/commands/yes.yaml +7 -0
  424. data/lib/rubik/security/classifier/commands/yq.yaml +10 -0
  425. data/lib/rubik/security/classifier/commands/zip.yaml +8 -0
  426. data/lib/rubik/security/classifier/commands/zsh.yaml +14 -0
  427. data/lib/rubik/security/classifier/database.rb +216 -0
  428. data/lib/rubik/security/classifier/matcher/delegation.rb +190 -0
  429. data/lib/rubik/security/classifier/matcher/options.rb +245 -0
  430. data/lib/rubik/security/classifier/matcher/subcommands.rb +103 -0
  431. data/lib/rubik/security/classifier/matcher.rb +249 -0
  432. data/lib/rubik/security/classifier/models.rb +170 -0
  433. data/lib/rubik/security/classifier/parser.rb +23 -0
  434. data/lib/rubik/security/classifier/parsers/bash.rb +280 -0
  435. data/lib/rubik/security/classifier/parsers/bash_extractors.rb +221 -0
  436. data/lib/rubik/security/classifier/parsers/powershell.rb +185 -0
  437. data/lib/rubik/security/classifier/pwsh_rules.rb +224 -0
  438. data/lib/rubik/security/classifier.rb +25 -0
  439. data/lib/rubik/security/errors.rb +39 -0
  440. data/lib/rubik/security/guard.rb +954 -0
  441. data/lib/rubik/security/redactor.rb +89 -0
  442. data/lib/rubik/security.rb +11 -0
  443. data/lib/rubik/session/manager.rb +331 -0
  444. data/lib/rubik/session/recorder.rb +149 -0
  445. data/lib/rubik/session/replayer.rb +65 -0
  446. data/lib/rubik/session.rb +10 -0
  447. data/lib/rubik/skills/frontmatter.rb +41 -0
  448. data/lib/rubik/skills/manager.rb +223 -0
  449. data/lib/rubik/skills/registry.rb +64 -0
  450. data/lib/rubik/skills/skill.rb +33 -0
  451. data/lib/rubik/skills/storage.rb +77 -0
  452. data/lib/rubik/skills/validator.rb +116 -0
  453. data/lib/rubik/skills.rb +20 -0
  454. data/lib/rubik/storage/folding.rb +23 -0
  455. data/lib/rubik/storage/layout.rb +145 -0
  456. data/lib/rubik/storage/render.rb +97 -0
  457. data/lib/rubik/storage/service.rb +42 -0
  458. data/lib/rubik/storage/store.rb +514 -0
  459. data/lib/rubik/storage/transcript_indexer.rb +116 -0
  460. data/lib/rubik/storage/uri_reader.rb +168 -0
  461. data/lib/rubik/storage/vakanuvis/annal_writer.rb +92 -0
  462. data/lib/rubik/storage/vakanuvis/distill_gate.rb +323 -0
  463. data/lib/rubik/storage/vakanuvis/gate.rb +250 -0
  464. data/lib/rubik/storage/vakanuvis/harness.rb +421 -0
  465. data/lib/rubik/storage/vakanuvis/memory_steward.rb +163 -0
  466. data/lib/rubik/storage/vakanuvis/service.rb +69 -0
  467. data/lib/rubik/storage/vakanuvis/vocabulary.rb +99 -0
  468. data/lib/rubik/storage/vakanuvis/wiki_skeleton.rb +67 -0
  469. data/lib/rubik/storage/vakanuvis.rb +77 -0
  470. data/lib/rubik/storage.rb +29 -0
  471. data/lib/rubik/tools/base.rb +107 -0
  472. data/lib/rubik/tools/builtins/dmesg.rb +74 -0
  473. data/lib/rubik/tools/builtins/exec.rb +293 -0
  474. data/lib/rubik/tools/builtins/files.rb +354 -0
  475. data/lib/rubik/tools/builtins/goal_ledger.rb +89 -0
  476. data/lib/rubik/tools/builtins/grep.rb +281 -0
  477. data/lib/rubik/tools/builtins/interactive.rb +175 -0
  478. data/lib/rubik/tools/builtins/lifecycle.rb +359 -0
  479. data/lib/rubik/tools/builtins/lsp.rb +283 -0
  480. data/lib/rubik/tools/builtins/pruner.rb +88 -0
  481. data/lib/rubik/tools/builtins/runtime.rb +120 -0
  482. data/lib/rubik/tools/builtins/send_message.rb +109 -0
  483. data/lib/rubik/tools/builtins/show_file.rb +84 -0
  484. data/lib/rubik/tools/builtins/skill.rb +72 -0
  485. data/lib/rubik/tools/builtins/storage.rb +232 -0
  486. data/lib/rubik/tools/builtins/subagent.rb +356 -0
  487. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +72 -0
  488. data/lib/rubik/tools/builtins/symbols.rb +209 -0
  489. data/lib/rubik/tools/builtins/synthesis.rb +314 -0
  490. data/lib/rubik/tools/builtins/task.rb +168 -0
  491. data/lib/rubik/tools/builtins/unsynthesize.rb +111 -0
  492. data/lib/rubik/tools/builtins/vakanuvis.rb +161 -0
  493. data/lib/rubik/tools/builtins.rb +30 -0
  494. data/lib/rubik/tools/catalog.rb +70 -0
  495. data/lib/rubik/tools/declaration.rb +90 -0
  496. data/lib/rubik/tools/diagnostic_reporter.rb +283 -0
  497. data/lib/rubik/tools/fuzzy_matcher.rb +172 -0
  498. data/lib/rubik/tools/native_search.rb +180 -0
  499. data/lib/rubik/tools/output.rb +54 -0
  500. data/lib/rubik/tools/preflight_runner.rb +209 -0
  501. data/lib/rubik/tools/registry.rb +247 -0
  502. data/lib/rubik/tools/result.rb +71 -0
  503. data/lib/rubik/tools/services.rb +96 -0
  504. data/lib/rubik/tools/truncator.rb +78 -0
  505. data/lib/rubik/tools/uri_resolver.rb +195 -0
  506. data/lib/rubik/tools.rb +14 -0
  507. data/lib/rubik/ui/approvals.rb +52 -0
  508. data/lib/rubik/ui/banner.rb +43 -0
  509. data/lib/rubik/ui/capability.rb +122 -0
  510. data/lib/rubik/ui/dialog.rb +106 -0
  511. data/lib/rubik/ui/diff_viewer.rb +78 -0
  512. data/lib/rubik/ui/frame.rb +95 -0
  513. data/lib/rubik/ui/markdown_stream.rb +107 -0
  514. data/lib/rubik/ui/panel.rb +98 -0
  515. data/lib/rubik/ui/renderer.rb +206 -0
  516. data/lib/rubik/ui/spinner.rb +120 -0
  517. data/lib/rubik/ui/statusline.rb +134 -0
  518. data/lib/rubik/ui/theme.rb +118 -0
  519. data/lib/rubik/ui.rb +57 -0
  520. data/lib/rubik/uris.rb +101 -0
  521. data/lib/rubik/version.rb +5 -0
  522. data/lib/rubik.rb +65 -0
  523. metadata +770 -0
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Security
5
+ # Situation-specific prose for a blocked command. The evidence and the
6
+ # gate-honest policy (which allowlist entry would open the gate) arrive
7
+ # as plain data on BlockedWriteError; this module only formats. It lives
8
+ # in the security domain, next to the error it renders —
9
+ # RunCommandTool.blocked_guidance is the stable public seam.
10
+ #
11
+ # Contract for every variant: the FIRST line starts with the literal
12
+ # "Security blocked:" and stays single-line (StepExecutor::ERROR_PREFIXES
13
+ # classifies by start_with?; the REPL/UI formatters collapse to the first
14
+ # line plus the first "!allow-cmd|!allow-dir" line they can regex out).
15
+ module BlockedGuidance
16
+ # Render caps for the multi-segment variant: how many dangerous
17
+ # segments and passed siblings to list, and how long the collapsed
18
+ # command preview on the first line may be.
19
+ SEGMENT_HINT_LIMIT = 5
20
+ SAFE_SEGMENT_LIMIT = 8
21
+ BLOCKED_PREVIEW_CAP = 80
22
+
23
+ # The operator-facing allowlist syntax is always a copy-pasteable
24
+ # one-liner: `!allow-cmd add "<command>"`.
25
+ def self.render(err)
26
+ cmd = err.respond_to?(:blocked_command) ? err.blocked_command : nil
27
+ rule = err.respond_to?(:rule_name) ? err.rule_name : nil
28
+ kind = err.respond_to?(:kind) ? err.kind : nil
29
+ quoted = cmd ? "\"#{cmd}\"" : nil
30
+ # Unstructured raises (critical-write) carry all their context in
31
+ # #message — never discard it behind "rule: unknown".
32
+ message = err.message
33
+ structural = cmd || rule
34
+
35
+ lines = if kind == :chdir_outside_root
36
+ # Sandbox path fence: the working gate is !allow-dir, not
37
+ # !allow-cmd — point the operator at the right one. Structured
38
+ # kind:/path: from Runner#verify_chdir_safety! (no message
39
+ # regex); the '<directory>' fallback covers a hypothetical
40
+ # field-less raise.
41
+ path = err.respond_to?(:path) ? err.path : nil
42
+ [
43
+ "Security blocked: #{message}",
44
+ "If intended, the operator can extend the sandbox once:",
45
+ " !allow-dir add \"#{path || '<directory>'}\""
46
+ ]
47
+ elsif !structural && message
48
+ [
49
+ "Security blocked: #{message}",
50
+ "If genuinely required and safe, the operator can allowlist it once:"
51
+ ]
52
+ elsif kind == :unknown_command
53
+ # Database-less binary (fail-closed), NOT an interpreter
54
+ # blocklist hit — the probe variant below would mislabel
55
+ # it ("base rule for shell interpreters").
56
+ [
57
+ "Security blocked: #{message}",
58
+ "The binary is not in the command database, so its effects could not be classified.",
59
+ "If genuinely required and safe, the operator can allowlist it once:",
60
+ " !allow-cmd add #{quoted}"
61
+ ]
62
+ elsif cmd && err.respond_to?(:probe_like) && err.probe_like
63
+ # Probe branch first: `python3 --version` is probe-shaped and
64
+ # must not be hijacked by the text-munging variant below.
65
+ [
66
+ "Security blocked: `#{cmd}` is blocklisted by the base rule for shell interpreters, even for version checks.",
67
+ "This looks like a safe read-only probe. If intended, the operator can allowlist it once:",
68
+ " !allow-cmd add #{quoted}"
69
+ ]
70
+ elsif (segment_lines = dangerous_segment_lines(err, cmd))
71
+ segment_lines
72
+ elsif (expression_lines = expression_elevation_lines(err, cmd))
73
+ expression_lines
74
+ elsif text_munging?(cmd)
75
+ [
76
+ "Security blocked: shell text-munging is not allowed for file edits.",
77
+ "Use the native tools instead: read_file → replace_file_content / write_file,",
78
+ "then verify with lsp_diagnostics or run_tests.",
79
+ "If genuinely required, the operator can allowlist the exact command:",
80
+ " !allow-cmd add #{quoted}"
81
+ ]
82
+ else
83
+ [
84
+ "Security blocked: `#{cmd || 'command'}` was denied (rule: #{rule || 'unknown'}).",
85
+ "If genuinely required and safe, the operator can allowlist it once:",
86
+ " !allow-cmd add #{quoted}"
87
+ ]
88
+ end
89
+
90
+ # Never print the generic text-munging boilerplate for probe scenarios.
91
+ lines.compact.join("\n")
92
+ end
93
+
94
+ # Wrapper / multi-segment variant: names EVERY dangerous segment with
95
+ # its rule and trigger instead of blaming the whole script on the first
96
+ # rule hit (a wrapped `bash -c '...'` used to render as one giant
97
+ # multi-line `cmd` plus "rule: git.merge" while the real triggers —
98
+ # `git merge --abort`, `git branch -D` — were discarded). Rendered when
99
+ # the danger lives inside a wrapper (depth > 0), spans several
100
+ # top-level commands, or shares the line with safe top-level siblings
101
+ # (the gate demands an entry for them too, and the concise variant
102
+ # would suggest the un-allowlistable compound string). A lone dangerous
103
+ # top-level command keeps the concise one-rule message. Returns nil so
104
+ # the caller falls through.
105
+ def self.dangerous_segment_lines(err, cmd)
106
+ report = err.respond_to?(:segment_report) ? err.segment_report : nil
107
+ return nil unless report.is_a?(Hash)
108
+
109
+ segments = Array(report[:segments]).grep(Hash)
110
+ return nil if segments.empty?
111
+ return nil if segments.size == 1 && segments.first[:depth].to_i.zero? && Array(report[:safe]).empty?
112
+
113
+ wrapper = report[:wrapper_preview]
114
+ preview = wrapper || cmd.to_s.gsub(/\s+/, " ").strip
115
+ preview = "command" if preview.empty?
116
+ preview = "#{preview[0, BLOCKED_PREVIEW_CAP - 1]}..." if preview.length > BLOCKED_PREVIEW_CAP
117
+
118
+ count = segments.size
119
+ plural = count == 1 ? "" : "s"
120
+ lines = ["Security blocked: #{count} dangerous segment#{plural} in `#{preview}` — nothing was executed."]
121
+ segments.first(SEGMENT_HINT_LIMIT).each_with_index do |s, i|
122
+ lines << " #{i + 1}. #{s[:display]} (rule: #{segment_annotation(s)})"
123
+ end
124
+ lines << " ...and #{count - SEGMENT_HINT_LIMIT} more dangerous segment#{plural}" if count > SEGMENT_HINT_LIMIT
125
+
126
+ safe = Array(report[:safe]).map(&:to_s).reject(&:empty?)
127
+ unless safe.empty?
128
+ shown = safe.first(SAFE_SEGMENT_LIMIT).join(", ")
129
+ shown += ", ..." if safe.size > SAFE_SEGMENT_LIMIT
130
+ lines << "Everything else in the #{wrapper ? 'script' : 'command'} passed classification (#{shown})."
131
+ end
132
+
133
+ entries = Array(report[:allowlist_entries]).map(&:to_s).reject(&:empty?)
134
+ lines << "Options: drop the blocked segment#{plural} and rerun each safe command directly, " \
135
+ "or the operator can allowlist #{wrapper ? 'once' : 'each one'}:"
136
+ entries.first(SEGMENT_HINT_LIMIT).each { |e| lines << %( !allow-cmd add "#{e}") }
137
+ lines << "(segment-level entries only cover top-level commands, not wrapped scripts)" if wrapper
138
+ lines
139
+ end
140
+
141
+ # Trigger annotation for one segment. The option form is used ONLY
142
+ # when the classifier reason confirms the override reached DANGEROUS —
143
+ # an option may override to a milder class (curl -o → external) while
144
+ # the real trigger is a system-path elevation named in the reason.
145
+ def self.segment_annotation(segment)
146
+ rule = segment[:rule] || "unknown"
147
+ reason = segment[:reason].to_s.strip
148
+ return "#{rule} — option #{segment[:option]} overrides to DANGEROUS" if segment[:option] && reason.match?(/overridden by option .+ to dangerous\z/i)
149
+
150
+ # Base-rule reasons restate matched_rule ("base rule: rm (…)") — the
151
+ # rule is already displayed; keep only the elevation evidence.
152
+ reason = reason.sub(/\A(?:blocked by default |base )rule:[^;]*;?\s*/i, "").strip
153
+ reason.empty? ? rule : "#{rule} — #{reason}"
154
+ end
155
+
156
+ # Expression-level variant: the danger emerges from the pipeline's
157
+ # shape (a mutating step reaching a system directory), not from any
158
+ # single dangerous segment — no segment report exists and NO allowlist
159
+ # entry would honestly open the gate, so steer toward restructuring
160
+ # instead of `!allow-cmd`.
161
+ def self.expression_elevation_lines(err, cmd)
162
+ return nil if err.respond_to?(:segment_report) && err.segment_report
163
+
164
+ reason = err.respond_to?(:classification_reason) ? err.classification_reason.to_s.strip : ""
165
+ return nil if reason.empty?
166
+
167
+ preview = cmd.to_s.gsub(/\s+/, " ").strip
168
+ preview = "command" if preview.empty?
169
+ preview = "#{preview[0, BLOCKED_PREVIEW_CAP - 1]}..." if preview.length > BLOCKED_PREVIEW_CAP
170
+
171
+ [
172
+ "Security blocked: `#{preview}` — nothing was executed.",
173
+ "#{reason}.",
174
+ "Restructure the pipeline so no mutating step touches a system directory —",
175
+ "stage intermediate output under /tmp, or run the steps as separate commands."
176
+ ]
177
+ end
178
+
179
+ # (b) variant trigger: file-editing via text-munging interpreters.
180
+ def self.text_munging?(cmd)
181
+ return false if cmd.to_s.strip.empty?
182
+
183
+ ["sed", "awk", "perl", "python3", "python", "ruby"].include?(cmd.split(/\s+/).first.to_s.downcase)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+ require_relative "database"
5
+ require_relative "parser"
6
+ require_relative "matcher"
7
+
8
+ module Rubik
9
+ module Security
10
+ module Classifier
11
+ class Engine
12
+ SYSTEM_DIRS = [
13
+ "/etc", "/lib", "/lib64", "/usr", "/bin", "/sbin", "/boot", "/sys",
14
+ "/proc", "/run", "/srv", "/root", "/opt", "/var", "/dev"
15
+ ].freeze
16
+ # How many mutating pipeline participants an expression-level danger
17
+ # reason names before eliding the rest — a hint, not a transcript.
18
+ EXPRESSION_MUTATOR_CAP = 3
19
+ SAFE_PREFIXES = [
20
+ "/tmp", "/var/tmp", "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/tcp", "/dev/udp"
21
+ ].freeze
22
+ DEV_PATHS = ["/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/fd/"].freeze
23
+ WRITE_OPERATORS = [">", ">>", "2>", "&>", ">&", "2>>", "*>"].freeze
24
+ READ_OPERATORS = ["<"].freeze
25
+
26
+ def self.default_database
27
+ @default_database ||= CommandDatabase.new
28
+ end
29
+
30
+ def self.classify_expression(expression, database: nil, shell: nil)
31
+ new(database: database, shell: shell).classify(expression)
32
+ end
33
+
34
+ def initialize(database: nil, shell: nil)
35
+ @database = database || self.class.default_database
36
+ @shell = shell
37
+ end
38
+
39
+ def classify(expression)
40
+ invocations, parse_warnings = Parser.parse(expression, shell: @shell)
41
+ command_results = []
42
+ all_redirects = []
43
+
44
+ invocations.each do |inv|
45
+ result = classify_invocation(inv, parse_warnings)
46
+ command_results << result
47
+ all_redirects.concat(inv.redirects)
48
+ end
49
+
50
+ build_expression_result(expression, command_results, all_redirects, parse_warnings)
51
+ end
52
+
53
+ private
54
+
55
+ def classify_invocation(inv, parse_warnings)
56
+ result = if inv.argv.any? && inv.argv[0].start_with?("$")
57
+ CommandResult.new(
58
+ command: [inv.argv[0]], argv: inv.argv.dup, classification: Classification::DANGEROUS,
59
+ risk: Risk::HIGH, matched_rule: nil, inner_commands: [],
60
+ classification_reason: "variable expansion in command position"
61
+ )
62
+ else
63
+ Matcher.match_command(inv, @database, parse_warnings, shell: @shell)
64
+ end
65
+
66
+ # Pre-command env assignments (VAR="value cmd"): the wrapped
67
+ # command may execute the VALUE (GIT_SSH_COMMAND) — classify it
68
+ # as a sibling inner command BEFORE redirects/system-path checks
69
+ # so the rest of the pipeline sees the elevation.
70
+ result = apply_assignment_payloads(result, inv, parse_warnings)
71
+
72
+ write_paths, read_paths, all_temp = extract_paths(inv.redirects)
73
+ result = apply_redirects(result, inv.redirects, write_paths, all_temp)
74
+ result = apply_network_checks(result, inv)
75
+ result = apply_backgrounding(result, inv)
76
+ result = apply_system_path_elevation(result, inv)
77
+
78
+ result.with(write_paths: (write_paths.empty? ? nil : write_paths),
79
+ read_paths: (read_paths.empty? ? nil : read_paths))
80
+ end
81
+
82
+ # `GIT_SSH_COMMAND="node -e evil" git push` — git executes the value
83
+ # as a shell command; `FOO=$(rm -rf /) cmd` substitutes one in. The
84
+ # VALUE becomes a classification input through the same inner-command
85
+ # machinery delegation uses, while the outer argv stays untouched so
86
+ # allowlist entries never derive from assignment payloads.
87
+ def apply_assignment_payloads(result, inv, parse_warnings)
88
+ return result if inv.assignments.empty?
89
+
90
+ inners = inv.assignments.filter_map do |assignment|
91
+ name, value = assignment.split("=", 2)
92
+ value = Matcher::Delegation.strip_quotes(value.to_s)
93
+ next nil if value.strip.empty?
94
+
95
+ inner_invs, warnings = Parser.parse(value, shell: @shell)
96
+ Matcher::Delegation.record_warnings(parse_warnings, warnings)
97
+ next nil unless command_shaped?(inner_invs)
98
+
99
+ inner_invs.map do |inner_inv|
100
+ Matcher::Delegation.match_inner_command(
101
+ inner_inv.argv, @database,
102
+ delegation_mode: "env_assignment",
103
+ delegation_source: name,
104
+ parse_warnings: parse_warnings,
105
+ redirects: inner_inv.redirects
106
+ ) { |i, db, w| Matcher.match_command(i, db, w, shell: @shell) }
107
+ end
108
+ end.flatten
109
+ return result if inners.empty?
110
+
111
+ classification = result.classification
112
+ risk = result.risk
113
+ reason = result.classification_reason
114
+ inners.each do |inner|
115
+ if Classification.severity(inner.classification) > Classification.severity(classification)
116
+ classification = Classification.max_severity(classification, inner.classification)
117
+ reason = reason ? "#{reason}; elevated by env assignment payload" : "elevated by env assignment payload"
118
+ end
119
+ risk = Risk.max_severity(risk, inner.risk)
120
+ end
121
+ result.with(inner_commands: result.inner_commands + inners,
122
+ classification: classification, risk: risk, classification_reason: reason)
123
+ end
124
+
125
+ # False-positive firewall: env values are usually scalars (LANG=C)
126
+ # or config blobs (CFLAGS="-O2 -Wall", PS1="\u@\h"). A value counts
127
+ # as a command payload only when it parses into an invocation whose
128
+ # binary the database KNOWS or that is invoked by path — the same
129
+ # trust boundary the matcher applies to any other argv[0].
130
+ # Known residual: unknown-binary payloads (a DB-less interpreter
131
+ # spelled without a path) stay unclassified, at parity with typing
132
+ # that binary directly (UNKNOWN). Closing that would need a
133
+ # known-env-exec-binaries list (GIT_SSH_COMMAND, GIT_EDITOR, SHELL).
134
+ def command_shaped?(inner_invs)
135
+ inner_invs.any? do |inv|
136
+ binary = inv.argv.first.to_s
137
+ !binary.empty? && !binary.start_with?("-") &&
138
+ (@database.get(binary, shell: @shell) || binary.include?("/") || binary.include?("\\"))
139
+ end
140
+ end
141
+
142
+ def extract_paths(redirects)
143
+ write_paths = []
144
+ read_paths = []
145
+ all_temp = true
146
+
147
+ redirects.each do |red|
148
+ if WRITE_OPERATORS.include?(red.operator) && real_file_path?(red.target)
149
+ write_paths << red.target
150
+ all_temp = false unless temp_path?(red.target)
151
+ elsif READ_OPERATORS.include?(red.operator) && real_file_path?(red.target)
152
+ read_paths << red.target
153
+ end
154
+ end
155
+ [write_paths, read_paths, all_temp]
156
+ end
157
+
158
+ def apply_redirects(result, redirects, write_paths, all_temp)
159
+ c = result.classification
160
+ r = result.risk
161
+ reason = result.classification_reason
162
+
163
+ redirects.each do |red|
164
+ if red.affects_classification
165
+ elevated = Classification.max_severity(c, Classification::LOCAL_EFFECTS)
166
+ if elevated != c
167
+ c = elevated
168
+ reason = reason ? "#{reason}; elevated by output redirect" : "elevated by output redirect"
169
+ end
170
+ r = Risk.max_severity(r, Risk::MEDIUM) unless write_paths.any? && all_temp
171
+ end
172
+
173
+ next unless red.target.start_with?("/dev/tcp/") || red.target.start_with?("/dev/udp/")
174
+
175
+ c = Classification::DANGEROUS
176
+ r = Risk::HIGH
177
+ reason = "elevated to DANGEROUS: /dev/tcp or /dev/udp access detected"
178
+ end
179
+
180
+ result.with(classification: c, risk: r, classification_reason: reason)
181
+ end
182
+
183
+ def apply_network_checks(result, inv)
184
+ if inv.argv.any? { |arg| arg.start_with?("/dev/tcp/") || arg.start_with?("/dev/udp/") }
185
+ return result.with(
186
+ classification: Classification::DANGEROUS, risk: Risk::HIGH,
187
+ classification_reason: "elevated to DANGEROUS: /dev/tcp or /dev/udp access detected"
188
+ )
189
+ end
190
+ result
191
+ end
192
+
193
+ def apply_backgrounding(result, inv)
194
+ return result unless inv.is_background
195
+
196
+ c = Classification.max_severity(result.classification, Classification::LOCAL_EFFECTS)
197
+ r = Risk.max_severity(result.risk, Risk::MEDIUM)
198
+ reason = result.classification_reason ? "#{result.classification_reason}; elevated by backgrounding" : "elevated by backgrounding"
199
+ result.with(classification: c, risk: r, classification_reason: reason)
200
+ end
201
+
202
+ def apply_system_path_elevation(result, inv)
203
+ updated_inners = result.inner_commands.map { |inner| check_inner_system_paths(inner) }
204
+ result = result.with(inner_commands: updated_inners) if updated_inners != result.inner_commands
205
+
206
+ if result.inner_commands.any? { |i| i.classification == Classification::DANGEROUS }
207
+ reason = "#{result.classification_reason}; elevated to DANGEROUS by inner command"
208
+ result = result.with(classification: Classification::DANGEROUS, risk: Risk::HIGH, classification_reason: reason)
209
+ end
210
+
211
+ return result if Classification.severity(result.classification) < Classification.severity(Classification::LOCAL_EFFECTS)
212
+
213
+ sys_paths = inv.argv.select { |t| system_path?(t) }
214
+ sys_paths.concat(inv.redirects.map(&:target).select { |t| system_path?(t) })
215
+
216
+ if sys_paths.any? && result.classification != Classification::DANGEROUS
217
+ reason = "#{result.classification_reason}; elevated to DANGEROUS: system path #{sys_paths[0]}"
218
+ result = result.with(classification: Classification::DANGEROUS, risk: Risk::HIGH, classification_reason: reason)
219
+ end
220
+ result
221
+ end
222
+
223
+ def check_inner_system_paths(inner)
224
+ # Redirect floor BEFORE the severity gate: an `echo ... > /etc/x`
225
+ # inner sits at READONLY until its redirects lift it to LOCAL.
226
+ inner = apply_inner_redirects(inner)
227
+ updated_sub = inner.inner_commands.map { |sub| check_inner_system_paths(sub) }
228
+ inner = inner.with(inner_commands: updated_sub) if updated_sub != inner.inner_commands
229
+
230
+ if inner.inner_commands.any? { |sub| sub.classification == Classification::DANGEROUS }
231
+ return inner.with(classification: Classification::DANGEROUS, risk: Risk::HIGH,
232
+ classification_reason: append_reason(inner.classification_reason,
233
+ "elevated to DANGEROUS by inner command"))
234
+ end
235
+
236
+ return inner if Classification.severity(inner.classification) < Classification.severity(Classification::LOCAL_EFFECTS)
237
+
238
+ sys_path = inner.argv.find { |t| system_path?(t) }
239
+ if !sys_path && (inner.classification != Classification::DANGEROUS)
240
+ # Redirect targets are attack surface too — the parser moved them
241
+ # out of argv. Skipped when already DANGEROUS so a /dev/tcp
242
+ # redirect does not stack a second reason (outer parity, line ~147).
243
+ sys_path = inner_redirects(inner).map(&:target).find { |t| system_path?(t) }
244
+ end
245
+ if sys_path
246
+ inner.with(classification: Classification::DANGEROUS, risk: Risk::HIGH,
247
+ classification_reason: append_reason(inner.classification_reason,
248
+ "elevated to DANGEROUS: system path #{sys_path}"))
249
+ else
250
+ inner
251
+ end
252
+ end
253
+
254
+ # Delegated scripts previously lost their redirects at the Matcher
255
+ # boundary (the synthetic re-match was argv-only), so `bash -c
256
+ # 'echo pwned > /etc/shadow'` classified clean while the unwrapped
257
+ # form was blocked. The Matcher now transports redirects as plain
258
+ # data; every bit of redirect semantics stays here — one source of
259
+ # classification truth.
260
+ def apply_inner_redirects(inner)
261
+ redirects = inner_redirects(inner)
262
+ return inner if redirects.empty?
263
+
264
+ write_paths, _read_paths, all_temp = extract_paths(redirects)
265
+ apply_redirects(inner, redirects, write_paths, all_temp)
266
+ end
267
+
268
+ # Nested layers may be plain CommandResults (no redirects field).
269
+ def inner_redirects(inner)
270
+ inner.respond_to?(:redirects) ? Array(inner.redirects) : []
271
+ end
272
+
273
+ # Inner elevations previously overwrote nothing (the reason field did
274
+ # not exist on InnerCommandResult); when one now fires on a result
275
+ # that already carries a reason, keep both, outer-elevation style.
276
+ def append_reason(prefix, suffix)
277
+ prefix.to_s.empty? ? suffix : "#{prefix}; #{suffix}"
278
+ end
279
+
280
+ def build_expression_result(expression, command_results, all_redirects, parse_warnings)
281
+ directories = collect_directories(command_results)
282
+ all_writes = (command_results.flat_map(&:write_paths).compact +
283
+ collect_inner_write_paths(command_results)).uniq
284
+ all_reads = command_results.flat_map(&:read_paths).compact
285
+
286
+ if command_results.empty? && parse_warnings.any?
287
+ overall = Classification::UNKNOWN
288
+ overall_risk = Risk::HIGH
289
+ elsif command_results.any?
290
+ overall = Classification.max_severity(command_results.map(&:classification))
291
+ overall_risk = Risk.max_severity(command_results.map(&:risk))
292
+ else
293
+ overall = Classification::READONLY
294
+ overall_risk = Risk::LOW
295
+ end
296
+
297
+ expression_reason = nil
298
+ if overall != Classification::DANGEROUS && directories.any? { |d| system_path?(d) } && mutating_pipeline?(command_results)
299
+ overall = Classification::DANGEROUS
300
+ overall_risk = Risk::HIGH
301
+ expression_reason = expression_danger_reason(command_results, directories)
302
+ end
303
+
304
+ ExpressionResult.new(
305
+ expression: expression, classification: overall, risk: overall_risk,
306
+ directories: directories, write_paths: all_writes, read_paths: all_reads,
307
+ commands: command_results, redirects: all_redirects, parse_warnings: parse_warnings,
308
+ classification_reason: expression_reason
309
+ )
310
+ end
311
+
312
+ # Expression-level elevation needs evidence too: without a composed
313
+ # reason the guard falls back to "dangerous_ast" and guidance blames
314
+ # an unnamed pattern instead of the pipeline that trips the gate.
315
+ def expression_danger_reason(command_results, directories)
316
+ mutators = command_results.select { |r| mutator?(r) }
317
+ .map { |r| r.command.join(" ") }.uniq
318
+ named = mutators.first(EXPRESSION_MUTATOR_CAP)
319
+ named << "..." if mutators.size > EXPRESSION_MUTATOR_CAP
320
+ named = "a mutating command" if named.empty?
321
+ "mutating pipeline (#{named.join(', ')}) touches system directory #{directories.find { |d| system_path?(d) }}"
322
+ end
323
+
324
+ def mutator?(result)
325
+ Classification.severity(result.classification) >= Classification.severity(Classification::LOCAL_EFFECTS) ||
326
+ result.inner_commands.any? { |i| Classification.severity(i.classification) >= Classification.severity(Classification::LOCAL_EFFECTS) }
327
+ end
328
+
329
+ def mutating_pipeline?(command_results)
330
+ command_results.any? { |r| mutator?(r) }
331
+ end
332
+
333
+ def temp_path?(path)
334
+ p = path.to_s
335
+ p == "/tmp" || p.start_with?("/tmp/") || p == "/var/tmp" || p.start_with?("/var/tmp/") ||
336
+ p.include?("AppData/Local/Temp") || p.include?("AppData\\Local\\Temp")
337
+ end
338
+
339
+ def real_file_path?(path)
340
+ p = path.to_s
341
+ DEV_PATHS.none? { |dev| p == dev || p.start_with?(dev) }
342
+ end
343
+
344
+ def system_path?(path)
345
+ p = path.to_s.strip
346
+ return false if p.empty? || p.start_with?("-")
347
+
348
+ p = p[1..-2] if (p.start_with?('"') && p.end_with?('"')) || (p.start_with?("'") && p.end_with?("'"))
349
+ return false if p.empty? || p.start_with?("-")
350
+ return false if !p.include?("..") && temp_path?(p)
351
+
352
+ return true if p =~ %r{\A/(?:\*|\.+)?/?\z}
353
+ return true if p =~ %r{\A[a-zA-Z]:(?:[\\/]|\*|\.+)?\z}i
354
+ return true if ["~", "~/", "$HOME", "${HOME}"].include?(p)
355
+
356
+ if p =~ %r{\A([a-zA-Z]):[/\\](.*)\z}
357
+ parts = []
358
+ Regexp.last_match(2).tr("\\", "/").split("/").each { |part| part == ".." ? parts.pop : (parts << part unless [".", ""].include?(part)) }
359
+ win_norm = "#{Regexp.last_match(1).downcase}:/#{parts.join('/')}".downcase
360
+ return false if win_norm.include?("/appdata/local/temp/") || win_norm.end_with?("/appdata/local/temp")
361
+
362
+ return true if win_norm =~ %r{\A[a-z]:/(?:windows|program files|program files \(x86\)|system32)(?:/|\z)} ||
363
+ win_norm =~ %r{\A[a-z]:/users/?(?:\*|\.+)?/?\z} ||
364
+ win_norm =~ %r{\A[a-z]:/?(?:\*|\.+)?/?\z}
365
+
366
+ return false
367
+ end
368
+
369
+ expanded = safe_expand_path(p)
370
+ norm = Rubik::Platform.normalize_path(expanded)
371
+
372
+ return true if Rubik::Platform.dangerous_system_roots.include?(norm) || Rubik::Platform.drive_root?(expanded)
373
+
374
+ if norm.start_with?("/tmp/") || norm.start_with?("/var/tmp/") ||
375
+ norm.include?("/appdata/local/temp/") || norm.include?("\\appdata\\local\\temp\\")
376
+ return false
377
+ end
378
+
379
+ return true if SYSTEM_DIRS.any? { |sys| Rubik::Platform.path_under?(norm, sys) }
380
+
381
+ false
382
+ end
383
+
384
+ def safe_expand_path(path)
385
+ File.expand_path(path)
386
+ rescue ArgumentError, TypeError
387
+ # NUL byte / bad tilde / invalid encoding raise ArgumentError,
388
+ # non-String without to_str raises TypeError — fall back to the
389
+ # raw token.
390
+ path
391
+ end
392
+
393
+ def collect_directories(results)
394
+ dirs = []
395
+ results.each do |r|
396
+ dirs << r.argv[1] if r.command.any? && ["cd", "pushd"].include?(r.command[0]) && r.argv.size > 1
397
+ dirs.concat(r.directories) if r.directories
398
+ dirs.concat(extract_argv_directories(r.command, r.argv))
399
+ dirs.concat(collect_inner_directories(r.inner_commands))
400
+ end
401
+ dirs.compact.uniq
402
+ end
403
+
404
+ def collect_inner_directories(inners)
405
+ dirs = []
406
+ inners.each do |inner|
407
+ dirs.concat(extract_argv_directories(inner.command, inner.argv))
408
+ dirs.concat(collect_inner_directories(inner.inner_commands))
409
+ end
410
+ dirs
411
+ end
412
+
413
+ # Inner redirects were invisible to the top-level aggregation; their
414
+ # write targets bucket with the same WRITE_OPERATORS semantics as
415
+ # top-level ones.
416
+ def collect_inner_write_paths(results)
417
+ results.flat_map do |r|
418
+ writes, = extract_paths(inner_redirects(r))
419
+ writes + collect_inner_write_paths(Array(r.inner_commands))
420
+ end
421
+ end
422
+
423
+ def extract_argv_directories(command, argv)
424
+ return [] if command.empty? || argv.size <= 1
425
+
426
+ bin = command[0]
427
+ pos = case bin
428
+ when "find", "ls" then argv[1..].find { |a| !a.start_with?("-") }
429
+ when "cat", "head", "tail", "less", "more"
430
+ t = argv[1..].find { |a| !a.start_with?("-") && a.include?("/") }
431
+ File.dirname(t) if t
432
+ when "echo", "printf"
433
+ argv[1..].find { |a| !a.start_with?("-") && (system_path?(a) || a.include?("/")) }
434
+ end
435
+ pos ? [pos] : []
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
@@ -0,0 +1,9 @@
1
+ # $schema: ../../../schemas/command.schema.json
2
+ command: agent-browser
3
+ description: "Fast browser automation CLI for AI agents"
4
+ classification: EXTERNAL_EFFECTS
5
+ strict: false
6
+ global_options:
7
+ --help: {overrides: READONLY}
8
+ --version: {overrides: READONLY}
9
+ -V: {overrides: READONLY}
@@ -0,0 +1,25 @@
1
+ # $schema: ../../../schemas/command.schema.json
2
+ command: apt
3
+ description: "Debian package management tool"
4
+ strict: false
5
+ global_options:
6
+ --help: {overrides: READONLY}
7
+ subcommands:
8
+ list:
9
+ classification: READONLY
10
+ show:
11
+ classification: READONLY
12
+ search:
13
+ classification: READONLY
14
+ install:
15
+ classification: DANGEROUS
16
+ remove:
17
+ classification: DANGEROUS
18
+ purge:
19
+ classification: DANGEROUS
20
+ update:
21
+ classification: EXTERNAL_EFFECTS
22
+ upgrade:
23
+ classification: DANGEROUS
24
+ autoremove:
25
+ classification: DANGEROUS
@@ -0,0 +1,10 @@
1
+ # $schema: ../../../schemas/command.schema.json
2
+ command: awk
3
+ description: "Pattern scanning and text processing language"
4
+ classification: READONLY
5
+ strict: false
6
+ global_options:
7
+ --help: {overrides: READONLY}
8
+ options:
9
+ -i: {overrides: DANGEROUS, risk: HIGH}
10
+ --inplace: {overrides: DANGEROUS, risk: HIGH, aliases: [-i]}