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_WRITE_TOOLS =

File tools whose WRITE TARGET path is run through the secret-file gate. WRITE side resolves the path from ‘file_path` (apply_patch from its patch text, see #secret_file_access?). The READ side (read/grep/glob) is NOT gated: reading a secret is allowed unprompted, matching the field norm (Claude Code / Codex / aider / Windsurf / LangChain all allow secret reads; protection is on write/exec/network, #480). Only writing/editing a secret still requires explicit approval.

STRUCTURED_EDIT_TOOLS
CODE_EXEC_TOOLS =

Dedicated code-execution tools that, under dangerous_only, must run unprompted — SYMMETRIC with (and never HARDER than) safe shell.

‘ruby` evaluates arbitrary code in a sandboxed child process and exposes NO reliable read-only signal, so it cannot be auto-allowed on a proven read-only basis the way step 6b auto-allows parse-validated read-only shell; instead it is aligned AT MOST to the same tier as raw safe shell (auto-run under dangerous_only, never gated harder than the `shell` path it would otherwise be driven through). It was the inversion: a dedicated eval tool prompting while arbitrary safe `shell` ran unprompted, pushing automation toward raw shell. It is :medium and would otherwise fall through to step 9 -> :ask. The hardline floor (step 1), permissions:deny (step 2) and doom guard (step 4) all run first and are unchanged; confirm_all (non-default) still routes it to step 9 -> :ask.

%w[ruby].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.



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
# File 'lib/rubino/security/approval_policy.rb', line 62

def initialize(config: nil, agent_overrides: nil)
  @config = config || Rubino.configuration
  @mode = @config.dig("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.



91
92
93
# File 'lib/rubino/security/approval_policy.rb', line 91

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



60
61
62
# File 'lib/rubino/security/approval_policy.rb', line 60

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



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/rubino/security/approval_policy.rb', line 419

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)


287
288
289
# File 'lib/rubino/security/approval_policy.rb', line 287

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)


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

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

#dangerous_flag_form_present?(command_str) ⇒ Boolean

True when ANY chain segment of the command is a flag-form that still warrants a prompt. Reuses the same quote-aware chain split as the read-only auto-allow so ‘echo hi && sort -o /tmp/x f` is screened per-segment. Fails SAFE: a segment that does not parse (split returns nil, or Shellwords raises) is treated as dangerous.

CONDITIONAL on the OS write-jail PROVING enforcement (slice 2 Part C): the jail confines arbitrary WRITES, so when it is ENFORCING the pure-write flag-forms (‘sort -o`, `sed -i`, `git –output`, `find -delete`, `tar` write/extract, …) no longer need a prompt — only the EXEC/network/system forms that run arbitrary code (`python -c`, `bash -c`, `git -c`/push, `perl -e`, …) do. When the jail is DEGRADED/off OR present-but-not- enforcing (helper fails open) the allowlist is the ONLY guard, so the broader WRITE+EXEC screen (#dangerous_flag_form?) stays in force exactly as before. `DangerousPatterns.dangerous?` + the hardline floor are checked separately and ALWAYS prompt/deny regardless of this gate.

Returns:

  • (Boolean)


335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/rubino/security/approval_policy.rb', line 335

def dangerous_flag_form_present?(command_str)
  segments = ReadonlyCommands.split_segments(command_str.to_s)
  return true if segments.nil?

  # Gate on PROVEN enforcement, not mere presence: a helper that fails
  # open (kernel without Landlock) reports active? but does NOT confine,
  # so relaxing on active? would auto-run unconfined writes. enforcing?
  # runs the launcher once and only returns true when a write outside the
  # jail is actually denied. Present-but-not-enforcing ⇒ broad screen.
  enforcing = Sandbox.enforcing?
  segments.any? do |segment|
    tokens = Shellwords.split(segment)
    enforcing ? ReadonlyCommands.exec_flag_form?(tokens) : ReadonlyCommands.dangerous_flag_form?(tokens)
  rescue ArgumentError
    true
  end
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.



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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/rubino/security/approval_policy.rb', line 121

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. SECRET-FILE WRITE GATE. 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 writes the secret;
  #     denied → refused) and into a FAIL-CLOSED block when headless
  #     (:noninteractive). Runs ABOVE the allow fast-paths (steps 6/6b/9)
  #     and BELOW yolo (step 3) so a --yolo operator who opted into full
  #     file trust isn't re-prompted.
  #
  #     READING a secret (read/grep/glob) is NOT gated: it is allowed
  #     unprompted like any broad read (#406), matching the field norm
  #     (Claude Code / Codex / aider / Windsurf / LangChain all allow
  #     secret reads; #480). The threat model is exfil/clobber, not the
  #     agent reading — so only the write side stays gated here.
  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.
  #
  # 8c. Code-execution tool symmetry. Under dangerous_only, arbitrary safe
  #    `shell` runs unprompted (step 7-8), yet the dedicated `ruby` tool
  #    is :medium and would fall through to step 9 -> :ask — an INVERSION:
  #    a dedicated eval tool gated HARDER than the raw shell it would
  #    otherwise be driven through. The field norm (Claude Code auto-mode,
  #    Codex full-auto, aider) auto-runs code without prompting. So under
  #    dangerous_only it is non-prompting too, aligned AT MOST to the
  #    safe-shell tier (see CODE_EXEC_TOOLS).
  #    Deny-class checks (hardline step 1, permissions:deny step 2, doom
  #    step 4) all ran first; confirm_all (non-default) still routes them
  #    through step 9 -> :ask unchanged.
  return :allow if @confirm_policy == :dangerous_only && dangerous_only_auto_allowed?(tool)

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



384
385
386
387
388
389
390
391
392
393
# File 'lib/rubino/security/approval_policy.rb', line 384

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)


408
409
410
411
412
413
# File 'lib/rubino/security/approval_policy.rb', line 408

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)



444
445
446
# File 'lib/rubino/security/approval_policy.rb', line 444

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.



398
399
400
401
402
403
# File 'lib/rubino/security/approval_policy.rb', line 398

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 WRITES a secret/credential path and so must be approval-gated. For write/edit/multi_edit the single target is resolved from file_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. (Reads are NOT gated — see #decide step 5b.)

Returns:

  • (Boolean)


359
360
361
362
363
# File 'lib/rubino/security/approval_policy.rb', line 359

def secret_file_access?(tool, arguments)
  return false unless 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 write tool will touch. apply_patch yields one per hunk target; every other gated tool yields its single file_path.



367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/rubino/security/approval_policy.rb', line 367

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 for a DangerousPattern OR a dangerous WRITE/EXEC flag-form, else :allow.

The flag-form screen (#dangerous_flag_form_present?) is the NARROW companion to DangerousPatterns: under the shipped dangerous_only default, patterns alone let genuinely dangerous flag-forms (‘git -c alias.x=!cmd`, `python3 -c ’…‘`, `sed -i`, `find -delete`, `tee FILE`) auto-run unprompted (arbitrary write/RCE), while ordinary script/filter invocations (`python test.py`, `sed ’s/a/b/‘`) must keep running without a prompt for an acceptable coding-agent UX.



313
314
315
316
317
# File 'lib/rubino/security/approval_policy.rb', line 313

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

  dangerous?(command_str) || dangerous_flag_form_present?(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)


294
295
296
297
298
299
# File 'lib/rubino/security/approval_policy.rb', line 294

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

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