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
|
@@ -4,6 +4,21 @@ module Rubino
|
|
|
4
4
|
module Security
|
|
5
5
|
# Hardline (unconditional) blocklist — a floor BELOW yolo.
|
|
6
6
|
#
|
|
7
|
+
# SCOPE: this is a best-effort anti-INCIDENT circuit-breaker, NOT an anti-
|
|
8
|
+
# adversary boundary. It stops the agent (or a careless user) from running an
|
|
9
|
+
# unrecoverable command via --yolo, INCLUDING the accidental/model-emitted
|
|
10
|
+
# indirection forms that pad real commands — `echo $(rm -rf /)`, a backticked
|
|
11
|
+
# `rm -rf /`, a `{ rm -rf /; }` brace-group. It is NOT a sandbox: a determined
|
|
12
|
+
# adversary with shell access can always evade a regex floor (base64, here-
|
|
13
|
+
# docs, an env-var/here-string built at runtime, a written-then-run script).
|
|
14
|
+
# The real containment boundary is the deferred OS-level sandbox (#290).
|
|
15
|
+
# Within that anti-incident scope we canonicalize aggressively — collapsing
|
|
16
|
+
# trivial evasions (quoting, trailing slashes, path-equivalents, ${HOME}) AND
|
|
17
|
+
# UNWRAPPING command substitution / backticks / brace-and-subshell groups so
|
|
18
|
+
# the floor patterns match the INNER command — rather than widening the
|
|
19
|
+
# blocklist patterns to chase each wrapper (whack-a-mole on a best-effort
|
|
20
|
+
# string blocklist buys little; the boundary is #290, not the regex) (#325).
|
|
21
|
+
#
|
|
7
22
|
# Commands so catastrophic they must NEVER run via the agent, regardless
|
|
8
23
|
# of --yolo, skip-approvals mode, a permissions:allow rule, or a
|
|
9
24
|
# command_allowlist entry. Opting into yolo is the user trusting the agent
|
|
@@ -58,21 +73,46 @@ module Rubino
|
|
|
58
73
|
[/#{CMDPOS}telinit\s+[06]\b/, "telinit 0/6 (shutdown/reboot)"]
|
|
59
74
|
].freeze
|
|
60
75
|
|
|
61
|
-
# sudo -S without a configured SUDO_PASSWORD is the model
|
|
62
|
-
# *guessed* password via stdin — a brute-force vector.
|
|
63
|
-
# block. Mirrors approval.py:_check_sudo_stdin_guard (:255).
|
|
64
|
-
|
|
76
|
+
# sudo -S / sudo --stdin without a configured SUDO_PASSWORD is the model
|
|
77
|
+
# piping a *guessed* password via stdin — a brute-force vector.
|
|
78
|
+
# Unconditional block. Mirrors approval.py:_check_sudo_stdin_guard (:255).
|
|
79
|
+
#
|
|
80
|
+
# CASE-SENSITIVE on purpose: `-S` (capital — read the password from STDIN)
|
|
81
|
+
# is the dangerous form this guard owns; `-s` (lowercase — run $SHELL) is a
|
|
82
|
+
# DIFFERENT flag and must NOT be flagged here (sudo's own privilege-flag
|
|
83
|
+
# handling lives in DangerousPatterns, not the hardline floor). The
|
|
84
|
+
# previous `-s\b` regex relied on #normalize lowercasing the command, which
|
|
85
|
+
# both over-matched `sudo -s` AND failed to anchor on the real intent — and
|
|
86
|
+
# it missed the `--stdin` long form entirely. We now match against a
|
|
87
|
+
# case-PRESERVED form (#sudo_stdin?): `-S` and combined short clusters
|
|
88
|
+
# ending in S (`-kS`, `-nS`), plus the GNU `--stdin` long form.
|
|
89
|
+
SUDO_STDIN_RE = /(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+(?:-[a-zA-Z]*S(?![a-zA-Z])|--stdin\b)/
|
|
65
90
|
|
|
66
91
|
module_function
|
|
67
92
|
|
|
68
93
|
# Returns [true, description] when the command hits the hardline floor
|
|
69
94
|
# (a HARDLINE_PATTERN or the sudo-stdin guard), else [false, nil].
|
|
95
|
+
#
|
|
96
|
+
# We match the patterns against TWO forms and OR the results: the raw
|
|
97
|
+
# whitespace/case-normalized string, and a canonicalized form that strips
|
|
98
|
+
# quoting, expands $HOME, collapses path-equivalents, and UNWRAPS command
|
|
99
|
+
# substitution / backticks / brace-and-subshell groups (see #canonicalize).
|
|
100
|
+
# Canonicalization closes the trivial bypasses (rm -rf '/', /usr/, ${HOME},
|
|
101
|
+
# //, /./) and the indirection bypasses ($(rm -rf /), `rm -rf /`,
|
|
102
|
+
# { rm -rf /; }); matching the raw form too is fail-open insurance for the
|
|
103
|
+
# rare case where canonicalization rewrites a separator/redirect out of a
|
|
104
|
+
# match (an anti-incident guard should never become LESS strict than before).
|
|
70
105
|
def detect(command)
|
|
71
106
|
normalized = normalize(command)
|
|
107
|
+
canonical = canonicalize(normalized)
|
|
72
108
|
HARDLINE_PATTERNS.each do |regex, description|
|
|
73
|
-
return [true, description] if normalized.match?(regex)
|
|
109
|
+
return [true, description] if normalized.match?(regex) || canonical.match?(regex)
|
|
74
110
|
end
|
|
75
|
-
|
|
111
|
+
# The sudo-stdin guard is CASE-SENSITIVE (`-S` ≠ `-s`), so it must NOT see
|
|
112
|
+
# the lowercased normalized/canonical forms. Match it against a
|
|
113
|
+
# whitespace-normalized but case-PRESERVED form of the raw command.
|
|
114
|
+
sudo_hit = sudo_stdin?(normalize_case_preserving(command))
|
|
115
|
+
return [true, "sudo password guessing via stdin (sudo -S)"] if sudo_hit
|
|
76
116
|
|
|
77
117
|
[false, nil]
|
|
78
118
|
end
|
|
@@ -84,21 +124,154 @@ module Rubino
|
|
|
84
124
|
blocked ? description : nil
|
|
85
125
|
end
|
|
86
126
|
|
|
87
|
-
# sudo -S only fires the guard when no SUDO_PASSWORD is
|
|
88
|
-
# with one set, an internal transform legitimately injects
|
|
89
|
-
|
|
127
|
+
# sudo -S / --stdin only fires the guard when no SUDO_PASSWORD is
|
|
128
|
+
# configured — with one set, an internal transform legitimately injects
|
|
129
|
+
# -S elsewhere. The input MUST be case-preserved (see #detect): the regex
|
|
130
|
+
# discriminates `-S` from `-s` on case.
|
|
131
|
+
def sudo_stdin?(case_preserved)
|
|
90
132
|
return false if ENV.key?("SUDO_PASSWORD")
|
|
91
133
|
|
|
92
|
-
|
|
134
|
+
case_preserved.match?(SUDO_STDIN_RE)
|
|
93
135
|
end
|
|
94
136
|
|
|
95
|
-
# Minimal normalization: collapse runs of
|
|
96
|
-
# the command-separator anchors still fire),
|
|
97
|
-
# trivial obfuscation (extra spaces, case) doesn't
|
|
98
|
-
# Deliberately NOT a full ANSI/Unicode normalizer —
|
|
99
|
-
# the hardline floor.
|
|
137
|
+
# Minimal normalization: strip shell line-continuations, collapse runs of
|
|
138
|
+
# spaces/tabs (newlines kept so the command-separator anchors still fire),
|
|
139
|
+
# trim, and lowercase so trivial obfuscation (extra spaces, case) doesn't
|
|
140
|
+
# slip through. Deliberately NOT a full ANSI/Unicode normalizer —
|
|
141
|
+
# over-engineering for the hardline floor.
|
|
142
|
+
#
|
|
143
|
+
# Line-continuation strip (#348): a backslash immediately before a newline
|
|
144
|
+
# is a shell line-continuation — the two characters fold the next line onto
|
|
145
|
+
# the current one with NO intervening character. The shell deletes the
|
|
146
|
+
# `\<newline>` pair entirely; it does NOT insert a space. Pre-fix we replaced
|
|
147
|
+
# it with a SPACE, so `rm -r\<newline>f /` became `rm -r f /` — the `-r` and
|
|
148
|
+
# `f` split into two tokens and the `\brm\s+(?:-\S*\s+)*/` pattern (which
|
|
149
|
+
# needs the flags glued) MISSED it, letting an unrecoverable `rm -rf /` past
|
|
150
|
+
# the floor (#348 residual). Replace the continuation with the EMPTY string
|
|
151
|
+
# so `rm -r\<newline>f /` folds to `rm -rf /` exactly as the shell sees it.
|
|
152
|
+
# (Trailing whitespace after the backslash is NOT part of the continuation
|
|
153
|
+
# and is left for the space-collapse below.)
|
|
100
154
|
def normalize(command)
|
|
101
|
-
command
|
|
155
|
+
normalize_case_preserving(command).downcase
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Same whitespace normalization as #normalize (line-continuation strip,
|
|
159
|
+
# space/tab collapse, trim) but WITHOUT lowercasing. Used only by the
|
|
160
|
+
# case-sensitive sudo-stdin guard so `-S` (stdin password) stays
|
|
161
|
+
# distinguishable from `-s` (start shell).
|
|
162
|
+
def normalize_case_preserving(command)
|
|
163
|
+
joined = command.to_s.gsub(/\\\r?\n/, "")
|
|
164
|
+
joined.gsub(/[ \t]+/, " ").strip
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Canonicalize the (already normalized) command so common, trivial
|
|
168
|
+
# evasions of the hardline patterns collapse onto the bare forms the
|
|
169
|
+
# patterns expect. This is the #325 hardening: instead of growing the
|
|
170
|
+
# pattern list to chase each quoting/path-equivalent variant, we normalize
|
|
171
|
+
# the INPUT the patterns see. Steps, per token:
|
|
172
|
+
# 1. Shell-word split (Shellwords) — strips quotes so '/' "/" '/usr'
|
|
173
|
+
# collapse to /, /usr. Unbalanced quotes raise ArgumentError; we then
|
|
174
|
+
# FALL BACK to the raw normalized string (fail-open — never raise out
|
|
175
|
+
# of a security check).
|
|
176
|
+
# 2. Expand a TINY fixed env set ($HOME, ${HOME}, "$HOME", $home, ${home})
|
|
177
|
+
# to ~ so the home-directory pattern fires on the brace/quote forms
|
|
178
|
+
# the (?:~|\$home) regex misses.
|
|
179
|
+
# 3. For path-shaped tokens (start with / or ~), Pathname#cleanpath
|
|
180
|
+
# (pure-string, no FS touch) collapses /usr/ -> /usr, // -> /,
|
|
181
|
+
# /. -> /, /./ -> /, /home/../ -> / .
|
|
182
|
+
# Re-join with single spaces and append a trailing space so a token-final
|
|
183
|
+
# `/` still satisfies the patterns' (?:\s|$) anchor.
|
|
184
|
+
def canonicalize(normalized)
|
|
185
|
+
require "shellwords"
|
|
186
|
+
require "pathname"
|
|
187
|
+
normalized = expand_word_splits(normalized)
|
|
188
|
+
normalized = unwrap_substitutions(normalized)
|
|
189
|
+
tokens = shell_split(normalized)
|
|
190
|
+
return normalized if tokens.nil? # unbalanced quotes: fail open to raw
|
|
191
|
+
|
|
192
|
+
cleaned = tokens.map { |tok| clean_token(tok) }
|
|
193
|
+
"#{cleaned.join(" ")} "
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Unwrap command-substitution / backtick / brace-and-subshell indirection so
|
|
197
|
+
# the floor patterns see the INNER command at command position (#325 gap).
|
|
198
|
+
# A model or a careless user can pad a catastrophic command with a wrapper
|
|
199
|
+
# the rm/dd/mkfs patterns don't anchor on — `$(rm -rf /)`, `` `rm -rf /` ``,
|
|
200
|
+
# `{ rm -rf /; }`, `echo $(rm -rf /*)`. The wrapper merely RUNS the inner
|
|
201
|
+
# text, so unwrapping it to bare text is faithful to what the shell executes.
|
|
202
|
+
#
|
|
203
|
+
# We turn each wrapper delimiter into a space, which promotes the inner
|
|
204
|
+
# command to the top level of the canonical string. Crucially we DO NOT
|
|
205
|
+
# touch `${...}` PARAMETER expansion (handled by #expand_word_splits /
|
|
206
|
+
# #clean_token) — only the `$(` COMMAND-substitution opener — so `${HOME}`
|
|
207
|
+
# and `${IFS}` keep their meaning. Applied repeatedly (capped) so nested
|
|
208
|
+
# `$(echo $(rm -rf /))` flattens too. Fail-open: a runaway input just stops
|
|
209
|
+
# after the cap and matching proceeds on whatever it reached.
|
|
210
|
+
def unwrap_substitutions(text)
|
|
211
|
+
5.times do
|
|
212
|
+
before = text
|
|
213
|
+
# $( ... ) command substitution. `$(` opener (NOT `${`), and bare
|
|
214
|
+
# `)`/`(` subshell parens -> spaces. The negative lookbehind keeps
|
|
215
|
+
# `${...}` intact while still splitting `$(`.
|
|
216
|
+
text = text.gsub("$(", " ").gsub(/(?<!\$)[()]/, " ")
|
|
217
|
+
# backtick command substitution -> spaces (open and close).
|
|
218
|
+
text = text.gsub("`", " ")
|
|
219
|
+
# `{ ... ; }` brace group: a brace GROUP delimiter is a `{` followed by
|
|
220
|
+
# whitespace and a closing `}` at word boundary, plus the command-
|
|
221
|
+
# terminating `;` — all separators, not part of the command. Map them to
|
|
222
|
+
# spaces so the inner `rm -rf /` lands at command position. We DELIBERATELY
|
|
223
|
+
# do NOT touch a `{`/`}` that is part of `${...}` parameter expansion
|
|
224
|
+
# (no surrounding space) so `${HOME}`/`${IFS}` keep their meaning.
|
|
225
|
+
text = text.gsub(/(?<=\s|\A){\s/, " ").gsub(/(?<=\s)}/, " ")
|
|
226
|
+
text = text.gsub(";", " ")
|
|
227
|
+
break if text == before
|
|
228
|
+
end
|
|
229
|
+
text
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# #348 follow-ups, applied BEFORE shell-splitting so the substituted text is
|
|
233
|
+
# re-tokenized into the bare `rm -rf /` form the patterns expect:
|
|
234
|
+
# * ${IFS} word-splitting: `rm${IFS}-rf${IFS}/` joins rm to / with no
|
|
235
|
+
# real whitespace, so Shellwords sees ONE token `rm-rf/`. Replace any
|
|
236
|
+
# ${IFS} / $IFS occurrence with a space so the shell's own field-split
|
|
237
|
+
# is reproduced. (lowercased input -> ${ifs}/$ifs.)
|
|
238
|
+
# * ${IFS:0:1} substring form (#348 residual): `${IFS:OFFSET:LENGTH}`
|
|
239
|
+
# takes a slice of $IFS — `${IFS:0:1}` is its first char (a space). It is
|
|
240
|
+
# the SAME field-split trick as ${IFS}; `rm${IFS:0:1}-rf${IFS:0:1}/` must
|
|
241
|
+
# also collapse to spaces. Match the brace-with-offset form too.
|
|
242
|
+
# * ${VAR:-/} / ${VAR:=/} param-default (#348 residual): the `:-`/`:=`
|
|
243
|
+
# default is used when VAR is unset/empty. The pre-fix only handled the
|
|
244
|
+
# literal name `home`; ANY unset varname works (`${X:-/}`, `${FOO:-/}`),
|
|
245
|
+
# so an attacker just picks a name that is unset and the default `/`
|
|
246
|
+
# expands to the root filesystem. Collapse `${<anyname>:-VALUE}` /
|
|
247
|
+
# `${<anyname>:=VALUE}` to its default VALUE so the root/home pattern
|
|
248
|
+
# fires regardless of the chosen variable name.
|
|
249
|
+
def expand_word_splits(text)
|
|
250
|
+
# ${ifs}, $ifs, and the ${ifs:OFFSET:LEN} substring form all -> a space.
|
|
251
|
+
out = text.gsub(/\$\{ifs(?::\d+(?::\d+)?)?\}|\$ifs\b/, " ")
|
|
252
|
+
# ${VAR:-VALUE} / ${VAR:=VALUE} -> VALUE (the default the shell uses when
|
|
253
|
+
# VAR is unset/empty), for ANY variable name. Captures the default path so
|
|
254
|
+
# `${x:-/}` becomes `/` and the root-filesystem pattern matches.
|
|
255
|
+
out.gsub(/\$\{[a-z_][a-z0-9_]*:[-=]([^}]*)\}/, '\1')
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Shell-word split, or nil on unbalanced quotes (caller falls back to raw).
|
|
259
|
+
def shell_split(normalized)
|
|
260
|
+
Shellwords.split(normalized)
|
|
261
|
+
rescue ArgumentError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Expand the tiny HOME env set, then cleanpath absolute-path tokens.
|
|
266
|
+
def clean_token(tok)
|
|
267
|
+
# $HOME / ${HOME} / $home / ${home} -> ~ (Shellwords already stripped the
|
|
268
|
+
# surrounding quotes of "$HOME"). Only when the token IS (or starts) the
|
|
269
|
+
# HOME ref, so we don't rewrite an unrelated $homedir.
|
|
270
|
+
tok = tok.sub(%r{\A\$\{?home\}?(?=\z|/)}, "~")
|
|
271
|
+
return tok unless tok.start_with?("/")
|
|
272
|
+
|
|
273
|
+
# Pure-string path cleanup: /usr/ -> /usr, // -> /, /. -> /, /home/../ -> /.
|
|
274
|
+
Pathname.new(tok).cleanpath.to_s
|
|
102
275
|
end
|
|
103
276
|
end
|
|
104
277
|
end
|
|
@@ -21,17 +21,40 @@ module Rubino
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Returns the action for a given tool call description
|
|
24
|
-
# Returns :allow, :ask, or :deny
|
|
24
|
+
# Returns :allow, :ask, or :deny (nil when no rule matches).
|
|
25
|
+
#
|
|
26
|
+
# DENY ALWAYS WINS (the documented permissions invariant). Resolution is
|
|
27
|
+
# NOT a plain first-hit on the specificity-sorted list — that let a longer,
|
|
28
|
+
# more specific :allow outrank a shorter overlapping :deny (e.g.
|
|
29
|
+
# "shell git push" => deny vs. "shell git push --force-with-lease …" =>
|
|
30
|
+
# allow), silently swallowing the deny. Instead we resolve in two passes
|
|
31
|
+
# over ALL matching rules:
|
|
32
|
+
#
|
|
33
|
+
# 1. If ANY matching rule is :deny, the result is :deny — regardless of
|
|
34
|
+
# a longer overlapping :allow/:ask. (deny wins ACROSS verdict classes)
|
|
35
|
+
# 2. Otherwise the FIRST (= longest/most-specific, per #parse_rules)
|
|
36
|
+
# matching :allow/:ask wins. (longest-match preserved WITHIN a class)
|
|
37
|
+
#
|
|
38
|
+
# The hardline floor is a separate, earlier layer (ApprovalPolicy step 1)
|
|
39
|
+
# and is unaffected.
|
|
25
40
|
def match(tool_name, command_or_args = nil)
|
|
26
41
|
full_string = [tool_name, command_or_args].compact.join(" ")
|
|
27
42
|
|
|
28
|
-
|
|
43
|
+
first_non_deny = nil
|
|
29
44
|
@rules.each do |pattern, action|
|
|
30
|
-
|
|
45
|
+
next unless matches_pattern?(full_string, pattern)
|
|
46
|
+
|
|
47
|
+
sym = action.to_sym
|
|
48
|
+
# Pass 1: a deny short-circuits everything — deny always wins.
|
|
49
|
+
return :deny if sym == :deny
|
|
50
|
+
|
|
51
|
+
# Pass 2 (deferred): remember the first (most-specific) allow/ask, but
|
|
52
|
+
# keep scanning in case a shorter overlapping deny is still ahead.
|
|
53
|
+
first_non_deny ||= sym
|
|
31
54
|
end
|
|
32
55
|
|
|
33
|
-
#
|
|
34
|
-
|
|
56
|
+
# No matching deny: the longest/most-specific allow/ask (or nil).
|
|
57
|
+
first_non_deny
|
|
35
58
|
end
|
|
36
59
|
|
|
37
60
|
# Returns true if the pattern matches the input
|
|
@@ -92,20 +92,25 @@ module Rubino
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# Leading safe-token run of a plain command:
|
|
95
|
-
# "git status" -> "git"
|
|
95
|
+
# "git status" -> "git status"
|
|
96
96
|
# "bundle exec rspec" -> "bundle exec"
|
|
97
97
|
# "npm run test --watch" -> "npm run"
|
|
98
|
-
# A
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
98
|
+
# A wrapper command (bundle/npm/...) keeps its declared verb (exec/run) so
|
|
99
|
+
# distinct wrapped tools don't collapse into one rule. `git` keeps its
|
|
100
|
+
# READ-ONLY subcommand (`git status` not bare `git`) and refuses to derive
|
|
101
|
+
# a prefix at all for any non-read-only git verb: a bare `git` prefix
|
|
102
|
+
# persisted to the allowlist would pre-approve `git apply`,
|
|
103
|
+
# `git -c alias.x=!cmd x`, ... = RCE/arbitrary-write (SEC-R2-1). For a
|
|
104
|
+
# plain command the head alone is the prefix. The run stops at the first
|
|
105
|
+
# flag or argument-shaped token, mirroring CommandAllowlist's match.
|
|
102
106
|
def command_prefix(command)
|
|
103
107
|
tokens = command.to_s.strip.split(/\s+/)
|
|
104
108
|
return "" if tokens.empty?
|
|
105
109
|
|
|
106
110
|
head = tokens.first
|
|
107
|
-
|
|
111
|
+
return git_prefix(tokens) if head == "git"
|
|
108
112
|
|
|
113
|
+
prefix = [head]
|
|
109
114
|
if WRAPPERS.key?(head)
|
|
110
115
|
verb = tokens[1]
|
|
111
116
|
prefix << verb if verb && plain_word?(verb) && WRAPPERS[head].include?(verb)
|
|
@@ -114,6 +119,20 @@ module Rubino
|
|
|
114
119
|
prefix.join(" ")
|
|
115
120
|
end
|
|
116
121
|
|
|
122
|
+
# The persistable prefix for a git command: `git <subcommand>` ONLY when
|
|
123
|
+
# the subcommand is provably read-only (no global flags before it, a
|
|
124
|
+
# read-only verb). Anything else — a bare `git`, a global flag like `-c`,
|
|
125
|
+
# a mutating/code-loading verb (apply/am/push/...) — returns "" so the
|
|
126
|
+
# caller offers no "always_prefix" choice and the broad rule is never
|
|
127
|
+
# persisted. The narrow exact-command path still remains available.
|
|
128
|
+
def git_prefix(tokens)
|
|
129
|
+
sub = tokens[1]
|
|
130
|
+
return "" if sub.nil? || sub.start_with?("-")
|
|
131
|
+
return "" unless Security::ReadonlyCommands::GIT_READONLY_SUBCOMMANDS.include?(sub)
|
|
132
|
+
|
|
133
|
+
"git #{sub}"
|
|
134
|
+
end
|
|
135
|
+
|
|
117
136
|
# A "plain word" is a bare token: not a flag, no path/assignment/glob
|
|
118
137
|
# punctuation — the shape that can safely extend a prefix.
|
|
119
138
|
def plain_word?(token)
|
|
@@ -45,13 +45,32 @@ module Rubino
|
|
|
45
45
|
].freeze
|
|
46
46
|
|
|
47
47
|
# Mutating/executing flags that disqualify an otherwise-safe head.
|
|
48
|
-
# Matched as exact token or `flag=value`.
|
|
48
|
+
# Matched as exact token or `flag=value`. Heads that read by default but
|
|
49
|
+
# gain a WRITE or an EXEC through one of these flags: an allowlisted entry
|
|
50
|
+
# for the head pre-approves the read, never the smuggled write/exec.
|
|
51
|
+
# sort -o FILE / --output=FILE arbitrary write (SEC-R2-2)
|
|
52
|
+
# (sed/tar/tee/awk/... are handled by CODE_EXEC_HEADS below — their
|
|
53
|
+
# argument is itself a program / they pipe to a shell, so no flag list can
|
|
54
|
+
# make an arbitrary-arg invocation safe.)
|
|
49
55
|
FORBIDDEN_FLAGS = {
|
|
50
56
|
"find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls],
|
|
51
57
|
"date" => %w[-s --set],
|
|
52
|
-
"tree" => %w[-o]
|
|
58
|
+
"tree" => %w[-o],
|
|
59
|
+
"sort" => %w[-o --output]
|
|
53
60
|
}.freeze
|
|
54
61
|
|
|
62
|
+
# Heads whose very PURPOSE is to run arbitrary code (or whose argument is
|
|
63
|
+
# a program that can spawn a shell), so no flag inspection can make an
|
|
64
|
+
# arbitrary-arg invocation provably safe. An allowlist entry for one of
|
|
65
|
+
# these is DENIED auto-approval outright (default-deny): being on the
|
|
66
|
+
# allowlist must never pre-approve `awk 'BEGIN{system("…")}'`,
|
|
67
|
+
# `sed -n '…e cmd'`, `perl -e …`, `tee FILE`, etc. (SEC-R2-2). This is the
|
|
68
|
+
# convenience-layer floor; these still run after an explicit prompt.
|
|
69
|
+
CODE_EXEC_HEADS = %w[
|
|
70
|
+
awk gawk mawk sed perl python python2 python3 ruby node php pwsh
|
|
71
|
+
bash sh zsh ksh dash env xargs eval source tee tar dd
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
55
74
|
# Leading `FOO=bar cmd` environment assignment — rejected, not stripped:
|
|
56
75
|
# an assignment can change what the command resolves to (PATH=...) or
|
|
57
76
|
# how it behaves, so it is never "provably read-only".
|
|
@@ -179,15 +198,136 @@ module Rubino
|
|
|
179
198
|
end
|
|
180
199
|
end
|
|
181
200
|
|
|
201
|
+
# Heads that are otherwise allowlistable but can still WRITE or EXEC
|
|
202
|
+
# through trailing flags (git --output, find -exec/-delete/-fprintf,
|
|
203
|
+
# date -s, tree -o, sort -o, tar --to-command). The user command allowlist
|
|
204
|
+
# reuses this to vet the flags of a matched entry, so an allowlisted head
|
|
205
|
+
# (e.g. `git diff`) can never smuggle an arbitrary write via `--output`.
|
|
206
|
+
FLAG_VETTED_HEADS = (["git"] + FORBIDDEN_FLAGS.keys).freeze
|
|
207
|
+
|
|
208
|
+
# True when `tokens` (a single already-split, non-chained segment whose
|
|
209
|
+
# head matched a user allowlist entry) must NOT be auto-approved because
|
|
210
|
+
# the head can WRITE or EXEC arbitrary code with these arguments. An
|
|
211
|
+
# allowlist entry pre-approves the EXACT read-only intent of a head, never
|
|
212
|
+
# a smuggled write/exec form. Pure inspection; it does NOT require the
|
|
213
|
+
# command to be read-only overall (an allowlist entry is user-chosen), it
|
|
214
|
+
# only rejects the forms that turn a read into a write/exec.
|
|
215
|
+
#
|
|
216
|
+
# - CODE_EXEC_HEADS (awk/sed/perl/python/tar/tee/xargs/...) are
|
|
217
|
+
# default-denied outright: their argument IS a program (or pipes to a
|
|
218
|
+
# shell), so `awk 'BEGIN{system("…")}'`, `sed -e '…'`, `tar
|
|
219
|
+
# --to-command=sh` can't be made provably safe by flag inspection
|
|
220
|
+
# (SEC-R2-2);
|
|
221
|
+
# - git is screened for BOTH global flags before the subcommand
|
|
222
|
+
# (`-c alias.X=!cmd`, `-c core.sshCommand=…`, `-C dir`, `--exec-path`)
|
|
223
|
+
# and a code-loading/writing subcommand (`apply`, `am`, hooks, …) and
|
|
224
|
+
# the --output/-o write flag (SEC-R2-1);
|
|
225
|
+
# - the remaining FORBIDDEN_FLAGS heads (find/date/tree/sort/...) are
|
|
226
|
+
# screened for their specific write/exec flags.
|
|
227
|
+
def dangerous_flags?(tokens)
|
|
228
|
+
head = tokens.first
|
|
229
|
+
return true if CODE_EXEC_HEADS.include?(head)
|
|
230
|
+
return false unless FLAG_VETTED_HEADS.include?(head)
|
|
231
|
+
|
|
232
|
+
if head == "git"
|
|
233
|
+
dangerous_git?(tokens)
|
|
234
|
+
else
|
|
235
|
+
!safe_flags?(head, tokens)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Git GLOBAL flags (between `git` and the subcommand) that load or run
|
|
240
|
+
# arbitrary code, and the dangerous subcommands an allowlisted bare `git`
|
|
241
|
+
# would otherwise pre-approve. None of these belong to a read-only git
|
|
242
|
+
# intent, so an allowlisted git head carrying any of them is rejected.
|
|
243
|
+
#
|
|
244
|
+
# -c <name>=<val> / -c<name>=<val> sets config for this invocation; the
|
|
245
|
+
# load-bearing ones are alias.* (a `!cmd` alias = RCE), core.sshCommand,
|
|
246
|
+
# core.pager, core.editor, core.hooksPath, core.fsmonitor,
|
|
247
|
+
# uploadpack/receivepack.* — all run a command. We reject -c entirely
|
|
248
|
+
# for an auto-approved git: a read-only git never needs per-call config.
|
|
249
|
+
# --config-env=<name>=<envvar> sets config like -c, sourcing the
|
|
250
|
+
# VALUE from an environment variable (so `--config-env=alias.x=PWNVAR`
|
|
251
|
+
# with PWNVAR='!cmd' is the same RCE as `-c alias.x='!cmd'`). Rejected
|
|
252
|
+
# for the same reason as -c (SEC-R3-1).
|
|
253
|
+
# --attr-source=<tree> reads .gitattributes from an
|
|
254
|
+
# arbitrary tree-ish; not config, but never part of a read-only intent,
|
|
255
|
+
# so rejected too (SEC-R3-1).
|
|
256
|
+
# -C <path> / --exec-path[=path] changes the working dir / git's exec
|
|
257
|
+
# path (which can point git at attacker binaries).
|
|
258
|
+
GIT_GLOBAL_EXEC_FLAGS = %w[
|
|
259
|
+
-c --config-env --exec-path --git-dir --work-tree --namespace --attr-source -C
|
|
260
|
+
].freeze
|
|
261
|
+
|
|
262
|
+
# Subcommands that mutate the working tree / apply attacker-supplied data /
|
|
263
|
+
# run hooks, never part of a read-only intent. An allowlisted git head must
|
|
264
|
+
# not pre-approve them even though they don't start with `-`.
|
|
265
|
+
GIT_DANGEROUS_SUBCOMMANDS = %w[
|
|
266
|
+
apply am rebase merge cherry-pick revert reset checkout switch restore
|
|
267
|
+
clean stash push pull fetch clone commit hook filter-branch
|
|
268
|
+
update-index update-ref symbolic-ref config send-email daemon
|
|
269
|
+
].freeze
|
|
270
|
+
|
|
271
|
+
# True when a git invocation whose head matched an allowlist entry carries
|
|
272
|
+
# a code-loading global flag, a dangerous subcommand, or an output-writing
|
|
273
|
+
# flag. Scans the GLOBAL flag region (before the subcommand) AND the rest.
|
|
274
|
+
def dangerous_git?(tokens)
|
|
275
|
+
rest = tokens.drop(1)
|
|
276
|
+
# Global flag region: everything up to the first non-flag token (the
|
|
277
|
+
# subcommand). `-c name=val` / `-C path` may consume the next token as
|
|
278
|
+
# their value, so a value that happens to look like a subcommand isn't
|
|
279
|
+
# mistaken for one.
|
|
280
|
+
i = 0
|
|
281
|
+
while i < rest.length
|
|
282
|
+
tok = rest[i]
|
|
283
|
+
break unless tok.start_with?("-")
|
|
284
|
+
|
|
285
|
+
return true if git_global_exec_flag?(tok)
|
|
286
|
+
|
|
287
|
+
# -c / -C / --exec-path take a value as the NEXT token when not glued.
|
|
288
|
+
i += 1 if %w[-c -C].include?(tok) && !rest[i + 1].nil?
|
|
289
|
+
i += 1
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
sub = rest[i]
|
|
293
|
+
return true if sub && GIT_DANGEROUS_SUBCOMMANDS.include?(sub)
|
|
294
|
+
|
|
295
|
+
git_write_flag?(rest.drop(i + 1))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A global-flag token matches when it is the exact flag (`-c`,
|
|
299
|
+
# `--exec-path`), its `flag=value` form (`--exec-path=/x`), or a glued
|
|
300
|
+
# short form (`-cNAME=VAL`, `-Cpath`).
|
|
301
|
+
def git_global_exec_flag?(tok)
|
|
302
|
+
GIT_GLOBAL_EXEC_FLAGS.any? do |f|
|
|
303
|
+
tok == f ||
|
|
304
|
+
tok.start_with?("#{f}=") ||
|
|
305
|
+
(f.length == 2 && f.start_with?("-") && !f.start_with?("--") && tok.start_with?(f) && tok.length > 2)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Git flags that write the output to an arbitrary file:
|
|
310
|
+
# --output <file> / --output=<file> (git diff/log/show/format-patch)
|
|
311
|
+
# -o <file> / -o<file> (short form, git log/format-patch)
|
|
312
|
+
# `-O<orderfile>` reads an orderfile (no write) but is rejected too, so a
|
|
313
|
+
# short-flag write form can never slip through ambiguity. Matched on the
|
|
314
|
+
# whole rest of the segment (token or `flag=value` / glued `-oFILE`).
|
|
315
|
+
def git_write_flag?(rest)
|
|
316
|
+
rest.any? do |t|
|
|
317
|
+
t == "--output" || t.start_with?("--output=", "-o", "-O")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
182
321
|
# Read-only git: a safe subcommand (no global flags before it — `git -C`
|
|
183
|
-
# falls to the prompt), never
|
|
184
|
-
# file with
|
|
322
|
+
# falls to the prompt), never an output-writing flag (git log/diff/show
|
|
323
|
+
# can write a file with --output/-o), branch/remote in their pure
|
|
324
|
+
# listing forms only.
|
|
185
325
|
def safe_git?(tokens)
|
|
186
326
|
sub = tokens[1]
|
|
187
327
|
return false if sub.nil? || sub.start_with?("-")
|
|
188
328
|
|
|
189
329
|
rest = tokens.drop(2)
|
|
190
|
-
return false if
|
|
330
|
+
return false if git_write_flag?(rest)
|
|
191
331
|
|
|
192
332
|
case sub
|
|
193
333
|
when *GIT_READONLY_SUBCOMMANDS then true
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Security
|
|
5
|
+
# ONE "is this a secret/credential path?" predicate, shared by the tool
|
|
6
|
+
# layer (read/grep/glob refuse to leak; write/edit refuse to clobber) and
|
|
7
|
+
# the approval layer (Security::ApprovalPolicy#decide → :ask). Previously
|
|
8
|
+
# the read side and the write side carried two parallel denylists; the
|
|
9
|
+
# maintainer decision (#446) is that reading OR writing a secret both
|
|
10
|
+
# require the SAME explicit user approval over the SAME set — so the set
|
|
11
|
+
# and the predicate live here, once.
|
|
12
|
+
#
|
|
13
|
+
# The gate itself is in ApprovalPolicy/ToolExecutor (interactive →
|
|
14
|
+
# approval dropdown; approved → tool proceeds; denied → refused; headless →
|
|
15
|
+
# fails closed). This module is pure detection — it never prompts, blocks,
|
|
16
|
+
# or touches IO beyond symlink resolution.
|
|
17
|
+
module SecretPath
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Credential/secret BASENAMES, in any directory.
|
|
21
|
+
BASENAME_RE = /
|
|
22
|
+
\A\.env(\..+)?\z | \A\.envrc\z |
|
|
23
|
+
\A\.netrc\z | \A\.pgpass\z | \A\.npmrc\z | \A\.pypirc\z |
|
|
24
|
+
\A\.git-credentials\z |
|
|
25
|
+
\A\.bashrc\z | \A\.zshrc\z | \A\.profile\z | \A\.bash_profile\z | \A\.zprofile\z
|
|
26
|
+
/x
|
|
27
|
+
|
|
28
|
+
# Home-relative credential subtrees (resolved against $HOME).
|
|
29
|
+
HOME_PREFIXES = [
|
|
30
|
+
".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure",
|
|
31
|
+
".config/gh", ".config/gcloud"
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Absolute system paths / prefixes.
|
|
35
|
+
SYSTEM_PATHS = ["/etc/sudoers", "/etc/passwd", "/etc/shadow"].freeze
|
|
36
|
+
SYSTEM_PREFIXES = ["/etc/sudoers.d", "/etc/systemd"].freeze
|
|
37
|
+
|
|
38
|
+
# Returns the matched-secret category string (truthy) for a secret path,
|
|
39
|
+
# or nil for a normal file. `path` may be relative or absolute; it is
|
|
40
|
+
# resolved through every symlink first so an in-workspace link to ~/.ssh
|
|
41
|
+
# can't slip past the basename/prefix checks.
|
|
42
|
+
def category(path)
|
|
43
|
+
base = File.basename(path.to_s)
|
|
44
|
+
target = canonical_path(path) || File.expand_path(path.to_s)
|
|
45
|
+
|
|
46
|
+
return "credential file (#{base})" if base.match?(BASENAME_RE)
|
|
47
|
+
if (cat = denied_path_category(target, base))
|
|
48
|
+
return cat
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
agent_home_category(path, base, target)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# True when `path` is a secret. Thin boolean wrapper over #category.
|
|
55
|
+
def secret?(path)
|
|
56
|
+
!category(path).nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Absolute-path / prefix matches (SSH keys, cloud creds, /etc system
|
|
60
|
+
# files), compared against the symlink-resolved target.
|
|
61
|
+
def denied_path_category(target, base)
|
|
62
|
+
home = File.expand_path("~")
|
|
63
|
+
HOME_PREFIXES.each do |rel|
|
|
64
|
+
return "credential directory (~/#{rel})" if under_path?(target, File.join(home, rel))
|
|
65
|
+
end
|
|
66
|
+
return "system file (#{base})" if SYSTEM_PATHS.include?(target)
|
|
67
|
+
|
|
68
|
+
SYSTEM_PREFIXES.each do |prefix|
|
|
69
|
+
return "system path (#{prefix})" if under_path?(target, prefix)
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Agent-home (~/.rubino) auth/secret material: the home .env, the token
|
|
75
|
+
# store sqlite DB, *oauth* files, mcp-tokens/, and *.key/*.pem material.
|
|
76
|
+
def agent_home_category(path, base, target)
|
|
77
|
+
return unless under_agent_home?(path)
|
|
78
|
+
|
|
79
|
+
lower = target.downcase
|
|
80
|
+
return unless base == ".env" || base.match?(BASENAME_RE) ||
|
|
81
|
+
base == "rubino.sqlite3" || base.end_with?(".sqlite3") ||
|
|
82
|
+
lower.include?("oauth") || lower.include?("/mcp-tokens/") ||
|
|
83
|
+
base.end_with?(".key") || base.end_with?(".pem")
|
|
84
|
+
|
|
85
|
+
"agent-home secret (#{base})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True when +target+ is +root+ itself or sits under it.
|
|
89
|
+
def under_path?(target, root)
|
|
90
|
+
target == root || target.start_with?("#{root}#{File::SEPARATOR}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Resolves `path` through every symlink to its canonical destination,
|
|
94
|
+
# re-joining the missing tail for a not-yet-created target. Mirrors
|
|
95
|
+
# Tools::Base#canonical_path so the write-creates-new-file flow resolves
|
|
96
|
+
# to the same place the tool will write.
|
|
97
|
+
def canonical_path(path)
|
|
98
|
+
return nil if path.nil? || path.to_s.empty?
|
|
99
|
+
|
|
100
|
+
expanded = File.expand_path(path.to_s)
|
|
101
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
102
|
+
|
|
103
|
+
ancestor = expanded
|
|
104
|
+
tail = []
|
|
105
|
+
until File.exist?(ancestor)
|
|
106
|
+
parent = File.dirname(ancestor)
|
|
107
|
+
break if parent == ancestor
|
|
108
|
+
|
|
109
|
+
tail.unshift(File.basename(ancestor))
|
|
110
|
+
ancestor = parent
|
|
111
|
+
end
|
|
112
|
+
return nil unless File.exist?(ancestor)
|
|
113
|
+
|
|
114
|
+
File.join(File.realpath(ancestor), *tail)
|
|
115
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True when `path` resolves under the Rubino home directory.
|
|
120
|
+
def under_agent_home?(path)
|
|
121
|
+
home = Rubino.home_path
|
|
122
|
+
return false if home.nil? || home.to_s.empty?
|
|
123
|
+
|
|
124
|
+
home_real = (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
|
|
125
|
+
target_real = canonical_path(path)
|
|
126
|
+
return false unless target_real
|
|
127
|
+
|
|
128
|
+
target_real == home_real || target_real.start_with?("#{home_real}#{File::SEPARATOR}")
|
|
129
|
+
rescue StandardError
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|