Class: Rubino::Security::ApprovalPolicy

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/security/approval_policy.rb

Overview

Determines whether a tool execution requires user approval. Uses pattern-based rules, tool risk levels, and doom loop detection.

Config example:

approvals:
  mode: "manual"  # manual | auto | skip
permissions:
  "git *": "allow"
  "shell rm *": "deny"
  "shell bundle *": "allow"
  "file_system write ~/.env": "deny"

Constant Summary collapse

MODES =
%w[manual auto skip].freeze
STRUCTURED_EDIT_TOOLS =

Structured in-workspace file-edit tools. Under dangerous_only these run unprompted — SYMMETRIC with safe shell — because the always-on #413 write-denylist + workspace sandbox (both enforced inside the tool’s #call, regardless of approval) are the boundary, not a per-edit prompt (#427, mirrors Hermes file_safety + Claude Code acceptEdits / Codex auto-edit / aider).

%w[edit write multi_edit apply_patch].freeze
SECRET_GATED_READ_TOOLS =

File tools whose TARGET path is run through the unified secret-file gate (#446). READ side resolves the path from ‘file_path`/`path`; WRITE side from `file_path` (apply_patch from its patch text, see #secret_file_access?).

%w[read grep glob].freeze
SECRET_GATED_WRITE_TOOLS =
STRUCTURED_EDIT_TOOLS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config: nil, agent_overrides: nil) ⇒ ApprovalPolicy

Returns a new instance of ApprovalPolicy.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/rubino/security/approval_policy.rb', line 41

def initialize(config: nil, agent_overrides: nil)
  @config = config || Rubino.configuration
  @mode = @config.approvals_mode
  # Effective shell prompt policy (:confirm_all | :dangerous_only), the
  # SOLE source of truth (item 7): security.confirm_policy only — the legacy
  # security.require_confirmation_for_shell alias was removed (see
  # Configuration#confirm_policy). Older config objects that predate the
  # accessor fall back to the reference-faithful :dangerous_only default.
  @confirm_policy =
    @config.respond_to?(:confirm_policy) ? @config.confirm_policy : :dangerous_only
  @pattern_matcher = PatternMatcher.new(
    rules: load_permission_rules(agent_overrides)
  )
  # Doom-loop guard, config-driven (#414). Default WARN-not-block with a
  # higher threshold (Hermes tool_guardrails alignment): a tripped detector
  # under hard_stop:false surfaces a warning but lets the call run.
  @doom_detector = DoomLoopDetector.new(
    threshold: @config.respond_to?(:doom_loop_threshold) ? @config.doom_loop_threshold : DoomLoopDetector::DEFAULT_THRESHOLD,
    hard_stop: @config.respond_to?(:doom_loop_hard_stop?) ? @config.doom_loop_hard_stop? : false
  )
  # Set true after a warn-mode doom-loop hit so ToolExecutor can surface a
  # one-time warning to the model without denying the call. Cleared each
  # #decide and on reset_turn!.
  @doom_loop_warning = false
end

Instance Attribute Details

#doom_loop_warningObject (readonly)

True when the LAST #decide tripped the doom-loop guard in WARN mode (hard_stop off): the call was allowed but the model should be told it is repeating an identical call. ToolExecutor reads this to attach a warning.



70
71
72
# File 'lib/rubino/security/approval_policy.rb', line 70

def doom_loop_warning
  @doom_loop_warning
end

#last_deny_reasonObject (readonly)

Why the most recent #decide returned :deny — :hardline (the non-bypassable floor), :permission_rule (an explicit permissions deny rule), or :doom_loop (the repeated-identical-call guard). nil when the last decision wasn’t a deny. ToolExecutor reads this right after #decide to build a reason-specific model-facing denial message, so a policy denial is never reported as “denied by user” (#143).



39
40
41
# File 'lib/rubino/security/approval_policy.rb', line 39

def last_deny_reason
  @last_deny_reason
end

Class Method Details

.command_string(tool, arguments) ⇒ Object

