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

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.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/rubino/security/approval_policy.rb', line 27

def initialize(config: nil, agent_overrides: nil)
  @config = config || Rubino.configuration
  @mode = @config.approvals_mode
  # Effective shell prompt policy (:confirm_all | :dangerous_only).
  # Derived from security.confirm_policy, with security.require_confirmation_for_shell
  # as a back-compat alias (see Configuration#confirm_policy). Older config
  # objects that predate the accessor fall back to :confirm_all.
  @confirm_policy =
    @config.respond_to?(:confirm_policy) ? @config.confirm_policy : :confirm_all
  @pattern_matcher = PatternMatcher.new(
    rules: load_permission_rules(agent_overrides)
  )
  @doom_detector = DoomLoopDetector.new
end

Instance Attribute Details

#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).



25
26
27
# File 'lib/rubino/security/approval_policy.rb', line 25

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 /`.



176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/rubino/security/approval_policy.rb', line 176

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 "shell_output", "shell_kill", "shell_input"
    (args["run_id"] || args[:run_id]).to_s
  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)


158
159
160
# File 'lib/rubino/security/approval_policy.rb', line 158

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)


152
153
154
# File 'lib/rubino/security/approval_policy.rb', line 152

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. yolo / skip-approvals      allow-exit (doom still guards it)
4. doom loop                  break a stuck autopilot
5. permissions:allow / :ask   remaining explicit rules
6. command_allowlist (prefix) pre-approved commands -> :allow
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
9. mode fallback

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.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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
# File 'lib/rubino/security/approval_policy.rb', line 62

def decide(tool, arguments: {})
  @last_deny_reason = nil
  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_detector.record(tool_name: tool.name, arguments: arguments)

    return :allow
  end

  # 4. Doom loop guard.
  if @doom_detector.record(tool_name: tool.name, arguments: arguments)
    return deny_with(:doom_loop) # Break the loop
  end

  # 5. Remaining explicit pattern rules (allow / ask). deny was already
  #    handled in step 2.
  return pattern_result if pattern_result

  # 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)

  # 7-8. confirm_policy gate for a shell command not otherwise resolved.
  #    NOT under config "skip" (nor runtime yolo, handled at step 3) —
  #    those are the explicit operator overrides that mean "stop
  #    prompting me".
  #
  #    confirm_all (DEFAULT, == legacy require_confirmation_for_shell:true)
  #      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 (reference-faithful, == legacy alias:false)
  #      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.
  if tool.name == "shell" && @mode != "skip"
    case @confirm_policy
    when :dangerous_only
      return :ask if dangerous?(command_str)

      return :allow
    else # :confirm_all
      return :ask
    end
  end

  # 9. Fall back to mode-based decision
  mode_based_decision(tool)
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)


165
166
167
168
169
170
# File 'lib/rubino/security/approval_policy.rb', line 165

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)



191
192
193
# File 'lib/rubino/security/approval_policy.rb', line 191

def reset_turn!
  @doom_detector.reset!
end