ask-permissions 0.1.0 → 0.2.0
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 +13 -1
- data/README.md +73 -5
- data/VERSIONING.md +2 -1
- data/lib/ask/permissions/approval_policy.rb +75 -11
- data/lib/ask/permissions/approval_queue.rb +108 -16
- data/lib/ask/permissions/plan_mode_policy.rb +25 -0
- data/lib/ask/permissions/session_permission_grants.rb +124 -0
- data/lib/ask/permissions/version.rb +1 -1
- data/lib/ask/permissions.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af1fd0c9368ebb102f0ca805b7303d781bf29e862ea19145fc4efcf24025cbe9
|
|
4
|
+
data.tar.gz: 4ce78f5aaf769b55fde844e1de746c319bcbc33001e7e9cb1f90a25e1c9a4850
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab3cd320947d7426dfececa4f73625df5d073fc751249521ac93e4104eaaa36d43512d30f6b9c92e25be60c9eafc26b7327f0bc9593ca2f80d614c7cba5efc44
|
|
7
|
+
data.tar.gz: 29e4857b244341160b6fd856c056c5fc3f098bbf350b9f07424c8ab8b3c90de3bac55c505c5b0e2d461d2a5efa22a73e9bc3c943a989807bb747771b3d8e1982
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add a reusable plan-mode gate and make tool-declared human approval requirements override ordinary allow rules.
|
|
15
|
+
- Add JSON-safe pending approval snapshots for hosts that resume sessions after a restart.
|
|
16
|
+
- Add capability-aware approval modes for read-only, ask-before-changes, and full-access sessions.
|
|
17
|
+
- Add approval resolution scopes (`once`/`session`/`project`) on explicit approvals and rejection feedback, carried on the resolved `Action` for the host to apply. Auto-approvals report `once`; snapshots stay pending-only.
|
|
18
|
+
- Add thread-safe `SessionPermissionGrants` for whole-tool session-scoped grants with versioned JSON-safe snapshot/restore, wired into `ApprovalPolicy` via optional `session_grants:` without touching project rules.
|
|
19
|
+
- Add optional host-owned `project_grants:` collaborator (`granted?(tool_name)`) to `ApprovalPolicy`; a matching session or project grant bypasses ordinary ask rules, `require_approval`/metadata, elevated-risk prompts, and `ask_before_changes` side-effect prompts, without bypassing explicit deny, `always_ask?`, or `read_only`. No project store is shipped.
|
|
20
|
+
## [0.1.0] - 2026-09-23
|
|
9
21
|
|
|
10
22
|
### Added
|
|
11
23
|
|
data/README.md
CHANGED
|
@@ -12,6 +12,17 @@ Everything lives under the `Ask::Permissions` namespace:
|
|
|
12
12
|
| `ApprovalPolicy` | Hook adapter: consults rules, `require_approval`, and tool metadata, then enqueues through a queue. |
|
|
13
13
|
| `ApprovalQueue` | Stores pending `Action`s, auto-approves eligible work in order, fires one-argument callbacks. |
|
|
14
14
|
| `Permissions` | Optional mode gate (`nil` by default, or `:ask_before_changes` / `:read_only` / `:full_access`) with sticky approvals per `tool_call_id`. |
|
|
15
|
+
| `PlanModePolicy` | Allows only declared read-only tools and the plan-submission tool while plan mode is active. |
|
|
16
|
+
| `SessionPermissionGrants` | Thread-safe whole-tool grants scoped to the current session, with versioned JSON-safe snapshot/restore for durable resume. |
|
|
17
|
+
|
|
18
|
+
Tools that expose `always_ask?` cannot be approved by a matching ordinary
|
|
19
|
+
`allow` rule; their calls enter the human approval queue and cannot be
|
|
20
|
+
auto-approved.
|
|
21
|
+
|
|
22
|
+
`ApprovalQueue#snapshot` and `#restore_pending` let a session host persist
|
|
23
|
+
pending approvals alongside its durable session state. Restoring does not
|
|
24
|
+
re-emit submission events or auto-approve work; the host remains responsible
|
|
25
|
+
for replaying its event log and reconnecting the restored queue to its session.
|
|
15
26
|
|
|
16
27
|
## Installation
|
|
17
28
|
|
|
@@ -190,10 +201,20 @@ policy = Ask::Permissions::ApprovalPolicy.new(
|
|
|
190
201
|
queue: queue, # required
|
|
191
202
|
rules: rules, # optional
|
|
192
203
|
require_approval: ["bash", /^write_/], # optional
|
|
193
|
-
tools: {"fetch" => fetch_tool}
|
|
204
|
+
tools: {"fetch" => fetch_tool}, # optional registry
|
|
205
|
+
session_grants: session_grants, # optional SessionPermissionGrants
|
|
206
|
+
project_grants: project_grants # optional host-owned collaborator responding to granted?(tool_name)
|
|
194
207
|
)
|
|
195
208
|
```
|
|
196
209
|
|
|
210
|
+
The optional `mode:` applies declared tool capabilities consistently: `:read_only`
|
|
211
|
+
blocks side-effecting or undeclared-scope tools, `:ask_before_changes` queues
|
|
212
|
+
them for a person, and `:full_access` bypasses ordinary risk gates. Explicit
|
|
213
|
+
`ask`/`deny` rules still apply in every mode, and a tool's `always_ask?` remains
|
|
214
|
+
non-bypassable. Tools with `:high` or `:critical` risk are queued unless an
|
|
215
|
+
explicit allow rule or `:full_access` mode permits them; risk-gated approvals
|
|
216
|
+
are never auto-approved.
|
|
217
|
+
|
|
197
218
|
### The hook: `before_tool_call`
|
|
198
219
|
|
|
199
220
|
`before_tool_call(tool_call, context = nil)` expects `tool_call` to respond to `name`, `arguments`, and `id`. It returns exactly one of three shapes:
|
|
@@ -206,9 +227,15 @@ policy = Ask::Permissions::ApprovalPolicy.new(
|
|
|
206
227
|
|
|
207
228
|
Resolution order — the first layer with an opinion wins:
|
|
208
229
|
|
|
209
|
-
1.
|
|
210
|
-
2.
|
|
211
|
-
3. **
|
|
230
|
+
1. **Explicit `deny` rule** — always blocks with the exact reason `"Denied by permission rules: '<name>'"`. Neither session nor project grants bypass it.
|
|
231
|
+
2. **Tool `always_ask?`** — always enqueues with `auto_approvable: false` and cannot be bypassed by an `allow` rule, `:full_access`, or either grants collaborator.
|
|
232
|
+
3. **`:read_only` mode** — blocks tools whose side-effect scope is not `:none`. Neither session nor project grants bypass it.
|
|
233
|
+
4. **Explicit `allow` rule** — proceeds. An explicit `allow` therefore wins over `require_approval`.
|
|
234
|
+
5. **Session or project grant** (`session_grants.granted?(name)` or `project_grants.granted?(name)`) — proceeds, bypassing ordinary `ask` rules, `require_approval` / `approval_required?` metadata, `:high`/`:critical` risk gates, and `:ask_before_changes` side-effect prompts.
|
|
235
|
+
6. **Ordinary `ask` rule** — enqueues with `auto_approvable: false`.
|
|
236
|
+
7. **`:full_access` mode** — proceeds (except `always_ask?` above).
|
|
237
|
+
8. **`require_approval` / tool metadata / risk / `:ask_before_changes`** — enqueues as `:pending`; `auto_approvable` comes from the tool's `auto_approvable?` (risk and `:ask_before_changes` prompts never auto-approve).
|
|
238
|
+
9. **Default** — otherwise the call proceeds. An unconfigured policy allows everything.
|
|
212
239
|
|
|
213
240
|
`require_approval` accepts `nil`, `:all` (queue every tool), a `String`/`Symbol` (exact name), a `Regexp`, or an `Array` of those (any match).
|
|
214
241
|
|
|
@@ -232,7 +259,48 @@ queue.approve(action.id) # fires on_approve
|
|
|
232
259
|
queue.reject(action.id) # fires on_reject
|
|
233
260
|
```
|
|
234
261
|
|
|
235
|
-
Readers: `policy.queue`, `policy.rules`, `policy.require_approval`, `policy.tools`.
|
|
262
|
+
Readers: `policy.queue`, `policy.rules`, `policy.require_approval`, `policy.tools`, `policy.session_grants`, `policy.project_grants`.
|
|
263
|
+
|
|
264
|
+
### Session-scoped grants: `SessionPermissionGrants`
|
|
265
|
+
|
|
266
|
+
Grants whole-tool access for the current session only. They are in-memory, per-instance (sharing one grants object shares grants; separate objects are isolated), and never rewrite project rules:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
grants = Ask::Permissions::SessionPermissionGrants.new
|
|
270
|
+
grants.grant("bash") # Symbol or String; duplicate grants are idempotent
|
|
271
|
+
grants.granted?("bash") # => true
|
|
272
|
+
grants.revoke("bash") # => self; unknown tools are a noop
|
|
273
|
+
grants.granted_tools # => ["bash"] (sorted Strings)
|
|
274
|
+
grants.clear
|
|
275
|
+
|
|
276
|
+
policy = Ask::Permissions::ApprovalPolicy.new(queue: queue, require_approval: "bash", session_grants: grants)
|
|
277
|
+
policy.before_tool_call(tool_call, context) # => {action: :proceed} while granted
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Grants bypass ordinary `ask` rules, `require_approval` / `approval_required?`, high-risk prompts, and `:ask_before_changes` side-effect prompts. They never bypass an explicit `deny`, a tool's `always_ask?`, or `:read_only` mode.
|
|
281
|
+
|
|
282
|
+
### Project-scoped grants: `project_grants`
|
|
283
|
+
|
|
284
|
+
`ApprovalPolicy` also accepts an optional host-owned `project_grants:` collaborator. It only needs to respond to `granted?(tool_name)` — the host owns storage and persistence, so this gem ships no project store:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
policy = Ask::Permissions::ApprovalPolicy.new(
|
|
288
|
+
queue: queue,
|
|
289
|
+
require_approval: "bash",
|
|
290
|
+
session_grants: session_grants, # optional, nil by default
|
|
291
|
+
project_grants: project_grants # optional, nil by default
|
|
292
|
+
)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A matching grant from **either** collaborator bypasses the same ordinary ask gates listed above; neither can bypass an explicit `deny`, `always_ask?`, or `:read_only`. When either (or both) is `nil`, existing behavior is preserved. `SessionPermissionGrants` stays isolated and session-owned — sharing one instance shares session grants, separate instances do not, and project grants never mutate session grants or project rules.
|
|
296
|
+
|
|
297
|
+
For durable resume, persist `grants.snapshot` (`{version: 1, granted_tools: [...]}`) alongside session state and restore it later. Snapshots survive a JSON round-trip (symbol/string keys both accepted); invalid versions, non-Array payloads, or blank/non-String entries raise `ArgumentError`:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
snapshot = grants.snapshot
|
|
301
|
+
restored = Ask::Permissions::SessionPermissionGrants.from_snapshot(JSON.parse(JSON.generate(snapshot)))
|
|
302
|
+
restored.granted?("bash") # => true
|
|
303
|
+
```
|
|
236
304
|
|
|
237
305
|
### Auto-approval through the policy
|
|
238
306
|
|
data/VERSIONING.md
CHANGED
|
@@ -7,6 +7,7 @@ This repository follows the ask-rb (Ask gem) versioning convention: exact sequen
|
|
|
7
7
|
- Every release advances the version by **exactly one step**. Never skip a number.
|
|
8
8
|
- While pre-1.0 (`0.x`), an incompatible feature (API or behavior change) increments the **minor** digit by one: `0.1.0 -> 0.2.0`.
|
|
9
9
|
- Compatible fixes increment the **patch** digit by one: `0.1.0 -> 0.1.1`, `0.2.0 -> 0.2.1`.
|
|
10
|
+
- Patch numbers are single digits (`0` through `9`). After patch `9`, advance the minor digit by one and reset the patch to `0` (for example, `0.1.9 -> 0.2.0`); never publish a patch above `9`.
|
|
10
11
|
- Skipping is never allowed: `0.1.0 -> 0.3.0` or `0.1.0 -> 0.1.2` from a single release are both violations.
|
|
11
12
|
- The version source of truth is `lib/ask/permissions/version.rb`; the gemspec reads it from there.
|
|
12
13
|
|
|
@@ -19,4 +20,4 @@ This repository follows the ask-rb (Ask gem) versioning convention: exact sequen
|
|
|
19
20
|
|
|
20
21
|
- **All releases go through `gemchain`** from the ask-rb workspace. Never `rake release`, `gem push`, or any other manual publish.
|
|
21
22
|
- A release requires a **clean working tree**, **passing tests** (`bundle exec rake test`), and **version agreement** — `lib/ask/permissions/version.rb`, the `CHANGELOG.md` heading, and the built gemspec version must all name the same version.
|
|
22
|
-
-
|
|
23
|
+
- `0.1.0` was published through `gemchain` on 2026-09-23.
|
|
@@ -4,31 +4,48 @@ module Ask
|
|
|
4
4
|
module Permissions
|
|
5
5
|
# Hook adapter that consults rules, require_approval, and tool metadata, then enqueues through a queue.
|
|
6
6
|
class ApprovalPolicy
|
|
7
|
-
|
|
7
|
+
MODES = %i[full_access ask_before_changes read_only].freeze
|
|
8
|
+
SIDE_EFFECT_SCOPES = %i[none session workspace project system external unknown].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :queue, :require_approval, :rules, :tools, :mode, :session_grants, :project_grants
|
|
11
|
+
|
|
12
|
+
def initialize(queue:, require_approval: nil, rules: nil, tools: nil, mode: nil, session_grants: nil,
|
|
13
|
+
project_grants: nil)
|
|
14
|
+
raise ArgumentError, "Unknown permission mode: #{mode.inspect}" if mode && !MODES.include?(mode.to_sym)
|
|
8
15
|
|
|
9
|
-
def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
|
|
10
16
|
@queue = queue
|
|
11
17
|
@require_approval = require_approval
|
|
12
18
|
@rules = rules
|
|
13
19
|
@tools = tools
|
|
20
|
+
@mode = mode&.to_sym
|
|
21
|
+
@session_grants = session_grants
|
|
22
|
+
@project_grants = project_grants
|
|
14
23
|
end
|
|
15
24
|
|
|
16
25
|
def before_tool_call(tool_call, _context = nil)
|
|
17
26
|
name = tool_call.name.to_s
|
|
18
27
|
args = tool_call.arguments
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
rule_decision = rules&.classify(name, args)
|
|
30
|
+
return { action: :block, reason: "Denied by permission rules: '#{name}'" } if rule_decision == :deny
|
|
31
|
+
|
|
32
|
+
# A tool's explicit human-confirmation requirement is a hard safety
|
|
33
|
+
# boundary: an ordinary allow rule must not be able to bypass it.
|
|
34
|
+
return enqueue(tool_call, auto_approvable: false) if always_ask?(name)
|
|
35
|
+
|
|
36
|
+
if mode == :read_only && side_effect_scope(name) != :none
|
|
37
|
+
return { action: :block, reason: "Read-only mode blocks tools with side effects (#{name})" }
|
|
27
38
|
end
|
|
28
39
|
|
|
29
|
-
return { action: :proceed }
|
|
40
|
+
return { action: :proceed } if rule_decision == :allow
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
# In-session and project whole-tool grants bypass ordinary ask rules,
|
|
43
|
+
# approval_required gates, high-risk gates, and ask_before_changes
|
|
44
|
+
# side-effect prompts. They never bypass deny, always_ask, or
|
|
45
|
+
# read_only above.
|
|
46
|
+
return { action: :proceed } if granted?(name)
|
|
47
|
+
|
|
48
|
+
fallback_decision(tool_call, name, rule_decision)
|
|
32
49
|
end
|
|
33
50
|
|
|
34
51
|
def lookup(action_id)
|
|
@@ -59,6 +76,53 @@ module Ask
|
|
|
59
76
|
!!(tool && tool.respond_to?(:auto_approvable?) && tool.auto_approvable?)
|
|
60
77
|
end
|
|
61
78
|
|
|
79
|
+
def always_ask?(name)
|
|
80
|
+
tool = find_tool(name)
|
|
81
|
+
!!(tool && tool.respond_to?(:always_ask?) && tool.always_ask?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def session_granted?(name)
|
|
85
|
+
grants = session_grants
|
|
86
|
+
!!(grants && grants.respond_to?(:granted?) && grants.granted?(name))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def project_granted?(name)
|
|
90
|
+
grants = project_grants
|
|
91
|
+
!!(grants && grants.respond_to?(:granted?) && grants.granted?(name))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def granted?(name)
|
|
95
|
+
session_granted?(name) || project_granted?(name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fallback_decision(tool_call, name, rule_decision)
|
|
99
|
+
return enqueue(tool_call, auto_approvable: false) if rule_decision == :ask
|
|
100
|
+
return { action: :proceed } if mode == :full_access
|
|
101
|
+
|
|
102
|
+
if mode == :ask_before_changes && side_effect_scope(name) != :none
|
|
103
|
+
return enqueue(tool_call, auto_approvable: false)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return enqueue(tool_call, auto_approvable: false) if elevated_risk?(name)
|
|
107
|
+
return { action: :proceed } unless approval_required?(name)
|
|
108
|
+
|
|
109
|
+
enqueue(tool_call, auto_approvable: auto_approvable?(name))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def elevated_risk?(name)
|
|
113
|
+
tool = find_tool(name)
|
|
114
|
+
risk = tool.risk_level if tool&.respond_to?(:risk_level)
|
|
115
|
+
risk = risk.to_sym if risk.respond_to?(:to_sym)
|
|
116
|
+
%i[high critical].include?(risk)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def side_effect_scope(name)
|
|
120
|
+
tool = find_tool(name)
|
|
121
|
+
scope = tool.side_effect_scope if tool&.respond_to?(:side_effect_scope)
|
|
122
|
+
scope = scope.to_sym if scope.respond_to?(:to_sym)
|
|
123
|
+
SIDE_EFFECT_SCOPES.include?(scope) ? scope : :unknown
|
|
124
|
+
end
|
|
125
|
+
|
|
62
126
|
def find_tool(name)
|
|
63
127
|
case tools
|
|
64
128
|
when nil then nil
|
|
@@ -6,8 +6,11 @@ module Ask
|
|
|
6
6
|
module Permissions
|
|
7
7
|
# Stores pending approval actions, auto-approves eligible work in order, and fires one-argument callbacks.
|
|
8
8
|
class ApprovalQueue
|
|
9
|
+
RESOLUTION_SCOPES = %i[once session project].freeze
|
|
10
|
+
|
|
9
11
|
Action = Data.define(
|
|
10
|
-
:id, :tool_call_id, :tool_name, :args, :auto_approvable, :status, :submitted_at, :message
|
|
12
|
+
:id, :tool_call_id, :tool_name, :args, :auto_approvable, :status, :submitted_at, :message,
|
|
13
|
+
:resolution_scope, :feedback
|
|
11
14
|
) do
|
|
12
15
|
def auto_approvable?
|
|
13
16
|
!!auto_approvable
|
|
@@ -28,6 +31,11 @@ module Ask
|
|
|
28
31
|
def rejected?
|
|
29
32
|
status == :rejected
|
|
30
33
|
end
|
|
34
|
+
|
|
35
|
+
# Alias for hosts that think in terms of approval scope.
|
|
36
|
+
def scope
|
|
37
|
+
resolution_scope
|
|
38
|
+
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
attr_reader :auto_approve
|
|
@@ -56,7 +64,9 @@ module Ask
|
|
|
56
64
|
auto_approvable: auto_approvable ? true : false,
|
|
57
65
|
status: :pending,
|
|
58
66
|
submitted_at: @clock.call,
|
|
59
|
-
message: message
|
|
67
|
+
message: message,
|
|
68
|
+
resolution_scope: nil,
|
|
69
|
+
feedback: nil
|
|
60
70
|
)
|
|
61
71
|
@actions[created.id] = created
|
|
62
72
|
created
|
|
@@ -73,6 +83,71 @@ module Ask
|
|
|
73
83
|
@mutex.synchronize { @actions.values.select(&:pending?) }
|
|
74
84
|
end
|
|
75
85
|
|
|
86
|
+
# A JSON-safe snapshot of pending approvals for a durable session store.
|
|
87
|
+
# Resolved actions are deliberately omitted so a restored approval can
|
|
88
|
+
# never execute twice after a restart.
|
|
89
|
+
def snapshot
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
{
|
|
92
|
+
version: 1,
|
|
93
|
+
next_id: @next_id,
|
|
94
|
+
pending_actions: @actions.values.select(&:pending?).map do |action|
|
|
95
|
+
{
|
|
96
|
+
id: action.id,
|
|
97
|
+
tool_call_id: action.tool_call_id,
|
|
98
|
+
tool_name: action.tool_name,
|
|
99
|
+
args: action.args,
|
|
100
|
+
auto_approvable: action.auto_approvable?,
|
|
101
|
+
message: action.message
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reconstitutes pending actions without emitting new submission events
|
|
109
|
+
# or draining auto-approvals. The host owns replaying its durable event
|
|
110
|
+
# log; this restores only the actionable queue state.
|
|
111
|
+
def restore_pending(snapshot)
|
|
112
|
+
version = snapshot_value(snapshot, :version)
|
|
113
|
+
raise ArgumentError, "Unsupported approval snapshot version: #{version.inspect}" unless version == 1
|
|
114
|
+
|
|
115
|
+
entries = snapshot_value(snapshot, :pending_actions)
|
|
116
|
+
raise ArgumentError, "Approval snapshot pending_actions must be an Array" unless entries.is_a?(Array)
|
|
117
|
+
|
|
118
|
+
restored = entries.map do |entry|
|
|
119
|
+
id = snapshot_value(entry, :id)
|
|
120
|
+
tool_name = snapshot_value(entry, :tool_name)
|
|
121
|
+
raise ArgumentError, "Approval snapshot action id must be a positive Integer" unless id.is_a?(Integer) && id.positive?
|
|
122
|
+
raise ArgumentError, "Approval snapshot tool_name must be a String" unless tool_name.is_a?(String)
|
|
123
|
+
|
|
124
|
+
Action.new(
|
|
125
|
+
id: id,
|
|
126
|
+
tool_call_id: snapshot_value(entry, :tool_call_id),
|
|
127
|
+
tool_name: tool_name,
|
|
128
|
+
args: snapshot_value(entry, :args) || {},
|
|
129
|
+
auto_approvable: snapshot_value(entry, :auto_approvable) == true,
|
|
130
|
+
status: :pending,
|
|
131
|
+
submitted_at: @clock.call,
|
|
132
|
+
message: snapshot_value(entry, :message),
|
|
133
|
+
resolution_scope: nil,
|
|
134
|
+
feedback: nil
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
ids = restored.map(&:id)
|
|
138
|
+
raise ArgumentError, "Approval snapshot contains duplicate action ids" unless ids.uniq == ids
|
|
139
|
+
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
raise ArgumentError, "Cannot restore approvals into a non-empty queue" unless @actions.empty?
|
|
142
|
+
|
|
143
|
+
restored.each { |action| @actions[action.id] = action }
|
|
144
|
+
requested_next_id = snapshot_value(snapshot, :next_id)
|
|
145
|
+
@next_id = [requested_next_id.to_i, ids.max.to_i].max
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
restored.size
|
|
149
|
+
end
|
|
150
|
+
|
|
76
151
|
def pending?(id)
|
|
77
152
|
@mutex.synchronize { @actions[id]&.pending? || false }
|
|
78
153
|
end
|
|
@@ -85,20 +160,21 @@ module Ask
|
|
|
85
160
|
@mutex.synchronize { @actions[id] }
|
|
86
161
|
end
|
|
87
162
|
|
|
88
|
-
def approve(*ids)
|
|
89
|
-
|
|
163
|
+
def approve(*ids, scope: :once)
|
|
164
|
+
validated = validate_resolution_scope!(scope)
|
|
165
|
+
resolve_all(ids) { |action| apply(action, scope: validated) }
|
|
90
166
|
end
|
|
91
167
|
|
|
92
|
-
def reject(*ids)
|
|
93
|
-
resolve_all(ids) { |action| reject_action(action) }
|
|
168
|
+
def reject(*ids, feedback: nil)
|
|
169
|
+
resolve_all(ids) { |action| reject_action(action, feedback: feedback) }
|
|
94
170
|
end
|
|
95
171
|
|
|
96
|
-
def approve_all
|
|
97
|
-
approve(*pending_actions.map(&:id))
|
|
172
|
+
def approve_all(scope: :once)
|
|
173
|
+
approve(*pending_actions.map(&:id), scope: scope)
|
|
98
174
|
end
|
|
99
175
|
|
|
100
|
-
def reject_all
|
|
101
|
-
reject(*pending_actions.map(&:id))
|
|
176
|
+
def reject_all(feedback: nil)
|
|
177
|
+
reject(*pending_actions.map(&:id), feedback: feedback)
|
|
102
178
|
end
|
|
103
179
|
|
|
104
180
|
def drain
|
|
@@ -121,12 +197,19 @@ module Ask
|
|
|
121
197
|
|
|
122
198
|
private
|
|
123
199
|
|
|
124
|
-
def
|
|
125
|
-
|
|
200
|
+
def snapshot_value(hash, key)
|
|
201
|
+
raise ArgumentError, "Approval snapshot values must be Hashes" unless hash.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
126
204
|
end
|
|
127
205
|
|
|
128
|
-
def
|
|
129
|
-
|
|
206
|
+
def apply(action, scope: :once)
|
|
207
|
+
validated = validate_resolution_scope!(scope)
|
|
208
|
+
resolve(action.id, @on_approve, :approved, resolution_scope: validated)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def reject_action(action, feedback: nil)
|
|
212
|
+
resolve(action.id, @on_reject, :rejected, feedback: feedback)
|
|
130
213
|
end
|
|
131
214
|
|
|
132
215
|
def resolve_all(ids)
|
|
@@ -144,7 +227,16 @@ module Ask
|
|
|
144
227
|
end
|
|
145
228
|
end
|
|
146
229
|
|
|
147
|
-
def
|
|
230
|
+
def validate_resolution_scope!(scope)
|
|
231
|
+
normalized = scope.respond_to?(:to_sym) ? scope.to_sym : scope
|
|
232
|
+
unless RESOLUTION_SCOPES.include?(normalized)
|
|
233
|
+
raise ArgumentError, "Unknown resolution scope: #{scope.inspect}. Valid: #{RESOLUTION_SCOPES.join(', ')}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
normalized
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def resolve(id, callback, status, resolution_scope: nil, feedback: nil)
|
|
148
240
|
previous = nil
|
|
149
241
|
applying = nil
|
|
150
242
|
|
|
@@ -152,7 +244,7 @@ module Ask
|
|
|
152
244
|
previous = @actions[id]
|
|
153
245
|
raise UnknownApprovalError, "unknown pending approval: #{id.inspect}" unless previous&.pending?
|
|
154
246
|
|
|
155
|
-
applying = previous.with(status: :applying)
|
|
247
|
+
applying = previous.with(status: :applying, resolution_scope: resolution_scope, feedback: feedback)
|
|
156
248
|
@actions[id] = applying
|
|
157
249
|
end
|
|
158
250
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# Blocks non-read-only tool calls while a session is preparing a plan.
|
|
6
|
+
# Install this hook only while plan mode is active.
|
|
7
|
+
class PlanModePolicy
|
|
8
|
+
DEFAULT_EXIT_TOOL = 'exit_plan_mode'
|
|
9
|
+
DEFAULT_REASON = 'Plan mode: only read-only tools until the plan is approved'
|
|
10
|
+
|
|
11
|
+
def initialize(allowed_tools:, exit_tool: DEFAULT_EXIT_TOOL, reason: DEFAULT_REASON)
|
|
12
|
+
@allowed_tools = Array(allowed_tools).map(&:to_s).freeze
|
|
13
|
+
@exit_tool = exit_tool.to_s
|
|
14
|
+
@reason = reason
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def before_tool_call(tool_call, _context = nil)
|
|
18
|
+
name = tool_call.name.to_s
|
|
19
|
+
return { action: :proceed } if name == @exit_tool || @allowed_tools.include?(name)
|
|
20
|
+
|
|
21
|
+
{ action: :block, reason: @reason }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# In-memory whole-tool grants scoped to the current session.
|
|
6
|
+
#
|
|
7
|
+
# A grant bypasses ordinary ask rules, approval_required metadata,
|
|
8
|
+
# high-risk gates, and ask_before_changes side-effect prompts when
|
|
9
|
+
# consulted through ApprovalPolicy#before_tool_call via the optional
|
|
10
|
+
# session_grants: collaborator. Grants never override an explicit deny
|
|
11
|
+
# rule, a tool's always_ask? requirement, or read_only mode.
|
|
12
|
+
#
|
|
13
|
+
# Grants live in memory only, are isolated per instance (sharing an
|
|
14
|
+
# instance shares grants; separate instances do not), and never touch
|
|
15
|
+
# project rules. Use #snapshot / #restore_snapshot (or .from_snapshot)
|
|
16
|
+
# to persist grants alongside durable session state.
|
|
17
|
+
class SessionPermissionGrants
|
|
18
|
+
SNAPSHOT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
def initialize(granted_tools: [])
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@granted = Set.new
|
|
23
|
+
Array(granted_tools).each { |name| grant(name) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def grant(tool_name)
|
|
27
|
+
normalized = normalize_tool_name!(tool_name)
|
|
28
|
+
@mutex.synchronize { @granted.add(normalized) }
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def revoke(tool_name)
|
|
33
|
+
normalized = normalize_tool_name!(tool_name)
|
|
34
|
+
@mutex.synchronize { @granted.delete(normalized) }
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def granted?(tool_name)
|
|
39
|
+
normalized = normalize_tool_name(tool_name)
|
|
40
|
+
return false if normalized.nil?
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize { @granted.include?(normalized) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def granted_tools
|
|
46
|
+
@mutex.synchronize { @granted.to_a.sort }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def size
|
|
50
|
+
@mutex.synchronize { @granted.size }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty?
|
|
54
|
+
@mutex.synchronize { @granted.empty? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def clear
|
|
58
|
+
@mutex.synchronize { @granted.clear }
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# JSON-safe snapshot for a durable session store.
|
|
63
|
+
def snapshot
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
{ version: SNAPSHOT_VERSION, granted_tools: @granted.to_a.sort }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Replaces current grants with validated snapshot contents.
|
|
70
|
+
def restore_snapshot(snapshot)
|
|
71
|
+
tools = validated_snapshot_tools!(snapshot)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@granted.clear
|
|
74
|
+
tools.each { |name| @granted.add(name) }
|
|
75
|
+
end
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.from_snapshot(snapshot)
|
|
80
|
+
new.restore_snapshot(snapshot)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def normalize_tool_name(tool_name)
|
|
86
|
+
return nil if tool_name.nil?
|
|
87
|
+
return nil unless tool_name.is_a?(String) || tool_name.is_a?(Symbol)
|
|
88
|
+
|
|
89
|
+
normalized = tool_name.to_s
|
|
90
|
+
normalized.empty? ? nil : normalized
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_tool_name!(tool_name)
|
|
94
|
+
normalized = normalize_tool_name(tool_name)
|
|
95
|
+
raise ArgumentError, 'Tool name must be a non-empty String or Symbol' if normalized.nil?
|
|
96
|
+
|
|
97
|
+
normalized
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def snapshot_value(hash, key)
|
|
101
|
+
raise ArgumentError, 'Session grants snapshot must be a Hash' unless hash.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validated_snapshot_tools!(snapshot)
|
|
107
|
+
version = snapshot_value(snapshot, :version)
|
|
108
|
+
unless version == SNAPSHOT_VERSION
|
|
109
|
+
raise ArgumentError, "Unsupported session grants version: #{version.inspect}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
entries = snapshot_value(snapshot, :granted_tools)
|
|
113
|
+
raise ArgumentError, 'Session grants snapshot granted_tools must be an Array' unless entries.is_a?(Array)
|
|
114
|
+
|
|
115
|
+
entries.map do |entry|
|
|
116
|
+
normalized = normalize_tool_name(entry)
|
|
117
|
+
raise ArgumentError, 'Session grants snapshot tool names must be non-empty Strings' if normalized.nil?
|
|
118
|
+
|
|
119
|
+
normalized
|
|
120
|
+
end.uniq
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/ask/permissions.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-permissions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -72,6 +72,8 @@ files:
|
|
|
72
72
|
- lib/ask/permissions/errors.rb
|
|
73
73
|
- lib/ask/permissions/permission_rules.rb
|
|
74
74
|
- lib/ask/permissions/permissions.rb
|
|
75
|
+
- lib/ask/permissions/plan_mode_policy.rb
|
|
76
|
+
- lib/ask/permissions/session_permission_grants.rb
|
|
75
77
|
- lib/ask/permissions/tool_pattern.rb
|
|
76
78
|
- lib/ask/permissions/version.rb
|
|
77
79
|
homepage: https://github.com/ask-rb/ask-permissions
|