rubino-agent 0.3.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/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Run
|
|
5
|
+
# Remembers approval decisions that should survive past the current
|
|
6
|
+
# call so the agent doesn't re-prompt the user for the same operation
|
|
7
|
+
# in the same session.
|
|
8
|
+
#
|
|
9
|
+
# Granularity: a decision is stored as a DERIVED RULE, not the exact
|
|
10
|
+
# "<tool>:<command>" string. The caller still passes a scope shaped like
|
|
11
|
+
# "shell:rm -rf /tmp/cache" or "write:report.md"; the cache splits it into
|
|
12
|
+
# (tool, command) and asks Security::PrefixDeriver for the rule to remember.
|
|
13
|
+
# This mirrors the reference, which keys session approvals on a PATTERN KEY rather
|
|
14
|
+
# than the raw command (approve_session / is_approved). The practical effect for S3:
|
|
15
|
+
# - a DANGEROUS command remembers its pattern CLASS, so approving e.g.
|
|
16
|
+
# `git push --force origin main` once also covers `git push -f other`
|
|
17
|
+
# in the same session (same "git force push" class);
|
|
18
|
+
# - a PLAIN command still remembers only the exact command, so approving
|
|
19
|
+
# `git status` does NOT auto-approve `git diff` (narrow for S3; the
|
|
20
|
+
# broad prefix rule is derived but wired into a decision only in S5).
|
|
21
|
+
#
|
|
22
|
+
# A scope with no ":" (a tool-wide scope like "shell") has no command to
|
|
23
|
+
# derive from and is stored/matched verbatim.
|
|
24
|
+
#
|
|
25
|
+
# Persistence: in-memory, process-lifetime. "session" decisions die with
|
|
26
|
+
# the process; "always" would deserve disk persistence but isn't wired up
|
|
27
|
+
# yet (S5), so we treat both as session-scoped for now.
|
|
28
|
+
#
|
|
29
|
+
# Thread-safe: every read/write goes through @mutex.
|
|
30
|
+
class SessionApprovalCache
|
|
31
|
+
# Singleton accessor. We don't use Dry::Container or similar here
|
|
32
|
+
# because the cache is process-global state that the runner needs
|
|
33
|
+
# to inject into per-run UI::API instances; one shared object is
|
|
34
|
+
# the simplest expression of "remember across runs of the same
|
|
35
|
+
# session".
|
|
36
|
+
def self.instance
|
|
37
|
+
@instance ||= new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resets the singleton — used by tests that need a clean slate.
|
|
41
|
+
# Avoids hidden cross-test leakage when specs share the process.
|
|
42
|
+
def self.reset_singleton!
|
|
43
|
+
@instance = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Decisions that should be persisted on approval.
|
|
47
|
+
REMEMBERED_DECISIONS = %w[session always].freeze
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@data = Hash.new { |h, k| h[k] = [] } # session_id => [Rule, ...]
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Records a decision for (session_id, scope) as a derived rule. No-op
|
|
55
|
+
# when either value is blank, or the decision isn't a remembered kind.
|
|
56
|
+
def remember(session_id, scope, decision)
|
|
57
|
+
return unless session_id && scope
|
|
58
|
+
return unless REMEMBERED_DECISIONS.include?(decision.to_s.downcase)
|
|
59
|
+
|
|
60
|
+
rule = rule_for_scope(scope)
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
rules = @data[session_id.to_s]
|
|
63
|
+
rules << rule unless rules.any? { |r| r == rule }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True when a prior decision for this session already covers the command
|
|
68
|
+
# carried by `scope` — pattern-class membership, prefix start_with?, or an
|
|
69
|
+
# exact-command match, per the stored rule kinds.
|
|
70
|
+
def allowed?(session_id, scope)
|
|
71
|
+
return false unless session_id && scope
|
|
72
|
+
|
|
73
|
+
command = scope_command(scope)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@data[session_id.to_s].any? { |rule| rule.covers?(command) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Drops every cached decision for one session (e.g. after a
|
|
80
|
+
# session is deleted). Pass nil to wipe every session.
|
|
81
|
+
def forget!(session_id = nil)
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
if session_id
|
|
84
|
+
@data.delete(session_id.to_s)
|
|
85
|
+
else
|
|
86
|
+
@data.clear
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Splits a "<tool>:<command>" scope into the rule to remember. A scope
|
|
94
|
+
# without a ":" is tool-wide (no command) — remember it verbatim as an
|
|
95
|
+
# exact rule so the tool-scope short-circuit in UI::CLI keeps working.
|
|
96
|
+
def rule_for_scope(scope)
|
|
97
|
+
tool, command = split_scope(scope)
|
|
98
|
+
return Security::PrefixDeriver::Rule.new(kind: :command, value: scope.to_s) if command.nil?
|
|
99
|
+
|
|
100
|
+
Security::PrefixDeriver.narrow_rule_for(tool: tool, command: command)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The command a query scope refers to: the part after the first ":",
|
|
104
|
+
# or the whole scope when there is none (tool-wide scope matched verbatim).
|
|
105
|
+
def scope_command(scope)
|
|
106
|
+
_tool, command = split_scope(scope)
|
|
107
|
+
command.nil? ? scope.to_s : command
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def split_scope(scope)
|
|
111
|
+
str = scope.to_s
|
|
112
|
+
return [str, nil] unless str.include?(":")
|
|
113
|
+
|
|
114
|
+
str.split(":", 2)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Persists an approved rule value to `security.command_allowlist` so it
|
|
6
|
+
# survives a process restart and pre-approves future sibling commands
|
|
7
|
+
# through the existing CommandAllowlist (prefix start_with?) path.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors the reference save_permanent_allowlist,
|
|
10
|
+
# which writes pattern keys to `command_allowlist` in config.yaml, and the
|
|
11
|
+
# resolve-time persistence on the gateway path (:1342-1351).
|
|
12
|
+
#
|
|
13
|
+
# Append-unique: an already-listed value is a no-op (no duplicate rows, no
|
|
14
|
+
# rewrite). The write goes through Config::Writer (dot-notation -> YAML) and
|
|
15
|
+
# ALSO updates the live Rubino.configuration so a CommandAllowlist built
|
|
16
|
+
# in the same process immediately sees the new prefix without a reload.
|
|
17
|
+
#
|
|
18
|
+
# SCOPING NOTE: Config::Writer writes the process-global config.yml. This
|
|
19
|
+
# assumes a single-process / single-home deployment, so process-global ==
|
|
20
|
+
# per-user here — acceptable. For any SHARED-server deployment, `always_*`
|
|
21
|
+
# persistence would need per-user config scoping (or web `always` treated as
|
|
22
|
+
# session-only); do NOT rely on this writer as-is in a multi-user process.
|
|
23
|
+
module AllowlistPersister
|
|
24
|
+
KEY = "security.command_allowlist"
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Appends `value` to security.command_allowlist (unique). Returns the
|
|
29
|
+
# resulting allowlist array. A blank value is a no-op.
|
|
30
|
+
def persist(value, config: nil, config_path: nil)
|
|
31
|
+
rule_value = value.to_s.strip
|
|
32
|
+
return current_allowlist(config) if rule_value.empty?
|
|
33
|
+
|
|
34
|
+
config ||= Rubino.configuration
|
|
35
|
+
existing = current_allowlist(config)
|
|
36
|
+
return existing if existing.include?(rule_value)
|
|
37
|
+
|
|
38
|
+
updated = existing + [rule_value]
|
|
39
|
+
Config::Writer.new(config_path: config_path || default_config_path).set(KEY, updated)
|
|
40
|
+
# Keep the live config in sync so a CommandAllowlist built this process
|
|
41
|
+
# sees the new prefix immediately (the writer only touches disk).
|
|
42
|
+
config.set("security", "command_allowlist", updated)
|
|
43
|
+
updated
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_allowlist(config)
|
|
47
|
+
(config || Rubino.configuration).security_command_allowlist.dup
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_config_path
|
|
51
|
+
Config::Loader.new.config_path
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Determines whether a tool execution requires user approval.
|
|
6
|
+
# Uses pattern-based rules, tool risk levels, and doom loop detection.
|
|
7
|
+
#
|
|
8
|
+
# Config example:
|
|
9
|
+
# approvals:
|
|
10
|
+
# mode: "manual" # manual | auto | skip
|
|
11
|
+
# permissions:
|
|
12
|
+
# "git *": "allow"
|
|
13
|
+
# "shell rm *": "deny"
|
|
14
|
+
# "shell bundle *": "allow"
|
|
15
|
+
# "file_system write ~/.env": "deny"
|
|
16
|
+
class ApprovalPolicy
|
|
17
|
+
MODES = %w[manual auto skip].freeze
|
|
18
|
+
|
|
19
|
+
# Why the most recent #decide returned :deny — :hardline (the
|
|
20
|
+
# non-bypassable floor), :permission_rule (an explicit permissions deny
|
|
21
|
+
# rule), or :doom_loop (the repeated-identical-call guard). nil when the
|
|
22
|
+
# last decision wasn't a deny. ToolExecutor reads this right after
|
|
23
|
+
# #decide to build a reason-specific model-facing denial message, so a
|
|
24
|
+
# policy denial is never reported as "denied by user" (#143).
|
|
25
|
+
attr_reader :last_deny_reason
|
|
26
|
+
|
|
27
|
+
def initialize(config: nil, agent_overrides: nil)
|
|
28
|
+
@config = config || Rubino.configuration
|
|
29
|
+
@mode = @config.approvals_mode
|
|
30
|
+
# Effective shell prompt policy (:confirm_all | :dangerous_only).
|
|
31
|
+
# Derived from security.confirm_policy, with security.require_confirmation_for_shell
|
|
32
|
+
# as a back-compat alias (see Configuration#confirm_policy). Older config
|
|
33
|
+
# objects that predate the accessor fall back to :confirm_all.
|
|
34
|
+
@confirm_policy =
|
|
35
|
+
@config.respond_to?(:confirm_policy) ? @config.confirm_policy : :confirm_all
|
|
36
|
+
@pattern_matcher = PatternMatcher.new(
|
|
37
|
+
rules: load_permission_rules(agent_overrides)
|
|
38
|
+
)
|
|
39
|
+
@doom_detector = DoomLoopDetector.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the decision for a tool call: :allow, :ask, :deny
|
|
43
|
+
#
|
|
44
|
+
# CANONICAL DECISION ORDER (deny-class checks precede every allow path).
|
|
45
|
+
# Mirrors the reconciled reference ordering:
|
|
46
|
+
#
|
|
47
|
+
# 1. hardline(:deny) non-bypassable floor BELOW yolo
|
|
48
|
+
# 2. permissions:deny an explicit deny rule also beats yolo
|
|
49
|
+
# 3. yolo / skip-approvals allow-exit (doom still guards it)
|
|
50
|
+
# 4. doom loop break a stuck autopilot
|
|
51
|
+
# 5. permissions:allow / :ask remaining explicit rules
|
|
52
|
+
# 6. command_allowlist (prefix) pre-approved commands -> :allow
|
|
53
|
+
# 6b. readonly auto-allow parse-validated read-only shell -> :allow
|
|
54
|
+
# 7-8. confirm_policy shell gate confirm_all -> :ask; dangerous_only
|
|
55
|
+
# -> :ask only if dangerous?, else :allow
|
|
56
|
+
# 9. mode fallback
|
|
57
|
+
#
|
|
58
|
+
# The invariant that makes this slice worth doing: HARDLINE and an
|
|
59
|
+
# explicit permissions:deny BOTH run before any allow path (yolo,
|
|
60
|
+
# permissions:allow, command_allowlist), so neither can be overridden
|
|
61
|
+
# by a fast-path the way yolo used to override deny rules.
|
|
62
|
+
def decide(tool, arguments: {})
|
|
63
|
+
@last_deny_reason = nil
|
|
64
|
+
command_str = self.class.command_string(tool, arguments)
|
|
65
|
+
|
|
66
|
+
# 1. Hardline floor — a floor BELOW yolo. Catastrophic, unrecoverable
|
|
67
|
+
# commands (rm -rf /, mkfs, dd to a raw device, fork bomb,
|
|
68
|
+
# shutdown/reboot, sudo -S password guessing) are denied
|
|
69
|
+
# UNCONDITIONALLY: before yolo/skip, before doom, before any
|
|
70
|
+
# permissions:allow rule or command_allowlist entry. Opting into
|
|
71
|
+
# yolo trusts the agent with your files, NOT to wipe the disk.
|
|
72
|
+
# Mirrors the reference approval module (enforced first).
|
|
73
|
+
blocked, = HardlineGuard.detect(command_str)
|
|
74
|
+
return deny_with(:hardline) if blocked
|
|
75
|
+
|
|
76
|
+
# 2. Explicit permissions:deny — like hardline, a deny rule is a
|
|
77
|
+
# deny-class check and must beat every allow path. We evaluate the
|
|
78
|
+
# pattern rules ONCE here and reuse the result below; only the :deny
|
|
79
|
+
# verdict short-circuits before yolo. allow/ask wait until after the
|
|
80
|
+
# yolo allow-exit and the doom guard (steps 3-4) so they keep their
|
|
81
|
+
# original precedence. Mirrors the deny-before-allow ordering in the
|
|
82
|
+
# plan (hardline -> permissions:deny -> yolo -> doom -> allow/ask).
|
|
83
|
+
pattern_result = @pattern_matcher.match(tool.name, command_str)
|
|
84
|
+
return deny_with(:permission_rule) if pattern_result == :deny
|
|
85
|
+
|
|
86
|
+
# 3. Modes.yolo short-circuits the remaining allow/ask logic. We still
|
|
87
|
+
# run the doom detector AFTER, because an autopilot stuck in a loop
|
|
88
|
+
# is the one thing yolo isn't supposed to license.
|
|
89
|
+
if Rubino::Modes.skip_approvals?
|
|
90
|
+
return deny_with(:doom_loop) if @doom_detector.record(tool_name: tool.name, arguments: arguments)
|
|
91
|
+
|
|
92
|
+
return :allow
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 4. Doom loop guard.
|
|
96
|
+
if @doom_detector.record(tool_name: tool.name, arguments: arguments)
|
|
97
|
+
return deny_with(:doom_loop) # Break the loop
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# 5. Remaining explicit pattern rules (allow / ask). deny was already
|
|
101
|
+
# handled in step 2.
|
|
102
|
+
return pattern_result if pattern_result
|
|
103
|
+
|
|
104
|
+
# 6. Config allowlist of pre-approved commands. Checked AFTER deny
|
|
105
|
+
# patterns (deny always wins) but BEFORE mode-based decision so a
|
|
106
|
+
# listed command never triggers a manual prompt.
|
|
107
|
+
return :allow if command_pre_approved?(command_str)
|
|
108
|
+
|
|
109
|
+
# 6b. Built-in read-only auto-allow — the same allowlist seam as
|
|
110
|
+
# step 6, just with a parse-validated built-in set instead of
|
|
111
|
+
# user-configured prefixes. Runs BELOW the hardline floor (step 1)
|
|
112
|
+
# and permissions:deny (step 2), so the floor always wins even for
|
|
113
|
+
# commands added via approvals.readonly_commands. A line the
|
|
114
|
+
# validator cannot prove read-only falls through to the prompt.
|
|
115
|
+
return :allow if readonly_auto_allowed?(tool, command_str)
|
|
116
|
+
|
|
117
|
+
# 7-8. confirm_policy gate for a shell command not otherwise resolved.
|
|
118
|
+
# NOT under config "skip" (nor runtime yolo, handled at step 3) —
|
|
119
|
+
# those are the explicit operator overrides that mean "stop
|
|
120
|
+
# prompting me".
|
|
121
|
+
#
|
|
122
|
+
# confirm_all (DEFAULT, == legacy require_confirmation_for_shell:true)
|
|
123
|
+
# every such shell command -> :ask. shell is :high risk so manual
|
|
124
|
+
# mode would ask anyway; this also keeps it gated under auto mode.
|
|
125
|
+
#
|
|
126
|
+
# dangerous_only (reference-faithful, == legacy alias:false)
|
|
127
|
+
# prompt ONLY when the command matches a DangerousPattern
|
|
128
|
+
# (git push --force, curl|sh, recursive rm of a non-root path,
|
|
129
|
+
# ...). Safe commands run unprompted. Mirrors approval.py:475
|
|
130
|
+
# where detect_dangerous_command is the sole prompt trigger.
|
|
131
|
+
# The hardline floor (step 1) and permissions:deny (step 2) already
|
|
132
|
+
# ran, so dangerous_only NEVER weakens the non-bypassable floor.
|
|
133
|
+
if tool.name == "shell" && @mode != "skip"
|
|
134
|
+
case @confirm_policy
|
|
135
|
+
when :dangerous_only
|
|
136
|
+
return :ask if dangerous?(command_str)
|
|
137
|
+
|
|
138
|
+
return :allow
|
|
139
|
+
else # :confirm_all
|
|
140
|
+
return :ask
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 9. Fall back to mode-based decision
|
|
145
|
+
mode_based_decision(tool)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# True when a command matches a recoverable-but-risky DangerousPattern
|
|
149
|
+
# (distinct from the hardline floor). Computed signal for the structured
|
|
150
|
+
# ask context and for S4's dangerous_only confirm policy; #decide does
|
|
151
|
+
# not yet branch on it (see step 7). Mirrors detect_dangerous_command.
|
|
152
|
+
def dangerous?(command)
|
|
153
|
+
DangerousPatterns.dangerous?(command)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns true if a specific command is pre-approved by the config
|
|
157
|
+
# allowlist. An empty allowlist pre-approves NOTHING.
|
|
158
|
+
def command_pre_approved?(command)
|
|
159
|
+
CommandAllowlist.new(config: @config).allowed?(command)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# True when the shell command is provably read-only and the
|
|
163
|
+
# approvals.auto_allow_readonly gate (default ON) is open. Shell-only:
|
|
164
|
+
# for every other tool the "command" is a path or argument fragment.
|
|
165
|
+
def readonly_auto_allowed?(tool, command)
|
|
166
|
+
return false unless tool.name == "shell"
|
|
167
|
+
return false unless @config.auto_allow_readonly?
|
|
168
|
+
|
|
169
|
+
ReadonlyCommands.auto_allowed?(command, extra: @config.approvals_readonly_commands)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Builds the string representation of a tool call used both for
|
|
173
|
+
# pattern-rule matching here and for the UI's session-approval scope
|
|
174
|
+
# in ToolExecutor. One builder so the granularity stays identical:
|
|
175
|
+
# approving `shell ls` never auto-approves `shell rm -rf /`.
|
|
176
|
+
def self.command_string(tool, arguments)
|
|
177
|
+
args = arguments || {}
|
|
178
|
+
case tool.name
|
|
179
|
+
when "shell"
|
|
180
|
+
(args["command"] || args[:command]).to_s
|
|
181
|
+
when "read", "write", "edit", "multi_edit", "attach_file"
|
|
182
|
+
(args["file_path"] || args[:file_path]).to_s
|
|
183
|
+
when "shell_output", "shell_kill", "shell_input"
|
|
184
|
+
(args["run_id"] || args[:run_id]).to_s
|
|
185
|
+
else
|
|
186
|
+
args.values.first.to_s
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resets doom loop detector (call on new user input)
|
|
191
|
+
def reset_turn!
|
|
192
|
+
@doom_detector.reset!
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Records WHY this deny fired before returning it (see #last_deny_reason).
|
|
198
|
+
def deny_with(reason)
|
|
199
|
+
@last_deny_reason = reason
|
|
200
|
+
:deny
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def mode_based_decision(tool)
|
|
204
|
+
case @mode
|
|
205
|
+
when "skip"
|
|
206
|
+
:allow
|
|
207
|
+
when "auto"
|
|
208
|
+
tool.risk_level == :high ? :ask : :allow
|
|
209
|
+
when "manual"
|
|
210
|
+
tool.risky? ? :ask : :allow
|
|
211
|
+
else
|
|
212
|
+
tool.risky? ? :ask : :allow
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def load_permission_rules(agent_overrides)
|
|
217
|
+
base_rules = @config.dig("permissions") || {}
|
|
218
|
+
|
|
219
|
+
if agent_overrides.is_a?(Hash)
|
|
220
|
+
base_rules.merge(agent_overrides)
|
|
221
|
+
else
|
|
222
|
+
base_rules
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Manages a whitelist of shell commands that can be executed without confirmation.
|
|
6
|
+
class CommandAllowlist
|
|
7
|
+
def initialize(config: nil)
|
|
8
|
+
@config = config || Rubino.configuration
|
|
9
|
+
@allowlist = @config.security_command_allowlist
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns true if the command matches an entry in the allowlist.
|
|
13
|
+
# An EMPTY allowlist matches NOTHING — pre-approval is opt-in, so an
|
|
14
|
+
# unconfigured allowlist must never auto-approve everything.
|
|
15
|
+
def allowed?(command)
|
|
16
|
+
return false if @allowlist.empty?
|
|
17
|
+
|
|
18
|
+
@allowlist.any? do |allowed|
|
|
19
|
+
command.strip.start_with?(allowed.strip)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Dangerous (recoverable-but-risky) command patterns — the layer ABOVE the
|
|
6
|
+
# hardline floor. These are operations that can lose work, rewrite shared
|
|
7
|
+
# history, escalate privilege, or touch system/credential files, but which
|
|
8
|
+
# a user might legitimately want to run with confirmation. Unlike
|
|
9
|
+
# HardlineGuard (catastrophic, no recovery path, never runs), a dangerous
|
|
10
|
+
# match is meant to drive an :ask — yolo/approval CAN pass it through.
|
|
11
|
+
#
|
|
12
|
+
# This is deliberately DISTINCT from HardlineGuard: there is NO overlap.
|
|
13
|
+
# Hardline owns "rm -rf /", "mkfs", "dd to /dev/sd*", shutdown/reboot,
|
|
14
|
+
# fork bomb, kill-all, sudo -S guessing. DangerousPatterns owns the
|
|
15
|
+
# recoverable cousins: recursive rm of NON-root paths, git force-push /
|
|
16
|
+
# reset --hard, curl|sh, broad chmod/chown, writes into /etc, sudo with
|
|
17
|
+
# privilege flags, find -delete, etc.
|
|
18
|
+
#
|
|
19
|
+
# Mirrors the reference approval module: DANGEROUS_PATTERNS and
|
|
20
|
+
# detect_dangerous_command. A faithful CORE subset of the reference ~47
|
|
21
|
+
# patterns — the important risk classes, not an exhaustive copy.
|
|
22
|
+
module DangerousPatterns
|
|
23
|
+
# Sensitive write targets (system config, block devices, ssh/credential
|
|
24
|
+
# files). Mirrors approval.py:_SENSITIVE_WRITE_TARGET (:152) in spirit,
|
|
25
|
+
# kept compact. /etc plus its macOS /private/etc mirror.
|
|
26
|
+
SYSTEM_CONFIG_PATH = %r{(?:/etc/|/private/(?:etc|var|tmp)/)}.source.freeze
|
|
27
|
+
SENSITIVE_WRITE_TARGET =
|
|
28
|
+
%r{(?:#{SYSTEM_CONFIG_PATH}|/dev/sd|(?:~|\$home)/\.ssh/|(?:~|\$home)/\.(?:netrc|pgpass|npmrc|pypirc)\b)}.source.freeze
|
|
29
|
+
|
|
30
|
+
# [regex, human description "pattern key"]. Matched against the
|
|
31
|
+
# lowercased, whitespace-normalized command. The description doubles as
|
|
32
|
+
# the persisted approval key in later slices (mirrors the reference pattern_key).
|
|
33
|
+
PATTERNS = [
|
|
34
|
+
# --- Recursive / forced delete of NON-root paths (root is hardline).
|
|
35
|
+
# -\S*r catches both -rf and the long --recursive form. ---
|
|
36
|
+
[/\brm\s+-\S*r/, "recursive delete"],
|
|
37
|
+
|
|
38
|
+
# --- Broad permission / ownership changes ---
|
|
39
|
+
[/\bchmod\s+(?:-\S*\s+)*(?:777|666|o\+[rwx]*w|a\+[rwx]*w)\b/, "world/other-writable permissions"],
|
|
40
|
+
[/\bchown\s+(?:-\S*)?r\s+root/, "recursive chown to root"],
|
|
41
|
+
|
|
42
|
+
# --- Privilege escalation: sudo with non-interactive privilege flags ---
|
|
43
|
+
# Plain `sudo cmd` is TTY-bound and excluded; these flags (stdin/
|
|
44
|
+
# askpass/shell/list) are the agent-reachable escalation forms.
|
|
45
|
+
# (sudo -S WITHOUT a configured password is hardline; this is the
|
|
46
|
+
# broader, recoverable privilege-flag class.)
|
|
47
|
+
[/\bsudo\b[^;|&\n]*?\s+(?:--stdin\b|-a\b|--askpass\b|-s\b)/,
|
|
48
|
+
"sudo with privilege flag (stdin/askpass/shell/list)"],
|
|
49
|
+
|
|
50
|
+
# --- Pipe remote content to a shell (curl|sh, wget|bash) ---
|
|
51
|
+
[%r{\b(?:curl|wget)\b.*\|\s*(?:[/\w]*/)?(?:ba)?sh(?:\s|$|-c)}, "pipe remote content to shell"],
|
|
52
|
+
[/\b(?:bash|sh|zsh|ksh)\s+<\s*<?\s*\(\s*(?:curl|wget)\b/, "execute remote script via process substitution"],
|
|
53
|
+
|
|
54
|
+
# --- Write / overwrite into system or credential files ---
|
|
55
|
+
[/>>?\s*["']?#{SENSITIVE_WRITE_TARGET}/, "overwrite system file via redirection"],
|
|
56
|
+
[/\btee\b.*["']?#{SENSITIVE_WRITE_TARGET}/, "overwrite system file via tee"],
|
|
57
|
+
[/\b(?:cp|mv|install)\b.*\s#{SYSTEM_CONFIG_PATH}/, "copy/move file into system config path"],
|
|
58
|
+
[/\bsed\s+-\S*i.*\s#{SYSTEM_CONFIG_PATH}/, "in-place edit of system config"],
|
|
59
|
+
|
|
60
|
+
# --- Service control ---
|
|
61
|
+
[/\bsystemctl\s+(?:-\S+\s+)*(?:stop|restart|disable|mask)\b/, "stop/restart system service"],
|
|
62
|
+
|
|
63
|
+
# --- Force-kill process sweeps (kill-all -1 is hardline) ---
|
|
64
|
+
[/\bpkill\s+-9\b/, "force kill processes"],
|
|
65
|
+
[/\bkillall\s+(?:-\S*\s+)*-(?:9|kill|sigkill)\b/, "force kill processes (killall -KILL)"],
|
|
66
|
+
[/\bkillall\s+(?:-\S*\s+)*-r\b/, "kill processes by regex (killall -r)"],
|
|
67
|
+
|
|
68
|
+
# --- find that deletes ---
|
|
69
|
+
[%r{\bfind\b.*-exec(?:dir)?\s+(?:/\S*/)?rm\b}, "find -exec/-execdir rm"],
|
|
70
|
+
[/\bfind\b.*-delete\b/, "find -delete"],
|
|
71
|
+
[/\bxargs\s+.*\brm\b/, "xargs with rm"],
|
|
72
|
+
|
|
73
|
+
# --- Git destructive / history-rewriting operations ---
|
|
74
|
+
[/\bgit\s+reset\s+--hard\b/, "git reset --hard (destroys uncommitted changes)"],
|
|
75
|
+
[/\bgit\s+push\b.*--force\b/, "git force push (rewrites remote history)"],
|
|
76
|
+
[/\bgit\s+push\b.*\s-f\b/, "git force push short flag (rewrites remote history)"],
|
|
77
|
+
[/\bgit\s+clean\s+-\S*f/, "git clean with force (deletes untracked files)"],
|
|
78
|
+
[/\bgit\s+branch\s+-d\b/, "git branch force delete"],
|
|
79
|
+
|
|
80
|
+
# --- Filesystem format / raw disk copy (the recoverable framings;
|
|
81
|
+
# mkfs and dd-to-/dev/sd* themselves are hardline) ---
|
|
82
|
+
[/\bdd\s+.*if=/, "disk copy"],
|
|
83
|
+
|
|
84
|
+
# --- Destructive SQL ---
|
|
85
|
+
[/\bdrop\s+(?:table|database)\b/, "SQL DROP"],
|
|
86
|
+
[/\bdelete\s+from\b(?![^\n]*\bwhere\b)/, "SQL DELETE without WHERE"],
|
|
87
|
+
[/\btruncate\s+(?:table)?\s*\w/, "SQL TRUNCATE"]
|
|
88
|
+
].freeze
|
|
89
|
+
|
|
90
|
+
module_function
|
|
91
|
+
|
|
92
|
+
# Returns [true, pattern_key, description] when the command matches a
|
|
93
|
+
# dangerous pattern, else [false, nil, nil]. The pattern_key and
|
|
94
|
+
# description are the same string (the human-readable key) — the tuple
|
|
95
|
+
# arity mirrors the reference detect_dangerous_command so later slices
|
|
96
|
+
# can persist the key.
|
|
97
|
+
def detect(command)
|
|
98
|
+
normalized = normalize(command)
|
|
99
|
+
PATTERNS.each do |regex, description|
|
|
100
|
+
return [true, description, description] if normalized.match?(regex)
|
|
101
|
+
end
|
|
102
|
+
[false, nil, nil]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Convenience predicate: true when the command hits a dangerous pattern.
|
|
106
|
+
def dangerous?(command)
|
|
107
|
+
detect(command).first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Same normalization idiom as HardlineGuard: collapse spaces/tabs (keep
|
|
111
|
+
# newlines so separator anchors fire), trim, lowercase. Trivial
|
|
112
|
+
# obfuscation (extra spaces, case) doesn't slip through.
|
|
113
|
+
def normalize(command)
|
|
114
|
+
command.to_s.gsub(/[ \t]+/, " ").strip.downcase
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Persists an explicit "deny always" verdict to the `permissions` map so it
|
|
6
|
+
# survives a process restart and auto-denies future sibling commands through
|
|
7
|
+
# ApprovalPolicy#decide, which evaluates a permissions:deny rule FIRST (before
|
|
8
|
+
# any allow path — see approval_policy.rb step 2).
|
|
9
|
+
#
|
|
10
|
+
# The DENY counterpart to AllowlistPersister: same Config::Writer (dot-notation
|
|
11
|
+
# -> YAML) + live-config sync, but it writes into `permissions` instead of
|
|
12
|
+
# `security.command_allowlist`, and the value is the verdict "deny" keyed by a
|
|
13
|
+
# PatternMatcher-format pattern ("<tool> <glob>") rather than a bare prefix.
|
|
14
|
+
#
|
|
15
|
+
# The pattern is derived from the SAME PrefixDeriver rule the allow side uses,
|
|
16
|
+
# so "deny always" is scoped consistently with "always allow":
|
|
17
|
+
# :prefix -> "<tool> <head>*" (e.g. "shell git*" — denies every sibling)
|
|
18
|
+
# :command -> "<tool> <command>" (e.g. "shell rm -rf /tmp/x" — exact)
|
|
19
|
+
# :pattern -> "<tool> <command>" (a dangerous-pattern description is not a
|
|
20
|
+
# command glob, so deny the exact command)
|
|
21
|
+
#
|
|
22
|
+
# Append-unique: an already-present "<pattern>": "deny" entry is a no-op.
|
|
23
|
+
#
|
|
24
|
+
# SCOPING NOTE: identical to AllowlistPersister — Config::Writer writes the
|
|
25
|
+
# process-global config.yml. Fine for a single-process / single-home setup; a
|
|
26
|
+
# shared-server deployment would need per-user config scoping.
|
|
27
|
+
module DenyPersister
|
|
28
|
+
KEY = "permissions"
|
|
29
|
+
DENY = "deny"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Persists a permissions:deny rule for `pattern` (unique). Returns the
|
|
34
|
+
# resulting permissions hash. A blank pattern is a no-op.
|
|
35
|
+
def persist(pattern, config: nil, config_path: nil)
|
|
36
|
+
key = pattern.to_s.strip
|
|
37
|
+
return current_permissions(config) if key.empty?
|
|
38
|
+
|
|
39
|
+
config ||= Rubino.configuration
|
|
40
|
+
existing = current_permissions(config)
|
|
41
|
+
return existing if existing[key] == DENY
|
|
42
|
+
|
|
43
|
+
updated = existing.merge(key => DENY)
|
|
44
|
+
Config::Writer.new(config_path: config_path || default_config_path).set(KEY, updated)
|
|
45
|
+
# Keep the live config in sync so an ApprovalPolicy built this process
|
|
46
|
+
# sees the new deny rule immediately (the writer only touches disk).
|
|
47
|
+
config.set(KEY, updated)
|
|
48
|
+
updated
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The PatternMatcher-format key a (tool, rule, command) "deny always"
|
|
52
|
+
# persists as. Mirrors the allow side's scoping: a derivable :prefix denies
|
|
53
|
+
# the whole prefix class ("<tool> <head>*"); everything else denies the
|
|
54
|
+
# exact command ("<tool> <command>"). Nil when there is nothing to key on.
|
|
55
|
+
def pattern_for(tool:, rule:, command:)
|
|
56
|
+
cmd = command.to_s.strip
|
|
57
|
+
if rule&.kind == :prefix && !rule.value.to_s.strip.empty?
|
|
58
|
+
"#{tool} #{rule.value.strip}*"
|
|
59
|
+
elsif !cmd.empty?
|
|
60
|
+
"#{tool} #{cmd}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def current_permissions(config)
|
|
65
|
+
((config || Rubino.configuration).dig("permissions") || {}).dup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_config_path
|
|
69
|
+
Config::Loader.new.config_path
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Detects when the agent enters a doom loop - repeatedly calling
|
|
6
|
+
# the same tool with identical arguments without progress.
|
|
7
|
+
class DoomLoopDetector
|
|
8
|
+
DEFAULT_THRESHOLD = 3
|
|
9
|
+
|
|
10
|
+
def initialize(threshold: DEFAULT_THRESHOLD)
|
|
11
|
+
@threshold = threshold
|
|
12
|
+
@history = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Records a tool call and returns true if a doom loop is detected
|
|
16
|
+
def record(tool_name:, arguments:)
|
|
17
|
+
signature = generate_signature(tool_name, arguments)
|
|
18
|
+
@history << signature
|
|
19
|
+
|
|
20
|
+
# Check if the last N calls are identical
|
|
21
|
+
if @history.size >= @threshold
|
|
22
|
+
recent = @history.last(@threshold)
|
|
23
|
+
return true if recent.uniq.size == 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resets the detector (e.g., when user provides new input)
|
|
30
|
+
def reset!
|
|
31
|
+
@history.clear
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def generate_signature(tool_name, arguments)
|
|
37
|
+
# Create a deterministic signature from tool name + sorted arguments
|
|
38
|
+
args_str = arguments.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}=#{v}" }.join("&")
|
|
39
|
+
"#{tool_name}:#{args_str}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|