ask-agent 0.30.0 → 0.31.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: 58b285b250efd49063ba2fb5c078ea3107d3ebd583ab33b4c92c07084280a242
4
- data.tar.gz: 1d6df4327981ec16e652cedbb864112721db2a6ae1c1c5d7d74ecacfe4a6aa38
3
+ metadata.gz: e039a90d18224ab29b454dd2094a81628ddf80cc39938419038378182ecbe472
4
+ data.tar.gz: 8d6d1a8005d2451469a998d36061adea945245beef774876b5b5bc1565f8fd5a
5
5
  SHA512:
6
- metadata.gz: 1adec012cf31271f1039068e6329c172981d631c221e5d18ce21fe2f19d2449bceac06a4fb4e8845b762c59b263b20ba947cd70463947b389e70f618beda501e
7
- data.tar.gz: 84cdadbbf141991d3c294e06ec9f42e689835407bf3a2577d6ce8a9e173bd3eb21030313e0a59b3663f6718bee88b245deab144aaeda440e443ae176877af4cb
6
+ metadata.gz: 014b65f0247e65970d3d4fa7496744aa7d38f00f2684c53ff02f74ec5c547b6534c906b24dc6594387069e5034b1916e7f4e3a2986c6bf55e42412177ac7a678
7
+ data.tar.gz: 72f571ba1ffafca8f9e9f181d51891ce9ea315e4ca5ebc15e1bf27557e8c05faf9ed6f3136a0a88a124f6833f096b5bb7d6cc9d7db9b32bc1dc0a27eb6cff1a9
data/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## [0.31.0] — 2026-08-06
2
+
3
+ ### Added
4
+
5
+ - **Permission rules — persisted allow/ask/deny patterns for tool calls.**
6
+ `Ask::Agent::Policies::PermissionRules` classifies every call before it
7
+ executes or prompts, so "approve once, remember the pattern" replaces
8
+ per-call prompting:
9
+ - DSL in declaration order (first match wins): `allow`, `ask`, `deny`
10
+ with a tool pattern (String, Symbol, Regexp, or `:all`) and an optional
11
+ argument pattern (Regexp, substring, or `nil` for any).
12
+ - Wire in via `Session.new(approval: { rules: rules })`. Rules take
13
+ precedence over a tool's own `approval_required` / `auto_approvable`
14
+ declarations: `:deny` blocks, `:allow` proceeds without the queue,
15
+ `:ask` queues regardless of auto-approvable.
16
+ - **Dangerous-rule guard**: an unrestricted `:allow` on a code-executing
17
+ tool (`bash`, `code`, `repl`, or `:all`) is downgraded to `:ask` unless
18
+ the ruleset is created with `auto_allow_dangerous: true` — "approve
19
+ once" can't become "approve anything". `dangerous_rules` reports which
20
+ rules were affected.
21
+
22
+ ## [0.30.1] — 2026-08-06
23
+
24
+ ### Fixed
25
+
26
+ - **Framework-injected tools are no longer persisted in session metadata.**
27
+ The built-in `load_skill` tool was saved alongside user tools and could
28
+ not be auto-instantiated on load (`LoadSkillTool` requires a registry), so
29
+ the previous 0.30.0 fix skipped it with a broad rescue. Persisted metadata
30
+ now contains only user-supplied tools (`persisted_tools`); `load_skill` is
31
+ re-added by `resolve_tools` per session with a proper registry.
32
+ - **`Session.load` no longer swallows tool-restore failures silently.** The
33
+ safety net now rescues only `NameError` (renamed/removed classes) and
34
+ `ArgumentError` (constructors with required args), and warns with the
35
+ tool name instead of failing the whole load — genuine tool bugs surface
36
+ instead of disappearing.
37
+
1
38
  ## [0.30.0] — 2026-08-06
2
39
 
3
40
  ### Added
@@ -32,13 +32,18 @@ module Ask
32
32
  # rule-based classification on top of the tool's own declaration.
33
33
  # Strings match tool names exactly, Regexps match against the name,
34
34
  # and :all requires approval for every tool call.
35
+ # @param rules [Ask::Agent::Policies::PermissionRules, nil] persisted
36
+ # allow/ask/deny patterns. Rules classify first and take precedence
37
+ # over tool declarations: :deny blocks, :allow proceeds without the
38
+ # queue, :ask queues regardless of auto-approvable.
35
39
  # @param tools [Array<Object>, nil] the session's resolved tool
36
40
  # instances, used to read class-level declarations
37
41
  # (`approval_required`, `auto_approvable`). When nil, only the
38
42
  # rule-based lists classify calls.
39
- def initialize(queue:, require_approval: nil, tools: nil)
43
+ def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
40
44
  @queue = queue
41
45
  @require_approval = require_approval
46
+ @rules = rules
42
47
  @tools = Array(tools)
43
48
  end
44
49
 
@@ -46,12 +51,27 @@ module Ask
46
51
  #
47
52
  # @param tool_call [Ask::Agent::ToolCallInfo]
48
53
  # @param _context [Hash]
