ask-permissions 0.1.0

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