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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/ask/agent/exit_plan_mode.rb +1 -1
- data/lib/ask/agent/session.rb +7 -7
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +4 -7
- metadata +15 -5
- data/lib/ask/agent/approval_queue.rb +0 -249
- data/lib/ask/agent/policies/approval_policy.rb +0 -114
- data/lib/ask/agent/policies/permission_rules.rb +0 -130
- data/lib/ask/agent/policies/permissions.rb +0 -86
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6cbbcaccaadc0c18309984778974b4bcd63266acfd5329b855bfe8549ceed08b
|
|
4
|
+
data.tar.gz: e57d10ac194c5fcd6811842955527da4aa1a029a8c1855cb386851230e1335b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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)
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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::
|
|
273
|
+
# @return [Ask::Permissions::ApprovalQueue, nil]
|
|
274
274
|
attr_reader :approval_queue
|
|
275
|
-
# @return [Ask::
|
|
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::
|
|
907
|
+
queue = if approval.is_a?(Ask::Permissions::ApprovalQueue)
|
|
908
908
|
approval
|
|
909
|
-
elsif policy_opts[:queue].is_a?(Ask::
|
|
909
|
+
elsif policy_opts[:queue].is_a?(Ask::Permissions::ApprovalQueue)
|
|
910
910
|
policy_opts[:queue]
|
|
911
911
|
else
|
|
912
|
-
Ask::
|
|
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::
|
|
937
|
+
policy = Ask::Permissions::ApprovalPolicy.new(
|
|
938
938
|
queue: queue,
|
|
939
939
|
require_approval: policy_opts[:require_approval],
|
|
940
940
|
rules: policy_opts[:rules],
|
data/lib/ask/agent/version.rb
CHANGED
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
|
-
#
|
|
33
|
-
#
|
|
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.
|
|
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
|