rubino-agent 0.3.0 → 0.5.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 +4 -4
- data/.rubocop_todo.yml +11 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +172 -5
- data/CONTRIBUTING.md +10 -1
- data/README.md +14 -5
- data/Rakefile +31 -0
- data/docs/agents.md +42 -23
- data/docs/architecture.md +2 -2
- data/docs/commands.md +35 -3
- data/docs/configuration.md +20 -23
- data/docs/getting-started.md +5 -3
- data/docs/security.md +16 -5
- data/docs/skills.md +31 -0
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/install.sh +721 -59
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +881 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +1 -9
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +476 -20
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +22 -5
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +133 -8
- data/lib/rubino/agent/tool_executor.rb +166 -14
- data/lib/rubino/agent/truncation_continuation.rb +4 -1
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +42 -6
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +87 -21
- data/lib/rubino/cli/chat_command.rb +1189 -50
- data/lib/rubino/cli/commands.rb +282 -2
- data/lib/rubino/cli/config_command.rb +68 -8
- data/lib/rubino/cli/doctor_command.rb +204 -12
- data/lib/rubino/cli/jobs_command.rb +12 -0
- data/lib/rubino/cli/memory_command.rb +53 -20
- data/lib/rubino/cli/onboarding_wizard.rb +79 -6
- data/lib/rubino/cli/session_command.rb +172 -18
- data/lib/rubino/cli/setup_command.rb +131 -8
- data/lib/rubino/cli/skills_command.rb +183 -9
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +2 -0
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +149 -12
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +156 -41
- data/lib/rubino/commands/handlers/config.rb +4 -1
- data/lib/rubino/commands/handlers/help.rb +113 -14
- data/lib/rubino/commands/handlers/memory.rb +15 -5
- data/lib/rubino/commands/handlers/sessions.rb +26 -3
- data/lib/rubino/commands/handlers/status.rb +9 -4
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/config/configuration.rb +86 -24
- data/lib/rubino/config/defaults.rb +140 -33
- data/lib/rubino/config/loader.rb +62 -12
- data/lib/rubino/config/validator.rb +341 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +184 -22
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/message_boundary.rb +27 -1
- data/lib/rubino/context/project_languages.rb +90 -0
- data/lib/rubino/context/prompt_assembler.rb +105 -22
- data/lib/rubino/context/summary_builder.rb +45 -4
- data/lib/rubino/context/token_budget.rb +36 -11
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +98 -5
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -0
- data/lib/rubino/interaction/lifecycle.rb +99 -13
- data/lib/rubino/interaction/polishing.rb +176 -0
- data/lib/rubino/jobs/cron_job_repository.rb +5 -8
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
- data/lib/rubino/jobs/queue.rb +63 -8
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +0 -4
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/credential_check.rb +15 -16
- data/lib/rubino/llm/error_classifier.rb +89 -1
- data/lib/rubino/llm/inline_think_filter.rb +69 -12
- data/lib/rubino/llm/request.rb +30 -3
- data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
- data/lib/rubino/llm/tool_bridge.rb +113 -9
- data/lib/rubino/mcp/manager.rb +18 -1
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +73 -44
- data/lib/rubino/memory/backends.rb +23 -7
- data/lib/rubino/memory/salience_gate.rb +103 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
- data/lib/rubino/memory/store.rb +33 -5
- data/lib/rubino/memory/threat_scanner.rb +52 -0
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/security/approval_policy.rb +227 -32
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +189 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +145 -5
- data/lib/rubino/security/secret_path.rb +134 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/repository.rb +212 -11
- data/lib/rubino/session/store.rb +139 -14
- data/lib/rubino/skills/installer.rb +230 -0
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +52 -1
- data/lib/rubino/skills/skill.rb +64 -3
- data/lib/rubino/skills/skill_tool.rb +16 -5
- data/lib/rubino/tools/background_tasks.rb +157 -13
- data/lib/rubino/tools/base.rb +204 -3
- data/lib/rubino/tools/edit_tool.rb +73 -18
- data/lib/rubino/tools/glob_tool.rb +48 -9
- data/lib/rubino/tools/grep_tool.rb +103 -9
- data/lib/rubino/tools/multi_edit_tool.rb +64 -9
- data/lib/rubino/tools/patch_tool.rb +5 -0
- data/lib/rubino/tools/read_attachment_tool.rb +3 -1
- data/lib/rubino/tools/read_tool.rb +33 -15
- data/lib/rubino/tools/read_tracker.rb +153 -35
- data/lib/rubino/tools/registry.rb +113 -12
- data/lib/rubino/tools/result.rb +9 -1
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_registry.rb +70 -0
- data/lib/rubino/tools/shell_tool.rb +40 -1
- data/lib/rubino/tools/summarize_file_tool.rb +6 -0
- data/lib/rubino/tools/task_stop_tool.rb +10 -16
- data/lib/rubino/tools/task_tool.rb +36 -8
- data/lib/rubino/tools/vision_tool.rb +5 -0
- data/lib/rubino/tools/webfetch_tool.rb +39 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +23 -4
- data/lib/rubino/ui/api.rb +10 -1
- data/lib/rubino/ui/base.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +382 -74
- data/lib/rubino/ui/cli.rb +515 -83
- data/lib/rubino/ui/completion_menu.rb +11 -7
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/live_region.rb +70 -7
- data/lib/rubino/ui/markdown_renderer.rb +142 -7
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +52 -5
- data/lib/rubino/ui/paste_store.rb +16 -2
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +59 -6
- data/lib/rubino/ui/subagent_view.rb +29 -4
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +117 -0
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +229 -12
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino/workspace.rb +9 -1
- data/lib/rubino.rb +191 -7
- data/rubino-agent.gemspec +1 -0
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +42 -12
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
|
@@ -16,6 +16,20 @@ module Rubino
|
|
|
16
16
|
class ApprovalPolicy
|
|
17
17
|
MODES = %w[manual auto skip].freeze
|
|
18
18
|
|
|
19
|
+
# Structured in-workspace file-edit tools. Under dangerous_only these run
|
|
20
|
+
# unprompted — SYMMETRIC with safe shell — because the always-on #413
|
|
21
|
+
# write-denylist + workspace sandbox (both enforced inside the tool's
|
|
22
|
+
# #call, regardless of approval) are the boundary, not a per-edit prompt
|
|
23
|
+
# (#427, mirrors Hermes file_safety + Claude Code acceptEdits / Codex
|
|
24
|
+
# auto-edit / aider).
|
|
25
|
+
STRUCTURED_EDIT_TOOLS = %w[edit write multi_edit apply_patch].freeze
|
|
26
|
+
|
|
27
|
+
# File tools whose TARGET path is run through the unified secret-file gate
|
|
28
|
+
# (#446). READ side resolves the path from `file_path`/`path`; WRITE side
|
|
29
|
+
# from `file_path` (apply_patch from its patch text, see #secret_file_access?).
|
|
30
|
+
SECRET_GATED_READ_TOOLS = %w[read grep glob].freeze
|
|
31
|
+
SECRET_GATED_WRITE_TOOLS = STRUCTURED_EDIT_TOOLS
|
|
32
|
+
|
|
19
33
|
# Why the most recent #decide returned :deny — :hardline (the
|
|
20
34
|
# non-bypassable floor), :permission_rule (an explicit permissions deny
|
|
21
35
|
# rule), or :doom_loop (the repeated-identical-call guard). nil when the
|
|
@@ -27,18 +41,34 @@ module Rubino
|
|
|
27
41
|
def initialize(config: nil, agent_overrides: nil)
|
|
28
42
|
@config = config || Rubino.configuration
|
|
29
43
|
@mode = @config.approvals_mode
|
|
30
|
-
# Effective shell prompt policy (:confirm_all | :dangerous_only)
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# objects that predate the
|
|
44
|
+
# Effective shell prompt policy (:confirm_all | :dangerous_only), the
|
|
45
|
+
# SOLE source of truth (item 7): security.confirm_policy only — the legacy
|
|
46
|
+
# security.require_confirmation_for_shell alias was removed (see
|
|
47
|
+
# Configuration#confirm_policy). Older config objects that predate the
|
|
48
|
+
# accessor fall back to the reference-faithful :dangerous_only default.
|
|
34
49
|
@confirm_policy =
|
|
35
|
-
@config.respond_to?(:confirm_policy) ? @config.confirm_policy : :
|
|
50
|
+
@config.respond_to?(:confirm_policy) ? @config.confirm_policy : :dangerous_only
|
|
36
51
|
@pattern_matcher = PatternMatcher.new(
|
|
37
52
|
rules: load_permission_rules(agent_overrides)
|
|
38
53
|
)
|
|
39
|
-
|
|
54
|
+
# Doom-loop guard, config-driven (#414). Default WARN-not-block with a
|
|
55
|
+
# higher threshold (Hermes tool_guardrails alignment): a tripped detector
|
|
56
|
+
# under hard_stop:false surfaces a warning but lets the call run.
|
|
57
|
+
@doom_detector = DoomLoopDetector.new(
|
|
58
|
+
threshold: @config.respond_to?(:doom_loop_threshold) ? @config.doom_loop_threshold : DoomLoopDetector::DEFAULT_THRESHOLD,
|
|
59
|
+
hard_stop: @config.respond_to?(:doom_loop_hard_stop?) ? @config.doom_loop_hard_stop? : false
|
|
60
|
+
)
|
|
61
|
+
# Set true after a warn-mode doom-loop hit so ToolExecutor can surface a
|
|
62
|
+
# one-time warning to the model without denying the call. Cleared each
|
|
63
|
+
# #decide and on reset_turn!.
|
|
64
|
+
@doom_loop_warning = false
|
|
40
65
|
end
|
|
41
66
|
|
|
67
|
+
# True when the LAST #decide tripped the doom-loop guard in WARN mode
|
|
68
|
+
# (hard_stop off): the call was allowed but the model should be told it is
|
|
69
|
+
# repeating an identical call. ToolExecutor reads this to attach a warning.
|
|
70
|
+
attr_reader :doom_loop_warning
|
|
71
|
+
|
|
42
72
|
# Returns the decision for a tool call: :allow, :ask, :deny
|
|
43
73
|
#
|
|
44
74
|
# CANONICAL DECISION ORDER (deny-class checks precede every allow path).
|
|
@@ -46,14 +76,22 @@ module Rubino
|
|
|
46
76
|
#
|
|
47
77
|
# 1. hardline(:deny) non-bypassable floor BELOW yolo
|
|
48
78
|
# 2. permissions:deny an explicit deny rule also beats yolo
|
|
49
|
-
# 3. yolo
|
|
79
|
+
# 3. runtime yolo (Modes) allow-exit (doom still guards it).
|
|
80
|
+
# config approvals.mode: "skip" does NOT
|
|
81
|
+
# take this exit — it is not a headless
|
|
82
|
+
# yolo (see steps 7-9 / #260).
|
|
50
83
|
# 4. doom loop break a stuck autopilot
|
|
51
84
|
# 5. permissions:allow / :ask remaining explicit rules
|
|
52
|
-
# 6. command_allowlist
|
|
85
|
+
# 6. command_allowlist pre-approved EXACT commands -> :allow
|
|
86
|
+
# (chain-aware, token-boundary; never a
|
|
87
|
+
# prefix of a compound line)
|
|
53
88
|
# 6b. readonly auto-allow parse-validated read-only shell -> :allow
|
|
54
89
|
# 7-8. confirm_policy shell gate confirm_all -> :ask; dangerous_only
|
|
55
|
-
# -> :ask only if dangerous?, else :allow
|
|
56
|
-
#
|
|
90
|
+
# -> :ask only if dangerous?, else :allow.
|
|
91
|
+
# Runs for mode "skip" too, so a write/
|
|
92
|
+
# shell under config "skip" still reaches
|
|
93
|
+
# the headless fail-closed floor (#260).
|
|
94
|
+
# 9. mode fallback ("skip" -> :ask for risky tools, not :allow)
|
|
57
95
|
#
|
|
58
96
|
# The invariant that makes this slice worth doing: HARDLINE and an
|
|
59
97
|
# explicit permissions:deny BOTH run before any allow path (yolo,
|
|
@@ -61,6 +99,7 @@ module Rubino
|
|
|
61
99
|
# by a fast-path the way yolo used to override deny rules.
|
|
62
100
|
def decide(tool, arguments: {})
|
|
63
101
|
@last_deny_reason = nil
|
|
102
|
+
@doom_loop_warning = false
|
|
64
103
|
command_str = self.class.command_string(tool, arguments)
|
|
65
104
|
|
|
66
105
|
# 1. Hardline floor — a floor BELOW yolo. Catastrophic, unrecoverable
|
|
@@ -87,20 +126,36 @@ module Rubino
|
|
|
87
126
|
# run the doom detector AFTER, because an autopilot stuck in a loop
|
|
88
127
|
# is the one thing yolo isn't supposed to license.
|
|
89
128
|
if Rubino::Modes.skip_approvals?
|
|
90
|
-
return deny_with(:doom_loop) if
|
|
129
|
+
return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)
|
|
91
130
|
|
|
92
131
|
return :allow
|
|
93
132
|
end
|
|
94
133
|
|
|
95
|
-
# 4. Doom loop guard.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
134
|
+
# 4. Doom loop guard. Blocks only under hard_stop (#414); in the default
|
|
135
|
+
# warn mode it sets @doom_loop_warning and falls through to the normal
|
|
136
|
+
# decision so a legitimate repeated call is not hard-denied.
|
|
137
|
+
return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)
|
|
99
138
|
|
|
100
139
|
# 5. Remaining explicit pattern rules (allow / ask). deny was already
|
|
101
|
-
# handled in step 2.
|
|
140
|
+
# handled in step 2. An explicit user permissions rule (allow/ask)
|
|
141
|
+
# wins over the secret gate below, so a user who wrote
|
|
142
|
+
# `read /path/.env: allow` is honored.
|
|
102
143
|
return pattern_result if pattern_result
|
|
103
144
|
|
|
145
|
+
# 5b. UNIFIED SECRET-FILE GATE (#446). Reading (read/grep/glob) OR
|
|
146
|
+
# writing/editing (write/edit/multi_edit/apply_patch) a SECRET path
|
|
147
|
+
# requires EXPLICIT user approval — the maintainer decision: not a
|
|
148
|
+
# silent allow, not a silent hard-block. Returns :ask, which the
|
|
149
|
+
# ToolExecutor turns into the approval dropdown when interactive
|
|
150
|
+
# (approved → the tool runs and reads/writes the secret; denied →
|
|
151
|
+
# refused) and into a FAIL-CLOSED block when headless (:noninteractive).
|
|
152
|
+
# Runs ABOVE the broad read/allow fast-paths (steps 6/6b/9) so a
|
|
153
|
+
# secret read isn't silently auto-allowed, and BELOW yolo (step 3) so
|
|
154
|
+
# a --yolo operator who opted into full file trust isn't re-prompted.
|
|
155
|
+
# NON-secret reads stay broad (clone-and-inspect, #406) — only the
|
|
156
|
+
# secret set is gated.
|
|
157
|
+
return :ask if secret_file_access?(tool, arguments)
|
|
158
|
+
|
|
104
159
|
# 6. Config allowlist of pre-approved commands. Checked AFTER deny
|
|
105
160
|
# patterns (deny always wins) but BEFORE mode-based decision so a
|
|
106
161
|
# listed command never triggers a manual prompt.
|
|
@@ -114,32 +169,70 @@ module Rubino
|
|
|
114
169
|
# validator cannot prove read-only falls through to the prompt.
|
|
115
170
|
return :allow if readonly_auto_allowed?(tool, command_str)
|
|
116
171
|
|
|
172
|
+
# 6c. skill(action: "create") WRITES <RUBINO_HOME>/skills/<name>/SKILL.md
|
|
173
|
+
# and must not be a silent low-risk allow (#405): the skill tool stays
|
|
174
|
+
# :low so a read_only agent keeps `skill load/list/show`, but the
|
|
175
|
+
# create action is a write and routes to :ask here — like any write.
|
|
176
|
+
# Below yolo (step 3), so a full-access --yolo agent still creates
|
|
177
|
+
# skills inline; a headless read_only subagent's :ask becomes the
|
|
178
|
+
# fail-closed block, closing the unapproved-write path. load is never
|
|
179
|
+
# gated (only the create action matches).
|
|
180
|
+
#
|
|
181
|
+
# This gate is now a real boundary, not theater (SK-2): authored
|
|
182
|
+
# skills are written under the agent HOME (outside the cwd workspace),
|
|
183
|
+
# so the model can't sidestep it by emitting a plain `write` of the
|
|
184
|
+
# same SKILL.md — the workspace sandbox (within_workspace?) refuses any
|
|
185
|
+
# write outside the workspace, leaving this :ask-gated helper as the
|
|
186
|
+
# ONLY way to author a skill.
|
|
187
|
+
return :ask if skill_create?(tool, arguments)
|
|
188
|
+
|
|
117
189
|
# 7-8. confirm_policy gate for a shell command not otherwise resolved.
|
|
118
|
-
# NOT under
|
|
119
|
-
#
|
|
120
|
-
# prompting me".
|
|
190
|
+
# NOT under runtime yolo (handled at step 3) — that is the explicit
|
|
191
|
+
# CLI operator override that means "stop prompting me".
|
|
121
192
|
#
|
|
122
|
-
#
|
|
193
|
+
# config approvals.mode: "skip" is NOT given the same allow-exit as
|
|
194
|
+
# runtime yolo here. #260 deliberately made the headless skip a
|
|
195
|
+
# CLI-only opt-in (--yolo): a config-file "skip" must NOT silently
|
|
196
|
+
# auto-run write/shell in a headless session. So a not-otherwise-
|
|
197
|
+
# resolved shell command still routes through this gate to :ask, and
|
|
198
|
+
# the ToolExecutor's headless fail-closed floor (#260) turns that
|
|
199
|
+
# :ask into a block when there is no interactive session. Interactive
|
|
200
|
+
# sessions still get a prompt — same as auto/manual. (Reads are
|
|
201
|
+
# already auto-allowed by step 6b / mode_based_decision, so this
|
|
202
|
+
# only constrains the write/shell side.)
|
|
203
|
+
#
|
|
204
|
+
# confirm_all (opt-in hardening)
|
|
123
205
|
# every such shell command -> :ask. shell is :high risk so manual
|
|
124
206
|
# mode would ask anyway; this also keeps it gated under auto mode.
|
|
125
207
|
#
|
|
126
|
-
# dangerous_only (reference-faithful
|
|
208
|
+
# dangerous_only (DEFAULT, reference-faithful)
|
|
127
209
|
# prompt ONLY when the command matches a DangerousPattern
|
|
128
210
|
# (git push --force, curl|sh, recursive rm of a non-root path,
|
|
129
211
|
# ...). Safe commands run unprompted. Mirrors approval.py:475
|
|
130
212
|
# where detect_dangerous_command is the sole prompt trigger.
|
|
131
213
|
# The hardline floor (step 1) and permissions:deny (step 2) already
|
|
132
214
|
# ran, so dangerous_only NEVER weakens the non-bypassable floor.
|
|
133
|
-
if tool.name == "shell"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
215
|
+
return shell_confirm_decision(command_str) if tool.name == "shell"
|
|
216
|
+
|
|
217
|
+
# 8b. Structured in-workspace edit symmetry (#427). Under dangerous_only,
|
|
218
|
+
# a safe `shell sed -i …` / `echo > file` runs UNPROMPTED (step 7-8),
|
|
219
|
+
# but the structured edit/write/multi_edit/apply_patch tools are
|
|
220
|
+
# :medium and would fall through to step 9 -> :ask, which fails closed
|
|
221
|
+
# headless. That asymmetry pushes automation AWAY from the clean,
|
|
222
|
+
# read-tracked, diff-producing structured tools and TOWARD raw shell
|
|
223
|
+
# mutation — worse for safety/observability and the inverse of the
|
|
224
|
+
# industry norm (Hermes runs structured in-workspace edits unprompted
|
|
225
|
+
# with file_safety.is_write_denied as the boundary; Claude Code
|
|
226
|
+
# acceptEdits, Codex auto-edit and aider all treat in-workspace edits
|
|
227
|
+
# as LOWER friction than shell). So under dangerous_only these
|
|
228
|
+
# structured edits are non-prompting too — SYMMETRIC with safe shell.
|
|
229
|
+
# This NEVER widens reach: the always-on #413 write-denylist (refuses
|
|
230
|
+
# .env/.ssh/.aws/etc even inside the workspace) and the workspace
|
|
231
|
+
# sandbox both run inside the tool's #call regardless of approval, and
|
|
232
|
+
# the hardline floor (step 1), permissions:deny (step 2) and
|
|
233
|
+
# skill-create gate (step 6c) all already ran above. confirm_all
|
|
234
|
+
# (non-default) still routes them through step 9 -> :ask unchanged.
|
|
235
|
+
return :allow if @confirm_policy == :dangerous_only && STRUCTURED_EDIT_TOOLS.include?(tool.name)
|
|
143
236
|
|
|
144
237
|
# 9. Fall back to mode-based decision
|
|
145
238
|
mode_based_decision(tool)
|
|
@@ -159,6 +252,78 @@ module Rubino
|
|
|
159
252
|
CommandAllowlist.new(config: @config).allowed?(command)
|
|
160
253
|
end
|
|
161
254
|
|
|
255
|
+
# True when this is the WRITE action of the skill tool (action: "create").
|
|
256
|
+
# The skill tool is :low (so read_only keeps load/list/show), but its
|
|
257
|
+
# create action writes a SKILL.md and must be approval-gated (#405).
|
|
258
|
+
def skill_create?(tool, arguments)
|
|
259
|
+
return false unless tool.name == "skill"
|
|
260
|
+
|
|
261
|
+
args = arguments || {}
|
|
262
|
+
(args["action"] || args[:action]).to_s == "create"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The confirm_policy shell gate (steps 7-8), extracted so #decide stays
|
|
266
|
+
# under the complexity limit. confirm_all → always :ask; dangerous_only →
|
|
267
|
+
# :ask only for a DangerousPattern, else :allow.
|
|
268
|
+
def shell_confirm_decision(command_str)
|
|
269
|
+
return :ask unless @confirm_policy == :dangerous_only
|
|
270
|
+
|
|
271
|
+
dangerous?(command_str) ? :ask : :allow
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# True when this call READS or WRITES a secret/credential path and so must
|
|
275
|
+
# be approval-gated (#446). For the path-arg tools (read/grep/glob/write/
|
|
276
|
+
# edit/multi_edit) the single target is resolved from file_path/path; for
|
|
277
|
+
# apply_patch every target file in the patch is checked, because one call
|
|
278
|
+
# can touch many files. Resolution is relative to the workspace primary
|
|
279
|
+
# root so a relative `.env` resolves to the same file the tool will open.
|
|
280
|
+
def secret_file_access?(tool, arguments)
|
|
281
|
+
return false unless SECRET_GATED_READ_TOOLS.include?(tool.name) ||
|
|
282
|
+
SECRET_GATED_WRITE_TOOLS.include?(tool.name)
|
|
283
|
+
|
|
284
|
+
secret_targets(tool, arguments).any? { |p| SecretPath.secret?(p) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The absolute path(s) a file tool will touch. apply_patch yields one per
|
|
288
|
+
# hunk target; every other gated tool yields its single file_path/path.
|
|
289
|
+
def secret_targets(tool, arguments)
|
|
290
|
+
args = arguments || {}
|
|
291
|
+
if tool.name == "apply_patch"
|
|
292
|
+
base = (args["base_path"] || args[:base_path]).to_s
|
|
293
|
+
base = Tools::Base.workspace_root if base.empty?
|
|
294
|
+
return patch_target_paths(args["patch"] || args[:patch], base)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
raw = self.class.command_string(tool, arguments)
|
|
298
|
+
return [] if raw.to_s.empty?
|
|
299
|
+
|
|
300
|
+
[resolve_workspace_path(raw)]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Extracts every destination file from a unified diff (`+++ b/<file>`, and
|
|
304
|
+
# `--- a/<file>` so a delete of a secret is gated too), absolutised against
|
|
305
|
+
# base_path. A `/dev/null` side carries no file and is skipped.
|
|
306
|
+
def patch_target_paths(patch, base_path)
|
|
307
|
+
return [] if patch.nil?
|
|
308
|
+
|
|
309
|
+
patch.to_s.each_line.filter_map do |line|
|
|
310
|
+
m = line.match(%r{^[-+]{3} [ab]/(.+)\s*$})
|
|
311
|
+
next if m.nil?
|
|
312
|
+
|
|
313
|
+
File.expand_path(m[1].strip, base_path)
|
|
314
|
+
end.uniq
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Anchors a relative path at the workspace primary root (matching
|
|
318
|
+
# Tools::Base#expand_workspace_path) so the gate sees the same target the
|
|
319
|
+
# tool will. Absolute/~ paths pass through.
|
|
320
|
+
def resolve_workspace_path(path)
|
|
321
|
+
str = path.to_s
|
|
322
|
+
return File.expand_path(str) if str.start_with?(File::SEPARATOR, "~")
|
|
323
|
+
|
|
324
|
+
File.expand_path(str, Tools::Base.workspace_root)
|
|
325
|
+
end
|
|
326
|
+
|
|
162
327
|
# True when the shell command is provably read-only and the
|
|
163
328
|
# approvals.auto_allow_readonly gate (default ON) is open. Shell-only:
|
|
164
329
|
# for every other tool the "command" is a path or argument fragment.
|
|
@@ -180,8 +345,18 @@ module Rubino
|
|
|
180
345
|
(args["command"] || args[:command]).to_s
|
|
181
346
|
when "read", "write", "edit", "multi_edit", "attach_file"
|
|
182
347
|
(args["file_path"] || args[:file_path]).to_s
|
|
348
|
+
when "grep", "glob"
|
|
349
|
+
# The SEARCH ROOT (a dir or a file) is what the secret gate resolves —
|
|
350
|
+
# `pattern` is the regex/glob, not a path. (Default "." like the tools.)
|
|
351
|
+
(args["path"] || args[:path] || ".").to_s
|
|
183
352
|
when "shell_output", "shell_kill", "shell_input"
|
|
184
353
|
(args["run_id"] || args[:run_id]).to_s
|
|
354
|
+
when "skill"
|
|
355
|
+
# "<action> <name>" so the approval scope distinguishes a create from
|
|
356
|
+
# a load and one skill name from another (granularity parity, #405).
|
|
357
|
+
action = args["action"] || args[:action] || "load"
|
|
358
|
+
name = args["name"] || args[:name]
|
|
359
|
+
[action, name].join(" ").strip
|
|
185
360
|
else
|
|
186
361
|
args.values.first.to_s
|
|
187
362
|
end
|
|
@@ -194,6 +369,21 @@ module Rubino
|
|
|
194
369
|
|
|
195
370
|
private
|
|
196
371
|
|
|
372
|
+
# Records the tool call in the doom detector and returns true ONLY when it
|
|
373
|
+
# tripped AND the guard is in hard_stop mode (=> block). In the default
|
|
374
|
+
# warn mode a trip sets @doom_loop_warning and returns false, so the call
|
|
375
|
+
# proceeds through the normal decision path (#414).
|
|
376
|
+
def doom_loop_blocks?(tool, arguments)
|
|
377
|
+
return false unless @doom_detector.record(tool_name: tool.name, arguments: arguments)
|
|
378
|
+
|
|
379
|
+
if @doom_detector.hard_stop?
|
|
380
|
+
true
|
|
381
|
+
else
|
|
382
|
+
@doom_loop_warning = true
|
|
383
|
+
false
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
197
387
|
# Records WHY this deny fired before returning it (see #last_deny_reason).
|
|
198
388
|
def deny_with(reason)
|
|
199
389
|
@last_deny_reason = reason
|
|
@@ -202,8 +392,13 @@ module Rubino
|
|
|
202
392
|
|
|
203
393
|
def mode_based_decision(tool)
|
|
204
394
|
case @mode
|
|
395
|
+
# config approvals.mode: "skip" is NOT a headless yolo (#260). It stays
|
|
396
|
+
# permissive for non-risky tools (reads), but a risky tool (write/edit/
|
|
397
|
+
# shell) routes to :ask so the headless fail-closed floor can block it
|
|
398
|
+
# when there is no interactive session — only runtime --yolo (step 3)
|
|
399
|
+
# may auto-run those headless. Interactive sessions still get a prompt.
|
|
205
400
|
when "skip"
|
|
206
|
-
:allow
|
|
401
|
+
tool.risky? ? :ask : :allow
|
|
207
402
|
when "auto"
|
|
208
403
|
tool.risk_level == :high ? :ask : :allow
|
|
209
404
|
when "manual"
|
|
@@ -1,24 +1,99 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
module Rubino
|
|
4
6
|
module Security
|
|
5
|
-
# Manages a whitelist of shell commands that can be executed without
|
|
7
|
+
# Manages a whitelist of shell commands that can be executed without
|
|
8
|
+
# confirmation.
|
|
9
|
+
#
|
|
10
|
+
# An allowlist entry pre-approves an EXACT single command, never a prefix
|
|
11
|
+
# of a larger compound line. A naive `start_with?` (the old behaviour) let
|
|
12
|
+
# any line whose head matched an entry auto-resolve to :allow — INCLUDING
|
|
13
|
+
# the chained tail: with `git status` allowlisted,
|
|
14
|
+
# `git status; echo k >> ~/.ssh/authorized_keys` resolved to :allow,
|
|
15
|
+
# turning a read-only pre-approval into headless RCE/exfil. So this matcher
|
|
16
|
+
# is chain-aware, mirroring ReadonlyCommands:
|
|
17
|
+
#
|
|
18
|
+
# - DangerousPatterns runs FIRST on the whole line, so a dangerous tail
|
|
19
|
+
# (curl|sh, recursive rm, write into ~/.ssh, ...) can never be beaten
|
|
20
|
+
# by an allowlisted head;
|
|
21
|
+
# - the line is split into chain segments (|, ||, &&, ;, newline) with the
|
|
22
|
+
# same quote-aware splitter as ReadonlyCommands, which REJECTS the line
|
|
23
|
+
# outright on redirection (>), backgrounding (&), command substitution
|
|
24
|
+
# ($(...) / backticks) or process substitution (<(...) / >()) — the
|
|
25
|
+
# constructs that smuggle a write or an execution past a head check;
|
|
26
|
+
# - EVERY segment must match an allowlist entry, and a match is on a TOKEN
|
|
27
|
+
# boundary (a prefix of token tokens), never a bare substring: `git`
|
|
28
|
+
# allowlisted does NOT pre-approve `git-secret-leak`, and `git status`
|
|
29
|
+
# does NOT pre-approve `git statusxyz`;
|
|
30
|
+
# - a matched head is FLAG-VETTED via ReadonlyCommands: an allowlisted
|
|
31
|
+
# read-capable head can not smuggle a write/exec flag past the prefix
|
|
32
|
+
# match. With `git diff` allowlisted, `git diff --output /tmp/PWN`
|
|
33
|
+
# (an arbitrary write) is REJECTED — same for `git diff -O...`,
|
|
34
|
+
# `find -exec/-delete/-fprintf`, `date -s`, `tree -o` (SEC-1).
|
|
6
35
|
class CommandAllowlist
|
|
7
36
|
def initialize(config: nil)
|
|
8
37
|
@config = config || Rubino.configuration
|
|
9
38
|
@allowlist = @config.security_command_allowlist
|
|
10
39
|
end
|
|
11
40
|
|
|
12
|
-
# Returns true
|
|
41
|
+
# Returns true ONLY when the ENTIRE command line is covered by the
|
|
42
|
+
# allowlist: not dangerous, splits cleanly into chain segments, and every
|
|
43
|
+
# segment's head matches an allowlist entry on a token boundary.
|
|
44
|
+
#
|
|
13
45
|
# An EMPTY allowlist matches NOTHING — pre-approval is opt-in, so an
|
|
14
46
|
# unconfigured allowlist must never auto-approve everything.
|
|
15
47
|
def allowed?(command)
|
|
16
48
|
return false if @allowlist.empty?
|
|
49
|
+
return false if DangerousPatterns.dangerous?(command)
|
|
50
|
+
|
|
51
|
+
entries = allowlist_token_lists
|
|
52
|
+
return false if entries.empty?
|
|
53
|
+
|
|
54
|
+
segments = ReadonlyCommands.split_segments(command.to_s)
|
|
55
|
+
return false if segments.nil? || segments.empty?
|
|
56
|
+
|
|
57
|
+
segments.all? { |segment| segment_allowed?(segment, entries) }
|
|
58
|
+
end
|
|
17
59
|
|
|
18
|
-
|
|
19
|
-
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Each allowlist entry as its token list (e.g. "bundle exec rspec" ->
|
|
63
|
+
# %w[bundle exec rspec]). Empty / blank entries are dropped so a stray ""
|
|
64
|
+
# in the config can't match every command.
|
|
65
|
+
def allowlist_token_lists
|
|
66
|
+
@allowlist.filter_map do |entry|
|
|
67
|
+
tokens = Shellwords.split(entry.to_s)
|
|
68
|
+
tokens unless tokens.empty?
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
nil # an entry that won't tokenize (unbalanced quote) can't match
|
|
20
71
|
end
|
|
21
72
|
end
|
|
73
|
+
|
|
74
|
+
# A single chain segment is allowed when its leading tokens exactly match
|
|
75
|
+
# some allowlist entry's tokens AND it carries no write/exec flag that
|
|
76
|
+
# would turn a matched read-capable head into an arbitrary write or
|
|
77
|
+
# execution. Matching on the token list (not the raw string) makes it
|
|
78
|
+
# boundary-safe: `git status` matches `git status -s` but never
|
|
79
|
+
# `git statusxyz`.
|
|
80
|
+
#
|
|
81
|
+
# Flag vetting reuses ReadonlyCommands' dangerous-flag logic so the
|
|
82
|
+
# allowlist and the read-only auto-allow agree on what `git --output`,
|
|
83
|
+
# `find -exec/-delete/-fprintf`, `date -s`, `tree -o` mean: a head being
|
|
84
|
+
# on the allowlist pre-approves the COMMAND, never a smuggled output/exec
|
|
85
|
+
# flag (SEC-1 — `git diff --output FILE` arbitrary write).
|
|
86
|
+
def segment_allowed?(segment, entries)
|
|
87
|
+
tokens = Shellwords.split(segment)
|
|
88
|
+
return false if tokens.empty?
|
|
89
|
+
|
|
90
|
+
matched = entries.any? { |entry_tokens| tokens.first(entry_tokens.length) == entry_tokens }
|
|
91
|
+
return false unless matched
|
|
92
|
+
|
|
93
|
+
!ReadonlyCommands.dangerous_flags?(tokens)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
false # unbalanced quotes etc. — fall through to the prompt
|
|
96
|
+
end
|
|
22
97
|
end
|
|
23
98
|
end
|
|
24
99
|
end
|
|
@@ -4,15 +4,34 @@ module Rubino
|
|
|
4
4
|
module Security
|
|
5
5
|
# Detects when the agent enters a doom loop - repeatedly calling
|
|
6
6
|
# the same tool with identical arguments without progress.
|
|
7
|
+
#
|
|
8
|
+
# Two dimensions, both config-driven (Hermes tool_guardrails alignment,
|
|
9
|
+
# #414):
|
|
10
|
+
# - threshold: how many identical consecutive calls trip detection
|
|
11
|
+
# (default 5; Hermes grades 5-8). The old default was 3, which hard-
|
|
12
|
+
# denied a legitimate 3rd retry of an idempotent read.
|
|
13
|
+
# - hard_stop: when true, a tripped detector means BLOCK (the policy
|
|
14
|
+
# returns :deny). When false (the default) it WARNS but allows — the
|
|
15
|
+
# policy surfaces a one-time warning to the model and lets the call run.
|
|
7
16
|
class DoomLoopDetector
|
|
8
|
-
DEFAULT_THRESHOLD =
|
|
17
|
+
DEFAULT_THRESHOLD = 5
|
|
9
18
|
|
|
10
|
-
|
|
19
|
+
attr_reader :threshold
|
|
20
|
+
|
|
21
|
+
def initialize(threshold: DEFAULT_THRESHOLD, hard_stop: false)
|
|
11
22
|
@threshold = threshold
|
|
23
|
+
@hard_stop = hard_stop
|
|
12
24
|
@history = []
|
|
13
25
|
end
|
|
14
26
|
|
|
27
|
+
# True when the detector is configured to BLOCK on detection (vs. warn).
|
|
28
|
+
def hard_stop?
|
|
29
|
+
@hard_stop == true
|
|
30
|
+
end
|
|
31
|
+
|
|
15
32
|
# Records a tool call and returns true if a doom loop is detected
|
|
33
|
+
# (the last `threshold` calls are identical). Detection is independent
|
|
34
|
+
# of hard_stop — the caller decides whether a hit blocks or only warns.
|
|
16
35
|
def record(tool_name:, arguments:)
|
|
17
36
|
signature = generate_signature(tool_name, arguments)
|
|
18
37
|
@history << signature
|