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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +25 -0
- data/lib/ask/permissions/approval_policy.rb +10 -3
- data/lib/ask/permissions/permission_rule_set.rb +100 -0
- data/lib/ask/permissions/permission_rules.rb +146 -0
- data/lib/ask/permissions/version.rb +1 -1
- data/lib/ask/permissions.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c087f59df91e1c192c30e0e6740ad8fe8d514da85d2e1c14fe29f21df1bfeb0b
|
|
4
|
+
data.tar.gz: 6463c8e2123aa2b2035905a9fd8ef1df602f3ad9bdbe7f04afb6d8dd8e7acb63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4c4934516b4f0bb5d38c68eb23f0edcd2037f674aa20b3ad0df41ad3c0a52b197d9fd7543d47bbc89f45bf3455e7a551c0e2fbdb13f6b713a669363b2ec345a
|
|
7
|
+
data.tar.gz: 74c0911e5a65433aef1cd957262ea5d9074f8ff81cbd7bb017b1d6f76fd67cd2012ce3b6d2024a453752ed771fd9504b3441da7b2fefb1a468caffa25599b0d0
|
data/CHANGELOG.md
CHANGED
|
@@ -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
|
data/lib/ask/permissions.rb
CHANGED
|
@@ -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.
|
|
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
|