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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Hardline (unconditional) blocklist — a floor BELOW yolo.
|
|
6
|
+
#
|
|
7
|
+
# Commands so catastrophic they must NEVER run via the agent, regardless
|
|
8
|
+
# of --yolo, skip-approvals mode, a permissions:allow rule, or a
|
|
9
|
+
# command_allowlist entry. Opting into yolo is the user trusting the agent
|
|
10
|
+
# to move fast on their files and services — NOT trusting it to wipe the
|
|
11
|
+
# disk or power the box off.
|
|
12
|
+
#
|
|
13
|
+
# The list is deliberately TINY: only things with no recovery path —
|
|
14
|
+
# filesystem destruction rooted at / (or ~), raw block-device overwrites,
|
|
15
|
+
# filesystem format, kernel shutdown/reboot, and fork-bomb / kill-all DoS.
|
|
16
|
+
# Recoverable-but-costly operations (git reset --hard, rm -rf /tmp/x,
|
|
17
|
+
# chmod -R 777, curl|sh) DO NOT belong here — they stay in the dangerous-
|
|
18
|
+
# pattern layer where yolo/approval can pass them through. Adding anything
|
|
19
|
+
# recoverable here is a false-positive that blocks legitimate work.
|
|
20
|
+
#
|
|
21
|
+
# Mirrors the reference approval module: HARDLINE_PATTERNS,
|
|
22
|
+
# detect_hardline_command, the sudo-stdin guard, and the
|
|
23
|
+
# "tiny, no recovery path" guidance.
|
|
24
|
+
module HardlineGuard
|
|
25
|
+
# Start-of-command anchor: matches positions where a shell begins
|
|
26
|
+
# parsing a new command (start of string, after a separator, after a
|
|
27
|
+
# subshell opener), optionally consuming leading wrappers (sudo, env
|
|
28
|
+
# VAR=VAL, exec/nohup/setsid/time) so we don't false-positive on
|
|
29
|
+
# "echo reboot" or "grep shutdown log". Mirrors approval.py:_CMDPOS.
|
|
30
|
+
CMDPOS = /(?:^|[;&|\n`]|\$\()\s*(?:sudo\s+(?:-\S+\s+)*)?(?:env\s+(?:\w+=\S*\s+)*)?(?:(?:exec|nohup|setsid|time)\s+)*\s*/.source.freeze
|
|
31
|
+
|
|
32
|
+
# [regex, human description]. Matched against the lowercased, whitespace-
|
|
33
|
+
# normalized command. KEEP TINY — unrecoverable only.
|
|
34
|
+
HARDLINE_PATTERNS = [
|
|
35
|
+
# rm -r/-rf targeting the root filesystem (/ or /*)
|
|
36
|
+
[%r{\brm\s+(?:-\S*\s+)*(?:/|/\*)(?:\s|$)}, "recursive delete of root filesystem"],
|
|
37
|
+
# rm -r/-rf targeting a protected system directory
|
|
38
|
+
[%r{\brm\s+(?:-\S*\s+)*(?:/home|/root|/etc|/usr|/var|/bin|/sbin|/boot|/lib)(?:/\*)?(?:\s|$)},
|
|
39
|
+
"recursive delete of system directory"],
|
|
40
|
+
# rm targeting the home directory (~ or $HOME)
|
|
41
|
+
[%r{\brm\s+(?:-\S*\s+)*(?:~|\$home)(?:/?|/\*)?(?:\s|$)}, "recursive delete of home directory"],
|
|
42
|
+
# Filesystem format
|
|
43
|
+
[/\bmkfs(?:\.[a-z0-9]+)?\b/, "format filesystem (mkfs)"],
|
|
44
|
+
# dd to a raw block device
|
|
45
|
+
[%r{\bdd\b[^\n]*\bof=/dev/(?:sd|nvme|hd|mmcblk|vd|xvd|disk|loop)[a-z0-9]*}, "dd to raw block device"],
|
|
46
|
+
# Redirect to a raw block device (echo x > /dev/sda)
|
|
47
|
+
[%r{>\s*/dev/(?:sd|nvme|hd|mmcblk|vd|xvd|disk|loop)[a-z0-9]*\b}, "redirect to raw block device"],
|
|
48
|
+
# chmod/chown -R on the root filesystem
|
|
49
|
+
[%r{\b(?:chmod|chown)\s+(?:-\S*\s+)*-\S*r\S*\s+\S+\s+/(?:\s|$)}, "recursive chmod/chown of root filesystem"],
|
|
50
|
+
# Fork bomb (classic shell form, whitespace-tolerant)
|
|
51
|
+
[/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, "fork bomb"],
|
|
52
|
+
# Kill every process on the system
|
|
53
|
+
[/\bkill\s+(?:-\S+\s+)*-1\b/, "kill all processes"],
|
|
54
|
+
# System shutdown / reboot / halt / poweroff (anchored to cmd position)
|
|
55
|
+
[/#{CMDPOS}(?:shutdown|reboot|halt|poweroff)\b/, "system shutdown/reboot"],
|
|
56
|
+
[/#{CMDPOS}init\s+[06]\b/, "init 0/6 (shutdown/reboot)"],
|
|
57
|
+
[/#{CMDPOS}systemctl\s+(?:poweroff|reboot|halt|kexec)\b/, "systemctl poweroff/reboot"],
|
|
58
|
+
[/#{CMDPOS}telinit\s+[06]\b/, "telinit 0/6 (shutdown/reboot)"]
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# sudo -S without a configured SUDO_PASSWORD is the model piping a
|
|
62
|
+
# *guessed* password via stdin — a brute-force vector. Unconditional
|
|
63
|
+
# block. Mirrors approval.py:_check_sudo_stdin_guard (:255).
|
|
64
|
+
SUDO_STDIN_RE = /(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-s\b/
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# Returns [true, description] when the command hits the hardline floor
|
|
69
|
+
# (a HARDLINE_PATTERN or the sudo-stdin guard), else [false, nil].
|
|
70
|
+
def detect(command)
|
|
71
|
+
normalized = normalize(command)
|
|
72
|
+
HARDLINE_PATTERNS.each do |regex, description|
|
|
73
|
+
return [true, description] if normalized.match?(regex)
|
|
74
|
+
end
|
|
75
|
+
return [true, "sudo password guessing via stdin (sudo -S)"] if sudo_stdin?(normalized)
|
|
76
|
+
|
|
77
|
+
[false, nil]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Convenience predicate for the post-approval defense-in-depth check in
|
|
81
|
+
# ShellTool. Returns the description, or nil when the command is clear.
|
|
82
|
+
def block_reason(command)
|
|
83
|
+
blocked, description = detect(command)
|
|
84
|
+
blocked ? description : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# sudo -S only fires the guard when no SUDO_PASSWORD is configured —
|
|
88
|
+
# with one set, an internal transform legitimately injects -S elsewhere.
|
|
89
|
+
def sudo_stdin?(normalized)
|
|
90
|
+
return false if ENV.key?("SUDO_PASSWORD")
|
|
91
|
+
|
|
92
|
+
normalized.match?(SUDO_STDIN_RE)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Minimal normalization: collapse runs of spaces/tabs (newlines kept so
|
|
96
|
+
# the command-separator anchors still fire), trim, and lowercase so
|
|
97
|
+
# trivial obfuscation (extra spaces, case) doesn't slip through.
|
|
98
|
+
# Deliberately NOT a full ANSI/Unicode normalizer — over-engineering for
|
|
99
|
+
# the hardline floor.
|
|
100
|
+
def normalize(command)
|
|
101
|
+
command.to_s.gsub(/[ \t]+/, " ").strip.downcase
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Pattern-based permission matcher supporting wildcards.
|
|
6
|
+
# Matches tool names, commands, and file paths against configured rules.
|
|
7
|
+
#
|
|
8
|
+
# Rules format in config:
|
|
9
|
+
# permissions:
|
|
10
|
+
# "git *": "allow"
|
|
11
|
+
# "shell rm -rf *": "deny"
|
|
12
|
+
# "file_system write ~/.env": "deny"
|
|
13
|
+
# "shell bundle *": "allow"
|
|
14
|
+
#
|
|
15
|
+
# Actions: "allow", "ask", "deny"
|
|
16
|
+
class PatternMatcher
|
|
17
|
+
ACTIONS = %w[allow ask deny].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(rules: {})
|
|
20
|
+
@rules = parse_rules(rules)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the action for a given tool call description
|
|
24
|
+
# Returns :allow, :ask, or :deny
|
|
25
|
+
def match(tool_name, command_or_args = nil)
|
|
26
|
+
full_string = [tool_name, command_or_args].compact.join(" ")
|
|
27
|
+
|
|
28
|
+
# Check rules from most specific to least specific
|
|
29
|
+
@rules.each do |pattern, action|
|
|
30
|
+
return action.to_sym if matches_pattern?(full_string, pattern)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Default: no explicit match
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns true if the pattern matches the input
|
|
38
|
+
def matches_pattern?(input, pattern)
|
|
39
|
+
# Convert glob-style pattern to regex
|
|
40
|
+
regex_str = Regexp.escape(pattern)
|
|
41
|
+
.gsub('\*', ".*")
|
|
42
|
+
.gsub('\?', ".")
|
|
43
|
+
regex = Regexp.new("\\A#{regex_str}\\z", Regexp::IGNORECASE)
|
|
44
|
+
input.match?(regex)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_rules(rules)
|
|
50
|
+
return {} unless rules.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
# Sort by specificity: more specific patterns first
|
|
53
|
+
# (longer patterns without wildcards are more specific)
|
|
54
|
+
rules.sort_by do |pattern, _|
|
|
55
|
+
specificity = pattern.length
|
|
56
|
+
specificity -= 10 if pattern.include?("*")
|
|
57
|
+
-specificity
|
|
58
|
+
end.to_h
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# Derives the REUSABLE rule that an approval should be remembered as,
|
|
6
|
+
# instead of pinning memory to the exact "<tool>:<command>" string.
|
|
7
|
+
#
|
|
8
|
+
# Mirrors the reference persistence unit: approve_session/is_approved key on a
|
|
9
|
+
# PATTERN KEY, not the raw command.
|
|
10
|
+
# For a dangerous command the pattern key IS the dangerous description, so
|
|
11
|
+
# approving it once covers the whole risk class for the session. For a plain
|
|
12
|
+
# command the reference allowlist is prefix-ish; we derive a leading-token prefix
|
|
13
|
+
# the same way CommandAllowlist matches (start_with?, command_allowlist.rb).
|
|
14
|
+
#
|
|
15
|
+
# Pure derivation — no I/O, no persistence. Returns a small immutable Rule.
|
|
16
|
+
#
|
|
17
|
+
# kind == :pattern -> remember a dangerous-pattern CLASS (value = key)
|
|
18
|
+
# kind == :prefix -> remember a command PREFIX (value = "git")
|
|
19
|
+
# kind == :command -> remember one EXACT command (value = "git status")
|
|
20
|
+
module PrefixDeriver
|
|
21
|
+
Rule = Struct.new(:kind, :value, keyword_init: true) do
|
|
22
|
+
# Does this remembered rule cover `command`? Matching mirrors the
|
|
23
|
+
# storage shape: a pattern covers any sibling of its class, a prefix
|
|
24
|
+
# covers any command that start_with? it (like CommandAllowlist), an
|
|
25
|
+
# exact command covers only itself.
|
|
26
|
+
def covers?(command)
|
|
27
|
+
cmd = command.to_s
|
|
28
|
+
case kind
|
|
29
|
+
when :pattern then DangerousPatterns.detect(cmd)[1] == value
|
|
30
|
+
when :prefix then cmd.strip.start_with?(value.to_s.strip)
|
|
31
|
+
else cmd.strip == value.to_s.strip
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wrapper commands whose first sub-token is part of the meaningful
|
|
37
|
+
# prefix ("bundle exec" / "npm run"), not the argument. Without this a
|
|
38
|
+
# naive "first token" prefix would collapse `bundle exec rspec` and
|
|
39
|
+
# `bundle install` into the same `bundle` rule.
|
|
40
|
+
WRAPPERS = {
|
|
41
|
+
"bundle" => %w[exec].freeze,
|
|
42
|
+
"npm" => %w[run].freeze,
|
|
43
|
+
"yarn" => %w[run].freeze,
|
|
44
|
+
"pnpm" => %w[run].freeze,
|
|
45
|
+
"rake" => [].freeze,
|
|
46
|
+
"cargo" => %w[run].freeze
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Builds the rule a (tool, command) approval should be remembered as.
|
|
52
|
+
#
|
|
53
|
+
# @param pattern_key [String, nil] the dangerous description when the
|
|
54
|
+
# caller has already detected one; we re-detect when absent so callers
|
|
55
|
+
# that only have the raw command still get a :pattern rule.
|
|
56
|
+
def rule_for(tool:, command:, pattern_key: nil)
|
|
57
|
+
cmd = command.to_s
|
|
58
|
+
key = pattern_key || DangerousPatterns.detect(cmd)[1]
|
|
59
|
+
return Rule.new(kind: :pattern, value: key) if key
|
|
60
|
+
|
|
61
|
+
# The :prefix rule ("allow `<head>` commands") only makes sense for the
|
|
62
|
+
# shell tool, where sibling commands genuinely share a leading
|
|
63
|
+
# executable (git status / git diff). For structured-arg tools the
|
|
64
|
+
# "command" is a file path (write/edit/read) or a code/arg fragment
|
|
65
|
+
# (ruby), so a derived prefix is nonsense — "allow `output.txt`
|
|
66
|
+
# commands", "allow `6` commands". Remember those by exact command
|
|
67
|
+
# instead, so the CLI/web offer no bogus prefix choice. (B6)
|
|
68
|
+
return command_rule(tool: tool, command: cmd) unless tool.to_s == "shell"
|
|
69
|
+
|
|
70
|
+
prefix = command_prefix(cmd)
|
|
71
|
+
return command_rule(tool: tool, command: cmd) if prefix.empty?
|
|
72
|
+
|
|
73
|
+
Rule.new(kind: :prefix, value: prefix)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The NARROW rule used by :session / :always_command for S3 so behavior
|
|
77
|
+
# stays stable: a dangerous command remembers its pattern class (matching
|
|
78
|
+
# the reference), everything else remembers the exact command. The broad :prefix
|
|
79
|
+
# rule is derivable via rule_for but only wired into a decision in S5.
|
|
80
|
+
def narrow_rule_for(tool:, command:, pattern_key: nil)
|
|
81
|
+
cmd = command.to_s
|
|
82
|
+
key = pattern_key || DangerousPatterns.detect(cmd)[1]
|
|
83
|
+
return Rule.new(kind: :pattern, value: key) if key
|
|
84
|
+
|
|
85
|
+
command_rule(tool: tool, command: cmd)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def command_rule(tool:, command:)
|
|
89
|
+
value = command.to_s.strip
|
|
90
|
+
value = tool.to_s if value.empty?
|
|
91
|
+
Rule.new(kind: :command, value: value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Leading safe-token run of a plain command:
|
|
95
|
+
# "git status" -> "git"
|
|
96
|
+
# "bundle exec rspec" -> "bundle exec"
|
|
97
|
+
# "npm run test --watch" -> "npm run"
|
|
98
|
+
# A plain command keeps only its head; a wrapper command (bundle/npm/...)
|
|
99
|
+
# additionally keeps its declared verb (exec/run) so distinct wrapped
|
|
100
|
+
# tools don't collapse into one rule. The run stops at the first flag or
|
|
101
|
+
# argument-shaped token, mirroring CommandAllowlist's start_with? match.
|
|
102
|
+
def command_prefix(command)
|
|
103
|
+
tokens = command.to_s.strip.split(/\s+/)
|
|
104
|
+
return "" if tokens.empty?
|
|
105
|
+
|
|
106
|
+
head = tokens.first
|
|
107
|
+
prefix = [head]
|
|
108
|
+
|
|
109
|
+
if WRAPPERS.key?(head)
|
|
110
|
+
verb = tokens[1]
|
|
111
|
+
prefix << verb if verb && plain_word?(verb) && WRAPPERS[head].include?(verb)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
prefix.join(" ")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A "plain word" is a bare token: not a flag, no path/assignment/glob
|
|
118
|
+
# punctuation — the shape that can safely extend a prefix.
|
|
119
|
+
def plain_word?(token)
|
|
120
|
+
token.match?(/\A[A-Za-z0-9_:.-]+\z/) && !token.start_with?("-")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Security
|
|
7
|
+
# Built-in auto-allow layer for provably READ-ONLY shell commands.
|
|
8
|
+
#
|
|
9
|
+
# Sits at the same decision step as the user command allowlist
|
|
10
|
+
# (ApprovalPolicy step 6) — BELOW the hardline floor and permissions:deny,
|
|
11
|
+
# which always run first, and ABOVE the confirm-policy prompt. A command
|
|
12
|
+
# auto-allows ONLY when the ENTIRE line parses as safe:
|
|
13
|
+
#
|
|
14
|
+
# - every chain segment (split on |, &&, ||, ;, newline) starts with a
|
|
15
|
+
# command from the read-only set (or approvals.readonly_commands);
|
|
16
|
+
# - no output redirection (>, >>, 2>; `tee` is simply not in the set),
|
|
17
|
+
# no command substitution ($(...) or backticks, live contexts only —
|
|
18
|
+
# single-quoted text is literal and stays allowed), no process
|
|
19
|
+
# substitution (<(...), >(...)), no backgrounding (&);
|
|
20
|
+
# - no leading variable assignments (FOO=bar cmd → prompt);
|
|
21
|
+
# - no mutating flags on otherwise-safe heads (find -exec/-delete/...,
|
|
22
|
+
# date -s, tree -o, git --output);
|
|
23
|
+
# - git only with a read-only subcommand, conservatively flag-checked;
|
|
24
|
+
# - no DangerousPatterns match on the whole line (defense-in-depth for
|
|
25
|
+
# user-extended sets).
|
|
26
|
+
#
|
|
27
|
+
# Anything the scanner cannot prove safe FAILS CLOSED to the normal
|
|
28
|
+
# approval prompt — never to silent execution. Pure functions, no I/O.
|
|
29
|
+
module ReadonlyCommands
|
|
30
|
+
# Read-only command heads auto-allowed by default. Conservative: each
|
|
31
|
+
# entry must be side-effect-free for ANY argument list once the flag
|
|
32
|
+
# checks below pass. `git` is handled separately (per-subcommand).
|
|
33
|
+
SAFE_COMMANDS = %w[
|
|
34
|
+
ls pwd find cat head tail grep rg wc file stat du df which
|
|
35
|
+
whoami date tree echo
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# git subcommands that never mutate the repository. `remote` is
|
|
39
|
+
# restricted further below (bare or -v only — `git remote add` mutates),
|
|
40
|
+
# `branch` to pure-flag listing forms (`git branch foo` CREATES a branch).
|
|
41
|
+
GIT_READONLY_SUBCOMMANDS = %w[status log diff show rev-parse blame].freeze
|
|
42
|
+
GIT_BRANCH_READONLY_FLAGS = %w[
|
|
43
|
+
-a -r -v -vv --list --all --remotes --show-current --verbose
|
|
44
|
+
--merged --no-merged --color --no-color
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# Mutating/executing flags that disqualify an otherwise-safe head.
|
|
48
|
+
# Matched as exact token or `flag=value`.
|
|
49
|
+
FORBIDDEN_FLAGS = {
|
|
50
|
+
"find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls],
|
|
51
|
+
"date" => %w[-s --set],
|
|
52
|
+
"tree" => %w[-o]
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Leading `FOO=bar cmd` environment assignment — rejected, not stripped:
|
|
56
|
+
# an assignment can change what the command resolves to (PATH=...) or
|
|
57
|
+
# how it behaves, so it is never "provably read-only".
|
|
58
|
+
ASSIGNMENT_RE = /\A[A-Za-z_][A-Za-z0-9_]*=/
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# True when the ENTIRE command line is provably read-only. `extra` is
|
|
63
|
+
# the approvals.readonly_commands config: command names or leading-token
|
|
64
|
+
# prefixes ("jq", "docker ps") merged into the built-in set.
|
|
65
|
+
def auto_allowed?(command, extra: [])
|
|
66
|
+
return false if DangerousPatterns.dangerous?(command)
|
|
67
|
+
|
|
68
|
+
segments = split_segments(command.to_s)
|
|
69
|
+
return false if segments.nil? || segments.empty?
|
|
70
|
+
|
|
71
|
+
segments.all? { |segment| safe_segment?(segment, extra: extra) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Splits a command line into chain segments (|, ||, &&, ;, newline),
|
|
75
|
+
# quote-aware. Returns nil — reject — on any construct that could smuggle
|
|
76
|
+
# a write or an execution: redirection (>), backgrounding (&), command
|
|
77
|
+
# substitution ($( or backtick in a live context), process substitution
|
|
78
|
+
# (<( / >( )), comments, trailing backslash, unterminated quotes. Plain
|
|
79
|
+
# `<` input redirection stays allowed. Single-quoted text is literal in
|
|
80
|
+
# POSIX shells, so substitutions inside it are safe to keep.
|
|
81
|
+
def split_segments(command)
|
|
82
|
+
segments = []
|
|
83
|
+
current = +""
|
|
84
|
+
i = 0
|
|
85
|
+
while i < command.length
|
|
86
|
+
char = command[i]
|
|
87
|
+
succ = command[i + 1]
|
|
88
|
+
case char
|
|
89
|
+
when "'", "\""
|
|
90
|
+
quoted = consume_quoted(command, i, char)
|
|
91
|
+
return nil unless quoted
|
|
92
|
+
|
|
93
|
+
current << quoted
|
|
94
|
+
i += quoted.length
|
|
95
|
+
next
|
|
96
|
+
when "\\"
|
|
97
|
+
return nil if succ.nil?
|
|
98
|
+
|
|
99
|
+
current << char << succ
|
|
100
|
+
i += 1
|
|
101
|
+
when "`", ">", "#"
|
|
102
|
+
return nil
|
|
103
|
+
when "$", "<"
|
|
104
|
+
return nil if succ == "("
|
|
105
|
+
|
|
106
|
+
current << char
|
|
107
|
+
when ";", "\n", "|", "&"
|
|
108
|
+
advance = flush_segment(char, succ, segments, current)
|
|
109
|
+
return nil unless advance
|
|
110
|
+
|
|
111
|
+
current = +""
|
|
112
|
+
i += advance
|
|
113
|
+
next
|
|
114
|
+
else
|
|
115
|
+
current << char
|
|
116
|
+
end
|
|
117
|
+
i += 1
|
|
118
|
+
end
|
|
119
|
+
segments << current
|
|
120
|
+
segments.map(&:strip).reject(&:empty?)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Flushes the segment ended by a chain operator and returns how many
|
|
124
|
+
# characters the operator consumes (2 for && and ||, 1 otherwise), or
|
|
125
|
+
# nil for a lone & — backgrounding is never provably read-only.
|
|
126
|
+
def flush_segment(char, succ, segments, current)
|
|
127
|
+
return nil if char == "&" && succ != "&"
|
|
128
|
+
|
|
129
|
+
segments << current
|
|
130
|
+
"|&".include?(char) && succ == char ? 2 : 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Consumes the quoted region opening at `start`. Returns the full
|
|
134
|
+
# substring including both quotes, or nil when the quote is unterminated
|
|
135
|
+
# or — for double quotes, where substitutions stay LIVE — when it
|
|
136
|
+
# contains $( or a backtick. Single-quoted text is literal in POSIX
|
|
137
|
+
# shells, so anything inside is safe to keep verbatim.
|
|
138
|
+
def consume_quoted(command, start, quote)
|
|
139
|
+
i = start + 1
|
|
140
|
+
while i < command.length
|
|
141
|
+
char = command[i]
|
|
142
|
+
if quote == "\""
|
|
143
|
+
return nil if char == "`" || (char == "$" && command[i + 1] == "(")
|
|
144
|
+
|
|
145
|
+
if char == "\\"
|
|
146
|
+
i += 2
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
return command[start..i] if char == quote
|
|
151
|
+
|
|
152
|
+
i += 1
|
|
153
|
+
end
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One pipeline segment: tokenize (Shellwords — a parse error rejects),
|
|
158
|
+
# refuse leading assignments, then require the head to be a safe command
|
|
159
|
+
# whose flags pass the per-command checks, or an `extra` config entry.
|
|
160
|
+
def safe_segment?(segment, extra: [])
|
|
161
|
+
tokens = Shellwords.split(segment)
|
|
162
|
+
return false if tokens.empty? || tokens.first.match?(ASSIGNMENT_RE)
|
|
163
|
+
|
|
164
|
+
head = tokens.first
|
|
165
|
+
return safe_git?(tokens) if head == "git"
|
|
166
|
+
return safe_flags?(head, tokens) if SAFE_COMMANDS.include?(head)
|
|
167
|
+
|
|
168
|
+
extra_match?(tokens, extra)
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
false # unbalanced quotes etc. — fall through to the prompt
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def safe_flags?(head, tokens)
|
|
174
|
+
forbidden = FORBIDDEN_FLAGS[head]
|
|
175
|
+
return true unless forbidden
|
|
176
|
+
|
|
177
|
+
tokens.drop(1).none? do |token|
|
|
178
|
+
forbidden.any? { |flag| token == flag || token.start_with?("#{flag}=") }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Read-only git: a safe subcommand (no global flags before it — `git -C`
|
|
183
|
+
# falls to the prompt), never --output (git log/diff/show can write a
|
|
184
|
+
# file with it), branch/remote in their pure listing forms only.
|
|
185
|
+
def safe_git?(tokens)
|
|
186
|
+
sub = tokens[1]
|
|
187
|
+
return false if sub.nil? || sub.start_with?("-")
|
|
188
|
+
|
|
189
|
+
rest = tokens.drop(2)
|
|
190
|
+
return false if rest.any? { |t| t == "--output" || t.start_with?("--output=") }
|
|
191
|
+
|
|
192
|
+
case sub
|
|
193
|
+
when *GIT_READONLY_SUBCOMMANDS then true
|
|
194
|
+
when "branch" then rest.all? { |t| GIT_BRANCH_READONLY_FLAGS.include?(t) }
|
|
195
|
+
when "remote" then rest.empty? || rest == ["-v"] || rest == ["--verbose"]
|
|
196
|
+
else false
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# approvals.readonly_commands entries extend the built-in set: a bare
|
|
201
|
+
# name ("jq") matches that head, a multi-word entry ("docker ps")
|
|
202
|
+
# matches those leading tokens exactly.
|
|
203
|
+
def extra_match?(tokens, extra)
|
|
204
|
+
Array(extra).any? do |entry|
|
|
205
|
+
entry_tokens = entry.to_s.strip.split(/\s+/)
|
|
206
|
+
!entry_tokens.empty? && tokens.first(entry_tokens.length) == entry_tokens
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Session
|
|
8
|
+
# Serializes one session's transcript to clean markdown — the `/export`
|
|
9
|
+
# backend. Deliberately minimal: user/assistant turns verbatim, tool
|
|
10
|
+
# calls and tool results as one-liners, system rows (prompt scaffolding,
|
|
11
|
+
# compaction summaries) omitted. Reasoning never reaches the message
|
|
12
|
+
# store, so a transcript export is reasoning-free by construction.
|
|
13
|
+
class Exporter
|
|
14
|
+
# Tool-call arguments are context, not payload — clamp the one-liner.
|
|
15
|
+
ARGS_PREVIEW_CHARS = 120
|
|
16
|
+
|
|
17
|
+
def initialize(session, store: Store.new)
|
|
18
|
+
@session = session
|
|
19
|
+
@store = store
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The full markdown document for the session.
|
|
23
|
+
def markdown
|
|
24
|
+
lines = header
|
|
25
|
+
@store.for_session(@session[:id]).each do |msg|
|
|
26
|
+
lines.concat(render(msg))
|
|
27
|
+
end
|
|
28
|
+
"#{lines.join("\n")}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Writes #markdown to +path+ (default ./rubino-session-<id8>.md in the
|
|
32
|
+
# current directory) and returns the absolute path written.
|
|
33
|
+
def write(path = nil)
|
|
34
|
+
target = File.expand_path(path.to_s.empty? ? default_filename : path.to_s)
|
|
35
|
+
File.write(target, markdown)
|
|
36
|
+
target
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def default_filename
|
|
40
|
+
"rubino-session-#{@session[:id].to_s[0, 8]}.md"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def header
|
|
46
|
+
meta = ["- session: #{@session[:id]}"]
|
|
47
|
+
meta << "- title: #{@session[:title]}" if @session[:title]
|
|
48
|
+
meta << "- model: #{@session[:model]}" if @session[:model]
|
|
49
|
+
meta << "- exported: #{Time.now.utc.iso8601}"
|
|
50
|
+
["# rubino session #{@session[:id].to_s[0, 8]}", "", *meta, ""]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render(msg)
|
|
54
|
+
case msg.role.to_s
|
|
55
|
+
when "user" then ["## User", "", msg.content.to_s, ""]
|
|
56
|
+
when "assistant" then render_assistant(msg)
|
|
57
|
+
when "tool" then render_tool(msg)
|
|
58
|
+
else []
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The assistant turn's visible text. The tool-call one-liner is emitted by
|
|
63
|
+
# #render_tool from the `tool`-role result row (which carries `tool_name` +
|
|
64
|
+
# `arguments` on BOTH the streaming and non-streaming paths), NOT here from
|
|
65
|
+
# the assistant row's `tool_calls` metadata — that metadata is absent on the
|
|
66
|
+
# streaming path (the default; the call is emitted mid-stream), so reading
|
|
67
|
+
# it left the call one-liner dead in every real export (#216). A pure
|
|
68
|
+
# tool-call turn has no visible text, so this renders just the heading.
|
|
69
|
+
def render_assistant(msg)
|
|
70
|
+
lines = ["## Assistant", ""]
|
|
71
|
+
text = msg.content.to_s
|
|
72
|
+
lines.push(text, "") unless text.empty?
|
|
73
|
+
lines
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A `tool`-role row renders its call one-liner (reconstructed from the
|
|
77
|
+
# row's own `tool_name` + `arguments` metadata) followed by its result
|
|
78
|
+
# one-liner. The call line is the only place the command/args survive on
|
|
79
|
+
# the streaming path, where the assistant turn persists without
|
|
80
|
+
# `tool_calls` (#216).
|
|
81
|
+
def render_tool(msg)
|
|
82
|
+
name = msg.tool_name || "tool"
|
|
83
|
+
args = msg.metadata.is_a?(Hash) ? msg.metadata[:arguments] : nil
|
|
84
|
+
[
|
|
85
|
+
"- tool call: `#{name}` #{args_preview(args)}".rstrip, "",
|
|
86
|
+
"- tool result: `#{name}` (#{msg.content.to_s.length} chars)", ""
|
|
87
|
+
]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def args_preview(arguments)
|
|
91
|
+
return "" if arguments.nil? || arguments == {}
|
|
92
|
+
|
|
93
|
+
text = arguments.is_a?(String) ? arguments : JSON.generate(arguments)
|
|
94
|
+
text = "#{text[0, ARGS_PREVIEW_CHARS]}…" if text.length > ARGS_PREVIEW_CHARS
|
|
95
|
+
"`#{text}`"
|
|
96
|
+
rescue StandardError
|
|
97
|
+
""
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Session
|
|
8
|
+
# Handles message persistence within a session.
|
|
9
|
+
# Messages include user input, assistant responses, tool calls and results.
|
|
10
|
+
class Message
|
|
11
|
+
VALID_ROLES = %w[system user assistant tool].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :id, :session_id, :role, :content, :tool_name,
|
|
14
|
+
:tool_call_id, :token_count, :metadata, :created_at
|
|
15
|
+
|
|
16
|
+
def initialize(attrs = {})
|
|
17
|
+
@id = attrs[:id] || SecureRandom.uuid
|
|
18
|
+
@session_id = attrs[:session_id]
|
|
19
|
+
@role = attrs[:role]
|
|
20
|
+
@content = attrs[:content]
|
|
21
|
+
@tool_name = attrs[:tool_name]
|
|
22
|
+
@tool_call_id = attrs[:tool_call_id]
|
|
23
|
+
@token_count = attrs[:token_count] || 0
|
|
24
|
+
@metadata = attrs[:metadata] || {}
|
|
25
|
+
@created_at = attrs[:created_at] || Time.now.utc.iso8601
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validates the message attributes
|
|
29
|
+
def valid?
|
|
30
|
+
VALID_ROLES.include?(@role) && @session_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns a hash suitable for database insertion
|
|
34
|
+
def to_row
|
|
35
|
+
{
|
|
36
|
+
id: @id,
|
|
37
|
+
session_id: @session_id,
|
|
38
|
+
role: @role,
|
|
39
|
+
content: @content,
|
|
40
|
+
tool_name: @tool_name,
|
|
41
|
+
tool_call_id: @tool_call_id,
|
|
42
|
+
token_count: @token_count,
|
|
43
|
+
metadata_json: @metadata.empty? ? nil : JSON.generate(@metadata),
|
|
44
|
+
created_at: @created_at
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns a hash for LLM context building. A user message that collapsed a
|
|
49
|
+
# large paste keeps the compact "[Pasted text #N …]" placeholder in its
|
|
50
|
+
# stored/displayed content (#213); here we expand each placeholder back to
|
|
51
|
+
# its full body for the model, so the provider sees everything while the
|
|
52
|
+
# transcript echo (live AND on resume) stays clean.
|
|
53
|
+
def to_context
|
|
54
|
+
msg = { role: @role, content: expand_pastes(@content) }
|
|
55
|
+
msg[:tool_call_id] = @tool_call_id if @tool_call_id
|
|
56
|
+
msg[:name] = @tool_name if @tool_name
|
|
57
|
+
# Surface assistant tool_calls (persisted as metadata) so the adapter
|
|
58
|
+
# can rebuild the toolUse block expected by strict providers on resume.
|
|
59
|
+
msg[:tool_calls] = @metadata[:tool_calls] if @metadata.is_a?(Hash) && @metadata[:tool_calls]
|
|
60
|
+
msg
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Substitutes each stored [token, body] paste expansion back into +text+.
|
|
66
|
+
# The pairs are stored as an array (not a hash) so the placeholder tokens
|
|
67
|
+
# survive the metadata JSON round-trip without being mangled into symbols.
|
|
68
|
+
def expand_pastes(text)
|
|
69
|
+
return text unless text.is_a?(String) && @metadata.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
Array(@metadata[:paste_expansions]).reduce(text) do |acc, (token, body)|
|
|
72
|
+
token && body ? acc.gsub(token, body) : acc
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|