ask-permissions 0.2.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: af1fd0c9368ebb102f0ca805b7303d781bf29e862ea19145fc4efcf24025cbe9
4
- data.tar.gz: 4ce78f5aaf769b55fde844e1de746c319bcbc33001e7e9cb1f90a25e1c9a4850
3
+ metadata.gz: c087f59df91e1c192c30e0e6740ad8fe8d514da85d2e1c14fe29f21df1bfeb0b
4
+ data.tar.gz: 6463c8e2123aa2b2035905a9fd8ef1df602f3ad9bdbe7f04afb6d8dd8e7acb63
5
5
  SHA512:
6
- metadata.gz: ab3cd320947d7426dfececa4f73625df5d073fc751249521ac93e4104eaaa36d43512d30f6b9c92e25be60c9eafc26b7327f0bc9593ca2f80d614c7cba5efc44
7
- data.tar.gz: 29e4857b244341160b6fd856c056c5fc3f098bbf350b9f07424c8ab8b3c90de3bac55c505c5b0e2d461d2a5efa22a73e9bc3c943a989807bb747771b3d8e1982
6
+ metadata.gz: e4c4934516b4f0bb5d38c68eb23f0edcd2037f674aa20b3ad0df41ad3c0a52b197d9fd7543d47bbc89f45bf3455e7a551c0e2fbdb13f6b713a669363b2ec345a
7
+ data.tar.gz: 74c0911e5a65433aef1cd957262ea5d9074f8ff81cbd7bb017b1d6f76fd67cd2012ce3b6d2024a453752ed771fd9504b3441da7b2fefb1a468caffa25599b0d0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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.
10
19
  ## [0.2.0] - 2026-09-23
11
20
 
12
21
  ### Added
data/README.md CHANGED
@@ -155,6 +155,31 @@ rules.allow "read_file", %r{/docs/} # Regexp against the serialized args
155
155
  rules.allow "search" # any arguments
156
156
  ```
157
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
+
158
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`.
159
184
 
160
185
  ### Dangerous allow downgrade
@@ -7,19 +7,26 @@ module Ask
7
7
  MODES = %i[full_access ask_before_changes read_only].freeze
8
8
  SIDE_EFFECT_SCOPES = %i[none session workspace project system external unknown].freeze
9
9
 
10
- attr_reader :queue, :require_approval, :rules, :tools, :mode, :session_grants, :project_grants
10
+ attr_reader :queue, :require_approval, :rules, :tools, :mode, :session_grants, :project_grants,
11
+ :project_rules
11
12
 
12
13
  def initialize(queue:, require_approval: nil, rules: nil, tools: nil, mode: nil, session_grants: nil,
13
- project_grants: nil)
14
+ project_grants: nil, project_rules: nil)
14
15
  raise ArgumentError, "Unknown permission mode: #{mode.inspect}" if mode && !MODES.include?(mode.to_sym)
15
16
 
16
17
  @queue = queue
17
18
  @require_approval = require_approval
18
- @rules = rules
19
19
  @tools = tools
20
20
  @mode = mode&.to_sym
21
21
  @session_grants = session_grants
22
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
23
30
  end
24
31
 
25
32
  def before_tool_call(tool_call, _context = nil)
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Permissions
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -4,6 +4,7 @@ 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'
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -70,6 +70,7 @@ 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
75
76
  - lib/ask/permissions/plan_mode_policy.rb