ask-agent 0.40.29 → 0.40.30

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b55a3e7db1b87fa2e915014f6e8a787b24fe60df99602b14b95da3bb06fdf3a5
4
- data.tar.gz: 57017799b4dfb6ab2f842212ba9eed1321bcd61c3872b92a5347a85e98f5ace7
3
+ metadata.gz: 6cbbcaccaadc0c18309984778974b4bcd63266acfd5329b855bfe8549ceed08b
4
+ data.tar.gz: e57d10ac194c5fcd6811842955527da4aa1a029a8c1855cb386851230e1335b7
5
5
  SHA512:
6
- metadata.gz: ef7705c41d359caed21b9424f09ec1aaf6164b3f0d773b78fdbfe0173a7f56dc1bff0af819d6aedb237fb45bdf990246282c2bfa4e1a64864df2d639f6c6d8e3
7
- data.tar.gz: '08aed23b7e951f35182a683d52ae2c67d534fb2ccf97d2c1a6fc99ec505e49ce88be55225b9250eae6a1fb8d18e6aaaeb17994cb0ddf11b207a9a34fa14231c9'
6
+ metadata.gz: c82139550f7fceacc363517c898f904a63900ba886db8f41532818721892a7a4e6e7e06b8cba78757831bed9846849238a3561032ba9e868f98e370310d8b8b3
7
+ data.tar.gz: 0eb17641350c57e697802700645edb9cdd271c2313b90a625a97ddf756f1fc4ca3dde68643aa7c5336de3bcdbee445e712673fe8b9bac709bc8199ae819f47b7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@
4
4
 
5
5
  - **`Session#run(runtime_event_sink:)` forwards ask-runtime tool lifecycle events** through the agent loop and recursive turns to `ToolExecutor#execute_batch`.
6
6
 
7
+ ### Changed
8
+
9
+ - **Permission machinery extracted to the `ask-permissions` gem.**
10
+ `Ask::Agent::ApprovalQueue`, `Ask::Agent::Policies::ApprovalPolicy`,
11
+ `Ask::Agent::Policies::PermissionRules`, and
12
+ `Ask::Agent::Policies::Permissions` are removed from ask-agent; the same
13
+ classes now ship as `Ask::Permissions::ApprovalQueue`,
14
+ `Ask::Permissions::ApprovalPolicy`, `Ask::Permissions::PermissionRules`,
15
+ and `Ask::Permissions::Permissions` behind `require "ask/permissions"`.
16
+ Session keeps its agent-specific wiring — the `approval:` option still
17
+ builds the queue, wires on_approve/on_reject/on_submit callbacks, and
18
+ prepends the policy hook; the plan queue is unchanged apart from the
19
+ constant. Requires the new runtime dependency `ask-permissions >= 0.1.0`.
20
+
7
21
  ### Fixed
8
22
 
9
23
  - **`SessionAdapter.resume` surfaces every ask-session miss as
@@ -20,7 +20,7 @@ module Ask
20
20
 
21
21
  param :plan, type: :string, desc: "The plan you propose to execute", required: true
22
22
 
23
- # @param plan_queue [Ask::Agent::ApprovalQueue] queue carrying plan
23
+ # @param plan_queue [Ask::Permissions::ApprovalQueue] queue carrying plan
24
24
  # approvals; the session wires approve/reject callbacks
25
25
  # @param on_submit [Proc, nil] called with the plan text when the plan
26
26
  # is submitted (used to emit PlanProposed)
@@ -166,7 +166,7 @@ module Ask
166
166
  else
167
167
  %w[read glob grep web_search]
168
168
  end
