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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '073941c27cb5785b8101b3fac162f47382cec0de2034b47f63edcd757c2b032f'
4
- data.tar.gz: 5675a346d09b4a52eabaa85b2795bc5dc5651d3e34afce230cff0745d723e598
3
+ metadata.gz: c087f59df91e1c192c30e0e6740ad8fe8d514da85d2e1c14fe29f21df1bfeb0b
4
+ data.tar.gz: 6463c8e2123aa2b2035905a9fd8ef1df602f3ad9bdbe7f04afb6d8dd8e7acb63
5
5
  SHA512:
6
- metadata.gz: ea269312c2aa211ccb2b51a1133ce74f7ecb2f9d6bdab68440abc62289c3d6aebeab496c2e15ce330c9642de816c7b8098db565de4bfd6974790836c04de26b8
7
- data.tar.gz: 050ad80d943e96accd166946da442b8705792cb1c25d291eae3b4988b5ec5f2faa047823566a311a98928a927e2a6f2997668612a8502faa3e653fd99ba81c77
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
- ## [0.1.0] - Unreleased
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} # optional registry
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. **`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.
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
- - No release has been made yet: `0.1.0` stays `Unreleased` until `gemchain` publishes it.
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
- attr_reader :queue, :require_approval, :rules, :tools
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
- 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)
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 } unless approval_required?(name)
47
+ return { action: :proceed } if rule_decision == :allow
30
48
 
31
- enqueue(tool_call, auto_approvable: auto_approvable?(name))
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
- resolve_all(ids) { |action| apply(action) }
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 apply(action)
125
- resolve(action.id, @on_approve, :approved)
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 reject_action(action)
129
- resolve(action.id, @on_reject, :rejected)
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 resolve(id, callback, status)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Permissions
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -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.1.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