Builds the string representation of a tool call used both for pattern-rule matching here and for the UI’s session-approval scope in ToolExecutor. One builder so the granularity stays identical: approving ‘shell ls` never auto-approves `shell rm -rf /`.



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/rubino/security/approval_policy.rb', line 341

def self.command_string(tool, arguments)
  args = arguments || {}
  case tool.name
  when "shell"
    (args["command"] || args[:command]).to_s
  when "read", "write", "edit", "multi_edit", "attach_file"
    (args["file_path"] || args[:file_path]).to_s
  when "grep", "glob"
    # The SEARCH ROOT (a dir or a file) is what the secret gate resolves —
    # `pattern` is the regex/glob, not a path. (Default "." like the tools.)
    (args["path"] || args[:path] || ".").to_s
  when "shell_output", "shell_kill", "shell_input"
    (args["run_id"] || args[:run_id]).to_s
  when "skill"
    # "<action> <name>" so the approval scope distinguishes a create from
    # a load and one skill name from another (granularity parity, #405).
    action = args["action"] || args[:action] || "load"
    name   = args["name"] || args[:name]
    [action, name].join(" ").strip
  else
    args.values.first.to_s
  end
end

Instance Method Details

#command_pre_approved?(command) ⇒ Boolean

Returns true if a specific command is pre-approved by the config allowlist. An empty allowlist pre-approves NOTHING.

Returns:

  • (Boolean)


251
252
253
# File 'lib/rubino/security/approval_policy.rb', line 251

def command_pre_approved?(command)
  CommandAllowlist.new(config: @config).allowed?(command)
end

#dangerous?(command) ⇒ Boolean

True when a command matches a recoverable-but-risky DangerousPattern (distinct from the hardline floor). Computed signal for the structured ask context and for S4’s dangerous_only confirm policy; #decide does not yet branch on it (see step 7). Mirrors detect_dangerous_command.

Returns:

  • (Boolean)


245
246
247
# File 'lib/rubino/security/approval_policy.rb', line 245

def dangerous?(command)
  DangerousPatterns.dangerous?(command)
end

#decide(tool, arguments: {}) ⇒ Object

Returns the decision for a tool call: :allow, :ask, :deny

CANONICAL DECISION ORDER (deny-class checks precede every allow path). Mirrors the reconciled reference ordering:

1. hardline(:deny)            non-bypassable floor BELOW yolo
2. permissions:deny           an explicit deny rule also beats yolo
3. runtime yolo (Modes)      allow-exit (doom still guards it).
                              config approvals.mode: "skip" does NOT
                              take this exit — it is not a headless
                              yolo (see steps 7-9 / #260).
4. doom loop                  break a stuck autopilot
5. permissions:allow / :ask   remaining explicit rules
6. command_allowlist          pre-approved EXACT commands -> :allow
                              (chain-aware, token-boundary; never a
                              prefix of a compound line)
6b. readonly auto-allow       parse-validated read-only shell -> :allow
7-8. confirm_policy shell gate  confirm_all -> :ask; dangerous_only
                              -> :ask only if dangerous?, else :allow.
                              Runs for mode "skip" too, so a write/
                              shell under config "skip" still reaches
                              the headless fail-closed floor (#260).
9. mode fallback             ("skip" -> :ask for risky tools, not :allow)

The invariant that makes this slice worth doing: HARDLINE and an explicit permissions:deny BOTH run before any allow path (yolo, permissions:allow, command_allowlist), so neither can be overridden by a fast-path the way yolo used to override deny rules.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/rubino/security/approval_policy.rb', line 100

def decide(tool, arguments: {})
  @last_deny_reason = nil
  @doom_loop_warning = false
  command_str = self.class.command_string(tool, arguments)

  # 1. Hardline floor — a floor BELOW yolo. Catastrophic, unrecoverable
  #    commands (rm -rf /, mkfs, dd to a raw device, fork bomb,
  #    shutdown/reboot, sudo -S password guessing) are denied
  #    UNCONDITIONALLY: before yolo/skip, before doom, before any
  #    permissions:allow rule or command_allowlist entry. Opting into
  #    yolo trusts the agent with your files, NOT to wipe the disk.
  #    Mirrors the reference approval module (enforced first).
  blocked, = HardlineGuard.detect(command_str)
  return deny_with(:hardline) if blocked

  # 2. Explicit permissions:deny — like hardline, a deny rule is a
  #    deny-class check and must beat every allow path. We evaluate the
  #    pattern rules ONCE here and reuse the result below; only the :deny
  #    verdict short-circuits before yolo. allow/ask wait until after the
  #    yolo allow-exit and the doom guard (steps 3-4) so they keep their
  #    original precedence. Mirrors the deny-before-allow ordering in the
  #    plan (hardline -> permissions:deny -> yolo -> doom -> allow/ask).
  pattern_result = @pattern_matcher.match(tool.name, command_str)
  return deny_with(:permission_rule) if pattern_result == :deny

  # 3. Modes.yolo short-circuits the remaining allow/ask logic. We still
  #    run the doom detector AFTER, because an autopilot stuck in a loop
  #    is the one thing yolo isn't supposed to license.
  if Rubino::Modes.skip_approvals?
    return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)

    return :allow
  end

  # 4. Doom loop guard. Blocks only under hard_stop (#414); in the default
  #    warn mode it sets @doom_loop_warning and falls through to the normal
  #    decision so a legitimate repeated call is not hard-denied.
  return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)

  # 5. Remaining explicit pattern rules (allow / ask). deny was already
  #    handled in step 2. An explicit user permissions rule (allow/ask)
  #    wins over the secret gate below, so a user who wrote
  #    `read /path/.env: allow` is honored.
  return pattern_result if pattern_result

  # 5b. UNIFIED SECRET-FILE GATE (#446). Reading (read/grep/glob) OR
  #     writing/editing (write/edit/multi_edit/apply_patch) a SECRET path
  #     requires EXPLICIT user approval — the maintainer decision: not a
  #     silent allow, not a silent hard-block. Returns :ask, which the
  #     ToolExecutor turns into the approval dropdown when interactive
  #     (approved → the tool runs and reads/writes the secret; denied →
  #     refused) and into a FAIL-CLOSED block when headless (:noninteractive).
  #     Runs ABOVE the broad read/allow fast-paths (steps 6/6b/9) so a
  #     secret read isn't silently auto-allowed, and BELOW yolo (step 3) so
  #     a --yolo operator who opted into full file trust isn't re-prompted.
  #     NON-secret reads stay broad (clone-and-inspect, #406) — only the
  #     secret set is gated.
  return :ask if secret_file_access?(tool, arguments)

  # 6. Config allowlist of pre-approved commands. Checked AFTER deny
  #    patterns (deny always wins) but BEFORE mode-based decision so a
  #    listed command never triggers a manual prompt.
  return :allow if command_pre_approved?(command_str)

  # 6b. Built-in read-only auto-allow — the same allowlist seam as
  #    step 6, just with a parse-validated built-in set instead of
  #    user-configured prefixes. Runs BELOW the hardline floor (step 1)
  #    and permissions:deny (step 2), so the floor always wins even for
  #    commands added via approvals.readonly_commands. A line the
  #    validator cannot prove read-only falls through to the prompt.
  return :allow if readonly_auto_allowed?(tool, command_str)

  # 6c. skill(action: "create") WRITES <RUBINO_HOME>/skills/<name>/SKILL.md
  #    and must not be a silent low-risk allow (#405): the skill tool stays
  #    :low so a read_only agent keeps `skill load/list/show`, but the
  #    create action is a write and routes to :ask here — like any write.
  #    Below yolo (step 3), so a full-access --yolo agent still creates
  #    skills inline; a headless read_only subagent's :ask becomes the
  #    fail-closed block, closing the unapproved-write path. load is never
  #    gated (only the create action matches).
  #
  #    This gate is now a real boundary, not theater (SK-2): authored
  #    skills are written under the agent HOME (outside the cwd workspace),
  #    so the model can't sidestep it by emitting a plain `write` of the
  #    same SKILL.md — the workspace sandbox (within_workspace?) refuses any
  #    write outside the workspace, leaving this :ask-gated helper as the
  #    ONLY way to author a skill.
  return :ask if skill_create?(tool, arguments)

  # 7-8. confirm_policy gate for a shell command not otherwise resolved.
  #    NOT under runtime yolo (handled at step 3) — that is the explicit
  #    CLI operator override that means "stop prompting me".
  #
  #    config approvals.mode: "skip" is NOT given the same allow-exit as
  #    runtime yolo here. #260 deliberately made the headless skip a
  #    CLI-only opt-in (--yolo): a config-file "skip" must NOT silently
  #    auto-run write/shell in a headless session. So a not-otherwise-
  #    resolved shell command still routes through this gate to :ask, and
  #    the ToolExecutor's headless fail-closed floor (#260) turns that
  #    :ask into a block when there is no interactive session. Interactive
  #    sessions still get a prompt — same as auto/manual. (Reads are
  #    already auto-allowed by step 6b / mode_based_decision, so this
  #    only constrains the write/shell side.)
  #
  #    confirm_all (opt-in hardening)
  #      every such shell command -> :ask. shell is :high risk so manual
  #      mode would ask anyway; this also keeps it gated under auto mode.
  #
  #    dangerous_only (DEFAULT, reference-faithful)
  #      prompt ONLY when the command matches a DangerousPattern
  #      (git push --force, curl|sh, recursive rm of a non-root path,
  #      ...). Safe commands run unprompted. Mirrors approval.py:475
  #      where detect_dangerous_command is the sole prompt trigger.
  #      The hardline floor (step 1) and permissions:deny (step 2) already
  #      ran, so dangerous_only NEVER weakens the non-bypassable floor.
  return shell_confirm_decision(command_str) if tool.name == "shell"

  # 8b. Structured in-workspace edit symmetry (#427). Under dangerous_only,
  #    a safe `shell sed -i …` / `echo > file` runs UNPROMPTED (step 7-8),
  #    but the structured edit/write/multi_edit/apply_patch tools are
  #    :medium and would fall through to step 9 -> :ask, which fails closed
  #    headless. That asymmetry pushes automation AWAY from the clean,
  #    read-tracked, diff-producing structured tools and TOWARD raw shell
  #    mutation — worse for safety/observability and the inverse of the
  #    industry norm (Hermes runs structured in-workspace edits unprompted
  #    with file_safety.is_write_denied as the boundary; Claude Code
  #    acceptEdits, Codex auto-edit and aider all treat in-workspace edits
  #    as LOWER friction than shell). So under dangerous_only these
  #    structured edits are non-prompting too — SYMMETRIC with safe shell.
  #    This NEVER widens reach: the always-on #413 write-denylist (refuses
  #    .env/.ssh/.aws/etc even inside the workspace) and the workspace
  #    sandbox both run inside the tool's #call regardless of approval, and
  #    the hardline floor (step 1), permissions:deny (step 2) and
  #    skill-create gate (step 6c) all already ran above. confirm_all
  #    (non-default) still routes them through step 9 -> :ask unchanged.
  return :allow if @confirm_policy == :dangerous_only && STRUCTURED_EDIT_TOOLS.include?(tool.name)

  # 9. Fall back to mode-based decision
  mode_based_decision(tool)
end

#patch_target_paths(patch, base_path) ⇒ Object

Extracts every destination file from a unified diff (‘+++ b/<file>`, and `— a/<file>` so a delete of a secret is gated too), absolutised against base_path. A `/dev/null` side carries no file and is skipped.