169
- @plan_queue = ApprovalQueue.new(
169
+ @plan_queue = Ask::Permissions::ApprovalQueue.new(
170
170
  on_approve: ->(action) { approve_plan(action) },
171
171
  on_reject: ->(action) { reject_plan(action) },
172
172
  # Same race closure as the tool approval queue: register the
@@ -270,9 +270,9 @@ module Ask
270
270
  # created without approval support. Use it to inspect pending actions
271
271
  # and approve/reject them.
272
272
  #
273
- # @return [Ask::Agent::ApprovalQueue, nil]
273
+ # @return [Ask::Permissions::ApprovalQueue, nil]
274
274
  attr_reader :approval_queue
275
- # @return [Ask::Agent::ApprovalQueue, nil] queue carrying plan
275
+ # @return [Ask::Permissions::ApprovalQueue, nil] queue carrying plan
276
276
  # approvals (only when plan mode is enabled)
277
277
  attr_reader :plan_queue
278
278
  # @return [Ask::Agent::TodoList, nil] session task list (only when
@@ -904,12 +904,12 @@ end
904
904
 
905
905
  policy_opts = approval.is_a?(Hash) ? approval : {}
906
906
 
907
- queue = if approval.is_a?(Ask::Agent::ApprovalQueue)
907
+ queue = if approval.is_a?(Ask::Permissions::ApprovalQueue)
908
908
  approval
909
- elsif policy_opts[:queue].is_a?(Ask::Agent::ApprovalQueue)
909
+ elsif policy_opts[:queue].is_a?(Ask::Permissions::ApprovalQueue)
910
910
  policy_opts[:queue]
911
911
  else
912
- Ask::Agent::ApprovalQueue.new(
912
+ Ask::Permissions::ApprovalQueue.new(
913
913
  auto_approve: policy_opts[:auto_approve],
914
914
  on_approve: ->(action) { apply_approved_action(action) },
915
915
  on_reject: ->(action) { reject_pending_action(action) }
@@ -934,7 +934,7 @@ end
934
934
  })
935
935
  }
936
936
 
937
- policy = Ask::Agent::Policies::ApprovalPolicy.new(
937
+ policy = Ask::Permissions::ApprovalPolicy.new(
938
938
  queue: queue,
939
939
  require_approval: policy_opts[:require_approval],
940
940
  rules: policy_opts[:rules],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.40.29"
5
+ VERSION = "0.40.30"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -9,6 +9,7 @@ require "ask-llm-providers"
9
9
  require "ask-tools"
10
10
  require "ask-state-providers"
11
11
  require "ask-runtime"
12
+ require "ask/permissions"
12
13
 
13
14
  module Ask
14
15
  module Agent
@@ -29,15 +30,12 @@ module Ask
29
30
  # swap them freely (see Ask::Agent::Hooks for the seam).
30
31
  #
31
32
  # A policy is NOT core machinery. Core mechanisms live on Session — the
32
- # approval queue, the :pending result status, and the `approval: true`
33
- # option are core; Policies::ApprovalPolicy is the reference
34
- # classification policy wired on top of them.
33
+ # :pending result status and the `approval: true` option are core;
34
+ # Ask::Permissions::ApprovalPolicy (from the ask-permissions gem) is the
35
+ # reference classification policy wired on top of them.
35
36
  module Policies
36
- autoload :Permissions, "ask/agent/policies/permissions"
37
37
  autoload :RateLimiter, "ask/agent/policies/rate_limiter"
38
38
  autoload :AuditLog, "ask/agent/policies/audit_log"
39
- autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
40
- autoload :PermissionRules, "ask/agent/policies/permission_rules"
41
39
  end
42
40
 
43
41
  autoload :ToolCallRepair, "ask/agent/tool_call_repair"
@@ -223,7 +221,6 @@ require_relative "agent/evaluator"
223
221
  require_relative "agent/tool_executor"
224
222
  require_relative "agent/compactor"
225
223
  require_relative "agent/hooks"
226
- require_relative "agent/approval_queue"
227
224
  require_relative "agent/configuration"
228
225
  require_relative "agent/meta_agent"
229
226
  require_relative "agent/persistence/base"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.29
4
+ version: 0.40.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - ">="
136
136
  - !ruby/object:Gem::Version
137
137
  version: 0.1.2
138
+ - !ruby/object:Gem::Dependency
139
+ name: ask-permissions
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 0.1.0
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 0.1.0
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: minitest
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -192,7 +206,6 @@ files:
192
206
  - exe/askr
193
207
  - lib/ask-agent.rb
194
208
  - lib/ask/agent.rb
195
- - lib/ask/agent/approval_queue.rb
196
209
  - lib/ask/agent/artifact_store.rb
197
210
  - lib/ask/agent/chat.rb
198
211
  - lib/ask/agent/checkpoint_store.rb
@@ -221,11 +234,8 @@ files:
221
234
  - lib/ask/agent/output_read.rb
222
235
  - lib/ask/agent/persistence/base.rb
223
236
  - lib/ask/agent/persistence/in_memory.rb
224
- - lib/ask/agent/policies/approval_policy.rb
225
237
  - lib/ask/agent/policies/audit_log.rb
226
238
  - lib/ask/agent/policies/audit_log/active_record_writer.rb
227
- - lib/ask/agent/policies/permission_rules.rb
228
- - lib/ask/agent/policies/permissions.rb
229
239
  - lib/ask/agent/policies/rate_limiter.rb
230
240
  - lib/ask/agent/reflector.rb
231
241
  - lib/ask/agent/scheduler.rb
@@ -1,249 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Agent
5
- # A queue of tool actions awaiting human approval.
6
- #
7
- # When an agent calls a tool that requires approval, the action is queued
8
- # instead of executed — the agent gets a pending result and continues.
9
- # The user approves or rejects actions later, in bulk or one-by-one.
10
- #
11
- # queue = Ask::Agent::ApprovalQueue.new
12
- # id = queue.submit(tool_call_id: "call_1", tool_name: "send_email",
13
- # args: { "to" => "x@y.com" }, auto_approvable: false)
14
- # queue.pending_actions # => [{id: 1, tool_name: "send_email", ...}]
15
- # queue.approve(1) # applies action 1 via the on_approve callback
16
- # queue.reject(1)
17
- #
18
- # Auto-approval is a dual signal: the action must be marked
19
- # +auto_approvable+ (by the tool) AND the queue must have a matching
20
- # auto-approval rule enabled by the user. Otherwise the action queues for
21
- # human review.
22
- #
23
- # Actions are applied in id order and the drainer never applies past a
24
- # non-auto-approvable (manual) gate — nothing is silently approved.
25
- class ApprovalQueue
26
- # One queued action.
27
- #
28
- # @!attribute [r] id
29
- # @return [Integer] sequential id assigned by the queue
30
- # @!attribute [r] tool_call_id
31
- # @return [String] the original tool call id from the LLM
32
- # @!attribute [r] tool_name
33
- # @return [String] tool name
34
- # @!attribute [r] args
35
- # @return [Hash] arguments to pass to the tool when applied
36
- # @!attribute [r] auto_approvable
37
- # @return [Boolean] per-action verdict (tool's declaration)
38
- # @!attribute [r] status
39
- # @return [Symbol] :pending, :applying, :approved, :rejected
40
- # @!attribute [r] submitted_at
41
- # @return [Time]
42
- # @!attribute [r] message
43
- # @return [String, nil] human-readable description of the action
44
- Action = Data.define(:id, :tool_call_id, :tool_name, :args,
45
- :auto_approvable, :status, :submitted_at, :message)
46
-
47
- # Create a new approval queue.
48
- #
49
- # @param on_approve [Proc, nil] called with an {Action} when it is
50
- # approved and applied. The session wires this to execute the real
51
- # tool call.
52
- # @param on_reject [Proc, nil] called with an {Action} when it is
53
- # rejected. The session wires this to notify the conversation.
54
- # @param auto_approve [Hash{String => Boolean}, nil] user-enabled
55
- # auto-approval rules keyed by tool name. An action is auto-applied
56
- # only when its tool is listed here with +true+ AND the action itself
57
- # is marked auto_approvable.
58
- # @!attribute [rw] on_approve
59
- # Called with an {Action} when it is approved and applied. The
60
- # session wires this to execute the real tool call; can be replaced
61
- # after construction (e.g. by queue subclasses that also emit events).
62
- # @!attribute [rw] on_reject
63
- # Called with an {Action} when it is rejected.
64
- # @!attribute [rw] on_submit
65
- # Called with the new {Action} when it is submitted — BEFORE the
66
- # auto-approval drain runs, so subscribers can register the pending
67
- # call before it is applied. The session wires this to register the
68
- # pending tool call, closing the race where an approval lands while
69
- # the executor is still in flight.
70
- attr_accessor :on_approve, :on_reject, :on_submit
71
-
72
- def initialize(on_approve: nil, on_reject: nil, auto_approve: nil, on_submit: nil)
73
- @on_approve = on_approve
74
- @on_reject = on_reject
75
- @on_submit = on_submit
76
- @auto_approve = auto_approve || {}
77
- @actions = {}
78
- @next_id = 1
79
- @mutex = Mutex.new
80
- @draining = false
81
- end
82
-
83
- # Queue a tool action for approval.
84
- #
85
- # @param tool_call_id [String] original tool call id from the LLM
86
- # @param tool_name [String] tool name
87
- # @param args [Hash] arguments for the tool
88
- # @param auto_approvable [Boolean] whether the tool permits auto-approval
89
- # @param message [String, nil] human-readable description
90
- # @return [Integer] the action id
91
- def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
92
- action = @mutex.synchronize do
93
- action = Action.new(
94
- id: @next_id,
95
- tool_call_id: tool_call_id,
96
- tool_name: tool_name,
97
- args: args || {},
98
- auto_approvable: auto_approvable,
99
- status: :pending,
100
- submitted_at: Time.now,
101
- message: message
102
- )
103
- @next_id += 1
104
- @actions[action.id] = action
105
- action
106
- end
107
-
108
- # Notify BEFORE the drain: an auto-approvable action is applied
109
- # (and possibly completed) inside drain, and listeners need to
110
- # observe the submission first.
111
- @on_submit&.call(action)
112
-
113
- drain
114
- action.id
115
- end
116
-
117
- # All actions still awaiting a decision, in id order.
118
- #
119
- # @return [Array<Action>]
120
- def pending_actions
121
- @mutex.synchronize do
122
- @actions.values.select { |a| a.status == :pending }.sort_by(&:id)
123
- end
124
- end
125
-
126
- # @param id [Integer]
127
- # @return [Boolean] whether the action is still awaiting a decision
128
- def pending?(id)
129
- @mutex.synchronize do
130
- a = @actions[id]
131
- a && a.status == :pending
132
- end
133
- end
134
-
135
- # @return [Boolean] true while at least one action awaits a decision
136
- def any_pending?
137
- pending_actions.any?
138
- end
139
-
140
- # Look up an action by id.
141
- #
142
- # @param id [Integer]
143
- # @return [Action, nil]
144
- def [](id)
145
- @mutex.synchronize { @actions[id] }
146
- end
147
-
148
- # Approve specific actions (by id), applying them in id order.
149
- #
150
- # @param ids [Array<Integer>]
151
- # @return [Array<Action>] the actions that were approved
152
- def approve(*ids)
153
- actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
154
- actions.select! { |a| a.status == :pending }
155
- actions.sort_by!(&:id)
156
- actions.each { |a| apply(a) }
157
- actions
158
- end
159
-
160
- # Reject specific actions (by id).
161
- #
162
- # @param ids [Array<Integer>]
163
- # @return [Array<Action>] the actions that were rejected
164
- def reject(*ids)
165
- actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
166
- actions.select! { |a| a.status == :pending }
167
- actions.sort_by!(&:id)
168
- actions.each { |a| reject_action(a) }
169
- actions
170
- end
171
-
172
- # Approve all currently pending actions, in id order.
173
- #
174
- # @return [Array<Action>]
175
- def approve_all
176
- approve(*pending_actions.map(&:id))
177
- end
178
-
179
- # Reject all currently pending actions, in id order.
180
- #
181
- # @return [Array<Action>]
182
- def reject_all
183
- reject(*pending_actions.map(&:id))
184
- end
185
-
186
- private
187
-
188
- # Apply eligible pending actions in id order, stopping at the first
189
- # action that is NOT auto-eligible (a manual gate) — nothing is
190
- # silently applied past a human review point. Single-flight guard so
191
- # concurrent drains cannot double-apply.
192
- def drain
193
- return if @mutex.synchronize { @draining }
194
- @mutex.synchronize { @draining = true }
195
-
196
- begin
197
- loop do
198
- action = @mutex.synchronize do
199
- @actions.values.select { |a| a.status == :pending }
200
- .sort_by(&:id)
201
- .first
202
- end
203
- break unless action
204
-
205
- if auto_approvable?(action)
206
- apply(action)
207
- else
208
- break # manual gate — stop, never skip ahead
209
- end
210
- end
211
- ensure
212
- @mutex.synchronize { @draining = false }
213
- end
214
- end
215
-
216
- def auto_approvable?(action)
217
- action.auto_approvable && @auto_approve[action.tool_name] == true
218
- end
219
-
220
- def apply(action)
221
- claimed = @mutex.synchronize do
222
- return false unless action.status == :pending
223
- @actions[action.id] = action.with(status: :applying)
224
- end
225
- @on_approve&.call(claimed)
226
- @mutex.synchronize do
227
- @actions[action.id] = claimed.with(status: :approved)
228
- end
229
- true
230
- rescue StandardError
231
- # Failed apply: leave the action pending so the user can retry or
232
- # reject it. Re-raise so the caller (approve/drain) surfaces it.
233
- @mutex.synchronize do
234
- @actions[action.id] = action.with(status: :pending) if @actions[action.id]&.status == :applying
235
- end
236
- raise
237
- end
238
-
239
- def reject_action(action)
240
- return false unless @mutex.synchronize { action.status == :pending }
241
- @on_reject&.call(action)
242
- @mutex.synchronize do
243
- @actions[action.id] = action.with(status: :rejected)
244
- end
245
- true
246
- end
247
- end
248
- end
249
- end
@@ -1,114 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Agent
5
- module Policies
6
- # Approval policy hook: classifies tool calls as approval-required and
7
- # routes them into an {Ask::Agent::ApprovalQueue}.
8
- #
9
- # Wire it as a +before_tool+ hook on a Session (or enable it with
10
- # `Session.new(approval: true)`), and any tool whose class declares
11
- # `approval_required true` — or whose name matches the policy's
12
- # rule-based lists — is queued for human approval instead of executed.
13
- # The agent receives a pending result and continues; the tool runs only
14
- # after a human approves it.
15
- #
16
- # @example
17
- # queue = Ask::Agent::ApprovalQueue.new
18
- # policy = Ask::Agent::Policies::ApprovalPolicy.new(queue: queue)
19
- # session = Ask::Agent::Session.new(
20
- # model: "gpt-4o",
21
- # tools: [SendEmail],
22
- # hooks: { before_tool: [policy.method(:before_tool_call)] }
23
- # )
24
- #
25
- # # Later, when the user decides:
26
- # session.approval_queue.approve_all
27
- class ApprovalPolicy
28
- # @param queue [Ask::Agent::ApprovalQueue] the queue actions go into.
29
- # The queue owns the user-enabled auto-approval rules; this policy
30
- # only reports each tool's own declaration.
31
- # @param require_approval [Array<String, Regexp>, :all, nil] extra
32
- # rule-based classification on top of the tool's own declaration.
33
- # Strings match tool names exactly, Regexps match against the name,
34
- # and :all requires approval for every tool call.
35
- # @param rules [Ask::Agent::Policies::PermissionRules, nil] persisted
36
- # allow/ask/deny patterns. Rules classify first and take precedence
37
- # over tool declarations: :deny blocks, :allow proceeds without the
38
- # queue, :ask queues regardless of auto-approvable.
39
- # @param tools [Array<Object>, nil] the session's resolved tool
40
- # instances, used to read class-level declarations
41
- # (`approval_required`, `auto_approvable`). When nil, only the
42
- # rule-based lists classify calls.
43
- def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
44
- @queue = queue
45
- @require_approval = require_approval
46
- @rules = rules
47
- @tools = Array(tools)
48
- end
49
-
50
- # Hook entry point — matches the +before_tool+ hook signature.
51
- #
52
- # @param tool_call [Ask::Agent::ToolCallInfo]
53
- # @param _context [Hash]
54
- # @return [Hash] {action: :proceed} to run, {action: :pending,
55
- # action_id:, reason:} to queue for approval, or {action: :block,
56
- # reason:} to refuse
57
- def before_tool_call(tool_call, _context)
58
- case @rules&.classify(tool_call.name, tool_call.arguments)
59
- when :deny
60
- return { action: :block, reason: "Denied by permission rules: '#{tool_call.name}'" }
61
- when :allow
62
- return { action: :proceed }
63
- when :ask
64
- return queue_for_approval(tool_call, auto_approvable: false)
65
- end
66
-
67
- return { action: :proceed } unless approval_required?(tool_call.name)
68
-
69
- queue_for_approval(tool_call, auto_approvable: tool_auto_approvable?(tool_call.name))
70
- end
71
-
72
- private
73
-
74
- def queue_for_approval(tool_call, auto_approvable:)
75
- action_id = @queue.submit(
76
- tool_call_id: tool_call.id,
77
- tool_name: tool_call.name,
78
- args: tool_call.arguments,
79
- auto_approvable: auto_approvable,
80
- message: "Calling \"#{tool_call.name}\" requires approval"
81
- )
82
-
83
- { action: :pending, action_id: action_id, reason: "Tool '#{tool_call.name}' requires approval" }
84
- end
85
-
86
- def approval_required?(tool_name)
87
- return true if @require_approval == :all
88
-
89
- rule_matches?(@require_approval, tool_name) || tool_declares_approval?(tool_name)
90
- end
91
-
92
- def rule_matches?(rules, tool_name)
93
- Array(rules).any? do |rule|
94
- rule == tool_name || (rule.is_a?(Regexp) && rule.match?(tool_name))
95
- end
96
- end
97
-
98
- def tool_declares_approval?(tool_name)
99
- tool = tool_for(tool_name)
100
- tool&.respond_to?(:approval_required?) && tool.approval_required?
101
- end
102
-
103
- def tool_auto_approvable?(tool_name)
104
- tool = tool_for(tool_name)
105
- tool&.respond_to?(:auto_approvable?) && tool.auto_approvable?
106
- end
107
-
108
- def tool_for(tool_name)
109
- @tools.find { |t| t.respond_to?(:name) && t.name == tool_name }
110
- end
111
- end
112
- end
113
- end
114
- end
@@ -1,130 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Ask
6
- module Agent
7
- module Policies
8
- # Persisted, matchable allow/ask/deny patterns for tool calls.
9
- #
10
- # Rules classify a tool call before it executes or prompts:
11
- #
12
- # rules = Ask::Agent::Policies::PermissionRules.new do |r|
13
- # r.allow :bash, /^git (pull|push|status)/
14
- # r.ask :bash, /^rm -rf/
15
- # r.deny :write, %r{/\.env(\.local)?$}
16
- # r.ask :destroy, :all
17
- # end
18
- #
19
- # session = Ask::Agent::Session.new(
20
- # model: "gpt-4o",
21
- # approval: { rules: rules }
22
- # )
23
- #
24
- # Classification is first-match-wins, in declaration order: :allow runs
25
- # the tool, :ask queues it for human approval, :deny blocks it. Rules
26
- # take precedence over a tool's own `approval_required` / `auto_approvable`
27
- # declarations — they are explicit user intent.
28
- #
29
- # Dangerous-rule guard: an :allow rule for a code-executing tool (bash,
30
- # code, repl) whose argument pattern is unrestricted would let the model
31
- # run anything without asking. Such rules are downgraded to :ask unless
32
- # the ruleset was created with `auto_allow_dangerous: true` — "approve
33
- # once, remember the pattern" must not become "approve everything".
34
- class PermissionRules
35
- # A single rule: tool pattern + optional argument pattern + decision.
36
- Rule = Data.define(:tool_pattern, :argument_pattern, :decision) do
37
- def matches?(tool_name, arguments)
38
- tool_matches?(tool_name) && argument_matches?(arguments)
39
- end
40
-
41
- def tool_matches?(tool_name)
42
- case tool_pattern
43
- when :all then true
44
- when Regexp then tool_pattern.match?(tool_name.to_s)
45
- else tool_pattern.to_s == tool_name.to_s
46
- end
47
- end
48
-
49
- def argument_matches?(arguments)
50
- return true if argument_pattern.nil?
51
-
52
- text = arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments.to_s
53
- case argument_pattern
54
- when Regexp then argument_pattern.match?(text)
55
- else text.include?(argument_pattern.to_s)
56
- end
57
- end
58
-
59
- def universal?
60
- argument_pattern.nil?
61
- end
62
- end
63
-
64
- # Tools that execute arbitrary code — an unrestricted :allow rule on
65
- # any of these is dangerous.
66
- DANGEROUS_TOOLS = %i[bash code repl].freeze
67
-
68
- # @param auto_allow_dangerous [Boolean] keep unrestricted :allow
69
- # rules on code-executing tools (default false — they downgrade to
70
- # :ask)
71
- def initialize(auto_allow_dangerous: false, &block)
72
- @auto_allow_dangerous = auto_allow_dangerous
73
- @rules = []
74
- instance_eval(&block) if block
75
- end
76
-
77
- # DSL — declare rules in priority order (first match wins).
78
- def allow(tool_pattern, argument_pattern = nil)
79
- add(:allow, tool_pattern, argument_pattern)
80
- end
81
-
82
- def ask(tool_pattern, argument_pattern = nil)
83
- add(:ask, tool_pattern, argument_pattern)
84
- end
85
-
86
- def deny(tool_pattern, argument_pattern = nil)
87
- add(:deny, tool_pattern, argument_pattern)
88
- end
89
-
90
- # @return [Array<Rule>] declared rules, in order
91
- def rules = @rules.dup
92
-
93
- # Classify a tool call.
94
- #
95
- # @param tool_name [String]
96
- # @param arguments [Hash, String, nil] tool arguments (hash or JSON)
97
- # @return [Symbol, nil] :allow, :ask, :deny — or nil when no rule
98
- # matches
99
- def classify(tool_name, arguments = nil)
100
- rule = @rules.find { |r| r.matches?(tool_name, arguments) }
101
- return nil unless rule
102
-
103
- if rule.decision == :allow && dangerous?(rule) && !@auto_allow_dangerous
104
- :ask
105
- else
106
- rule.decision
107
- end
108
- end
109
-
110
- # @return [Array<Rule>] rules that would allow unrestricted execution
111
- # of a code-executing tool
112
- def dangerous_rules
113
- @rules.select { |r| dangerous?(r) }
114
- end
115
-
116
- private
117
-
118
- def add(decision, tool_pattern, argument_pattern)
119
- @rules << Rule.new(tool_pattern, argument_pattern, decision)
120
- end
121
-
122
- def dangerous?(rule)
123
- return false unless rule.decision == :allow && rule.universal?
124
-
125
- rule.tool_pattern == :all || DANGEROUS_TOOLS.any? { |t| rule.tool_matches?(t) }
126
- end
127
- end
128
- end
129
- end
130
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Agent
5
- module Policies
6
- class Permissions
7
- DEFAULT_TOOLS = %i[write edit bash destroy].freeze
8
-
9
- ACCESS_MODES = {
10
- full_access: { blocked_tools: [] }.freeze,
11
- ask_before_changes: { blocked_tools: DEFAULT_TOOLS }.freeze,
12
- read_only: { blocked_tools: %i[write edit bash destroy] }.freeze
13
- }.freeze
14
-
15
- def initialize(mode: nil, blocked_tools: nil, timeout: nil)
16
- @mode = mode
17
- @timeout = timeout
18
- @pending = {}
19
- @mutex = Mutex.new
20
-
21
- @blocked_tools = if mode
22
- config = ACCESS_MODES[mode]
23
- raise ArgumentError, "Unknown access mode: #{mode.inspect}. Valid: #{ACCESS_MODES.keys.join(', ')}" unless config
24
- config[:blocked_tools].dup
25
- elsif blocked_tools
26
- Array(blocked_tools).map(&:to_sym)
27
- else
28
- DEFAULT_TOOLS.dup
29
- end
30
- end
31
-
32
- def before_tool_call(tool_call, _context)
33
- return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
34
-
35
- if approved?(tool_call)
36
- { action: :proceed }
37
- else
38
- request_approval(tool_call)
39
- end
40
- end
41
-
42
- def approve(tool_call_id)
43
- @mutex.synchronize do
44
- entry = @pending[tool_call_id]
45
- return false unless entry
46
- entry[:approved] = true
47
- end
48
- end
49
-
50
- def pending_approvals
51
- @mutex.synchronize { @pending.values.reject { |e| e[:approved] } }
52
- end
53
-
54
- private
55
-
56
- def approved?(tool_call)
57
- @mutex.synchronize do
58
- key = tool_call.id
59
- entry = @pending[key]
60
- return false unless entry
61
-
62
- if @timeout && (Time.now - entry[:created_at]) > @timeout
63
- @pending.delete(key)
64
- return false
65
- end
66
-
67
- entry[:approved]
68
- end
69
- end
70
-
71
- def request_approval(tool_call)
72
- @mutex.synchronize do
73
- @pending[tool_call.id] = {
74
- tool_call: tool_call,
75
- approved: false,
76
- created_at: Time.now
77
- }
78
- end
79
-
80
- warn "[Permissions] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
81
- { action: :block, reason: "Tool '#{tool_call.name}' requires approval" }
82
- end
83
- end
84
- end
85
- end
86
- end