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.
- checksums.yaml +7 -0
- data/ATTRIBUTION.md +139 -0
- data/CHANGELOG.md +236 -0
- data/LICENSE +21 -0
- data/README.md +269 -0
- data/bin/rubik +82 -0
- data/lib/rubik/adapter/base.rb +195 -0
- data/lib/rubik/adapter/c.rb +36 -0
- data/lib/rubik/adapter/crystal.rb +78 -0
- data/lib/rubik/adapter/generic.rb +86 -0
- data/lib/rubik/adapter/go.rb +36 -0
- data/lib/rubik/adapter/node.rb +146 -0
- data/lib/rubik/adapter/process.rb +78 -0
- data/lib/rubik/adapter/python.rb +30 -0
- data/lib/rubik/adapter/registry.rb +81 -0
- data/lib/rubik/adapter/ruby.rb +30 -0
- data/lib/rubik/adapter/rust.rb +36 -0
- data/lib/rubik/adapter/zig.rb +36 -0
- data/lib/rubik/adapter.rb +18 -0
- data/lib/rubik/ast/body_loader.rb +19 -0
- data/lib/rubik/ast/c_extractor.rb +114 -0
- data/lib/rubik/ast/cache_store.rb +191 -0
- data/lib/rubik/ast/extractor.rb +59 -0
- data/lib/rubik/ast/fallback_cleaner.rb +145 -0
- data/lib/rubik/ast/go_extractor.rb +148 -0
- data/lib/rubik/ast/graph.rb +272 -0
- data/lib/rubik/ast/python_extractor.rb +120 -0
- data/lib/rubik/ast/ruby_extractor.rb +260 -0
- data/lib/rubik/ast/rust_extractor.rb +127 -0
- data/lib/rubik/ast/symbol.rb +103 -0
- data/lib/rubik/ast/treesitter/base_walker.rb +109 -0
- data/lib/rubik/ast/treesitter/c_walker.rb +122 -0
- data/lib/rubik/ast/treesitter/go_walker.rb +120 -0
- data/lib/rubik/ast/treesitter/python_walker.rb +107 -0
- data/lib/rubik/ast/treesitter/rust_walker.rb +130 -0
- data/lib/rubik/ast/treesitter/typescript_walker.rb +182 -0
- data/lib/rubik/ast/treesitter/zig_walker.rb +109 -0
- data/lib/rubik/ast/treesitter_extractor.rb +93 -0
- data/lib/rubik/ast/typescript_extractor.rb +283 -0
- data/lib/rubik/ast/zig_extractor.rb +116 -0
- data/lib/rubik/ast.rb +13 -0
- data/lib/rubik/benchmark/runner.rb +224 -0
- data/lib/rubik/benchmark/synth_repo.rb +59 -0
- data/lib/rubik/benchmark.rb +9 -0
- data/lib/rubik/buffer/diff.rb +43 -0
- data/lib/rubik/buffer/manager.rb +427 -0
- data/lib/rubik/buffer/path_validator.rb +60 -0
- data/lib/rubik/buffer/snapshot.rb +99 -0
- data/lib/rubik/buffer.rb +10 -0
- data/lib/rubik/coeffects.rb +129 -0
- data/lib/rubik/concurrency/boundary.rb +74 -0
- data/lib/rubik/concurrency/dns_pool.rb +68 -0
- data/lib/rubik/concurrency/errors.rb +17 -0
- data/lib/rubik/concurrency/event_loop.rb +441 -0
- data/lib/rubik/concurrency/join.rb +55 -0
- data/lib/rubik/concurrency/process_waiter.rb +55 -0
- data/lib/rubik/concurrency/scheduler.rb +84 -0
- data/lib/rubik/concurrency/timers.rb +109 -0
- data/lib/rubik/concurrency.rb +132 -0
- data/lib/rubik/config/deep_ops.rb +80 -0
- data/lib/rubik/config/defaults.rb +248 -0
- data/lib/rubik/config/model_catalog.rb +93 -0
- data/lib/rubik/config/store.rb +155 -0
- data/lib/rubik/config.rb +216 -0
- data/lib/rubik/context/git_info.rb +84 -0
- data/lib/rubik/context/git_lister.rb +37 -0
- data/lib/rubik/context/index.rb +134 -0
- data/lib/rubik/context/repo_context_reader.rb +121 -0
- data/lib/rubik/context/scanner.rb +439 -0
- data/lib/rubik/context.rb +11 -0
- data/lib/rubik/diagnostics/collector.rb +200 -0
- data/lib/rubik/diagnostics/entry.rb +60 -0
- data/lib/rubik/diagnostics/parser.rb +107 -0
- data/lib/rubik/diagnostics.rb +10 -0
- data/lib/rubik/event_bus.rb +56 -0
- data/lib/rubik/kernel/dispatcher.rb +171 -0
- data/lib/rubik/kernel/dmesg.rb +116 -0
- data/lib/rubik/kernel/init.rb +177 -0
- data/lib/rubik/kernel/record.rb +85 -0
- data/lib/rubik/kernel/unit.rb +160 -0
- data/lib/rubik/llm/anthropic.rb +202 -0
- data/lib/rubik/llm/chunk_assembler.rb +285 -0
- data/lib/rubik/llm/client.rb +526 -0
- data/lib/rubik/llm/formatter.rb +450 -0
- data/lib/rubik/llm/message_sanitizer.rb +87 -0
- data/lib/rubik/llm/ollama.rb +167 -0
- data/lib/rubik/llm/openai.rb +21 -0
- data/lib/rubik/llm/openai_compatible.rb +216 -0
- data/lib/rubik/llm/provider.rb +125 -0
- data/lib/rubik/llm/registry.rb +336 -0
- data/lib/rubik/llm/router.rb +100 -0
- data/lib/rubik/llm.rb +18 -0
- data/lib/rubik/log.rb +38 -0
- data/lib/rubik/lsp/client.rb +475 -0
- data/lib/rubik/lsp/manager.rb +366 -0
- data/lib/rubik/lsp.rb +9 -0
- data/lib/rubik/mcp/boot_lifecycle.rb +114 -0
- data/lib/rubik/mcp/client.rb +169 -0
- data/lib/rubik/mcp/errors.rb +11 -0
- data/lib/rubik/mcp/manager.rb +619 -0
- data/lib/rubik/mcp/proxy_tool.rb +104 -0
- data/lib/rubik/mcp/transport/stdio.rb +255 -0
- data/lib/rubik/mcp.rb +12 -0
- data/lib/rubik/orchestrator/activity_log.rb +75 -0
- data/lib/rubik/orchestrator/auto_apply.rb +166 -0
- data/lib/rubik/orchestrator/budget_manager.rb +97 -0
- data/lib/rubik/orchestrator/continuation_manager.rb +161 -0
- data/lib/rubik/orchestrator/finish_message_selector.rb +55 -0
- data/lib/rubik/orchestrator/goal_ledger/drift.rb +71 -0
- data/lib/rubik/orchestrator/goal_ledger/ledger.rb +399 -0
- data/lib/rubik/orchestrator/goal_ledger/observation_projection.rb +179 -0
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +268 -0
- data/lib/rubik/orchestrator/goal_ledger/schema.rb +123 -0
- data/lib/rubik/orchestrator/goal_ledger/validator.rb +361 -0
- data/lib/rubik/orchestrator/goal_ledger/verifier.rb +129 -0
- data/lib/rubik/orchestrator/goal_ledger/verify_spec.rb +50 -0
- data/lib/rubik/orchestrator/goal_ledger.rb +65 -0
- data/lib/rubik/orchestrator/goal_state.rb +184 -0
- data/lib/rubik/orchestrator/goal_wait.rb +125 -0
- data/lib/rubik/orchestrator/loop.rb +388 -0
- data/lib/rubik/orchestrator/lsp_feedback.rb +55 -0
- data/lib/rubik/orchestrator/maestro_gate.rb +65 -0
- data/lib/rubik/orchestrator/provider_recovery.rb +90 -0
- data/lib/rubik/orchestrator/provider_resolver.rb +69 -0
- data/lib/rubik/orchestrator/shell_deps.rb +85 -0
- data/lib/rubik/orchestrator/step_executor.rb +328 -0
- data/lib/rubik/orchestrator/stream_manager.rb +118 -0
- data/lib/rubik/orchestrator/subagent/activation.rb +65 -0
- data/lib/rubik/orchestrator/subagent/budget.rb +108 -0
- data/lib/rubik/orchestrator/subagent/completion.rb +61 -0
- data/lib/rubik/orchestrator/subagent/concurrency_gate.rb +37 -0
- data/lib/rubik/orchestrator/subagent/conversation_id.rb +67 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +398 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +384 -0
- data/lib/rubik/orchestrator/subagent/lifecycle.rb +118 -0
- data/lib/rubik/orchestrator/subagent/messaging.rb +61 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +125 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +209 -0
- data/lib/rubik/orchestrator/subagent/questioner.rb +80 -0
- data/lib/rubik/orchestrator/subagent/reaper.rb +98 -0
- data/lib/rubik/orchestrator/subagent/resilience.rb +70 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +127 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +79 -0
- data/lib/rubik/orchestrator/subagent/step_runner.rb +240 -0
- data/lib/rubik/orchestrator/subagent/storage_id.rb +26 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +327 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +141 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +199 -0
- data/lib/rubik/orchestrator/subagent_notice_inbox.rb +80 -0
- data/lib/rubik/orchestrator/tool_call_id.rb +47 -0
- data/lib/rubik/orchestrator/tool_circuit_breaker.rb +91 -0
- data/lib/rubik/orchestrator/tool_history.rb +39 -0
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +102 -0
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +172 -0
- data/lib/rubik/orchestrator/turn_executor.rb +376 -0
- data/lib/rubik/orchestrator.rb +11 -0
- data/lib/rubik/platform/filesystem.rb +64 -0
- data/lib/rubik/platform/io_drain.rb +53 -0
- data/lib/rubik/platform/path.rb +210 -0
- data/lib/rubik/platform/process.rb +38 -0
- data/lib/rubik/platform/sanitize.rb +157 -0
- data/lib/rubik/platform.rb +113 -0
- data/lib/rubik/plugin/dsl.rb +51 -0
- data/lib/rubik/plugin/manager.rb +247 -0
- data/lib/rubik/plugin/storage.rb +195 -0
- data/lib/rubik/plugin.rb +10 -0
- data/lib/rubik/prompt/context_builder.rb +377 -0
- data/lib/rubik/prompt/context_compactor.rb +326 -0
- data/lib/rubik/prompt/conversation_rows.rb +74 -0
- data/lib/rubik/prompt/doctrine.rb +160 -0
- data/lib/rubik/prompt/emergency_compactor.rb +81 -0
- data/lib/rubik/prompt/services.rb +60 -0
- data/lib/rubik/prompt/token_estimator.rb +63 -0
- data/lib/rubik/prompt/tool_teachings.rb +78 -0
- data/lib/rubik/prompt.rb +13 -0
- data/lib/rubik/repair/loop.rb +95 -0
- data/lib/rubik/repair.rb +8 -0
- data/lib/rubik/repl/ast_warmup.rb +129 -0
- data/lib/rubik/repl/command_context.rb +214 -0
- data/lib/rubik/repl/command_parser.rb +48 -0
- data/lib/rubik/repl/commands/agent.rb +30 -0
- data/lib/rubik/repl/commands/agent_context.rb +289 -0
- data/lib/rubik/repl/commands/agent_execution.rb +342 -0
- data/lib/rubik/repl/commands/agent_models.rb +212 -0
- data/lib/rubik/repl/commands/agent_providers.rb +232 -0
- data/lib/rubik/repl/commands/base.rb +55 -0
- data/lib/rubik/repl/commands/buffer.rb +124 -0
- data/lib/rubik/repl/commands/dmesg.rb +87 -0
- data/lib/rubik/repl/commands/lsp.rb +235 -0
- data/lib/rubik/repl/commands/mcp.rb +194 -0
- data/lib/rubik/repl/commands/plugins.rb +208 -0
- data/lib/rubik/repl/commands/pruner.rb +71 -0
- data/lib/rubik/repl/commands/session.rb +183 -0
- data/lib/rubik/repl/commands/skills.rb +111 -0
- data/lib/rubik/repl/commands/storage.rb +85 -0
- data/lib/rubik/repl/commands/subagents.rb +195 -0
- data/lib/rubik/repl/commands/system.rb +85 -0
- data/lib/rubik/repl/commands/tasks.rb +120 -0
- data/lib/rubik/repl/commands/workspace.rb +443 -0
- data/lib/rubik/repl/commands.rb +44 -0
- data/lib/rubik/repl/composition.rb +32 -0
- data/lib/rubik/repl/config_wiring.rb +142 -0
- data/lib/rubik/repl/confirmations.rb +56 -0
- data/lib/rubik/repl/hooks.rb +47 -0
- data/lib/rubik/repl/input_execution.rb +323 -0
- data/lib/rubik/repl/input_loop.rb +213 -0
- data/lib/rubik/repl/interrupt_handler.rb +217 -0
- data/lib/rubik/repl/lifecycle.rb +410 -0
- data/lib/rubik/repl/paste_support.rb +88 -0
- data/lib/rubik/repl/pending_notice_queue.rb +60 -0
- data/lib/rubik/repl/presentation.rb +132 -0
- data/lib/rubik/repl/pruner/brief.rb +101 -0
- data/lib/rubik/repl/pruner/gate.rb +279 -0
- data/lib/rubik/repl/pruner/harness.rb +236 -0
- data/lib/rubik/repl/pruner/rules.rb +316 -0
- data/lib/rubik/repl/pruner/service.rb +82 -0
- data/lib/rubik/repl/pruner.rb +39 -0
- data/lib/rubik/repl/service_catalog.rb +374 -0
- data/lib/rubik/repl/setup_factories.rb +66 -0
- data/lib/rubik/repl/shell.rb +231 -0
- data/lib/rubik/repl/step_formatter.rb +324 -0
- data/lib/rubik/repl/tee_io.rb +105 -0
- data/lib/rubik/repl/terminal_guard.rb +157 -0
- data/lib/rubik/repl/turn_recorder.rb +234 -0
- data/lib/rubik/repl.rb +14 -0
- data/lib/rubik/runtime/ast_service.rb +78 -0
- data/lib/rubik/runtime/context.rb +335 -0
- data/lib/rubik/runtime/diagnostic_engine.rb +188 -0
- data/lib/rubik/runtime/discovery.rb +342 -0
- data/lib/rubik/runtime/fiber.rb +142 -0
- data/lib/rubik/runtime/fiber_ordering.rb +52 -0
- data/lib/rubik/runtime/fiber_retry_gate.rb +84 -0
- data/lib/rubik/runtime/index_service.rb +58 -0
- data/lib/rubik/runtime/loop_service.rb +73 -0
- data/lib/rubik/runtime/manager.rb +241 -0
- data/lib/rubik/runtime/overlay_storage.rb +481 -0
- data/lib/rubik/runtime/service_definition.rb +54 -0
- data/lib/rubik/runtime/service_registration.rb +33 -0
- data/lib/rubik/runtime/task_manager.rb +302 -0
- data/lib/rubik/runtime.rb +19 -0
- data/lib/rubik/sandbox/driver/base.rb +30 -0
- data/lib/rubik/sandbox/driver/bubblewrap.rb +111 -0
- data/lib/rubik/sandbox/driver/docker.rb +113 -0
- data/lib/rubik/sandbox/driver/podman.rb +98 -0
- data/lib/rubik/sandbox/driver/process.rb +190 -0
- data/lib/rubik/sandbox/driver.rb +91 -0
- data/lib/rubik/sandbox/runner.rb +218 -0
- data/lib/rubik/sandbox/shell_resolver.rb +178 -0
- data/lib/rubik/sandbox.rb +8 -0
- data/lib/rubik/security/blocked_guidance.rb +187 -0
- data/lib/rubik/security/classifier/classifier.rb +440 -0
- data/lib/rubik/security/classifier/commands/agent-browser.yaml +9 -0
- data/lib/rubik/security/classifier/commands/apt.yaml +25 -0
- data/lib/rubik/security/classifier/commands/awk.yaml +10 -0
- data/lib/rubik/security/classifier/commands/basename.yaml +7 -0
- data/lib/rubik/security/classifier/commands/bash-classify.yaml +8 -0
- data/lib/rubik/security/classifier/commands/bash.yaml +20 -0
- data/lib/rubik/security/classifier/commands/black.yaml +12 -0
- data/lib/rubik/security/classifier/commands/brew.yaml +31 -0
- data/lib/rubik/security/classifier/commands/builtin.yaml +8 -0
- data/lib/rubik/security/classifier/commands/cargo.yaml +114 -0
- data/lib/rubik/security/classifier/commands/cat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/chgrp.yaml +10 -0
- data/lib/rubik/security/classifier/commands/chmod.yaml +10 -0
- data/lib/rubik/security/classifier/commands/chown.yaml +10 -0
- data/lib/rubik/security/classifier/commands/column.yaml +7 -0
- data/lib/rubik/security/classifier/commands/comm.yaml +7 -0
- data/lib/rubik/security/classifier/commands/command.yaml +12 -0
- data/lib/rubik/security/classifier/commands/cp.yaml +7 -0
- data/lib/rubik/security/classifier/commands/crystal.yaml +63 -0
- data/lib/rubik/security/classifier/commands/curl.yaml +30 -0
- data/lib/rubik/security/classifier/commands/cut.yaml +7 -0
- data/lib/rubik/security/classifier/commands/date.yaml +10 -0
- data/lib/rubik/security/classifier/commands/df.yaml +7 -0
- data/lib/rubik/security/classifier/commands/diff.yaml +7 -0
- data/lib/rubik/security/classifier/commands/dig.yaml +7 -0
- data/lib/rubik/security/classifier/commands/dirname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/docker.yaml +177 -0
- data/lib/rubik/security/classifier/commands/du.yaml +7 -0
- data/lib/rubik/security/classifier/commands/echo.yaml +7 -0
- data/lib/rubik/security/classifier/commands/env.yaml +15 -0
- data/lib/rubik/security/classifier/commands/eslint.yaml +12 -0
- data/lib/rubik/security/classifier/commands/eval.yaml +8 -0
- data/lib/rubik/security/classifier/commands/exec.yaml +12 -0
- data/lib/rubik/security/classifier/commands/expand.yaml +7 -0
- data/lib/rubik/security/classifier/commands/false.yaml +7 -0
- data/lib/rubik/security/classifier/commands/file.yaml +7 -0
- data/lib/rubik/security/classifier/commands/find.yaml +25 -0
- data/lib/rubik/security/classifier/commands/fmt.yaml +7 -0
- data/lib/rubik/security/classifier/commands/fold.yaml +7 -0
- data/lib/rubik/security/classifier/commands/free.yaml +7 -0
- data/lib/rubik/security/classifier/commands/gawk.yaml +10 -0
- data/lib/rubik/security/classifier/commands/gcloud.yaml +979 -0
- data/lib/rubik/security/classifier/commands/gh.yaml +615 -0
- data/lib/rubik/security/classifier/commands/git.yaml +524 -0
- data/lib/rubik/security/classifier/commands/glab-discussion.yaml +21 -0
- data/lib/rubik/security/classifier/commands/glab.yaml +807 -0
- data/lib/rubik/security/classifier/commands/go.yaml +44 -0
- data/lib/rubik/security/classifier/commands/gog.yaml +983 -0
- data/lib/rubik/security/classifier/commands/gradle.yaml +92 -0
- data/lib/rubik/security/classifier/commands/gradlew.yaml +92 -0
- data/lib/rubik/security/classifier/commands/grep.yaml +7 -0
- data/lib/rubik/security/classifier/commands/groups.yaml +7 -0
- data/lib/rubik/security/classifier/commands/gunzip.yaml +8 -0
- data/lib/rubik/security/classifier/commands/gws.yaml +211 -0
- data/lib/rubik/security/classifier/commands/gzip.yaml +7 -0
- data/lib/rubik/security/classifier/commands/hadolint.yaml +9 -0
- data/lib/rubik/security/classifier/commands/halt.yaml +8 -0
- data/lib/rubik/security/classifier/commands/head.yaml +7 -0
- data/lib/rubik/security/classifier/commands/helm.yaml +129 -0
- data/lib/rubik/security/classifier/commands/hostname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/id.yaml +7 -0
- data/lib/rubik/security/classifier/commands/install.yaml +7 -0
- data/lib/rubik/security/classifier/commands/isort.yaml +14 -0
- data/lib/rubik/security/classifier/commands/java.yaml +35 -0
- data/lib/rubik/security/classifier/commands/javac.yaml +39 -0
- data/lib/rubik/security/classifier/commands/join.yaml +7 -0
- data/lib/rubik/security/classifier/commands/journalctl.yaml +14 -0
- data/lib/rubik/security/classifier/commands/jq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/kill.yaml +7 -0
- data/lib/rubik/security/classifier/commands/killall.yaml +7 -0
- data/lib/rubik/security/classifier/commands/kubectl.yaml +211 -0
- data/lib/rubik/security/classifier/commands/less.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ln.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ls.yaml +7 -0
- data/lib/rubik/security/classifier/commands/make.yaml +21 -0
- data/lib/rubik/security/classifier/commands/md5sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mise.yaml +579 -0
- data/lib/rubik/security/classifier/commands/mkdir.yaml +8 -0
- data/lib/rubik/security/classifier/commands/more.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mv.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mvn.yaml +83 -0
- data/lib/rubik/security/classifier/commands/mvnw.yaml +83 -0
- data/lib/rubik/security/classifier/commands/mypy.yaml +9 -0
- data/lib/rubik/security/classifier/commands/netstat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/nice.yaml +11 -0
- data/lib/rubik/security/classifier/commands/nl.yaml +7 -0
- data/lib/rubik/security/classifier/commands/node.yaml +20 -0
- data/lib/rubik/security/classifier/commands/nohup.yaml +9 -0
- data/lib/rubik/security/classifier/commands/npm.yaml +71 -0
- data/lib/rubik/security/classifier/commands/npx.yaml +42 -0
- data/lib/rubik/security/classifier/commands/nslookup.yaml +7 -0
- data/lib/rubik/security/classifier/commands/paste.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ping.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pip.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pip3.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pipx.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pkill.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pnpm.yaml +346 -0
- data/lib/rubik/security/classifier/commands/poetry.yaml +97 -0
- data/lib/rubik/security/classifier/commands/poweroff.yaml +8 -0
- data/lib/rubik/security/classifier/commands/prettier.yaml +13 -0
- data/lib/rubik/security/classifier/commands/printenv.yaml +7 -0
- data/lib/rubik/security/classifier/commands/printf.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ps.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pup.yaml +882 -0
- data/lib/rubik/security/classifier/commands/pwd.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pytest.yaml +7 -0
- data/lib/rubik/security/classifier/commands/python.yaml +9 -0
- data/lib/rubik/security/classifier/commands/python3.yaml +9 -0
- data/lib/rubik/security/classifier/commands/read.yaml +7 -0
- data/lib/rubik/security/classifier/commands/readlink.yaml +7 -0
- data/lib/rubik/security/classifier/commands/realpath.yaml +7 -0
- data/lib/rubik/security/classifier/commands/reboot.yaml +8 -0
- data/lib/rubik/security/classifier/commands/rev.yaml +7 -0
- data/lib/rubik/security/classifier/commands/rg.yaml +7 -0
- data/lib/rubik/security/classifier/commands/rm.yaml +8 -0
- data/lib/rubik/security/classifier/commands/rsync.yaml +12 -0
- data/lib/rubik/security/classifier/commands/rtk.yaml +140 -0
- data/lib/rubik/security/classifier/commands/ruff.yaml +29 -0
- data/lib/rubik/security/classifier/commands/rustfmt.yaml +14 -0
- data/lib/rubik/security/classifier/commands/scp.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sed.yaml +14 -0
- data/lib/rubik/security/classifier/commands/seq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sh.yaml +14 -0
- data/lib/rubik/security/classifier/commands/sha1sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sha256sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/shellcheck.yaml +10 -0
- data/lib/rubik/security/classifier/commands/shutdown.yaml +8 -0
- data/lib/rubik/security/classifier/commands/slack.yaml +113 -0
- data/lib/rubik/security/classifier/commands/sleep.yaml +8 -0
- data/lib/rubik/security/classifier/commands/snip.yaml +79 -0
- data/lib/rubik/security/classifier/commands/sort.yaml +10 -0
- data/lib/rubik/security/classifier/commands/ss.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ssh.yaml +41 -0
- data/lib/rubik/security/classifier/commands/stat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/stdbuf.yaml +15 -0
- data/lib/rubik/security/classifier/commands/strace.yaml +20 -0
- data/lib/rubik/security/classifier/commands/strings.yaml +11 -0
- data/lib/rubik/security/classifier/commands/sudo.yaml +24 -0
- data/lib/rubik/security/classifier/commands/systemctl.yaml +58 -0
- data/lib/rubik/security/classifier/commands/systemd-run.yaml +30 -0
- data/lib/rubik/security/classifier/commands/tac.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tail.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tar.yaml +25 -0
- data/lib/rubik/security/classifier/commands/tee.yaml +8 -0
- data/lib/rubik/security/classifier/commands/terraform.yaml +70 -0
- data/lib/rubik/security/classifier/commands/test.yaml +7 -0
- data/lib/rubik/security/classifier/commands/time.yaml +8 -0
- data/lib/rubik/security/classifier/commands/timeout.yaml +18 -0
- data/lib/rubik/security/classifier/commands/top.yaml +7 -0
- data/lib/rubik/security/classifier/commands/touch.yaml +8 -0
- data/lib/rubik/security/classifier/commands/tr.yaml +7 -0
- data/lib/rubik/security/classifier/commands/traceroute.yaml +7 -0
- data/lib/rubik/security/classifier/commands/true.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tsc.yaml +16 -0
- data/lib/rubik/security/classifier/commands/type.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/unexpand.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uniq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/unzip.yaml +10 -0
- data/lib/rubik/security/classifier/commands/uptime.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uv.yaml +156 -0
- data/lib/rubik/security/classifier/commands/wait.yaml +11 -0
- data/lib/rubik/security/classifier/commands/wc.yaml +7 -0
- data/lib/rubik/security/classifier/commands/wget.yaml +7 -0
- data/lib/rubik/security/classifier/commands/whereis.yaml +7 -0
- data/lib/rubik/security/classifier/commands/which.yaml +7 -0
- data/lib/rubik/security/classifier/commands/whoami.yaml +7 -0
- data/lib/rubik/security/classifier/commands/xargs.yaml +26 -0
- data/lib/rubik/security/classifier/commands/xmllint.yaml +7 -0
- data/lib/rubik/security/classifier/commands/yarn.yaml +72 -0
- data/lib/rubik/security/classifier/commands/yes.yaml +7 -0
- data/lib/rubik/security/classifier/commands/yq.yaml +10 -0
- data/lib/rubik/security/classifier/commands/zip.yaml +8 -0
- data/lib/rubik/security/classifier/commands/zsh.yaml +14 -0
- data/lib/rubik/security/classifier/database.rb +216 -0
- data/lib/rubik/security/classifier/matcher/delegation.rb +190 -0
- data/lib/rubik/security/classifier/matcher/options.rb +245 -0
- data/lib/rubik/security/classifier/matcher/subcommands.rb +103 -0
- data/lib/rubik/security/classifier/matcher.rb +249 -0
- data/lib/rubik/security/classifier/models.rb +170 -0
- data/lib/rubik/security/classifier/parser.rb +23 -0
- data/lib/rubik/security/classifier/parsers/bash.rb +280 -0
- data/lib/rubik/security/classifier/parsers/bash_extractors.rb +221 -0
- data/lib/rubik/security/classifier/parsers/powershell.rb +185 -0
- data/lib/rubik/security/classifier/pwsh_rules.rb +224 -0
- data/lib/rubik/security/classifier.rb +25 -0
- data/lib/rubik/security/errors.rb +39 -0
- data/lib/rubik/security/guard.rb +954 -0
- data/lib/rubik/security/redactor.rb +89 -0
- data/lib/rubik/security.rb +11 -0
- data/lib/rubik/session/manager.rb +331 -0
- data/lib/rubik/session/recorder.rb +149 -0
- data/lib/rubik/session/replayer.rb +65 -0
- data/lib/rubik/session.rb +10 -0
- data/lib/rubik/skills/frontmatter.rb +41 -0
- data/lib/rubik/skills/manager.rb +223 -0
- data/lib/rubik/skills/registry.rb +64 -0
- data/lib/rubik/skills/skill.rb +33 -0
- data/lib/rubik/skills/storage.rb +77 -0
- data/lib/rubik/skills/validator.rb +116 -0
- data/lib/rubik/skills.rb +20 -0
- data/lib/rubik/storage/folding.rb +23 -0
- data/lib/rubik/storage/layout.rb +145 -0
- data/lib/rubik/storage/render.rb +97 -0
- data/lib/rubik/storage/service.rb +42 -0
- data/lib/rubik/storage/store.rb +514 -0
- data/lib/rubik/storage/transcript_indexer.rb +116 -0
- data/lib/rubik/storage/uri_reader.rb +168 -0
- data/lib/rubik/storage/vakanuvis/annal_writer.rb +92 -0
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +323 -0
- data/lib/rubik/storage/vakanuvis/gate.rb +250 -0
- data/lib/rubik/storage/vakanuvis/harness.rb +421 -0
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +163 -0
- data/lib/rubik/storage/vakanuvis/service.rb +69 -0
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +99 -0
- data/lib/rubik/storage/vakanuvis/wiki_skeleton.rb +67 -0
- data/lib/rubik/storage/vakanuvis.rb +77 -0
- data/lib/rubik/storage.rb +29 -0
- data/lib/rubik/tools/base.rb +107 -0
- data/lib/rubik/tools/builtins/dmesg.rb +74 -0
- data/lib/rubik/tools/builtins/exec.rb +293 -0
- data/lib/rubik/tools/builtins/files.rb +354 -0
- data/lib/rubik/tools/builtins/goal_ledger.rb +89 -0
- data/lib/rubik/tools/builtins/grep.rb +281 -0
- data/lib/rubik/tools/builtins/interactive.rb +175 -0
- data/lib/rubik/tools/builtins/lifecycle.rb +359 -0
- data/lib/rubik/tools/builtins/lsp.rb +283 -0
- data/lib/rubik/tools/builtins/pruner.rb +88 -0
- data/lib/rubik/tools/builtins/runtime.rb +120 -0
- data/lib/rubik/tools/builtins/send_message.rb +109 -0
- data/lib/rubik/tools/builtins/show_file.rb +84 -0
- data/lib/rubik/tools/builtins/skill.rb +72 -0
- data/lib/rubik/tools/builtins/storage.rb +232 -0
- data/lib/rubik/tools/builtins/subagent.rb +356 -0
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +72 -0
- data/lib/rubik/tools/builtins/symbols.rb +209 -0
- data/lib/rubik/tools/builtins/synthesis.rb +314 -0
- data/lib/rubik/tools/builtins/task.rb +168 -0
- data/lib/rubik/tools/builtins/unsynthesize.rb +111 -0
- data/lib/rubik/tools/builtins/vakanuvis.rb +161 -0
- data/lib/rubik/tools/builtins.rb +30 -0
- data/lib/rubik/tools/catalog.rb +70 -0
- data/lib/rubik/tools/declaration.rb +90 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +283 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +172 -0
- data/lib/rubik/tools/native_search.rb +180 -0
- data/lib/rubik/tools/output.rb +54 -0
- data/lib/rubik/tools/preflight_runner.rb +209 -0
- data/lib/rubik/tools/registry.rb +247 -0
- data/lib/rubik/tools/result.rb +71 -0
- data/lib/rubik/tools/services.rb +96 -0
- data/lib/rubik/tools/truncator.rb +78 -0
- data/lib/rubik/tools/uri_resolver.rb +195 -0
- data/lib/rubik/tools.rb +14 -0
- data/lib/rubik/ui/approvals.rb +52 -0
- data/lib/rubik/ui/banner.rb +43 -0
- data/lib/rubik/ui/capability.rb +122 -0
- data/lib/rubik/ui/dialog.rb +106 -0
- data/lib/rubik/ui/diff_viewer.rb +78 -0
- data/lib/rubik/ui/frame.rb +95 -0
- data/lib/rubik/ui/markdown_stream.rb +107 -0
- data/lib/rubik/ui/panel.rb +98 -0
- data/lib/rubik/ui/renderer.rb +206 -0
- data/lib/rubik/ui/spinner.rb +120 -0
- data/lib/rubik/ui/statusline.rb +134 -0
- data/lib/rubik/ui/theme.rb +118 -0
- data/lib/rubik/ui.rb +57 -0
- data/lib/rubik/uris.rb +101 -0
- data/lib/rubik/version.rb +5 -0
- data/lib/rubik.rb +65 -0
- 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
|