ask-permissions 0.1.0 → 0.3.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 +22 -1
- data/README.md +98 -5
- data/VERSIONING.md +2 -1
- data/lib/ask/permissions/approval_policy.rb +83 -12
- data/lib/ask/permissions/approval_queue.rb +108 -16
- data/lib/ask/permissions/permission_rule_set.rb +100 -0
- data/lib/ask/permissions/permission_rules.rb +146 -0
- 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 +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c087f59df91e1c192c30e0e6740ad8fe8d514da85d2e1c14fe29f21df1bfeb0b
|
|
4
|
+
data.tar.gz: 6463c8e2123aa2b2035905a9fd8ef1df602f3ad9bdbe7f04afb6d8dd8e7acb63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4c4934516b4f0bb5d38c68eb23f0edcd2037f674aa20b3ad0df41ad3c0a52b197d9fd7543d47bbc89f45bf3455e7a551c0e2fbdb13f6b713a669363b2ec345a
|
|
7
|
+
data.tar.gz: 74c0911e5a65433aef1cd957262ea5d9074f8ff81cbd7bb017b1d6f76fd67cd2012ce3b6d2024a453752ed771fd9504b3441da7b2fefb1a468caffa25599b0d0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,28 @@ 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.3.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `PermissionRuleSet` for composing default and project rules with
|
|
15
|
+
deny-first precedence, plus versioned JSON-safe snapshots for portable
|
|
16
|
+
host-owned rule storage.
|
|
17
|
+
- Allow `ApprovalPolicy` to receive optional `project_rules:` while
|
|
18
|
+
preserving the existing `rules:` API.
|
|
19
|
+
## [0.2.0] - 2026-09-23
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Add a reusable plan-mode gate and make tool-declared human approval requirements override ordinary allow rules.
|
|
24
|
+
- Add JSON-safe pending approval snapshots for hosts that resume sessions after a restart.
|
|
25
|
+
- Add capability-aware approval modes for read-only, ask-before-changes, and full-access sessions.
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
## [0.1.0] - 2026-09-23
|
|
9
30
|
|
|
10
31
|
### Added
|
|
11
32
|
|
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
|
|
|
@@ -144,6 +155,31 @@ rules.allow "read_file", %r{/docs/} # Regexp against the serialized args
|
|
|
144
155
|
rules.allow "search" # any arguments
|
|
145
156
|
```
|
|
146
157
|
|
|
158
|
+
### Project rule layers and snapshots
|
|
159
|
+
|
|
160
|
+
Compose application defaults with host-selected project rules using
|
|
161
|
+
`PermissionRuleSet`. A deny in either layer wins; otherwise a matching
|
|
162
|
+
project decision overrides the default, and an unmatched project rule falls
|
|
163
|
+
back to the default layer.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
defaults = Ask::Permissions::PermissionRules.new { deny :bash, /rm\s+-rf/ }
|
|
167
|
+
project = Ask::Permissions::PermissionRules.new { allow :read_file }
|
|
168
|
+
rules = Ask::Permissions::PermissionRuleSet.new(
|
|
169
|
+
default_rules: defaults,
|
|
170
|
+
project_rules: project
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
rules.classify(:bash, { command: "rm -rf /tmp" }) # => :deny
|
|
174
|
+
rules.classify(:read_file) # => :allow
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`PermissionRules#snapshot` and `PermissionRuleSet#snapshot` produce
|
|
178
|
+
versioned, JSON-safe data restored with `.from_snapshot`. The gem does not
|
|
179
|
+
persist rules or resolve project identity: the host must authorize and scope
|
|
180
|
+
the storage itself. Pass a project's rules to `ApprovalPolicy` with
|
|
181
|
+
`project_rules:` alongside default `rules:`.
|
|
182
|
+
|
|
147
183
|
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
184
|
|
|
149
185
|
### Dangerous allow downgrade
|
|
@@ -190,10 +226,20 @@ policy = Ask::Permissions::ApprovalPolicy.new(
|
|
|
190
226
|
queue: queue, # required
|
|
191
227
|
rules: rules, # optional
|
|
192
228
|
require_approval: ["bash", /^write_/], # optional
|
|
193
|
-
tools: {"fetch" => fetch_tool}
|
|
229
|
+
tools: {"fetch" => fetch_tool}, # optional registry
|
|
230
|
+
session_grants: session_grants, # optional SessionPermissionGrants
|
|
231
|
+
project_grants: project_grants # optional host-owned collaborator responding to granted?(tool_name)
|
|
194
232
|
)
|
|
195
233
|
```
|
|
196
234
|
|
|
235
|
+
The optional `mode:` applies declared tool capabilities consistently: `:read_only`
|
|
236
|
+
blocks side-effecting or undeclared-scope tools, `:ask_before_changes` queues
|
|
237
|
+
them for a person, and `:full_access` bypasses ordinary risk gates. Explicit
|
|
238
|
+
`ask`/`deny` rules still apply in every mode, and a tool's `always_ask?` remains
|
|
239
|
+
non-bypassable. Tools with `:high` or `:critical` risk are queued unless an
|
|
240
|
+
explicit allow rule or `:full_access` mode permits them; risk-gated approvals
|
|
241
|
+
are never auto-approved.
|
|
242
|
+
|
|
197
243
|
### The hook: `before_tool_call`
|
|
198
244
|
|
|
199
245
|
`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 +252,15 @@ policy = Ask::Permissions::ApprovalPolicy.new(
|
|
|
206
252
|
|
|
207
253
|
Resolution order — the first layer with an opinion wins:
|
|
208
254
|
|
|
209
|
-
1.
|
|
210
|
-
2.
|
|
211
|
-
3. **
|
|
255
|
+
1. **Explicit `deny` rule** — always blocks with the exact reason `"Denied by permission rules: '<name>'"`. Neither session nor project grants bypass it.
|
|
256
|
+
2. **Tool `always_ask?`** — always enqueues with `auto_approvable: false` and cannot be bypassed by an `allow` rule, `:full_access`, or either grants collaborator.
|
|
257
|
+
3. **`:read_only` mode** — blocks tools whose side-effect scope is not `:none`. Neither session nor project grants bypass it.
|
|
258
|
+
4. **Explicit `allow` rule** — proceeds. An explicit `allow` therefore wins over `require_approval`.
|
|
259
|
+
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.
|
|
260
|
+
6. **Ordinary `ask` rule** — enqueues with `auto_approvable: false`.
|
|
261
|
+
7. **`:full_access` mode** — proceeds (except `always_ask?` above).
|
|
262
|
+
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).
|
|
263
|
+
9. **Default** — otherwise the call proceeds. An unconfigured policy allows everything.
|
|
212
264
|
|
|
213
265
|
`require_approval` accepts `nil`, `:all` (queue every tool), a `String`/`Symbol` (exact name), a `Regexp`, or an `Array` of those (any match).
|
|
214
266
|
|
|
@@ -232,7 +284,48 @@ queue.approve(action.id) # fires on_approve
|
|
|
232
284
|
queue.reject(action.id) # fires on_reject
|
|
233
285
|
```
|
|
234
286
|
|
|
235
|
-
Readers: `policy.queue`, `policy.rules`, `policy.require_approval`, `policy.tools`.
|
|
287
|
+
Readers: `policy.queue`, `policy.rules`, `policy.require_approval`, `policy.tools`, `policy.session_grants`, `policy.project_grants`.
|
|
288
|
+
|
|
289
|
+
### Session-scoped grants: `SessionPermissionGrants`
|
|
290
|
+
|
|
291
|
+
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:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
grants = Ask::Permissions::SessionPermissionGrants.new
|
|
295
|
+
grants.grant("bash") # Symbol or String; duplicate grants are idempotent
|
|
296
|
+
grants.granted?("bash") # => true
|
|
297
|
+
grants.revoke("bash") # => self; unknown tools are a noop
|
|
298
|
+
grants.granted_tools # => ["bash"] (sorted Strings)
|
|
299
|
+
grants.clear
|
|
300
|
+
|
|
301
|
+
policy = Ask::Permissions::ApprovalPolicy.new(queue: queue, require_approval: "bash", session_grants: grants)
|
|
302
|
+
policy.before_tool_call(tool_call, context) # => {action: :proceed} while granted
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
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.
|
|
306
|
+
|
|
307
|
+
### Project-scoped grants: `project_grants`
|
|
308
|
+
|
|
309
|
+
`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:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
policy = Ask::Permissions::ApprovalPolicy.new(
|
|
313
|
+
queue: queue,
|
|
314
|
+
require_approval: "bash",
|
|
315
|
+
session_grants: session_grants, # optional, nil by default
|
|
316
|
+
project_grants: project_grants # optional, nil by default
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
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.
|
|
321
|
+
|
|
322
|
+
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`:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
snapshot = grants.snapshot
|
|
326
|
+
restored = Ask::Permissions::SessionPermissionGrants.from_snapshot(JSON.parse(JSON.generate(snapshot)))
|
|
327
|
+
restored.granted?("bash") # => true
|
|
328
|
+
```
|
|
236
329
|
|
|
237
330
|
### Auto-approval through the policy
|
|
238
331
|
|
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,55 @@ 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
|
+
:project_rules
|
|
12
|
+
|
|
13
|
+
def initialize(queue:, require_approval: nil, rules: nil, tools: nil, mode: nil, session_grants: nil,
|
|
14
|
+
project_grants: nil, project_rules: nil)
|
|
15
|
+
raise ArgumentError, "Unknown permission mode: #{mode.inspect}" if mode && !MODES.include?(mode.to_sym)
|
|
8
16
|
|
|
9
|
-
def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
|
|
10
17
|
@queue = queue
|
|
11
18
|
@require_approval = require_approval
|
|
12
|
-
@rules = rules
|
|
13
19
|
@tools = tools
|
|
20
|
+
@mode = mode&.to_sym
|
|
21
|
+
@session_grants = session_grants
|
|
22
|
+
@project_grants = project_grants
|
|
23
|
+
@project_rules = project_rules
|
|
24
|
+
|
|
25
|
+
@rules = if project_rules
|
|
26
|
+
PermissionRuleSet.new(default_rules: rules, project_rules: project_rules)
|
|
27
|
+
else
|
|
28
|
+
rules
|
|
29
|
+
end
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
def before_tool_call(tool_call, _context = nil)
|
|
17
33
|
name = tool_call.name.to_s
|
|
18
34
|
args = tool_call.arguments
|
|
19
35
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
rule_decision = rules&.classify(name, args)
|
|
37
|
+
return { action: :block, reason: "Denied by permission rules: '#{name}'" } if rule_decision == :deny
|
|
38
|
+
|
|
39
|
+
# A tool's explicit human-confirmation requirement is a hard safety
|
|
40
|
+
# boundary: an ordinary allow rule must not be able to bypass it.
|
|
41
|
+
return enqueue(tool_call, auto_approvable: false) if always_ask?(name)
|
|
42
|
+
|
|
43
|
+
if mode == :read_only && side_effect_scope(name) != :none
|
|
44
|
+
return { action: :block, reason: "Read-only mode blocks tools with side effects (#{name})" }
|
|
27
45
|
end
|
|
28
46
|
|
|
29
|
-
return { action: :proceed }
|
|
47
|
+
return { action: :proceed } if rule_decision == :allow
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
# In-session and project whole-tool grants bypass ordinary ask rules,
|
|
50
|
+
# approval_required gates, high-risk gates, and ask_before_changes
|
|
51
|
+
# side-effect prompts. They never bypass deny, always_ask, or
|
|
52
|
+
# read_only above.
|
|
53
|
+
return { action: :proceed } if granted?(name)
|
|
54
|
+
|
|
55
|
+
fallback_decision(tool_call, name, rule_decision)
|
|
32
56
|
end
|
|
33
57
|
|
|
34
58
|
def lookup(action_id)
|
|
@@ -59,6 +83,53 @@ module Ask
|
|
|
59
83
|
!!(tool && tool.respond_to?(:auto_approvable?) && tool.auto_approvable?)
|
|
60
84
|
end
|
|
61
85
|
|
|
86
|
+
def always_ask?(name)
|
|
87
|
+
tool = find_tool(name)
|
|
88
|
+
!!(tool && tool.respond_to?(:always_ask?) && tool.always_ask?)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def session_granted?(name)
|
|
92
|
+
grants = session_grants
|
|
93
|
+
!!(grants && grants.respond_to?(:granted?) && grants.granted?(name))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def project_granted?(name)
|
|
97
|
+
grants = project_grants
|
|
98
|
+
!!(grants && grants.respond_to?(:granted?) && grants.granted?(name))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def granted?(name)
|
|
102
|
+
session_granted?(name) || project_granted?(name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fallback_decision(tool_call, name, rule_decision)
|
|
106
|
+
return enqueue(tool_call, auto_approvable: false) if rule_decision == :ask
|
|
107
|
+
return { action: :proceed } if mode == :full_access
|
|
108
|
+
|
|
109
|
+
if mode == :ask_before_changes && side_effect_scope(name) != :none
|
|
110
|
+
return enqueue(tool_call, auto_approvable: false)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return enqueue(tool_call, auto_approvable: false) if elevated_risk?(name)
|
|
114
|
+
return { action: :proceed } unless approval_required?(name)
|
|
115
|
+
|
|
116
|
+
enqueue(tool_call, auto_approvable: auto_approvable?(name))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def elevated_risk?(name)
|
|
120
|
+
tool = find_tool(name)
|
|
121
|
+
risk = tool.risk_level if tool&.respond_to?(:risk_level)
|
|
122
|
+
risk = risk.to_sym if risk.respond_to?(:to_sym)
|
|
123
|
+
%i[high critical].include?(risk)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def side_effect_scope(name)
|
|
127
|
+
tool = find_tool(name)
|
|
128
|
+
scope = tool.side_effect_scope if tool&.respond_to?(:side_effect_scope)
|
|
129
|
+
scope = scope.to_sym if scope.respond_to?(:to_sym)
|
|
130
|
+
SIDE_EFFECT_SCOPES.include?(scope) ? scope : :unknown
|
|
131
|
+
end
|
|
132
|
+
|
|
62
133
|
def find_tool(name)
|
|
63
134
|
case tools
|
|
64
135
|
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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Permissions
|
|
5
|
+
# Composes default and project rule layers into a single classifier.
|
|
6
|
+
#
|
|
7
|
+
# Resolution: any matched :deny from either layer wins. Otherwise the
|
|
8
|
+
# project decision wins over the default. If neither layer matches,
|
|
9
|
+
# classify returns nil.
|
|
10
|
+
class PermissionRuleSet
|
|
11
|
+
SNAPSHOT_VERSION = 1
|
|
12
|
+
|
|
13
|
+
attr_reader :default_rules, :project_rules
|
|
14
|
+
|
|
15
|
+
def initialize(default_rules: nil, project_rules: nil)
|
|
16
|
+
validate_layer!(default_rules, 'default_rules')
|
|
17
|
+
validate_layer!(project_rules, 'project_rules')
|
|
18
|
+
|
|
19
|
+
@default_rules = default_rules
|
|
20
|
+
@project_rules = project_rules
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def classify(tool_name, args = nil)
|
|
24
|
+
default_decision = @default_rules&.classify(tool_name, args)
|
|
25
|
+
project_decision = @project_rules&.classify(tool_name, args)
|
|
26
|
+
|
|
27
|
+
return :deny if default_decision == :deny || project_decision == :deny
|
|
28
|
+
|
|
29
|
+
project_decision || default_decision
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def snapshot
|
|
33
|
+
{
|
|
34
|
+
version: SNAPSHOT_VERSION,
|
|
35
|
+
default_rules: snapshot_layer(@default_rules, 'default_rules'),
|
|
36
|
+
project_rules: snapshot_layer(@project_rules, 'project_rules')
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.from_snapshot(snapshot)
|
|
41
|
+
raise ArgumentError, 'Snapshot must be a Hash' unless snapshot.is_a?(Hash)
|
|
42
|
+
validate_snapshot_keys!(snapshot, %i[version default_rules project_rules])
|
|
43
|
+
|
|
44
|
+
version = snapshot_value(snapshot, :version)
|
|
45
|
+
raise ArgumentError, "Unsupported snapshot version: #{version.inspect}" unless version == 1
|
|
46
|
+
|
|
47
|
+
default_raw = snapshot_value(snapshot, :default_rules)
|
|
48
|
+
project_raw = snapshot_value(snapshot, :project_rules)
|
|
49
|
+
validate_snapshot_layer!(default_raw, 'default_rules')
|
|
50
|
+
validate_snapshot_layer!(project_raw, 'project_rules')
|
|
51
|
+
|
|
52
|
+
default_rules = default_raw ? PermissionRules.from_snapshot(default_raw) : nil
|
|
53
|
+
project_rules = project_raw ? PermissionRules.from_snapshot(project_raw) : nil
|
|
54
|
+
|
|
55
|
+
new(default_rules: default_rules, project_rules: project_rules)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def self.snapshot_value(hash, key)
|
|
61
|
+
return hash[key] if hash.key?(key)
|
|
62
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "Snapshot is missing #{key}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.validate_snapshot_keys!(hash, allowed)
|
|
68
|
+
keys = hash.keys.map(&:to_s)
|
|
69
|
+
unknown = keys - allowed.map(&:to_s)
|
|
70
|
+
raise ArgumentError, "Snapshot has unknown fields: #{unknown.join(', ')}" unless unknown.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.validate_snapshot_layer!(value, name)
|
|
74
|
+
return if value.nil? || value.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "Snapshot #{name} must be a Hash or nil"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def snapshot_layer(layer, name)
|
|
80
|
+
return nil unless layer
|
|
81
|
+
unless layer.respond_to?(:snapshot)
|
|
82
|
+
raise ArgumentError, "#{name} must respond to :snapshot to be serialized"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
snapshot = layer.snapshot
|
|
86
|
+
raise ArgumentError, "#{name} snapshot must be a Hash" unless snapshot.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
snapshot
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_layer!(layer, name)
|
|
92
|
+
return if layer.nil?
|
|
93
|
+
|
|
94
|
+
unless layer.respond_to?(:classify)
|
|
95
|
+
raise ArgumentError, "#{name} must respond to :classify"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -7,6 +7,9 @@ module Ask
|
|
|
7
7
|
module Permissions
|
|
8
8
|
# Evaluates tool-invocation rules and returns allow/ask/deny decisions.
|
|
9
9
|
class PermissionRules
|
|
10
|
+
SNAPSHOT_VERSION = 1
|
|
11
|
+
VALID_DECISIONS = %w[allow ask deny].freeze
|
|
12
|
+
VALID_PATTERN_TYPES = %w[string symbol regexp all].freeze
|
|
10
13
|
DANGEROUS_TOOLS = %i[bash code repl].freeze
|
|
11
14
|
|
|
12
15
|
Rule = Data.define(
|
|
@@ -74,6 +77,58 @@ module Ask
|
|
|
74
77
|
@mutex.synchronize { @dangerous_rules.dup }
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
def snapshot
|
|
81
|
+
entries = rules.map do |rule|
|
|
82
|
+
entry = {
|
|
83
|
+
decision: rule.declared_decision.to_s,
|
|
84
|
+
tool_pattern: serialize_pattern(rule.tool_pattern)
|
|
85
|
+
}
|
|
86
|
+
entry[:argument_pattern] = serialize_pattern(rule.argument_pattern) if rule.argument_pattern
|
|
87
|
+
entry
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
{ version: SNAPSHOT_VERSION, auto_allow_dangerous: @auto_allow_dangerous, rules: entries }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.from_snapshot(snapshot)
|
|
94
|
+
raise ArgumentError, 'Snapshot must be a Hash' unless snapshot.is_a?(Hash)
|
|
95
|
+
validate_snapshot_keys!(snapshot, %i[version auto_allow_dangerous rules], 'snapshot')
|
|
96
|
+
|
|
97
|
+
version = snapshot_value(snapshot, :version)
|
|
98
|
+
raise ArgumentError, "Unsupported snapshot version: #{version.inspect}" unless version == 1
|
|
99
|
+
|
|
100
|
+
auto_allow = snapshot_value(snapshot, :auto_allow_dangerous)
|
|
101
|
+
unless [true, false].include?(auto_allow)
|
|
102
|
+
raise ArgumentError, 'Snapshot auto_allow_dangerous must be true or false'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
raw_rules = snapshot_value(snapshot, :rules)
|
|
106
|
+
raise ArgumentError, 'Snapshot rules must be an Array' unless raw_rules.is_a?(Array)
|
|
107
|
+
|
|
108
|
+
instance = new(auto_allow_dangerous: !!auto_allow)
|
|
109
|
+
raw_rules.each do |entry|
|
|
110
|
+
raise ArgumentError, 'Snapshot rule entry must be a Hash' unless entry.is_a?(Hash)
|
|
111
|
+
validate_snapshot_keys!(entry, %i[decision tool_pattern argument_pattern], 'rule entry')
|
|
112
|
+
|
|
113
|
+
decision_str = snapshot_value(entry, :decision)
|
|
114
|
+
raise ArgumentError, 'Rule entry missing decision' if decision_str.nil?
|
|
115
|
+
unless decision_str.is_a?(String) && VALID_DECISIONS.include?(decision_str)
|
|
116
|
+
raise ArgumentError, "Invalid decision: #{decision_str.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
tool_pattern_raw = snapshot_value(entry, :tool_pattern)
|
|
120
|
+
raise ArgumentError, 'Rule entry missing tool_pattern' if tool_pattern_raw.nil?
|
|
121
|
+
tool_pattern = deserialize_pattern(tool_pattern_raw)
|
|
122
|
+
|
|
123
|
+
argument_pattern_raw = optional_snapshot_value(entry, :argument_pattern)
|
|
124
|
+
argument_pattern = deserialize_pattern(argument_pattern_raw) unless argument_pattern_raw.nil?
|
|
125
|
+
|
|
126
|
+
instance.send(:register, decision_str.to_sym, tool_pattern, argument_pattern)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
instance
|
|
130
|
+
end
|
|
131
|
+
|
|
77
132
|
def classify(tool_name, args = nil)
|
|
78
133
|
rule = rules.find { |candidate| candidate.matches?(tool_name, args) }
|
|
79
134
|
rule&.effective_decision
|
|
@@ -93,6 +148,26 @@ module Ask
|
|
|
93
148
|
|
|
94
149
|
private
|
|
95
150
|
|
|
151
|
+
def self.snapshot_value(hash, key)
|
|
152
|
+
return hash[key] if hash.key?(key)
|
|
153
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "Snapshot is missing #{key}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.optional_snapshot_value(hash, key)
|
|
159
|
+
return hash[key] if hash.key?(key)
|
|
160
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
161
|
+
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.validate_snapshot_keys!(hash, allowed, name)
|
|
166
|
+
keys = hash.keys.map(&:to_s)
|
|
167
|
+
unknown = keys - allowed.map(&:to_s)
|
|
168
|
+
raise ArgumentError, "Snapshot #{name} has unknown fields: #{unknown.join(', ')}" unless unknown.empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
96
171
|
def register(decision, tool_pattern, argument_pattern)
|
|
97
172
|
dangerous = dangerous_allow?(decision, tool_pattern, argument_pattern)
|
|
98
173
|
effective = dangerous && !@auto_allow_dangerous ? :ask : decision
|
|
@@ -124,6 +199,77 @@ module Ask
|
|
|
124
199
|
|
|
125
200
|
DANGEROUS_TOOLS.include?(tool_pattern.to_s.to_sym)
|
|
126
201
|
end
|
|
202
|
+
|
|
203
|
+
REGEXP_FLAG_MAP = {
|
|
204
|
+
'i' => Regexp::IGNORECASE,
|
|
205
|
+
'm' => Regexp::MULTILINE,
|
|
206
|
+
'x' => Regexp::EXTENDED
|
|
207
|
+
}.freeze
|
|
208
|
+
|
|
209
|
+
def serialize_pattern(pattern)
|
|
210
|
+
case pattern
|
|
211
|
+
when Regexp
|
|
212
|
+
supported_options = Regexp::IGNORECASE | Regexp::MULTILINE | Regexp::EXTENDED
|
|
213
|
+
if (pattern.options & ~supported_options).nonzero?
|
|
214
|
+
raise ArgumentError, "Unsupported regexp options: #{pattern.options}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
flags_str = +''
|
|
218
|
+
flags_str << 'i' if (pattern.options & Regexp::IGNORECASE).nonzero?
|
|
219
|
+
flags_str << 'm' if (pattern.options & Regexp::MULTILINE).nonzero?
|
|
220
|
+
flags_str << 'x' if (pattern.options & Regexp::EXTENDED).nonzero?
|
|
221
|
+
{ type: 'regexp', source: pattern.source, flags: flags_str }
|
|
222
|
+
when Symbol
|
|
223
|
+
pattern == :all ? { type: 'all' } : { type: 'symbol', value: pattern.to_s }
|
|
224
|
+
when String
|
|
225
|
+
{ type: 'string', value: pattern }
|
|
226
|
+
else
|
|
227
|
+
raise ArgumentError, "Unsupported permission pattern type: #{pattern.class}"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.deserialize_pattern(raw)
|
|
232
|
+
raise ArgumentError, 'Pattern must be a Hash' unless raw.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
type = snapshot_value(raw, :type)
|
|
235
|
+
raise ArgumentError, "Invalid pattern type: #{type.inspect}" unless type.is_a?(String) && VALID_PATTERN_TYPES.include?(type)
|
|
236
|
+
|
|
237
|
+
allowed_keys = case type
|
|
238
|
+
when 'string', 'symbol' then %i[type value]
|
|
239
|
+
when 'regexp' then %i[type source flags]
|
|
240
|
+
when 'all' then %i[type]
|
|
241
|
+
end
|
|
242
|
+
validate_snapshot_keys!(raw, allowed_keys, 'pattern')
|
|
243
|
+
|
|
244
|
+
case type
|
|
245
|
+
when 'string'
|
|
246
|
+
value = snapshot_value(raw, :value)
|
|
247
|
+
raise ArgumentError, 'String pattern value must be a String' unless value.is_a?(String)
|
|
248
|
+
|
|
249
|
+
value
|
|
250
|
+
when 'symbol'
|
|
251
|
+
value = snapshot_value(raw, :value)
|
|
252
|
+
raise ArgumentError, 'Symbol pattern value must be a String' unless value.is_a?(String)
|
|
253
|
+
|
|
254
|
+
value.to_sym
|
|
255
|
+
when 'regexp'
|
|
256
|
+
source = snapshot_value(raw, :source)
|
|
257
|
+
flags_str = snapshot_value(raw, :flags)
|
|
258
|
+
raise ArgumentError, 'Regexp source must be a String' unless source.is_a?(String)
|
|
259
|
+
unless flags_str.is_a?(String) && flags_str.chars.uniq == flags_str.chars && flags_str.chars.all? { |flag| REGEXP_FLAG_MAP.key?(flag) }
|
|
260
|
+
raise ArgumentError, "Invalid regexp flags: #{flags_str.inspect}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
flags = flags_str.chars.reduce(0) { |sum, ch| sum | REGEXP_FLAG_MAP.fetch(ch) }
|
|
264
|
+
Regexp.new(source, flags)
|
|
265
|
+
when 'all'
|
|
266
|
+
:all
|
|
267
|
+
end
|
|
268
|
+
rescue RegexpError, TypeError => error
|
|
269
|
+
raise ArgumentError, "Invalid regexp pattern: #{error.message}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private_class_method :deserialize_pattern
|
|
127
273
|
end
|
|
128
274
|
end
|
|
129
275
|
end
|
|
@@ -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
|
@@ -4,6 +4,9 @@ require 'ask/permissions/version'
|
|
|
4
4
|
require 'ask/permissions/errors'
|
|
5
5
|
require 'ask/permissions/tool_pattern'
|
|
6
6
|
require 'ask/permissions/permission_rules'
|
|
7
|
+
require 'ask/permissions/permission_rule_set'
|
|
7
8
|
require 'ask/permissions/approval_queue'
|
|
8
9
|
require 'ask/permissions/permissions'
|
|
9
10
|
require 'ask/permissions/approval_policy'
|
|
11
|
+
require 'ask/permissions/plan_mode_policy'
|
|
12
|
+
require 'ask/permissions/session_permission_grants'
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -70,8 +70,11 @@ files:
|
|
|
70
70
|
- lib/ask/permissions/approval_policy.rb
|
|
71
71
|
- lib/ask/permissions/approval_queue.rb
|
|
72
72
|
- lib/ask/permissions/errors.rb
|
|
73
|
+
- lib/ask/permissions/permission_rule_set.rb
|
|
73
74
|
- lib/ask/permissions/permission_rules.rb
|
|
74
75
|
- lib/ask/permissions/permissions.rb
|
|
76
|
+
- lib/ask/permissions/plan_mode_policy.rb
|
|
77
|
+
- lib/ask/permissions/session_permission_grants.rb
|
|
75
78
|
- lib/ask/permissions/tool_pattern.rb
|
|
76
79
|
- lib/ask/permissions/version.rb
|
|
77
80
|
homepage: https://github.com/ask-rb/ask-permissions
|