ask-permissions 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/CONTRIBUTING.md +35 -0
- data/LICENSE +21 -0
- data/README.md +316 -0
- data/RELEASE.md +23 -0
- data/VERSIONING.md +22 -0
- data/lib/ask/permissions/approval_policy.rb +88 -0
- data/lib/ask/permissions/approval_queue.rb +191 -0
- data/lib/ask/permissions/errors.rb +11 -0
- data/lib/ask/permissions/permission_rules.rb +129 -0
- data/lib/ask/permissions/permissions.rb +151 -0
- data/lib/ask/permissions/tool_pattern.rb +20 -0
- data/lib/ask/permissions/version.rb +7 -0
- data/lib/ask/permissions.rb +9 -0
- data/lib/ask-permissions.rb +3 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '073941c27cb5785b8101b3fac162f47382cec0de2034b47f63edcd757c2b032f'
|
|
4
|
+
data.tar.gz: 5675a346d09b4a52eabaa85b2795bc5dc5651d3e34afce230cff0745d723e598
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ea269312c2aa211ccb2b51a1133ce74f7ecb2f9d6bdab68440abc62289c3d6aebeab496c2e15ce330c9642de816c7b8098db565de4bfd6974790836c04de26b8
|
|
7
|
+
data.tar.gz: 050ad80d943e96accd166946da442b8705792cb1c25d291eae3b4988b5ec5f2faa047823566a311a98928a927e2a6f2997668612a8502faa3e653fd99ba81c77
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial permission rules, mode policies, approval policy, and generic approval queue for ask-rb.
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Contributing — ask-permissions
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bundle install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Ruby >= 3.2 (see `ask-permissions.gemspec`).
|
|
10
|
+
|
|
11
|
+
### Local sibling path overrides
|
|
12
|
+
|
|
13
|
+
When testing against an unreleased sibling Ask gem, temporarily point the dependency at a local checkout instead of the published version — e.g. add a `path:` override in the `Gemfile` (or an equivalent Bundler local override) targeting the sibling directory. Revert the override before opening a PR; never commit path-specific local overrides.
|
|
14
|
+
|
|
15
|
+
## Tests
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bundle exec rake test # full suite
|
|
19
|
+
bundle exec rake test TEST=test/permissions_test.rb # single file
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Style
|
|
23
|
+
|
|
24
|
+
- Every Ruby file starts with `# frozen_string_literal: true`.
|
|
25
|
+
- RuboCop is the single style authority (global Ask RuboCop config via `.rubocop.yml`); run `rubocop` (globally installed — it is deliberately not a Gemfile dependency) and keep it clean alongside the tests.
|
|
26
|
+
|
|
27
|
+
## Pull requests
|
|
28
|
+
|
|
29
|
+
- Keep PRs focused: one change, one purpose.
|
|
30
|
+
- Update `CHANGELOG.md` under the current `[X.Y.Z] - Unreleased` section for anything user-visible.
|
|
31
|
+
- Tests are required for behavior changes; the suite and RuboCop must pass.
|
|
32
|
+
|
|
33
|
+
## Scope
|
|
34
|
+
|
|
35
|
+
This gem is single-purpose: framework-independent permission rules, policies, and approval queues for ask-rb. Features that belong to other Ask gems (agent loops, LLM providers, transport, etc.) stay out — contribute those to the owning repository instead.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# Ask::Permissions
|
|
2
|
+
|
|
3
|
+
Shared permission rules and approval workflows for the [ask-rb](https://github.com/ask-rb) ecosystem.
|
|
4
|
+
|
|
5
|
+
`ask-permissions` answers one question before a tool runs: may this call proceed? It is framework-independent — **your agent owns session execution** (running turns, invoking tools, pausing and resuming), while this gem owns the reusable classification, queueing, and approval logic you plug into a `before_tool_call` hook.
|
|
6
|
+
|
|
7
|
+
Everything lives under the `Ask::Permissions` namespace:
|
|
8
|
+
|
|
9
|
+
| Class | Role |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `PermissionRules` | Ordered `allow` / `ask` / `deny` rules over tool names and arguments; `classify` returns the first match. |
|
|
12
|
+
| `ApprovalPolicy` | Hook adapter: consults rules, `require_approval`, and tool metadata, then enqueues through a queue. |
|
|
13
|
+
| `ApprovalQueue` | Stores pending `Action`s, auto-approves eligible work in order, fires one-argument callbacks. |
|
|
14
|
+
| `Permissions` | Optional mode gate (`nil` by default, or `:ask_before_changes` / `:read_only` / `:full_access`) with sticky approvals per `tool_call_id`. |
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "ask-permissions"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "ask-permissions" # or require "ask/permissions"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Ruby >= 3.2.
|
|
32
|
+
|
|
33
|
+
## ApprovalQueue
|
|
34
|
+
|
|
35
|
+
The queue is the source of truth for pending human decisions. Each submission becomes an immutable `Action` (a `Data` object) with a **sequential integer id** (`1`, `2`, `3`, …) assigned at submission time.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
queue = Ask::Permissions::ApprovalQueue.new(
|
|
39
|
+
auto_approve: {"fetch" => true}, # Hash keyed by tool name; entry must be exactly true
|
|
40
|
+
on_submit: ->(action) { UI.enqueue(action) }, # one argument: the Action
|
|
41
|
+
on_approve: ->(action) { Session.resume(action.tool_call_id) },
|
|
42
|
+
on_reject: ->(action) { Session.deny(action.tool_call_id) }
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
id = queue.submit(
|
|
46
|
+
tool_call_id: "tc-1",
|
|
47
|
+
tool_name: "bash",
|
|
48
|
+
args: {"command" => "ls"},
|
|
49
|
+
auto_approvable: false,
|
|
50
|
+
message: "needs eyes"
|
|
51
|
+
) # => 1 (integer id)
|
|
52
|
+
|
|
53
|
+
queue[id] # => the Action (or nil)
|
|
54
|
+
queue.pending_actions # => Actions with status :pending
|
|
55
|
+
queue.pending?(id) # => true / false
|
|
56
|
+
queue.any_pending? # => true / false
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Resolving
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
queue.approve(id) # => [action] — fires on_approve(action); does not drain
|
|
63
|
+
queue.reject(id) # => [action] — fires on_reject(action); does not drain
|
|
64
|
+
queue.approve(1, 2, 3) # accepts several ids; returns only the resolved Actions
|
|
65
|
+
queue.approve_all # approves every pending Action (empty array if none)
|
|
66
|
+
queue.reject_all
|
|
67
|
+
queue.drain # re-runs the auto-approval pass explicitly
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Unknown or already-resolved ids are ignored — they never raise. `approve` / `reject` return only the subset that was still pending (in id order), or `[]` when nothing qualified, and they do **not** drain afterward.
|
|
71
|
+
|
|
72
|
+
### Action
|
|
73
|
+
|
|
74
|
+
Fields: `id`, `tool_call_id`, `tool_name`, `args`, `auto_approvable`, `status`, `submitted_at`, `message`.
|
|
75
|
+
Statuses: `:pending` → `:applying` (transient, inside the callback) → `:approved` or `:rejected`.
|
|
76
|
+
Predicates: `pending?`, `applying?`, `approved?`, `rejected?`, `auto_approvable?`. Actions are frozen.
|
|
77
|
+
|
|
78
|
+
### Callbacks
|
|
79
|
+
|
|
80
|
+
All three callbacks take **exactly one argument — the `Action`**:
|
|
81
|
+
|
|
82
|
+
- `on_submit(action)` — fires while the action is still `:pending`, before any auto-approval.
|
|
83
|
+
- `on_approve(action)` / `on_reject(action)` — fire during resolution. If they raise, the action rolls back to `:pending` and the exception propagates.
|
|
84
|
+
|
|
85
|
+
### Auto-approval
|
|
86
|
+
|
|
87
|
+
Auto-approval needs **both** conditions:
|
|
88
|
+
|
|
89
|
+
1. the submission carries `auto_approvable: true`, and
|
|
90
|
+
2. `auto_approve` contains that tool name with the value exactly `true`.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
queue = Ask::Permissions::ApprovalQueue.new(auto_approve: {"read" => true})
|
|
94
|
+
queue.submit(tool_call_id: "tc-1", tool_name: "read", auto_approvable: true)
|
|
95
|
+
# => immediately :approved via on_approve
|
|
96
|
+
|
|
97
|
+
queue.submit(tool_call_id: "tc-2", tool_name: "read") # stays :pending
|
|
98
|
+
queue.submit(tool_call_id: "tc-3", tool_name: "other", auto_approvable: true) # stays :pending
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Drain is **ordered and never overtakes a manual item**: it approves from the head of the queue while that head is eligible and stops at the first manual entry. Auto-drain runs on every `submit`. `approve` / `reject` do **not** drain, so work behind a manual item waits until the next `submit` or an explicit `queue.drain`.
|
|
102
|
+
|
|
103
|
+
Queues are mutex-guarded and safe to share across threads. Approval state lives in memory only.
|
|
104
|
+
|
|
105
|
+
## PermissionRules
|
|
106
|
+
|
|
107
|
+
Rules are evaluated in declaration order; the first match wins.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
rules = Ask::Permissions::PermissionRules.new # auto_allow_dangerous: false (default)
|
|
111
|
+
|
|
112
|
+
rules.allow "read_file"
|
|
113
|
+
rules.ask "bash", "rm -rf"
|
|
114
|
+
rules.deny "delete_user"
|
|
115
|
+
|
|
116
|
+
rules.classify("bash", {"command" => "sudo rm -rf /tmp"}) # => :ask
|
|
117
|
+
rules.classify("read_file") # => :allow
|
|
118
|
+
rules.classify("delete_user") # => :deny
|
|
119
|
+
rules.classify("unknown") # => nil (no match)
|
|
120
|
+
|
|
121
|
+
rules.allow?("read_file") # => true
|
|
122
|
+
rules.ask?("bash", {"command" => "rm -rf /"})
|
|
123
|
+
rules.deny?("delete_user")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`allow` / `ask` / `deny` take `(tool_pattern, argument_pattern = nil)` and return `self`, so they chain. `PermissionRules.new` also accepts an optional block, which is `instance_eval`'d against the ruleset:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
rules = Ask::Permissions::PermissionRules.new do
|
|
130
|
+
allow "read_file"
|
|
131
|
+
deny "delete_user"
|
|
132
|
+
ask "bash", "rm -rf"
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Patterns
|
|
137
|
+
|
|
138
|
+
- **Tool pattern** — exact `String`/`Symbol` name, `Regexp`, or `:all` (matches every tool).
|
|
139
|
+
- **Argument pattern** (optional) — `String` (substring of the serialized args) or `Regexp` (against the serialized form). Hash args are serialized with `JSON.generate`; anything else with `to_s`. Omit it to match any arguments.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
rules.ask "bash", "rm -rf" # substring of the serialized args
|
|
143
|
+
rules.allow "read_file", %r{/docs/} # Regexp against the serialized args
|
|
144
|
+
rules.allow "search" # any arguments
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Introspection: `rules` and `dangerous_rules` (both in declaration order). Each entry is a `Rule` with `decision`, `declared_decision`, `effective_decision`, `tool_pattern`, `argument_pattern`, `dangerous`, plus the predicates `dangerous?` and `universal?` (unrestricted argument pattern — `argument_pattern` is `nil`, regardless of tool pattern) and the matchers `tool_matches?(name)`, `argument_matches?(args)`, and `matches?(name, args)`. `decision` and `declared_decision` always keep the decision as written at declaration time; `classify` returns `effective_decision`.
|
|
148
|
+
|
|
149
|
+
### Dangerous allow downgrade
|
|
150
|
+
|
|
151
|
+
An unrestricted `allow` on a dangerous tool is downgraded to `:ask` **for classification only**, so a broad allow can never silently permit an execution tool:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
rules = Ask::Permissions::PermissionRules.new
|
|
155
|
+
rules.allow "bash" # declared :allow, effective :ask
|
|
156
|
+
rule = rules.rules.first
|
|
157
|
+
rule.decision # => :allow (declared decision is preserved)
|
|
158
|
+
rule.declared_decision # => :allow
|
|
159
|
+
rule.effective_decision # => :ask
|
|
160
|
+
rules.classify("bash") # => :ask
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The dangerous set is the public frozen `DANGEROUS_TOOLS` constant, `%i[bash code repl]`. The `:all` pattern and any `Regexp` that matches one of those names also counts as dangerous — so `rules.allow :all` downgrades *every* tool to `:ask`. The downgrade only applies when the declared decision is `:allow` **and** no argument pattern was given:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
rules.allow "bash", "ls -la" # restricted allow → stays :allow
|
|
167
|
+
rules.deny "bash" # deny/ask are never downgraded
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`PermissionRules.new(auto_allow_dangerous: true)` disables the guard. That is an explicit opt-out — leave it off unless you have a narrowly scoped reason.
|
|
171
|
+
|
|
172
|
+
## ApprovalPolicy
|
|
173
|
+
|
|
174
|
+
`ApprovalPolicy` requires a `queue:`; everything else is optional:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
rules = Ask::Permissions::PermissionRules.new
|
|
178
|
+
rules.deny "delete_user"
|
|
179
|
+
rules.ask "bash", "rm -rf"
|
|
180
|
+
rules.allow "read_file"
|
|
181
|
+
|
|
182
|
+
queue = Ask::Permissions::ApprovalQueue.new(
|
|
183
|
+
auto_approve: {"fetch" => true},
|
|
184
|
+
on_submit: ->(action) { UI.enqueue(action) },
|
|
185
|
+
on_approve: ->(action) { Session.resume(action.tool_call_id) },
|
|
186
|
+
on_reject: ->(action) { Session.deny(action.tool_call_id) }
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
policy = Ask::Permissions::ApprovalPolicy.new(
|
|
190
|
+
queue: queue, # required
|
|
191
|
+
rules: rules, # optional
|
|
192
|
+
require_approval: ["bash", /^write_/], # optional
|
|
193
|
+
tools: {"fetch" => fetch_tool} # optional registry
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### The hook: `before_tool_call`
|
|
198
|
+
|
|
199
|
+
`before_tool_call(tool_call, context = nil)` expects `tool_call` to respond to `name`, `arguments`, and `id`. It returns exactly one of three shapes:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
{action: :proceed}
|
|
203
|
+
{action: :block, reason: "Denied by permission rules: 'delete_user'"}
|
|
204
|
+
{action: :pending, action_id: 1, reason: "Tool 'bash' requires approval"}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Resolution order — the first layer with an opinion wins:
|
|
208
|
+
|
|
209
|
+
1. **`rules.classify(name, arguments)`** — `:deny` → block with the exact reason `"Denied by permission rules: '<name>'"`, `:allow` → proceed, `:ask` → enqueue with `auto_approvable: false`. An explicit `allow` rule therefore wins over `require_approval`.
|
|
210
|
+
2. **`require_approval` / tool metadata** — if no rule matched, a `require_approval` pattern or a tool whose metadata reports `approval_required?` enqueues as `:pending`; `auto_approvable` comes from the tool's `auto_approvable?`.
|
|
211
|
+
3. **Default** — otherwise the call proceeds. An unconfigured policy allows everything.
|
|
212
|
+
|
|
213
|
+
`require_approval` accepts `nil`, `:all` (queue every tool), a `String`/`Symbol` (exact name), a `Regexp`, or an `Array` of those (any match).
|
|
214
|
+
|
|
215
|
+
The `tools` registry may be a `Hash` (String or Symbol keys), an `Array` of objects that respond to `name`, or any object indexable with `[]`. Values should respond to `approval_required?` and/or `auto_approvable?`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
tools = {"fetch" => fetch_tool} # fetch_tool.approval_required? && fetch_tool.auto_approvable?
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Looking up and resolving
|
|
222
|
+
|
|
223
|
+
`result[:action_id]` **is** the queue's action id — exactly what `queue.submit` returned (sequential integers assigned in submission order). `policy.lookup(id)` simply delegates to `queue[id]`:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
result = policy.before_tool_call(tool_call, context)
|
|
227
|
+
return proceed if result[:action] == :proceed
|
|
228
|
+
return refuse(result[:reason]) if result[:action] == :block
|
|
229
|
+
|
|
230
|
+
action = policy.lookup(result[:action_id]) # the queue Action, or nil
|
|
231
|
+
queue.approve(action.id) # fires on_approve
|
|
232
|
+
queue.reject(action.id) # fires on_reject
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Readers: `policy.queue`, `policy.rules`, `policy.require_approval`, `policy.tools`.
|
|
236
|
+
|
|
237
|
+
### Auto-approval through the policy
|
|
238
|
+
|
|
239
|
+
Only the `require_approval` / tool-metadata path can set `auto_approvable: true` — an `ask` rule always enqueues with `auto_approvable: false`. Even then the queue must also list the tool in its `auto_approve` hash. A `:pending` result can therefore come back with the action already `:approved`; check `action.status` (or `action.approved?`) before pausing.
|
|
240
|
+
|
|
241
|
+
## Wiring it into an agent
|
|
242
|
+
|
|
243
|
+
`ApprovalPolicy` sits in your agent's tool-call hook; this gem never executes tools itself:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
result = policy.before_tool_call(tool_call, context)
|
|
247
|
+
|
|
248
|
+
case result[:action]
|
|
249
|
+
when :proceed then proceed(result)
|
|
250
|
+
when :block then refuse(result[:reason])
|
|
251
|
+
when :pending
|
|
252
|
+
action = policy.lookup(result[:action_id])
|
|
253
|
+
action.approved? ? proceed(result) : pause_until_decided(action)
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`proceed` / `refuse` / `pause_until_decided`, `Session`, and `UI` stand in for your own integration. A human (or an automated reviewer) later resolves the submission through the queue — `queue.approve(action.id)` or `queue.reject(action.id)`.
|
|
258
|
+
|
|
259
|
+
Notes for hook authors:
|
|
260
|
+
|
|
261
|
+
- Callbacks are the only side-effect seam; if one raises, the action stays `:pending` (or rolls back to `:pending`) and your hook sees the exception.
|
|
262
|
+
- Approving does **not** rewrite the rules: the next identical call is classified from scratch and queues again if it still matches an `ask` rule. Approve per submission, not per tool.
|
|
263
|
+
- `result[:action_id]` is the queue's own action id; `policy.lookup(action_id)` just delegates to `queue[action_id]`.
|
|
264
|
+
|
|
265
|
+
## Mode gate: `Permissions`
|
|
266
|
+
|
|
267
|
+
`Ask::Permissions::Permissions` is the simpler mode-based gate. It blocks a fixed set of change tools and records a sticky approval per `tool_call_id`.
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
gate = Ask::Permissions::Permissions.new # mode: nil (default)
|
|
271
|
+
|
|
272
|
+
result = gate.before_tool_call(tool_call, {})
|
|
273
|
+
# => {action: :block, reason: "bash requires approval"}
|
|
274
|
+
|
|
275
|
+
gate.pending_approvals # => pending Approval entries
|
|
276
|
+
gate.approve("tc-1") # => true
|
|
277
|
+
|
|
278
|
+
gate.before_tool_call(tool_call, {})
|
|
279
|
+
# => {action: :proceed} (same tool_call id, already approved)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- **Modes** — omit `mode:` and `mode` stays `nil`; blocked tools default to the symbols `:write`, `:edit`, `:bash`, `:destroy`. With an explicit mode (`:ask_before_changes`, `:read_only`, or `:full_access`) the blocked set comes from the mode — `:ask_before_changes` and `:read_only` block those same four defaults, `:full_access` blocks nothing — and **`blocked_tools:` is ignored**. Without a mode, a custom `blocked_tools:` list **replaces** the default entirely (entries may be Strings or Symbols; they normalize to Symbols). An unknown mode (including a String) raises `ArgumentError` with the exact message `"Unknown access mode: <mode>. Valid: full_access, ask_before_changes, read_only"`. With an explicit mode the block reason reads `"bash requires approval (mode: ask_before_changes)"`; with `mode` omitted it is just `"bash requires approval"`.
|
|
283
|
+
- **Public constants** — `Permissions::DEFAULT_TOOLS` is the frozen default set (`%i[write edit bash destroy]`) and `Permissions::ACCESS_MODES` maps each mode key to a frozen config hash with a `:blocked_tools` key (`full_access` → `[]`, the others → `DEFAULT_TOOLS`). The pre-existing `DEFAULT_BLOCKED_TOOLS` and `MODE_BLOCKED_TOOLS` remain available as aliases derived from them.
|
|
284
|
+
- **Sticky approvals** — keyed by `tool_call_id`. The first blocked call records one pending `Approval`; repeated calls with the same `tool_call_id` reuse it. `approve(tool_call_id)` returns `true` for **any existing entry** — pending or already approved — and `false` only when the id is unknown; it never raises. Once approved, later calls with that id proceed. Approving one `tool_call_id` never unlocks another. There is no reject API: an entry you never approve keeps blocking.
|
|
285
|
+
- **Approval reminder** — the first blocked request for a `tool_call_id` writes a reminder to stderr (`warn`, i.e. `$stderr`); repeated checks against the same still-pending entry stay silent, and an expired entry that gets recreated on the next call warns again.
|
|
286
|
+
- **Timeout** — `Permissions.new(timeout: 60)` expires **both pending and approved** entries once `now - created_at` **exceeds** the timeout (`elapsed > timeout` — an entry exactly at the timeout has *not* expired yet), measured from the entry's **original** `created_at`. Expiry is acted on by `before_tool_call` for that `tool_call_id`: the stale entry is removed, a fresh pending `Approval` is recorded with a new `created_at`, and the call blocks again. `pending_approvals` only lists entries whose current status is `:pending` — it never purges expired ones. Without `timeout:` nothing ever expires.
|
|
287
|
+
- Tool names normalize to **Symbols** (so `Approval#tool_name` is a Symbol like `:bash`); the gate is mutex-guarded.
|
|
288
|
+
|
|
289
|
+
Prefer `ApprovalPolicy` for new integrations — it composes with ordered rules and the shared queue. Reach for the mode gate when you only need coarse "block changes until approved" behavior.
|
|
290
|
+
|
|
291
|
+
## Defaults and safety caveats
|
|
292
|
+
|
|
293
|
+
- **Unconfigured means allow.** `ApprovalPolicy.new(queue: queue)` with no rules, no `require_approval`, and no tool metadata proceeds on every call; a `PermissionRules` with no matching rule returns `nil`, which the policy treats as proceed. Configure before wiring a policy into an agent.
|
|
294
|
+
- **Dangerous tools are downgraded, not blocked.** An unrestricted `allow` on `bash`/`code`/`repl` classifies as `:ask` (the rule's `decision` stays `:allow`; `effective_decision` is `:ask`), but `auto_allow_dangerous: true` turns the guard off — and `:full_access` in the mode gate blocks nothing (`blocked_tools:` is ignored whenever a mode is set).
|
|
295
|
+
- **Auto-approve is opt-in twice.** The queue's `auto_approve` hash must contain the tool name `=> true` *and* the submission must be `auto_approvable`; `require_approval` items without matching tool metadata always wait for a human.
|
|
296
|
+
- **Auto-approval never skips the line.** A manual item halts the drain until a human resolves it; later auto items do not jump ahead. `approve` / `reject` do not resume the drain themselves — the next `submit` or an explicit `queue.drain` does.
|
|
297
|
+
- **Approval state is in memory.** Queues and gate approvals are not persisted and do not survive a restart.
|
|
298
|
+
- **Queue approvals are per submission; gate approvals are sticky per `tool_call_id`.**
|
|
299
|
+
- **Callback failures are visible.** `on_submit` / `on_approve` / `on_reject` exceptions propagate and leave actions `:pending`; keep callbacks idempotent.
|
|
300
|
+
|
|
301
|
+
## Development
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
bundle install
|
|
305
|
+
bundle exec rake test
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Requires Ruby >= 3.2 (see `ask-permissions.gemspec`).
|
|
309
|
+
|
|
310
|
+
## Versioning
|
|
311
|
+
|
|
312
|
+
See [VERSIONING.md](VERSIONING.md). Every release advances the version by exactly one step, and all releases go through `gemchain` — never publish this gem by hand.
|
|
313
|
+
|
|
314
|
+
## License
|
|
315
|
+
|
|
316
|
+
MIT. See [LICENSE](LICENSE).
|
data/RELEASE.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Releasing — ask-permissions
|
|
2
|
+
|
|
3
|
+
[VERSIONING.md](VERSIONING.md) is the canonical versioning document (exact sequential steps, version agreement rules). Follow it; this file only covers the release procedure.
|
|
4
|
+
|
|
5
|
+
## Preconditions
|
|
6
|
+
|
|
7
|
+
All must hold before any release:
|
|
8
|
+
|
|
9
|
+
- **Clean working tree** — no uncommitted changes.
|
|
10
|
+
- **Passing tests** — `bundle exec rake test` (and global `rubocop`) are green.
|
|
11
|
+
- **Up-to-date CHANGELOG** — the `[X.Y.Z] - Unreleased` heading in `CHANGELOG.md` names the version being released and matches `lib/ask/permissions/version.rb` (version agreement per VERSIONING.md).
|
|
12
|
+
- **Runtime dependencies available** — any runtime dependency of this gem (and of its dependents in the ecosystem) is resolvable from RubyGems at the required versions.
|
|
13
|
+
- **Build verification** — the gem builds cleanly (`gem build ask-permissions.gemspec`) and the built artifact reports the expected version.
|
|
14
|
+
|
|
15
|
+
## Publishing: gemchain only
|
|
16
|
+
|
|
17
|
+
**ALL Ask ecosystem publishing goes through `gemchain`. Never run `rake release`, `gem push`, or any other manual publish.**
|
|
18
|
+
|
|
19
|
+
Order matters:
|
|
20
|
+
|
|
21
|
+
1. **ask-permissions is new** — publish it first, before releasing any dependent gem, so dependents resolve it from RubyGems.
|
|
22
|
+
2. Then run gemchain's **cascade checks and dry run** across the affected gems (verify version agreement, changelog state, and dependency ordering without publishing). Use `gemchain guard` for pre-release checks and `gemchain update` where a version bump is needed; consult gemchain itself for exact subcommand syntax.
|
|
23
|
+
3. **Actual publish requires explicit authorization** — do not publish until authorization is given, even if all checks pass.
|
data/VERSIONING.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Versioning — ask-permissions
|
|
2
|
+
|
|
3
|
+
This repository follows the ask-rb (Ask gem) versioning convention: exact sequential steps, never skipped numbers.
|
|
4
|
+
|
|
5
|
+
## Increment rules
|
|
6
|
+
|
|
7
|
+
- Every release advances the version by **exactly one step**. Never skip a number.
|
|
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
|
+
- Compatible fixes increment the **patch** digit by one: `0.1.0 -> 0.1.1`, `0.2.0 -> 0.2.1`.
|
|
10
|
+
- 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
|
+
- The version source of truth is `lib/ask/permissions/version.rb`; the gemspec reads it from there.
|
|
12
|
+
|
|
13
|
+
## Changelog
|
|
14
|
+
|
|
15
|
+
- `CHANGELOG.md` keeps a `[X.Y.Z] - Unreleased` section that is filled in as work lands and dated only when that version ships.
|
|
16
|
+
- Before any release, the unreleased changelog heading and `lib/ask/permissions/version.rb` must name the same version (version agreement).
|
|
17
|
+
|
|
18
|
+
## Releasing
|
|
19
|
+
|
|
20
|
+
- **All releases go through `gemchain`** from the ask-rb workspace. Never `rake release`, `gem push`, or any other manual publish.
|
|
21
|
+
- 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
|
+
- No release has been made yet: `0.1.0` stays `Unreleased` until `gemchain` publishes it.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# Hook adapter that consults rules, require_approval, and tool metadata, then enqueues through a queue.
|
|
6
|
+
class ApprovalPolicy
|
|
7
|
+
attr_reader :queue, :require_approval, :rules, :tools
|
|
8
|
+
|
|
9
|
+
def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
|
|
10
|
+
@queue = queue
|
|
11
|
+
@require_approval = require_approval
|
|
12
|
+
@rules = rules
|
|
13
|
+
@tools = tools
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def before_tool_call(tool_call, _context = nil)
|
|
17
|
+
name = tool_call.name.to_s
|
|
18
|
+
args = tool_call.arguments
|
|
19
|
+
|
|
20
|
+
case rules&.classify(name, args)
|
|
21
|
+
when :deny
|
|
22
|
+
return { action: :block, reason: "Denied by permission rules: '#{name}'" }
|
|
23
|
+
when :allow
|
|
24
|
+
return { action: :proceed }
|
|
25
|
+
when :ask
|
|
26
|
+
return enqueue(tool_call, auto_approvable: false)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return { action: :proceed } unless approval_required?(name)
|
|
30
|
+
|
|
31
|
+
enqueue(tool_call, auto_approvable: auto_approvable?(name))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lookup(action_id)
|
|
35
|
+
queue[action_id]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def approval_required?(name)
|
|
41
|
+
return true if matches_require_approval?(require_approval, name)
|
|
42
|
+
|
|
43
|
+
tool = find_tool(name)
|
|
44
|
+
!!(tool && tool.respond_to?(:approval_required?) && tool.approval_required?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def matches_require_approval?(criterion, name)
|
|
48
|
+
case criterion
|
|
49
|
+
when nil then false
|
|
50
|
+
when :all then true
|
|
51
|
+
when Array then criterion.any? { |entry| matches_require_approval?(entry, name) }
|
|
52
|
+
when Regexp then criterion.match?(name)
|
|
53
|
+
else criterion.to_s == name
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def auto_approvable?(name)
|
|
58
|
+
tool = find_tool(name)
|
|
59
|
+
!!(tool && tool.respond_to?(:auto_approvable?) && tool.auto_approvable?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def find_tool(name)
|
|
63
|
+
case tools
|
|
64
|
+
when nil then nil
|
|
65
|
+
when Hash then tools[name] || tools[name.to_sym]
|
|
66
|
+
when Array then tools.find { |tool| tool.respond_to?(:name) && tool.name.to_s == name }
|
|
67
|
+
else tools.respond_to?(:[]) ? tools[name] : nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def enqueue(tool_call, auto_approvable:)
|
|
72
|
+
name = tool_call.name.to_s
|
|
73
|
+
reason = "Tool '#{name}' requires approval"
|
|
74
|
+
message = "Calling \"#{name}\" requires approval"
|
|
75
|
+
|
|
76
|
+
action_id = queue.submit(
|
|
77
|
+
tool_name: name,
|
|
78
|
+
args: tool_call.arguments,
|
|
79
|
+
tool_call_id: tool_call.id,
|
|
80
|
+
auto_approvable: auto_approvable,
|
|
81
|
+
message: message
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
{ action: :pending, action_id: action_id, reason: reason }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Permissions
|
|
7
|
+
# Stores pending approval actions, auto-approves eligible work in order, and fires one-argument callbacks.
|
|
8
|
+
class ApprovalQueue
|
|
9
|
+
Action = Data.define(
|
|
10
|
+
:id, :tool_call_id, :tool_name, :args, :auto_approvable, :status, :submitted_at, :message
|
|
11
|
+
) do
|
|
12
|
+
def auto_approvable?
|
|
13
|
+
!!auto_approvable
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def pending?
|
|
17
|
+
status == :pending
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def applying?
|
|
21
|
+
status == :applying
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def approved?
|
|
25
|
+
status == :approved
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rejected?
|
|
29
|
+
status == :rejected
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :auto_approve
|
|
34
|
+
attr_accessor :on_approve, :on_reject, :on_submit
|
|
35
|
+
|
|
36
|
+
def initialize(on_approve: nil, on_reject: nil, auto_approve: {}, on_submit: nil, clock: nil)
|
|
37
|
+
@on_approve = on_approve
|
|
38
|
+
@on_reject = on_reject
|
|
39
|
+
@auto_approve = auto_approve || {}
|
|
40
|
+
@on_submit = on_submit
|
|
41
|
+
@clock = clock || -> { Time.now }
|
|
42
|
+
@actions = {}
|
|
43
|
+
@next_id = 0
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@draining = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
|
|
49
|
+
action = @mutex.synchronize do
|
|
50
|
+
@next_id += 1
|
|
51
|
+
created = Action.new(
|
|
52
|
+
id: @next_id,
|
|
53
|
+
tool_call_id: tool_call_id,
|
|
54
|
+
tool_name: tool_name.to_s,
|
|
55
|
+
args: args.nil? ? {} : args,
|
|
56
|
+
auto_approvable: auto_approvable ? true : false,
|
|
57
|
+
status: :pending,
|
|
58
|
+
submitted_at: @clock.call,
|
|
59
|
+
message: message
|
|
60
|
+
)
|
|
61
|
+
@actions[created.id] = created
|
|
62
|
+
created
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@on_submit&.call(action)
|
|
66
|
+
|
|
67
|
+
drain
|
|
68
|
+
|
|
69
|
+
action.id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pending_actions
|
|
73
|
+
@mutex.synchronize { @actions.values.select(&:pending?) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def pending?(id)
|
|
77
|
+
@mutex.synchronize { @actions[id]&.pending? || false }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def any_pending?
|
|
81
|
+
@mutex.synchronize { @actions.each_value.any?(&:pending?) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def [](id)
|
|
85
|
+
@mutex.synchronize { @actions[id] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def approve(*ids)
|
|
89
|
+
resolve_all(ids) { |action| apply(action) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reject(*ids)
|
|
93
|
+
resolve_all(ids) { |action| reject_action(action) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def approve_all
|
|
97
|
+
approve(*pending_actions.map(&:id))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def reject_all
|
|
101
|
+
reject(*pending_actions.map(&:id))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def drain
|
|
105
|
+
return self unless start_draining
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
while (head = next_auto_head)
|
|
109
|
+
begin
|
|
110
|
+
apply(head)
|
|
111
|
+
rescue UnknownApprovalError
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
ensure
|
|
116
|
+
stop_draining
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def apply(action)
|
|
125
|
+
resolve(action.id, @on_approve, :approved)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reject_action(action)
|
|
129
|
+
resolve(action.id, @on_reject, :rejected)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_all(ids)
|
|
133
|
+
actions = @mutex.synchronize do
|
|
134
|
+
ids.flatten.uniq
|
|
135
|
+
.filter_map { |id| @actions[id] }
|
|
136
|
+
.select(&:pending?)
|
|
137
|
+
.sort_by(&:id)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
actions.filter_map do |action|
|
|
141
|
+
yield action
|
|
142
|
+
rescue UnknownApprovalError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve(id, callback, status)
|
|
148
|
+
previous = nil
|
|
149
|
+
applying = nil
|
|
150
|
+
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
previous = @actions[id]
|
|
153
|
+
raise UnknownApprovalError, "unknown pending approval: #{id.inspect}" unless previous&.pending?
|
|
154
|
+
|
|
155
|
+
applying = previous.with(status: :applying)
|
|
156
|
+
@actions[id] = applying
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
callback&.call(applying)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
@mutex.synchronize { @actions[id] = previous }
|
|
163
|
+
raise
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
resolved = applying.with(status: status)
|
|
167
|
+
@mutex.synchronize { @actions[id] = resolved }
|
|
168
|
+
resolved
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def next_auto_head
|
|
172
|
+
@mutex.synchronize do
|
|
173
|
+
head = @actions.each_value.find(&:pending?)
|
|
174
|
+
head if head&.auto_approvable? && @auto_approve[head.tool_name] == true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def start_draining
|
|
179
|
+
@mutex.synchronize do
|
|
180
|
+
next false if @draining
|
|
181
|
+
|
|
182
|
+
@draining = true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def stop_draining
|
|
187
|
+
@mutex.synchronize { @draining = false }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'tool_pattern'
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Permissions
|
|
8
|
+
# Evaluates tool-invocation rules and returns allow/ask/deny decisions.
|
|
9
|
+
class PermissionRules
|
|
10
|
+
DANGEROUS_TOOLS = %i[bash code repl].freeze
|
|
11
|
+
|
|
12
|
+
Rule = Data.define(
|
|
13
|
+
:decision, :declared_decision, :effective_decision, :tool_pattern, :argument_pattern, :dangerous
|
|
14
|
+
) do
|
|
15
|
+
def dangerous?
|
|
16
|
+
dangerous
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def universal?
|
|
20
|
+
argument_pattern.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tool_matches?(tool_name)
|
|
24
|
+
ToolPattern.match?(tool_pattern, tool_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def argument_matches?(args)
|
|
28
|
+
return true if argument_pattern.nil?
|
|
29
|
+
|
|
30
|
+
haystack = serialize(args)
|
|
31
|
+
|
|
32
|
+
case argument_pattern
|
|
33
|
+
when Regexp then argument_pattern.match?(haystack)
|
|
34
|
+
else haystack.include?(argument_pattern.to_s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def matches?(tool_name, args = nil)
|
|
39
|
+
tool_matches?(tool_name) && argument_matches?(args)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def serialize(value)
|
|
45
|
+
value.is_a?(Hash) ? JSON.generate(value) : value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(auto_allow_dangerous: false, &block)
|
|
50
|
+
@auto_allow_dangerous = auto_allow_dangerous ? true : false
|
|
51
|
+
@rules = []
|
|
52
|
+
@dangerous_rules = []
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
instance_eval(&block) if block
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def allow(tool_pattern, argument_pattern = nil)
|
|
58
|
+
register(:allow, tool_pattern, argument_pattern)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ask(tool_pattern, argument_pattern = nil)
|
|
62
|
+
register(:ask, tool_pattern, argument_pattern)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deny(tool_pattern, argument_pattern = nil)
|
|
66
|
+
register(:deny, tool_pattern, argument_pattern)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rules
|
|
70
|
+
@mutex.synchronize { @rules.dup }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dangerous_rules
|
|
74
|
+
@mutex.synchronize { @dangerous_rules.dup }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def classify(tool_name, args = nil)
|
|
78
|
+
rule = rules.find { |candidate| candidate.matches?(tool_name, args) }
|
|
79
|
+
rule&.effective_decision
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def allow?(tool_name, args = nil)
|
|
83
|
+
classify(tool_name, args) == :allow
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ask?(tool_name, args = nil)
|
|
87
|
+
classify(tool_name, args) == :ask
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deny?(tool_name, args = nil)
|
|
91
|
+
classify(tool_name, args) == :deny
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def register(decision, tool_pattern, argument_pattern)
|
|
97
|
+
dangerous = dangerous_allow?(decision, tool_pattern, argument_pattern)
|
|
98
|
+
effective = dangerous && !@auto_allow_dangerous ? :ask : decision
|
|
99
|
+
|
|
100
|
+
rule = Rule.new(
|
|
101
|
+
decision: decision,
|
|
102
|
+
declared_decision: decision,
|
|
103
|
+
effective_decision: effective,
|
|
104
|
+
tool_pattern: tool_pattern,
|
|
105
|
+
argument_pattern: argument_pattern,
|
|
106
|
+
dangerous: dangerous
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@rules << rule
|
|
111
|
+
@dangerous_rules << rule if dangerous
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def dangerous_allow?(decision, tool_pattern, argument_pattern)
|
|
118
|
+
decision == :allow && argument_pattern.nil? && dangerous_tool?(tool_pattern)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def dangerous_tool?(tool_pattern)
|
|
122
|
+
return true if tool_pattern == :all
|
|
123
|
+
return DANGEROUS_TOOLS.any? { |name| tool_pattern.match?(name.to_s) } if tool_pattern.is_a?(Regexp)
|
|
124
|
+
|
|
125
|
+
DANGEROUS_TOOLS.include?(tool_pattern.to_s.to_sym)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# Mode gate that blocks change tools until approved, with sticky approvals per tool_call_id.
|
|
6
|
+
class Permissions
|
|
7
|
+
DEFAULT_TOOLS = %i[write edit bash destroy].freeze
|
|
8
|
+
|
|
9
|
+
ACCESS_MODES = {
|
|
10
|
+
full_access: { blocked_tools: [].freeze }.freeze,
|
|
11
|
+
ask_before_changes: { blocked_tools: DEFAULT_TOOLS }.freeze,
|
|
12
|
+
read_only: { blocked_tools: DEFAULT_TOOLS }.freeze
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_BLOCKED_TOOLS = DEFAULT_TOOLS
|
|
16
|
+
MODE_BLOCKED_TOOLS = ACCESS_MODES.transform_values { |config| config[:blocked_tools] }.freeze
|
|
17
|
+
|
|
18
|
+
Approval = Data.define(
|
|
19
|
+
:tool_call_id, :tool_name, :arguments, :reason, :status,
|
|
20
|
+
:created_at, :approved_at, :tool_call
|
|
21
|
+
) do
|
|
22
|
+
def pending?
|
|
23
|
+
status == :pending
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def approved?
|
|
27
|
+
status == :approved
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def [](key)
|
|
31
|
+
to_h.fetch(key.to_sym)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :mode, :blocked_tools, :timeout
|
|
36
|
+
|
|
37
|
+
def initialize(mode: nil, blocked_tools: nil, timeout: nil, clock: nil)
|
|
38
|
+
@mode = mode
|
|
39
|
+
@blocked_tools =
|
|
40
|
+
if mode
|
|
41
|
+
mode_tools(mode)
|
|
42
|
+
elsif blocked_tools
|
|
43
|
+
Array(blocked_tools).map { |name| name.to_s.to_sym }.uniq
|
|
44
|
+
else
|
|
45
|
+
DEFAULT_BLOCKED_TOOLS.dup
|
|
46
|
+
end
|
|
47
|
+
@timeout = timeout
|
|
48
|
+
@clock = clock || -> { Time.now }
|
|
49
|
+
@approvals = {}
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def before_tool_call(tool_call, _context = nil)
|
|
54
|
+
name = tool_call.name.to_s.to_sym
|
|
55
|
+
return { action: :proceed } unless blocked_tools.include?(name)
|
|
56
|
+
|
|
57
|
+
id = tool_call.id
|
|
58
|
+
created = false
|
|
59
|
+
|
|
60
|
+
result = @mutex.synchronize do
|
|
61
|
+
decision = existing_decision(id)
|
|
62
|
+
if decision
|
|
63
|
+
decision
|
|
64
|
+
else
|
|
65
|
+
created = true
|
|
66
|
+
record_pending(tool_call, name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
warn_approval(tool_call) if created
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def approve(tool_call_id)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
entry = @approvals[tool_call_id]
|
|
77
|
+
next false unless entry
|
|
78
|
+
|
|
79
|
+
@approvals[tool_call_id] = entry.with(status: :approved, approved_at: @clock.call)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_approvals
|
|
85
|
+
@mutex.synchronize { @approvals.values.select(&:pending?) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def approved?(tool_call)
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
existing_decision(tool_call.id)&.dig(:action) == :proceed
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def mode_tools(mode)
|
|
97
|
+
config = ACCESS_MODES.fetch(mode) do
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"Unknown access mode: #{mode.inspect}. Valid: #{ACCESS_MODES.keys.join(', ')}"
|
|
100
|
+
end
|
|
101
|
+
config[:blocked_tools].dup
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reason_for(tool_name)
|
|
105
|
+
return "#{tool_name} requires approval" if mode.nil?
|
|
106
|
+
|
|
107
|
+
"#{tool_name} requires approval (mode: #{mode})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def expired?(entry)
|
|
111
|
+
return false if timeout.nil?
|
|
112
|
+
|
|
113
|
+
(@clock.call - entry.created_at) > timeout
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def existing_decision(id)
|
|
117
|
+
entry = @approvals[id]
|
|
118
|
+
|
|
119
|
+
if entry && expired?(entry)
|
|
120
|
+
@approvals.delete(id)
|
|
121
|
+
entry = nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return nil unless entry
|
|
125
|
+
return { action: :proceed } if entry.approved?
|
|
126
|
+
|
|
127
|
+
{ action: :block, reason: entry.reason }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def record_pending(tool_call, name)
|
|
131
|
+
entry = Approval.new(
|
|
132
|
+
tool_call_id: tool_call.id,
|
|
133
|
+
tool_name: name,
|
|
134
|
+
arguments: tool_call.arguments,
|
|
135
|
+
reason: reason_for(name),
|
|
136
|
+
status: :pending,
|
|
137
|
+
created_at: @clock.call,
|
|
138
|
+
approved_at: nil,
|
|
139
|
+
tool_call: tool_call
|
|
140
|
+
)
|
|
141
|
+
@approvals[tool_call.id] = entry
|
|
142
|
+
{ action: :block, reason: entry.reason }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def warn_approval(tool_call)
|
|
146
|
+
warn "[Permissions] Tool '#{tool_call.name}' requires approval. " \
|
|
147
|
+
"Call approve('#{tool_call.id}') to allow."
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# Matches declared rule patterns against tool names.
|
|
6
|
+
module ToolPattern
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def match?(pattern, tool_name)
|
|
10
|
+
name = tool_name.to_s
|
|
11
|
+
|
|
12
|
+
case pattern
|
|
13
|
+
when :all then true
|
|
14
|
+
when Regexp then pattern.match?(name)
|
|
15
|
+
else pattern.to_s == name
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ask/permissions/version'
|
|
4
|
+
require 'ask/permissions/errors'
|
|
5
|
+
require 'ask/permissions/tool_pattern'
|
|
6
|
+
require 'ask/permissions/permission_rules'
|
|
7
|
+
require 'ask/permissions/approval_queue'
|
|
8
|
+
require 'ask/permissions/permissions'
|
|
9
|
+
require 'ask/permissions/approval_policy'
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-permissions
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.25'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.25'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: mocha
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.1'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
description: Provides framework-independent permission rules, policies, and approval
|
|
55
|
+
queues for ask-rb agents and integrations.
|
|
56
|
+
email:
|
|
57
|
+
- kaka@myrrlabs.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- CONTRIBUTING.md
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- RELEASE.md
|
|
67
|
+
- VERSIONING.md
|
|
68
|
+
- lib/ask-permissions.rb
|
|
69
|
+
- lib/ask/permissions.rb
|
|
70
|
+
- lib/ask/permissions/approval_policy.rb
|
|
71
|
+
- lib/ask/permissions/approval_queue.rb
|
|
72
|
+
- lib/ask/permissions/errors.rb
|
|
73
|
+
- lib/ask/permissions/permission_rules.rb
|
|
74
|
+
- lib/ask/permissions/permissions.rb
|
|
75
|
+
- lib/ask/permissions/tool_pattern.rb
|
|
76
|
+
- lib/ask/permissions/version.rb
|
|
77
|
+
homepage: https://github.com/ask-rb/ask-permissions
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
homepage_uri: https://github.com/ask-rb/ask-permissions
|
|
82
|
+
source_code_uri: https://github.com/ask-rb/ask-permissions/tree/master/lib
|
|
83
|
+
changelog_uri: https://github.com/ask-rb/ask-permissions/blob/master/CHANGELOG.md
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '3.2'
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 4.0.18
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Permission rules and approval workflows for ask-rb.
|
|
102
|
+
test_files: []
|