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,954 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "fileutils"
6
+ require "shellwords"
7
+ require_relative "../platform"
8
+ require_relative "classifier"
9
+ require_relative "errors"
10
+ require_relative "redactor"
11
+
12
+ module Rubik
13
+ module Security
14
+ class Guard
15
+ DEFAULT_CRITICAL_PATTERNS = [
16
+ ".git/**",
17
+ ".env*",
18
+ "secrets.*",
19
+ "*.pem",
20
+ "*.key",
21
+ "id_rsa*"
22
+ ].freeze
23
+
24
+ # Pipe-to-interpreter (the curl|bash RCE shape). This pattern lives in
25
+ # the UN-BYPASSABLE fallback gate: verify_command_fallback_regex! runs
26
+ # even on allowlisted and safe-probe commands, so anything it matches
27
+ # cannot be allowlisted away — keep any widening surgical. The
28
+ # intermediaries between the pipe and the interpreter are flag-less
29
+ # (sudo bash, env sh); flag-bearing forms (sudo -u root bash) are
30
+ # already blocked earlier by the AST classifier's delegation rules
31
+ # when it is healthy, so the fallback does not need to cover them.
32
+ # Word boundaries on both ends also stop substring matches like
33
+ # "mycurl ... | sh" or "| shadowtool".
34
+ PIPE_TO_INTERPRETER_PATTERN = /
35
+ \b(?:curl|wget)\b.*\|\s*
36
+ (?:(?:sudo|doas|env|nohup|nice|stdbuf|xargs)\s+)*
37
+ (?:bash|zsh|dash|ksh|fish|sh|pwsh|python\d?|ruby|perl|node|php|lua)\b
38
+ /xi
39
+
40
+ DANGEROUS_COMMAND_PATTERNS = [
41
+ Regexp.new(
42
+ 'rm\s+(?:-[a-z0-9-]+\s+)*(?:-[a-z0-9]*r[a-z0-9]*f[a-z0-9]*|-[a-z0-9]*f[a-z0-9]*r[a-z0-9]*|' \
43
+ '(?:(?:-[a-z0-9]*r[a-z0-9]*|--recursive)\s+(?:-[a-z0-9]*f[a-z0-9]*|--force))|' \
44
+ '(?:(?:-[a-z0-9]*f[a-z0-9]*|--force)\s+(?:-[a-z0-9]*r[a-z0-9]*|--recursive)))' \
45
+ '\s+(?:-[a-z0-9-]+\s+)*(?:--\s+)?/(?:\*|\.+(?![a-zA-Z0-9_-])|(?![a-zA-Z0-9_.-]))',
46
+ Regexp::IGNORECASE
47
+ ),
48
+ PIPE_TO_INTERPRETER_PATTERN,
49
+ /:\(\)\{\s*:\|:&\s*\};:/, # fork bomb
50
+ %r{\bdd\s+.*(?:if=|of=/dev/)}i,
51
+ /\bmkfs(?:\.\w+)?\b/i,
52
+ /\b(?:shutdown|reboot|poweroff|halt)\b(?:\s+.*)?/i,
53
+ /\b(?:Format-Volume|Clear-Disk|Initialize-Disk|Remove-Partition|Stop-Computer|Restart-Computer)\b/i,
54
+ /\bdiskpart\b/i,
55
+ /\brm\s+.*--no-preserve-root/i,
56
+ %r{del\s+.*/s.*[a-z]:\\}i,
57
+ %r{erase\s+.*/s.*[a-z]:\\}i,
58
+ %r{rmdir\s+.*/s.*[a-z]:\\}i,
59
+ %r{rd\s+.*/s.*[a-z]:\\}i,
60
+ /format\s+[a-z]:/i,
61
+ /Remove-Item\s+.*-Recurse.*[a-z]:\\/i,
62
+ /%0\|%0/
63
+ ].freeze
64
+
65
+ # Display caps for the blocked-segment report (guidance rendering, not
66
+ # gate semantics): one segment's argv rendering and the one-line
67
+ # wrapper preview never exceed these.
68
+ SEGMENT_DISPLAY_CAP = 120
69
+ WRAPPER_PREVIEW_CAP = 60
70
+
71
+ # Synth-unsafe patterns. These catch RCE-y escapes a synthesis step might
72
+ # smuggle past the classifier (tuned for shell commands, not Ruby):
73
+ # `method(:x).call`, variable send/public_send/__send__, eval/binding.eval,
74
+ # and backticks/%x. The interpreter/eval lines precede the backtick and %x
75
+ # patterns so strings merely *mentioning* backticks aren't mislabelled.
76
+ # Symbol-level denials live in SYNTHESIS_CAPABILITIES below; the two
77
+ # layers are equivalence-locked by test_synthesis_capability_coverage.rb.
78
+ DANGEROUS_SYNTHESIS_PATTERNS = [
79
+ /\b(?:send|public_send|__send__)\s*[( \s]/,
80
+ /\.(?:send|public_send|__send__)\b/,
81
+ /\b(?:method|public_method|singleton_method)\s*(?:\(\s*[:"']|\s+[:"'])/i,
82
+ /\beval\s*[( \s]/,
83
+ /\bbinding\s*\.\s*eval\b/i,
84
+ /\b(?:instance_eval|class_eval|module_eval)\b/,
85
+ /\b(?:instance_variable_get|instance_variable_set)\b/,
86
+ /\b(?:const_set|const_get|remove_const)\b/,
87
+ /(?:^|[;{\n\r])\s*(?:system|exec|spawn)\b|\b(?:system|exec|spawn)\s*\(/,
88
+ /(?:^|[;{\n\r])\s*fork\b|\bfork\s*(?:do|\{|\()/,
89
+ /\bsyscall\s*\(/,
90
+ /\bexit!/,
91
+ /\bIO\s*\.\s*popen\b/,
92
+ /\bOpen3\s*\.\s*(?:capture2|capture2e|capture3|popen2|popen2e|popen3|pipeline)\b/,
93
+ /\bProcess\s*\.\s*(?:kill|fork|daemon|spawn|exec)\b/,
94
+ /\bKernel\s*\.\s*(?:exec|system|fork|spawn|syscall|exit!)\b/,
95
+ # Unmanaged background threads survive context disposal — the exact
96
+ # "Leaked Side Effects" anti-pattern (AGENTS.md §5).
97
+ /\bThread\s*\.\s*(?:new|fork|start)\b/,
98
+ /`[^`]*`/,
99
+ Regexp.new('%x\s*[({\[<|/]', Regexp::IGNORECASE)
100
+ ].freeze
101
+
102
+ DANGEROUS_SYSTEM_ROOTS = [
103
+ "/", "/root", "/home", "/etc", "/usr", "/var", "/bin", "/sbin", "/lib", "/lib64", "/opt", "/boot", "/dev", "/proc", "/sys"
104
+ ].freeze
105
+
106
+ MASK_PATTERNS = Rubik::Security::Redactor::PATTERNS
107
+
108
+ # Salt version/help probe'ları (Plan: safe-probe muafiyeti): a command
109
+ # whose ONLY arguments are these flags is read-only by construction.
110
+ SAFE_PROBE_FLAGS = ["--version", "-v", "--help", "-h"].freeze
111
+
112
+ attr_reader :audit_log_path
113
+
114
+ # Live-reconfigurable audit destination (ConfigWiring applies config
115
+ # changes in place — zero restart). Same blank-means-disabled semantics
116
+ # as for_config; non-string values pass through and surface loudly in
117
+ # log_audit's unconditional warn.
118
+ def audit_log_path=(val)
119
+ @audit_log_path = val.is_a?(String) && val.strip.empty? ? nil : val
120
+ end
121
+
122
+ # Default audit location, resolved at call time (not a load-time
123
+ # constant) so Platform.home_dir stubs in tests and runtime HOME
124
+ # changes are honored. Single source shared by the constructor
125
+ # default and for_config.
126
+ def self.default_audit_log_path
127
+ File.join(Rubik::Platform.home_dir, ".rubik", "audit.log")
128
+ end
129
+
130
+ # The one sanctioned way to build a policy-carrying Guard from operator
131
+ # config (security.* keys). Composition root and Registry.build_default
132
+ # both route through here so key names and disable-semantics live in
133
+ # exactly one place.
134
+ #
135
+ # audit_log_path semantics: default ONLY when the key is absent. An
136
+ # explicit nil (or blank string) DISABLES auditing — #log_audit already
137
+ # suppresses on nil/empty. (The previous composition call site's
138
+ # `fetch(...) || default` silently re-enabled auditing an operator had
139
+ # turned off.)
140
+ def self.for_config(config)
141
+ return new if config.nil? || !config.respond_to?(:fetch)
142
+
143
+ raw_audit = config.fetch("security.audit_log_path", default_audit_log_path)
144
+ audit_path =
145
+ case raw_audit
146
+ when nil, "" then nil
147
+ when String then raw_audit.strip.empty? ? nil : raw_audit
148
+ else
149
+ # Unconditional (not RUBIK_DEBUG-gated): Config's scalar parser
150
+ # turns "!config set security.audit_log_path false" into boolean
151
+ # false, which would otherwise reach log_audit and die in a
152
+ # swallowed TypeError — silently dropping every audit event.
153
+ warn "[Guard] security.audit_log_path has non-path value #{raw_audit.inspect}; auditing disabled"
154
+ nil
155
+ end
156
+ new(
157
+ audit_log_path: audit_path,
158
+ command_allowlist: Array(config.fetch("security.command_allowlist", [])),
159
+ # Stringy "false" from persisted config must disable probes too.
160
+ allow_safe_probes: config.fetch("security.allow_safe_probes", true).to_s.downcase != "false"
161
+ )
162
+ end
163
+
164
+ def initialize(custom_patterns: [], command_allowlist: [], allow_safe_probes: true,
165
+ audit_log_path: Guard.default_audit_log_path)
166
+ @critical_patterns = (DEFAULT_CRITICAL_PATTERNS + custom_patterns).uniq
167
+ @command_allowlist = normalize_allowlist(command_allowlist)
168
+ @allow_safe_probes = allow_safe_probes == true
169
+ @audit_log_path = audit_log_path
170
+ end
171
+
172
+ # security.allow_safe_probes — operator-managed toggle (live-reconfigurable
173
+ # via ConfigWiring, zero restart). Default ON.
174
+ def allow_safe_probes?
175
+ @allow_safe_probes
176
+ end
177
+
178
+ def allow_safe_probes=(val)
179
+ # Accept both booleans and stringy config values ("true"/"false").
180
+ @allow_safe_probes = val == true || val.to_s.downcase == "true"
181
+ end
182
+
183
+ # True when the command's arguments are exclusively version/help flags
184
+ # (e.g. `bash --version`, `node -v`, `git --help`); no other arguments.
185
+ # Gate is the operator toggle; shape detection lives in probe_shape? so
186
+ # blocked-error annotation can label probe-shaped commands even when the
187
+ # exemption is disabled — otherwise the variant-(a) probe guidance would
188
+ # be unreachable (a probe only reaches the block path when disabled).
189
+ def safe_probe?(cmd_str)
190
+ return false unless @allow_safe_probes
191
+
192
+ probe_shape?(cmd_str)
193
+ end
194
+
195
+ def probe_shape?(cmd_str)
196
+ str = cmd_str.to_s
197
+ return false if str.match?(/[\r\n]/)
198
+
199
+ tokens = begin
200
+ Shellwords.split(str)
201
+ rescue ArgumentError
202
+ str.split(/\s+/)
203
+ end
204
+ return false if tokens.size < 2
205
+
206
+ tokens[1..].all? { |t| SAFE_PROBE_FLAGS.include?(t) }
207
+ end
208
+
209
+ # Operator-controlled command allowlist (Plan: komut-allowlist-güvenlik-kapısı).
210
+ # Entries are token-prefixes: "node" allows every node invocation (broad);
211
+ # "node .gitnexus/run.cjs" allows only that script (narrow — preferred).
212
+ # Applied AFTER classifier verdict, BEFORE the raise, and the catastrophic
213
+ # fallback regex still runs afterwards (defense-in-depth is never skipped).
214
+ def command_allowlist
215
+ @command_allowlist.dup
216
+ end
217
+
218
+ def allowed_commands=(entries)
219
+ @command_allowlist = normalize_allowlist(entries)
220
+ end
221
+ alias set_allowed_commands allowed_commands=
222
+
223
+ def command_allowlisted?(ast, cmd_str: nil)
224
+ _ = cmd_str
225
+ return false if @command_allowlist.empty?
226
+ return false unless ast.respond_to?(:commands)
227
+
228
+ ast_commands = ast.commands.to_a
229
+ return false if ast_commands.empty?
230
+
231
+ # EVERY dangerous segment (recursively incl. pipeline inner commands)
232
+ # must match an entry — a single allowlisted prefix cannot rescue
233
+ # `allowlisted-cmd && rm -rf /` chains.
234
+ ast_commands.all? { |c| command_matches_allowlist?(c) }
235
+ end
236
+
237
+ private
238
+
239
+ def normalize_allowlist(entries)
240
+ Array(entries).filter_map do |e|
241
+ tokens = e.to_s.strip.split(/\s+/).reject(&:empty?)
242
+ tokens unless tokens.empty?
243
+ end
244
+ end
245
+
246
+ def canonical_binary_path(bin)
247
+ return nil if bin.nil? || bin.to_s.empty?
248
+
249
+ str = bin.to_s
250
+ if str.include?("/") || str.include?("\\") || File.exist?(str)
251
+ Rubik::Platform.normalize_path(File.expand_path(str))
252
+ else
253
+ resolved = Rubik::Platform.which(str)
254
+ resolved ? Rubik::Platform.normalize_path(resolved) : nil
255
+ end
256
+ end
257
+
258
+ def binary_allowlist_match?(arg, token)
259
+ return true if arg == token
260
+
261
+ c_arg = canonical_binary_path(arg)
262
+ c_token = canonical_binary_path(token)
263
+
264
+ return true if c_arg && c_token && Rubik::Platform.path_equal?(c_arg, c_token)
265
+
266
+ false
267
+ end
268
+
269
+ def command_matches_allowlist?(result)
270
+ # Missing argv (e.g. empty invocation) can never be allowlisted.
271
+ argv = result.respond_to?(:argv) ? result.argv : nil
272
+ return false if argv.nil? || argv.empty?
273
+
274
+ # Dangerous shell builtins (`source`, `.`) are never rescuable by
275
+ # allowlist: they execute arbitrary file content by design.
276
+ binary = argv[0].to_s
277
+ return false if Rubik::Security::Classifier::Matcher::BUILTIN_DANGEROUS_CMDS.include?(binary)
278
+
279
+ # W6: If this command wraps inner commands, EVERY inner command must also match allowlist
280
+ if result.respond_to?(:inner_commands) && result.inner_commands && !result.inner_commands.empty? && !result.inner_commands.all? do |inner|
281
+ command_matches_allowlist?(inner)
282
+ end
283
+ return false
284
+ end
285
+
286
+ @command_allowlist.any? do |entry|
287
+ next false if entry.length > argv.length
288
+
289
+ entry.each_with_index.all? do |token, i|
290
+ arg = argv[i]
291
+ if i.zero?
292
+ binary_allowlist_match?(arg, token)
293
+ else
294
+ arg == token
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ public
301
+
302
+ def verify_path_write!(path)
303
+ return true if path.nil? || path.to_s.strip.empty?
304
+
305
+ raw = Rubik::Platform.normalize_path_raw(path.to_s)
306
+ expanded = Rubik::Platform.normalize_path_raw(File.expand_path(path.to_s))
307
+
308
+ # Check raw relative path, expanded path, and relative-from-pwd
309
+ candidates = [raw, expanded]
310
+ begin
311
+ pwd_raw = Rubik::Platform.normalize_path_raw(Dir.pwd)
312
+ candidates << expanded.delete_prefix("#{pwd_raw}/") if expanded.start_with?("#{pwd_raw}/")
313
+ rescue SystemCallError, ArgumentError
314
+ nil
315
+ end
316
+
317
+ candidates.uniq.each do |c|
318
+ if critical_path?(c)
319
+ log_audit("blocked_write", path: path.to_s, reason: "critical_path")
320
+ raise BlockedWriteError, "Write blocked on critical path: #{path}"
321
+ end
322
+ end
323
+
324
+ # Symlink re-check (fail-closed): the lexical candidates above cannot
325
+ # see `foo -> .git/config`. Resolve the deepest EXISTING ancestor and
326
+ # re-check; a missing tail is reattached lexically so ordinary
327
+ # new-file writes resolve to their lexical equivalent. A nil here
328
+ # means containment is unprovable (symlink loop / permission wall).
329
+ resolved = Rubik::Platform.resolved_path(path.to_s, strict: true)
330
+ if resolved.nil?
331
+ log_audit("blocked_write", path: path.to_s, reason: "path_resolution_failed")
332
+ raise BlockedWriteError, "Write blocked: path could not be safely resolved: #{path}"
333
+ end
334
+ if critical_path?(resolved)
335
+ log_audit("blocked_write", path: path.to_s, reason: "critical_path_symlink", resolved: resolved)
336
+ raise BlockedWriteError, "Write blocked on critical path (symlink-resolved): #{path} -> #{resolved}"
337
+ end
338
+ true
339
+ end
340
+
341
+ def verify_root_safety!(path)
342
+ return true if path.nil? || path.to_s.strip.empty?
343
+
344
+ expanded = File.expand_path(path.to_s)
345
+ normalized = Rubik::Platform.normalize_path(expanded)
346
+ dangerous_roots = Rubik::Platform.dangerous_system_roots
347
+
348
+ if dangerous_roots.include?(normalized) ||
349
+ Rubik::Platform.drive_root?(expanded) ||
350
+ Rubik::Platform.path_equal?(expanded, Rubik::Platform.home_dir)
351
+ log_audit("blocked_root_addition", path: expanded, reason: "dangerous_system_root")
352
+ raise BlockedWriteError, "Adding dangerous system root as workspace is blocked: #{path} (#{expanded})"
353
+ end
354
+ true
355
+ end
356
+
357
+ def critical_path?(path)
358
+ norm_path = path.to_s.tr("\\", "/").gsub(%r{^\./}, "")
359
+ @critical_patterns.any? do |pattern|
360
+ File.fnmatch?(pattern, norm_path, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
361
+ File.fnmatch?("**/#{pattern}", norm_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
362
+ end
363
+ end
364
+
365
+ # allow_unknown: the ONLY sanctioned true is the synthesis pre-check —
366
+ # Ruby source ("->(args) { … }") legitimately classifies UNKNOWN through
367
+ # the shell classifier, and synthesis carries its own deny layers
368
+ # (DANGEROUS_SYNTHESIS_PATTERNS + AST walk). Shell-gate callers must keep
369
+ # the default: an UNKNOWN verdict means an unvetted binary, and a binary
370
+ # the database cannot classify is exactly the one that must not run
371
+ # without operator approval.
372
+ def verify_command_safety!(cmd, shell: nil, allow_unknown: false)
373
+ cmd_str = cmd.to_s
374
+ begin
375
+ ast = Rubik::Security::Classifier.classify(cmd_str, shell: shell)
376
+ if ast.classification == :dangerous
377
+ # Safe-probe exemption (highest-priority friction fix): run the
378
+ # catastrophic fallback regex first — it never loosens.
379
+ # Strictly single, isolated command without inner commands or chains.
380
+ single_cmd = ast.respond_to?(:commands) && ast.commands.to_a.size == 1 &&
381
+ ast.respond_to?(:parse_warnings) && ast.parse_warnings.to_a.empty? &&
382
+ (!ast.commands.first.respond_to?(:inner_commands) || ast.commands.first.inner_commands.nil? || ast.commands.first.inner_commands.empty?)
383
+ if single_cmd && safe_probe?(cmd_str)
384
+ verify_command_fallback_regex!(cmd_str)
385
+ log_audit("safe_probe", command: cmd_str, reason: "safe_probe_exemption")
386
+ return true
387
+ end
388
+ walk_reason = dangerous_reason(ast.commands)
389
+ # Expression-level elevation marks no single command dangerous —
390
+ # the per-command walk bottoms out at its sentinel while the
391
+ # composed evidence travels on the ExpressionResult itself.
392
+ expression_reason = walk_reason == "dangerous_ast" ? expression_level_reason(ast) : nil
393
+ reason = expression_reason || walk_reason
394
+ # Operator allowlist gate (fail-closed default): the entry must
395
+ # cover EVERY dangerous segment recursively; catastrophic fallback
396
+ # regex still runs afterwards — allowlisting never bypasses it.
397
+ # A bug in the gate itself must fail closed, not fall through to
398
+ # the regex-only downgrade below.
399
+ allowlisted = begin
400
+ command_allowlisted?(ast, cmd_str: cmd_str)
401
+ rescue StandardError => e
402
+ warn "[Guard] allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
403
+ false
404
+ end
405
+ if allowlisted
406
+ log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist")
407
+ verify_command_fallback_regex!(cmd_str)
408
+ return true
409
+ end
410
+ # Enrichment must never be able to un-block: a bug in the report
411
+ # walk downgrades to nil here, the raise below still fires.
412
+ report = begin
413
+ dangerous_segment_report(ast.commands)
414
+ rescue StandardError => e
415
+ warn "[Guard] segment report failed: #{e.class}: #{e.message}"
416
+ nil
417
+ end
418
+ log_audit("blocked_command", command: cmd_str, reason: reason,
419
+ dangerous_segments: segment_count(report))
420
+ # probe_shape? (not safe_probe?): probes only reach this raise when
421
+ # the exemption is disabled, so safe_probe? would be constantly
422
+ # false and the variant-(a) guidance dead code.
423
+ raise BlockedWriteError.new(
424
+ "Dangerous command execution blocked: #{cmd_str} (reason: #{reason})",
425
+ kind: :dangerous_command,
426
+ blocked_command: cmd_str,
427
+ rule_name: dangerous_rule_name(ast.commands) || (expression_reason ? "expression_level" : "dangerous_pattern"),
428
+ probe_like: probe_shape?(cmd_str),
429
+ segment_report: report,
430
+ classification_reason: expression_reason
431
+ )
432
+ end
433
+
434
+ # UNKNOWN is fail-closed, not pass-through: the matcher rates every
435
+ # database-less binary unknown/high, so letting it fall through to
436
+ # the catastrophic-only regex would run `socat`/`nmap`/any unlisted
437
+ # tool with zero approval while the tool copy advertises fail-closed.
438
+ # Allowlist rescue unchanged (every top-level segment must match).
439
+ # No safe-probe exemption: an unknown binary's --version still
440
+ # executes an unvetted binary — the allowlist is the valve.
441
+ if ast.classification == :unknown && !allow_unknown
442
+ # Same fail-closed discipline as the dangerous branch: gate
443
+ # trouble must never convert this block into a pass.
444
+ allowlisted = begin
445
+ command_allowlisted?(ast, cmd_str: cmd_str)
446
+ rescue StandardError => e
447
+ warn "[Guard] allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
448
+ false
449
+ end
450
+ if allowlisted
451
+ log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist")
452
+ verify_command_fallback_regex!(cmd_str)
453
+ return true
454
+ end
455
+ log_audit("blocked_command", command: cmd_str, reason: "unknown_command")
456
+ raise BlockedWriteError.new(
457
+ "Unknown command execution blocked (fail-closed): #{cmd_str}. The binary is not in the " \
458
+ "command database, so its effects cannot be classified. If it is genuinely safe, the " \
459
+ "operator can allowlist the exact prefix: !allow-cmd add \"<binary> [args]\"",
460
+ kind: :unknown_command,
461
+ blocked_command: cmd_str,
462
+ rule_name: "unknown_command",
463
+ probe_like: probe_shape?(cmd_str),
464
+ segment_report: nil
465
+ )
466
+ end
467
+
468
+ return true if [:readonly, :local_effects, :external_effects].include?(ast.classification) && ast.parse_warnings.empty?
469
+ rescue BlockedWriteError
470
+ raise
471
+ rescue StandardError => e
472
+ # Unconditional warn + audit trail: a classifier crash downgrades
473
+ # this verification to the narrow fallback regex, silently skipping
474
+ # allowlist/probe logic — a security-observable downgrade nobody
475
+ # should need RUBIK_DEBUG to notice.
476
+ warn "[WARN] AST classification error, falling back to regex gate: #{e.message}"
477
+ log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}")
478
+ end
479
+
480
+ verify_command_fallback_regex!(cmd_str)
481
+ end
482
+
483
+ def dangerous_reason(commands)
484
+ commands.each do |c|
485
+ next unless c.classification == :dangerous
486
+
487
+ # Dangerous INNERS only: recursing into safe inners would shadow
488
+ # the command's own reason with the "dangerous_ast" sentinel —
489
+ # which must stay exclusive to expression-level elevations (the
490
+ # raise site uses it as the expression-case signal).
491
+ inner = nil
492
+ inner = dangerous_reason(c.inner_commands) if c.respond_to?(:inner_commands) && c.inner_commands&.any? { |i| i.classification == :dangerous }
493
+ return inner || (c.respond_to?(:classification_reason) && c.classification_reason) || "dangerous_command"
494
+ end
495
+ "dangerous_ast"
496
+ end
497
+
498
+ # Innermost dangerous segment's rule name — surfaced in blocked-command
499
+ # guidance so the operator knows exactly which rule matched and what
500
+ # prefix to allowlist (e.g. rule: bash, rule: rm).
501
+ def dangerous_rule_name(commands)
502
+ commands.each do |c|
503
+ next unless c.classification == :dangerous
504
+
505
+ inner = dangerous_rule_name(c.inner_commands) if c.respond_to?(:inner_commands) && c.inner_commands&.any?
506
+ return inner || (c.respond_to?(:matched_rule) && c.matched_rule)
507
+ end
508
+ nil
509
+ end
510
+
511
+ # Expression-level elevations (Engine#build_expression_result) are
512
+ # dangerous with no dangerous command to name — the composed evidence
513
+ # rides on the ExpressionResult itself. Presence (non-nil) is the
514
+ # expression-case signal.
515
+ def expression_level_reason(ast)
516
+ return nil unless ast.respond_to?(:classification_reason)
517
+
518
+ reason = ast.classification_reason.to_s.strip
519
+ reason.empty? ? nil : reason
520
+ end
521
+
522
+ # Structured report for wrapper/multi-segment blocked-command guidance:
523
+ # every innermost dangerous segment with its trigger, the sibling
524
+ # commands that passed, a one-line wrapper preview, and the allowlist
525
+ # entries that would actually open the gate. Plain data only —
526
+ # BlockedWriteError must stay loadable without the classifier stack.
527
+ # nil when no single command is dangerous (expression-level elevations
528
+ # keep the scalar rule_name guidance path).
529
+ def dangerous_segment_report(commands)
530
+ segments = []
531
+ safe = []
532
+ safe_top_argv = []
533
+ wrappers = []
534
+ collect_dangerous_units(commands, 0, segments, safe, safe_top_argv, wrappers)
535
+ return nil if segments.empty?
536
+
537
+ # The gate (command_allowlisted?) matches EVERY top-level command,
538
+ # safe ones included — the entry set must cover them all to be
539
+ # honest: wrapper prefix + top-level dangerous segments + top-level
540
+ # safe units.
541
+ entries = wrappers.map { |w| wrapper_allowlist_entry(w) }
542
+ entries += segments.select { |s| s[:depth].zero? }.map { |s| allowlist_entry_for_argv(s[:argv]) }
543
+ entries += safe_top_argv.map { |argv| allowlist_entry_for_argv(argv) }
544
+
545
+ {
546
+ segments: segments,
547
+ safe: safe.uniq,
548
+ wrapper_preview: wrappers.empty? ? nil : wrapper_preview(wrappers.first),
549
+ allowlist_entries: entries.uniq
550
+ }.freeze
551
+ end
552
+
553
+ # Depth-first walk over the classified command tree. A dangerous
554
+ # command whose danger comes from dangerous inners contributes those
555
+ # inner leaves instead of itself (bash -c "rm -rf /" reports the inner
556
+ # rm, not the wrapper); a dangerous leaf is a segment; non-dangerous
557
+ # leaves join the passed roll call. Top-level wrappers (dangerous only
558
+ # through their inners) are tracked separately for preview + hint;
559
+ # top-level safe units keep their argv for the gate-honest entry set.
560
+ def collect_dangerous_units(commands, depth, segments, safe, safe_top_argv, wrappers)
561
+ Array(commands).each do |c|
562
+ inners = Array(c.inner_commands)
563
+ before = segments.size
564
+ collect_dangerous_units(inners, depth + 1, segments, safe, safe_top_argv, wrappers)
565
+ added = segments.size - before
566
+
567
+ if c.classification == :dangerous
568
+ if added.positive?
569
+ wrappers << c if depth.zero?
570
+ else
571
+ segments << dangerous_segment_data(c, depth)
572
+ end
573
+ elsif inners.empty?
574
+ safe << safe_segment_name(c)
575
+ safe_top_argv << c.argv.dup if depth.zero?
576
+ end
577
+ end
578
+ end
579
+
580
+ def dangerous_segment_data(result, depth)
581
+ argv = Array(result.argv)
582
+ {
583
+ display: segment_display(display_tokens(argv, result)),
584
+ argv: argv.dup.freeze,
585
+ rule: result.matched_rule,
586
+ option: result.overriding_option,
587
+ reason: result.classification_reason,
588
+ depth: depth
589
+ }
590
+ end
591
+
592
+ # Redirect-bearing segments (delegated inners) render their effects,
593
+ # not just their argv: `echo pwned > /etc/shadow`, not `echo pwned`.
594
+ # Flat operator/target tokens keep segment_display's quoting correct;
595
+ # argv itself stays pure — allowlist entries derive from it.
596
+ def display_tokens(argv, result)
597
+ reds = result.respond_to?(:redirects) ? Array(result.redirects) : []
598
+ reds.empty? ? argv : argv + reds.flat_map { |r| [r.operator, r.target] }
599
+ end
600
+
601
+ # Subcommand-aware roll call of the units that passed ("git checkout",
602
+ # not just "git") — matches the operator's mental model of the script.
603
+ def safe_segment_name(result)
604
+ result.command.join(" ")
605
+ end
606
+
607
+ # One-line wrapper preview for the guidance first line. The delegated
608
+ # payload (the whole -c script) is the argv token that contains
609
+ # whitespace — elide it so the full script is never re-embedded in the
610
+ # message (the defect this report exists to fix).
611
+ def wrapper_preview(result)
612
+ argv = Array(result.argv)
613
+ tokens = argv.dup
614
+ tokens[-1] = "'...'" if tokens.size > 1 && tokens[-1].to_s.match?(/\s/)
615
+ preview = segment_display(tokens)
616
+ preview.length > WRAPPER_PREVIEW_CAP ? "#{preview[0, WRAPPER_PREVIEW_CAP - 1]}..." : preview
617
+ end
618
+
619
+ # Shortest top-level argv prefix that actually satisfies the gate's
620
+ # token-prefix match (command_allowlisted?): the wrapper binary up to
621
+ # and including its delegation flag for flag-value payloads
622
+ # ("bash -c", "bash -e -c"), the bare binary for rest-argv wrappers
623
+ # ("sudo", "xargs"). Deriving it from delegation_source keeps the hint
624
+ # gate-honest for flag permutations a naive first-two-tokens guess
625
+ # would miss.
626
+ def wrapper_allowlist_entry(wrapper)
627
+ argv = Array(wrapper.argv)
628
+ return argv.first.to_s if argv.size <= 1
629
+
630
+ first_inner = Array(wrapper.inner_commands).first
631
+ if first_inner&.delegation_mode.to_s == "flag_value_is_expression"
632
+ idx = argv.index(first_inner.delegation_source)
633
+ return argv[0..idx].join(" ") if idx&.positive?
634
+ end
635
+ argv.first.to_s
636
+ end
637
+
638
+ # An allowlist entry can only carry whitespace-free tokens:
639
+ # normalize_allowlist splits entries on whitespace, so an argv token
640
+ # containing spaces ("feature x") can never be matched by an entry.
641
+ # Prefix up to (excluding) the first such token.
642
+ def allowlist_entry_for_argv(argv)
643
+ prefix = []
644
+ Array(argv).each do |token|
645
+ break if token.to_s.match?(/\s/)
646
+
647
+ prefix << token
648
+ end
649
+ prefix.empty? ? Array(argv).first.to_s : prefix.join(" ")
650
+ end
651
+
652
+ # Display-only rendering: collapse newlines, single-quote tokens that
653
+ # contain whitespace, hard-cap length. Shellwords.escape would
654
+ # backslash-quote every token (noise on Windows paths) — quote
655
+ # conditionally instead.
656
+ def segment_display(argv)
657
+ text = argv.map { |t| t.to_s.tr("\r\n", " ") }
658
+ .map { |t| t.match?(/\s/) ? "'#{t}'" : t }
659
+ .join(" ")
660
+ text.length > SEGMENT_DISPLAY_CAP ? "#{text[0, SEGMENT_DISPLAY_CAP - 1]}..." : text
661
+ end
662
+
663
+ def segment_count(report)
664
+ report ? report[:segments].size : 0
665
+ end
666
+
667
+ def verify_command_fallback_regex!(cmd_str)
668
+ return true unless DANGEROUS_COMMAND_PATTERNS.any? { |p| cmd_str.match?(p) }
669
+
670
+ log_audit("blocked_command", command: cmd_str, reason: "dangerous_pattern")
671
+ raise BlockedWriteError.new(
672
+ "Dangerous command execution blocked: #{cmd_str}",
673
+ kind: :dangerous_pattern,
674
+ blocked_command: cmd_str,
675
+ rule_name: "dangerous_pattern"
676
+ )
677
+ end
678
+
679
+ # ── Synthesis capability table (Candidate 07) ───────────────────────
680
+ # The ONE declaration point for everything the synthesis AST walk
681
+ # denies (ARCHITECTURE.md §2 "single declaration point" doctrine; §8.3
682
+ # "the seam is enforced where it is declared"). Each row is a capability
683
+ # a synthesized tool must never exercise; layer fields say WHERE the
684
+ # denial bites:
685
+ # nodes — RubyVM node types refused on sight
686
+ # calls — call names refused on ANY call shape (:fcall/:vcall
687
+ # AND :call/:qcall/:attrasgn)
688
+ # bare_calls — names refused on :fcall/:vcall only; receiver forms
689
+ # stay legal (ctx.require is the coeffect accessor)
690
+ # consts — constants refused as call RECEIVERS only
691
+ # any_const — constants refused on ANY :const/:colon3 reference,
692
+ # anti `X = TCPSocket; X.new` aliasing
693
+ # The FORBIDDEN_SYNTHESIS_* constants below are derived projections of
694
+ # this table, kept under their historical names for the walk's call
695
+ # sites; test_synthesis_capability_coverage.rb locks table <-> constant
696
+ # <-> behavior equivalence so the derivation cannot drift. Note: the
697
+ # :block node (grouped expressions) intentionally has NO row —
698
+ # `(a || b).to_i`-style expressions must stay allowed.
699
+ SynthesisCapability = Struct.new(:name, :reason, :nodes, :calls,
700
+ :bare_calls, :consts, :any_const,
701
+ keyword_init: true)
702
+
703
+ SYNTHESIS_CAPABILITIES = [
704
+ SynthesisCapability.new(
705
+ name: :shell_execution,
706
+ reason: "backtick/%x shell escapes execute outside every Ruby-level guard",
707
+ nodes: [:xstr, :dxstr]
708
+ ),
709
+ SynthesisCapability.new(
710
+ name: :code_definition,
711
+ reason: "method/class definitions mutate the eval target; synthesized payloads are single lambdas",
712
+ nodes: [:defn, :defs, :class, :sclass]
713
+ ),
714
+ SynthesisCapability.new(
715
+ name: :method_table_mutation,
716
+ reason: "alias/undef rewrite the method table behind the guard",
717
+ nodes: [:alias, :undef]
718
+ ),
719
+ SynthesisCapability.new(
720
+ name: :global_state,
721
+ reason: "global variables are process-wide ambient state the host context cannot revert",
722
+ nodes: [:gvar, :gasgn]
723
+ ),
724
+ SynthesisCapability.new(
725
+ name: :dynamic_execution,
726
+ reason: "string eval and Binding turn data into code outside the AST walk",
727
+ calls: [:eval, :instance_eval, :class_eval, :module_eval, :binding]
728
+ ),
729
+ SynthesisCapability.new(
730
+ name: :process_control,
731
+ reason: "subprocess spawn and process lifecycle control escape the sandbox and audit trail; " \
732
+ "trap/at_exit install process-wide hooks the host cannot revert — Signal.trap can " \
733
+ "swallow the host REPL's Interrupt/exit handling",
734
+ calls: [:system, :exec, :spawn, :fork, :syscall, :exit!, :trap, :at_exit,
735
+ :popen, :capture2, :capture2e, :capture3, :popen2, :popen2e,
736
+ :popen3, :pipeline, :kill, :daemon],
737
+ consts: [:Process, :Thread, :Signal, :Open3]
738
+ ),
739
+ SynthesisCapability.new(
740
+ name: :reflection,
741
+ reason: "send/method-object families bypass static call-name checks (the method(:x).call escape)",
742
+ calls: [:send, :public_send, :__send__,
743
+ :method, :public_method, :singleton_method]
744
+ ),
745
+ SynthesisCapability.new(
746
+ name: :state_introspection,
747
+ reason: "object internals and object-graph walks fish for live objects and sidestep the AST deny surface (the method(:x).call family at runtime)",
748
+ calls: [:instance_variable_get, :instance_variable_set],
749
+ consts: [:ObjectSpace]
750
+ ),
751
+ SynthesisCapability.new(
752
+ name: :namespace_mutation,
753
+ reason: "const_get/const_set/remove_const read and rewrite constant namespaces at runtime",
754
+ calls: [:const_get, :const_set, :remove_const]
755
+ ),
756
+ SynthesisCapability.new(
757
+ name: :filesystem,
758
+ reason: "raw file/IO handles bypass the OutputView and every guard write check",
759
+ calls: [:open, :read, :write, :binread, :binwrite, :delete, :unlink, :rm_rf],
760
+ consts: [:IO, :File, :Dir, :FileUtils, :STDIN, :STDOUT, :STDERR, :ARGF]
761
+ ),
762
+ SynthesisCapability.new(
763
+ name: :code_loading,
764
+ reason: "require/load execute files outside every guard. :require/:require_relative must NOT live in calls: " \
765
+ "ctx.require('database') is the canonical coeffect accessor (receiver call) and every synthesized " \
766
+ "tool depends on it — the bare forms are the escape. Kernel.require/Kernel.load are already caught " \
767
+ "via :Kernel in runtime_root; obj.load would load arbitrary code or deserialize untrusted state",
768
+ calls: [:load, :autoload],
769
+ bare_calls: [:require, :require_relative, :load, :autoload]
770
+ ),
771
+ SynthesisCapability.new(
772
+ name: :runtime_root,
773
+ reason: "core runtime singletons (Kernel/RubyVM/Class/Module/...) re-open every denied capability",
774
+ consts: [:Kernel, :RubyVM, :Object, :Class, :Module, :BasicObject]
775
+ ),
776
+ SynthesisCapability.new(
777
+ name: :ambient_state,
778
+ reason: "ENV reads expose provider keys / ambient config and mutate process-wide state the host cannot revert",
779
+ consts: [:ENV]
780
+ ),
781
+ SynthesisCapability.new(
782
+ name: :network_serialization,
783
+ reason: "sockets and Marshal have no legitimate synthesized-tool use; receiver-only checks leave " \
784
+ "`X = TCPSocket; X.new` (:casgn aliasing) wide open, so their mere appearance is a refusal",
785
+ any_const: [:Socket, :IPSocket, :TCPSocket, :TCPServer, :UDPSocket,
786
+ :UNIXSocket, :UNIXServer, :Net, :Marshal]
787
+ )
788
+ ].map do |cap|
789
+ # Deep-freeze: the ONE declaration point for a security gate must be
790
+ # immutable by construction, not by convention — Struct#freeze alone
791
+ # leaves the member arrays mutable.
792
+ cap.members.each { |member| cap[member].freeze if cap[member].is_a?(Array) }
793
+ cap.freeze
794
+ end.freeze
795
+
796
+ # Fold one capability layer into its denial list. Load-time, pure;
797
+ # test_synthesis_capability_coverage.rb pins this helper as the ONLY
798
+ # fold from table to denial constants.
799
+ def self.synthesis_capability_layer(field)
800
+ SYNTHESIS_CAPABILITIES.flat_map { |cap| cap[field].to_a }.freeze
801
+ end
802
+
803
+ FORBIDDEN_SYNTHESIS_NODES = synthesis_capability_layer(:nodes)
804
+ FORBIDDEN_SYNTHESIS_CALLS = synthesis_capability_layer(:calls)
805
+ FORBIDDEN_SYNTHESIS_BARE_CALLS = synthesis_capability_layer(:bare_calls)
806
+ FORBIDDEN_SYNTHESIS_CONSTS = synthesis_capability_layer(:consts)
807
+ FORBIDDEN_SYNTHESIS_NETWORK_CONSTS = synthesis_capability_layer(:any_const)
808
+
809
+ # The ONE synthesis gate: every site about to eval synthesized code
810
+ # comes through here (synthesis builtins AND PreflightRunner's
811
+ # spawn-boundary re-check). A guard lacking verify_synthesis_safety!
812
+ # is downgraded to the shell-only gate — the duck-typed fallback is a
813
+ # test-double affordance, but skipping the entire AST deny surface is
814
+ # security-observable, so the downgrade is loud. $stderr.puts, not
815
+ # Kernel#warn: warn is silenced when $VERBOSE is nil (bundler/rake
816
+ # -W0) — same ruling as Log.emit. A failing emit is fail-closed AND
817
+ # honest: refusal with the real cause, never a misattributed
818
+ # "dangerous operations".
819
+ def self.run_synthesis_gate(guard, code)
820
+ if guard.respond_to?(:verify_synthesis_safety!)
821
+ guard.verify_synthesis_safety!(code)
822
+ else
823
+ begin
824
+ $stderr.puts "[Synthesize] guard lacks verify_synthesis_safety!; downgraded to shell-only gate" # rubocop:disable Style/StderrPuts
825
+ rescue StandardError => e
826
+ raise BlockedWriteError,
827
+ "guard downgrade warn could not be written to stderr (#{e.class}: #{e.message}); refusing to eval"
828
+ end
829
+ guard.verify_command_safety!(code)
830
+ end
831
+ end
832
+
833
+ # Reject synthesized Ruby before it is ever eval'd.
834
+ #
835
+ # Defense-in-depth architecture:
836
+ # 1. verify_command_safety! checks shell command patterns.
837
+ # 2. Fast regex pattern matching (DANGEROUS_SYNTHESIS_PATTERNS) intercepts known keywords.
838
+ # 3. RubyVM::AbstractSyntaxTree semantic walk checks normalized node types, method calls,
839
+ # and receiver constants against explicit deny rules to block AST-level evasion vectors.
840
+ #
841
+ # Raises Rubik::Security::BlockedWriteError with an actionable message.
842
+ def verify_synthesis_safety!(code)
843
+ code_str = code.to_s
844
+ # allow_unknown: synthesized Ruby is not shell — its UNKNOWN verdicts
845
+ # are "not shell-shaped" noise (see verify_command_safety! comment).
846
+ # The shell-classifier layer here still blocks dangerous shapes and
847
+ # still runs the catastrophic regex; synthesis-specific escapes are
848
+ # denied by DANGEROUS_SYNTHESIS_PATTERNS and the AST walk below.
849
+ verify_command_safety!(code_str, allow_unknown: true)
850
+
851
+ matched = DANGEROUS_SYNTHESIS_PATTERNS.find { |p| code_str.match?(p) }
852
+ if matched
853
+ log_audit("blocked_synthesis", code: code_str, reason: "dangerous_synthesis_pattern")
854
+ raise BlockedWriteError, "Dangerous synthesis code blocked by security guard: #{matched.inspect}"
855
+ end
856
+
857
+ synthesize_safety_walk(code_str)
858
+ end
859
+
860
+ def synthesize_safety_walk(code_str)
861
+ ast = begin
862
+ RubyVM::AbstractSyntaxTree.parse(code_str)
863
+ rescue ArgumentError, SyntaxError
864
+ # Code with syntax errors cannot execute; let synthesis eval safely report
865
+ # a SyntaxError to the user rather than misclassifying a syntax typo as a security breach.
866
+ nil
867
+ end
868
+
869
+ return true if ast.nil?
870
+
871
+ synthesize_safety_check_node(ast)
872
+
873
+ true
874
+ end
875
+
876
+ # Recursively walk the AST, refusing if any disallowed construct is encountered.
877
+ def synthesize_safety_check_node(node)
878
+ return true unless node.respond_to?(:type)
879
+
880
+ type = node.type.to_s.downcase.to_sym
881
+ reject_unsafe(node, "node type :#{type}") if FORBIDDEN_SYNTHESIS_NODES.include?(type)
882
+
883
+ case type
884
+ when :const, :colon3
885
+ # :const covers Net::HTTP / Marshal.dump receivers and `X = Socket`
886
+ # RHS values; :colon3 covers top-level scoped `::TCPSocket` — the
887
+ # one-character bypass (children[0] is the name symbol in both).
888
+ name = node.children[0]
889
+ reject_unsafe(node, "reference to forbidden constant :#{name}") if FORBIDDEN_SYNTHESIS_NETWORK_CONSTS.include?(name)
890
+ when :fcall, :vcall
891
+ name = node.children[0]
892
+ reject_unsafe(node, "call to :#{name}") if FORBIDDEN_SYNTHESIS_CALLS.include?(name)
893
+ reject_unsafe(node, "bare call to :#{name}") if FORBIDDEN_SYNTHESIS_BARE_CALLS.include?(name)
894
+ when :call, :qcall, :attrasgn
895
+ recv = node.children[0]
896
+ name = node.children[1]
897
+
898
+ reject_unsafe(node, "call to :#{name}") if FORBIDDEN_SYNTHESIS_CALLS.include?(name)
899
+
900
+ # Receiver const check must accept BOTH const node shapes: :const
901
+ # (Net::HTTP) and :colon3 (::ENV, ::Thread.new, ::Kernel.require —
902
+ # top-level scoping would otherwise re-open every denylist entry).
903
+ if recv.respond_to?(:type) && [:const, :colon3].include?(recv.type.to_s.downcase.to_sym)
904
+ const_name = recv.children[0]
905
+ reject_unsafe(node, "access to #{const_name}.#{name}") if FORBIDDEN_SYNTHESIS_CONSTS.include?(const_name)
906
+ end
907
+ end
908
+
909
+ node.children.compact.each do |c|
910
+ synthesize_safety_check_node(c) if c.respond_to?(:type)
911
+ end
912
+ true
913
+ end
914
+
915
+ def reject_unsafe(node, detail = nil)
916
+ kind = node.type.inspect
917
+ reason = detail ? "unsafe_#{detail.to_s.gsub(/\s+/, '_')}" : "unsafe_node_#{kind}"
918
+ log_audit("blocked_synthesis", code: node.children[0].to_s, reason: reason)
919
+ raise BlockedWriteError,
920
+ "Synthesized code was refused by the security guard: #{detail || "a #{kind} node"} is not permitted in synthesized tools."
921
+ end
922
+
923
+ def mask_text(text)
924
+ Rubik::Security::Redactor.redact(text)
925
+ end
926
+
927
+ def log_audit(action, details = {})
928
+ return if @audit_log_path.nil? || @audit_log_path.to_s.strip.empty?
929
+
930
+ # Nil-safety: a nil details hash (or nil value) must never crash the
931
+ # command path — audit failure is already swallowed below.
932
+ masked_details = (details || {}).transform_values { |v| v.nil? ? "" : mask_text(v.to_s) }
933
+
934
+ entry = {
935
+ ts: Time.now.iso8601,
936
+ action: action,
937
+ details: masked_details
938
+ }
939
+
940
+ dir = File.dirname(@audit_log_path)
941
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
942
+
943
+ File.open(@audit_log_path, "a") do |f|
944
+ f.puts(JSON.dump(entry))
945
+ end
946
+ rescue StandardError => e
947
+ # Unconditional: an audit trail failing silently is indistinguishable
948
+ # from an operator's intentional disable — the one warn in the guard
949
+ # that must not hide behind RUBIK_DEBUG.
950
+ warn "[WARN] Failed to write audit log #{@audit_log_path}: #{e.message}"
951
+ end
952
+ end
953
+ end
954
+ end