ask-permissions 0.1.0 → 0.2.0

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