49
- # @return [Hash] {action: :proceed} to run, or
50
- # {action: :pending, action_id:, reason:} to queue for approval
54
+ # @return [Hash] {action: :proceed} to run, {action: :pending,
55
+ # action_id:, reason:} to queue for approval, or {action: :block,
56
+ # reason:} to refuse
51
57
  def before_tool_call(tool_call, _context)
58
+ case @rules&.classify(tool_call.name, tool_call.arguments)
59
+ when :deny
60
+ return { action: :block, reason: "Denied by permission rules: '#{tool_call.name}'" }
61
+ when :allow
62
+ return { action: :proceed }
63
+ when :ask
64
+ return queue_for_approval(tool_call, auto_approvable: false)
65
+ end
66
+
52
67
  return { action: :proceed } unless approval_required?(tool_call.name)
53
68
 
54
- auto_approvable = tool_auto_approvable?(tool_call.name)
69
+ queue_for_approval(tool_call, auto_approvable: tool_auto_approvable?(tool_call.name))
70
+ end
71
+
72
+ private
73
+
74
+ def queue_for_approval(tool_call, auto_approvable:)
55
75
  action_id = @queue.submit(
56
76
  tool_call_id: tool_call.id,
57
77
  tool_name: tool_call.name,
@@ -63,8 +83,6 @@ module Ask
63
83
  { action: :pending, action_id: action_id, reason: "Tool '#{tool_call.name}' requires approval" }
64
84
  end
65
85
 
66
- private
67
-
68
86
  def approval_required?(tool_name)
69
87
  return true if @require_approval == :all
70
88
 
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Agent
7
+ module Policies
8
+ # Persisted, matchable allow/ask/deny patterns for tool calls.
9
+ #
10
+ # Rules classify a tool call before it executes or prompts:
11
+ #
12
+ # rules = Ask::Agent::Policies::PermissionRules.new do |r|
13
+ # r.allow :bash, /^git (pull|push|status)/
14
+ # r.ask :bash, /^rm -rf/
15
+ # r.deny :write, %r{/\.env(\.local)?$}
16
+ # r.ask :destroy, :all
17
+ # end
18
+ #
19
+ # session = Ask::Agent::Session.new(
20
+ # model: "gpt-4o",
21
+ # approval: { rules: rules }
22
+ # )
23
+ #
24
+ # Classification is first-match-wins, in declaration order: :allow runs
25
+ # the tool, :ask queues it for human approval, :deny blocks it. Rules
26
+ # take precedence over a tool's own `approval_required` / `auto_approvable`
27
+ # declarations — they are explicit user intent.
28
+ #
29
+ # Dangerous-rule guard: an :allow rule for a code-executing tool (bash,
30
+ # code, repl) whose argument pattern is unrestricted would let the model
31
+ # run anything without asking. Such rules are downgraded to :ask unless
32
+ # the ruleset was created with `auto_allow_dangerous: true` — "approve
33
+ # once, remember the pattern" must not become "approve everything".
34
+ class PermissionRules
35
+ # A single rule: tool pattern + optional argument pattern + decision.
36
+ Rule = Data.define(:tool_pattern, :argument_pattern, :decision) do
37
+ def matches?(tool_name, arguments)
38
+ tool_matches?(tool_name) && argument_matches?(arguments)
39
+ end
40
+
41
+ def tool_matches?(tool_name)
42
+ case tool_pattern
43
+ when :all then true
44
+ when Regexp then tool_pattern.match?(tool_name.to_s)
45
+ else tool_pattern.to_s == tool_name.to_s
46
+ end
47
+ end
48
+
49
+ def argument_matches?(arguments)
50
+ return true if argument_pattern.nil?
51
+
52
+ text = arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments.to_s
53
+ case argument_pattern
54
+ when Regexp then argument_pattern.match?(text)
55
+ else text.include?(argument_pattern.to_s)
56
+ end
57
+ end
58
+
59
+ def universal?
60
+ argument_pattern.nil?
61
+ end
62
+ end
63
+
64
+ # Tools that execute arbitrary code — an unrestricted :allow rule on
65
+ # any of these is dangerous.
66
+ DANGEROUS_TOOLS = %i[bash code repl].freeze
67
+
68
+ # @param auto_allow_dangerous [Boolean] keep unrestricted :allow
69
+ # rules on code-executing tools (default false — they downgrade to
70
+ # :ask)
71
+ def initialize(auto_allow_dangerous: false, &block)
72
+ @auto_allow_dangerous = auto_allow_dangerous
73
+ @rules = []
74
+ instance_eval(&block) if block
75
+ end
76
+
77
+ # DSL — declare rules in priority order (first match wins).
78
+ def allow(tool_pattern, argument_pattern = nil)
79
+ add(:allow, tool_pattern, argument_pattern)
80
+ end
81
+
82
+ def ask(tool_pattern, argument_pattern = nil)
83
+ add(:ask, tool_pattern, argument_pattern)
84
+ end
85
+
86
+ def deny(tool_pattern, argument_pattern = nil)
87
+ add(:deny, tool_pattern, argument_pattern)
88
+ end
89
+
90
+ # @return [Array<Rule>] declared rules, in order
91
+ def rules = @rules.dup
92
+
93
+ # Classify a tool call.
94
+ #
95
+ # @param tool_name [String]
96
+ # @param arguments [Hash, String, nil] tool arguments (hash or JSON)
97
+ # @return [Symbol, nil] :allow, :ask, :deny — or nil when no rule
98
+ # matches
99
+ def classify(tool_name, arguments = nil)
100
+ rule = @rules.find { |r| r.matches?(tool_name, arguments) }
101
+ return nil unless rule
102
+
103
+ if rule.decision == :allow && dangerous?(rule) && !@auto_allow_dangerous
104
+ :ask
105
+ else
106
+ rule.decision
107
+ end
108
+ end
109
+
110
+ # @return [Array<Rule>] rules that would allow unrestricted execution
111
+ # of a code-executing tool
112
+ def dangerous_rules
113
+ @rules.select { |r| dangerous?(r) }
114
+ end
115
+
116
+ private
117
+
118
+ def add(decision, tool_pattern, argument_pattern)
119
+ @rules << Rule.new(tool_pattern, argument_pattern, decision)
120
+ end
121
+
122
+ def dangerous?(rule)
123
+ return false unless rule.decision == :allow && rule.universal?
124
+
125
+ rule.tool_pattern == :all || DANGEROUS_TOOLS.any? { |t| rule.tool_matches?(t) }
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -314,14 +314,16 @@ module Ask
314
314
  session = new(
315
315
  id: data[:id],
316
316
  model: data.dig(:metadata, :model),
317
- # Instantiate saved tool classes defensively: tools that cannot be
318
- # auto-constructed (e.g. the built-in LoadSkillTool, which needs a
319
- # registry) are skippedresolve_tools re-adds them with a proper
320
- # registry. Callers can also pass their own `tools:` after load.
317
+ # Restore saved user tools by class name. Tools that cannot be
318
+ # restored renamed/removed classes (NameError) or constructors
319
+ # with required args (ArgumentError) are skipped with a warning
320
+ # instead of failing the whole load; resolve_tools re-adds the
321
+ # framework-injected load_skill tool with a proper registry.
321
322
  tools: data.dig(:metadata, :tools).to_a.filter_map do |name|
322
323
  begin
323
324
  name.constantize.new
324
- rescue StandardError
325
+ rescue NameError, ArgumentError => e
326
+ warn "[ask-agent] Session.load skipped tool '#{name}': #{e.class}: #{e.message}"
325
327
  nil
326
328
  end
327
329
  end,
@@ -553,6 +555,7 @@ module Ask
553
555
  policy = Ask::Agent::Policies::ApprovalPolicy.new(
554
556
  queue: queue,
555
557
  require_approval: policy_opts[:require_approval],
558
+ rules: policy_opts[:rules],
556
559
  tools: @tools
557
560
  )
558
561
 
@@ -694,6 +697,15 @@ module Ask
694
697
  target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
695
698
  end
696
699
 
700
+ # User-supplied tools only. Framework-injected tools (the built-in
701
+ # load_skill tool) are re-created by resolve_tools on every session, so
702
+ # persisting them would leak framework internals into user data — and
703
+ # they cannot be auto-instantiated on load anyway (LoadSkillTool needs
704
+ # a registry).
705
+ def persisted_tools
706
+ @tools.reject { |t| t.is_a?(Ask::Skills::LoadSkillTool) }
707
+ end
708
+
697
709
  def persist!
698
710
  payload = {
699
711
  id: @id,
@@ -707,7 +719,7 @@ module Ask
707
719
  },
708
720
  metadata: {
709
721
  model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
710
- tools: @tools.map { |t| t.class.name },
722
+ tools: persisted_tools.map { |t| t.class.name },
711
723
  max_turns: @max_turns,
712
724
  turn_count: @turn_count,
713
725
  created_at: @created_at.iso8601,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.30.0"
5
+ VERSION = "0.31.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -35,6 +35,7 @@ module Ask
35
35
  autoload :RateLimiter, "ask/agent/policies/rate_limiter"
36
36
  autoload :AuditLog, "ask/agent/policies/audit_log"
37
37
  autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
38
+ autoload :PermissionRules, "ask/agent/policies/permission_rules"
38
39
  end
39
40
 
40
41
  autoload :ToolCallRepair, "ask/agent/tool_call_repair"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -189,6 +189,7 @@ files:
189
189
  - lib/ask/agent/policies/approval_policy.rb
190
190
  - lib/ask/agent/policies/audit_log.rb
191
191
  - lib/ask/agent/policies/audit_log/active_record_writer.rb
192
+ - lib/ask/agent/policies/permission_rules.rb
192
193
  - lib/ask/agent/policies/permissions.rb
193
194
  - lib/ask/agent/policies/rate_limiter.rb
194
195
  - lib/ask/agent/reflector.rb