306
307
308
309
310
311
312
313
314
315
# File 'lib/rubino/security/approval_policy.rb', line 306

def patch_target_paths(patch, base_path)
  return [] if patch.nil?

  patch.to_s.each_line.filter_map do |line|
    m = line.match(%r{^[-+]{3} [ab]/(.+)\s*$})
    next if m.nil?

    File.expand_path(m[1].strip, base_path)
  end.uniq
end

#readonly_auto_allowed?(tool, command) ⇒ Boolean

True when the shell command is provably read-only and the approvals.auto_allow_readonly gate (default ON) is open. Shell-only: for every other tool the “command” is a path or argument fragment.

Returns:

  • (Boolean)


330
331
332
333
334
335
# File 'lib/rubino/security/approval_policy.rb', line 330

def readonly_auto_allowed?(tool, command)
  return false unless tool.name == "shell"
  return false unless @config.auto_allow_readonly?

  ReadonlyCommands.auto_allowed?(command, extra: @config.approvals_readonly_commands)
end

#reset_turn!Object

Resets doom loop detector (call on new user input)



366
367
368
# File 'lib/rubino/security/approval_policy.rb', line 366

def reset_turn!
  @doom_detector.reset!
end

#resolve_workspace_path(path) ⇒ Object

Anchors a relative path at the workspace primary root (matching Tools::Base#expand_workspace_path) so the gate sees the same target the tool will. Absolute/~ paths pass through.



320
321
322
323
324
325
# File 'lib/rubino/security/approval_policy.rb', line 320

def resolve_workspace_path(path)
  str = path.to_s
  return File.expand_path(str) if str.start_with?(File::SEPARATOR, "~")

  File.expand_path(str, Tools::Base.workspace_root)
end

#secret_file_access?(tool, arguments) ⇒ Boolean

True when this call READS or WRITES a secret/credential path and so must be approval-gated (#446). For the path-arg tools (read/grep/glob/write/ edit/multi_edit) the single target is resolved from file_path/path; for apply_patch every target file in the patch is checked, because one call can touch many files. Resolution is relative to the workspace primary root so a relative ‘.env` resolves to the same file the tool will open.

Returns:

  • (Boolean)


280
281
282
283
284
285
# File 'lib/rubino/security/approval_policy.rb', line 280

def secret_file_access?(tool, arguments)
  return false unless SECRET_GATED_READ_TOOLS.include?(tool.name) ||
                      SECRET_GATED_WRITE_TOOLS.include?(tool.name)

  secret_targets(tool, arguments).any? { |p| SecretPath.secret?(p) }
end

#secret_targets(tool, arguments) ⇒ Object

The absolute path(s) a file tool will touch. apply_patch yields one per hunk target; every other gated tool yields its single file_path/path.



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/rubino/security/approval_policy.rb', line 289

def secret_targets(tool, arguments)
  args = arguments || {}
  if tool.name == "apply_patch"
    base = (args["base_path"] || args[:base_path]).to_s
    base = Tools::Base.workspace_root if base.empty?
    return patch_target_paths(args["patch"] || args[:patch], base)
  end

  raw = self.class.command_string(tool, arguments)
  return [] if raw.to_s.empty?

  [resolve_workspace_path(raw)]
end

#shell_confirm_decision(command_str) ⇒ Object

The confirm_policy shell gate (steps 7-8), extracted so #decide stays under the complexity limit. confirm_all → always :ask; dangerous_only →:ask only for a DangerousPattern, else :allow.



268
269
270
271
272
# File 'lib/rubino/security/approval_policy.rb', line 268

def shell_confirm_decision(command_str)
  return :ask unless @confirm_policy == :dangerous_only

  dangerous?(command_str) ? :ask : :allow
end

#skill_create?(tool, arguments) ⇒ Boolean

True when this is the WRITE action of the skill tool (action: “create”). The skill tool is :low (so read_only keeps load/list/show), but its create action writes a SKILL.md and must be approval-gated (#405).

Returns:

  • (Boolean)


258
259
260
261
262
263
# File 'lib/rubino/security/approval_policy.rb', line 258

def skill_create?(tool, arguments)
  return false unless tool.name == "skill"

  args = arguments || {}
  (args["action"] || args[:action]).to_s == "create"
end