ask-agent 0.40.29 → 0.41.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: b55a3e7db1b87fa2e915014f6e8a787b24fe60df99602b14b95da3bb06fdf3a5
4
- data.tar.gz: 57017799b4dfb6ab2f842212ba9eed1321bcd61c3872b92a5347a85e98f5ace7
3
+ metadata.gz: 511a1d165664c94fd0a9cc596e880d6f1d2cf43a3e1342d6954a221aed9c28ab
4
+ data.tar.gz: 3e253551f404881c1aea04517c7fd5b15733e845751b113c51dcd3baf73f041e
5
5
  SHA512:
6
- metadata.gz: ef7705c41d359caed21b9424f09ec1aaf6164b3f0d773b78fdbfe0173a7f56dc1bff0af819d6aedb237fb45bdf990246282c2bfa4e1a64864df2d639f6c6d8e3
7
- data.tar.gz: '08aed23b7e951f35182a683d52ae2c67d534fb2ccf97d2c1a6fc99ec505e49ce88be55225b9250eae6a1fb8d18e6aaaeb17994cb0ddf11b207a9a34fa14231c9'
6
+ metadata.gz: 1cc67751a3b745ebd674be1351f071d440746be4f985a3a82a3d36514473bb7249e1ba2a7a91692adf5fc557aedec88b106e67ceb1555fd36cec9ab24a2962f0
7
+ data.tar.gz: d6bb8e963ee254d668826cf713e35484634e7ab98900b850d15e7a3bf246bc859e1c4761802bb2c85b312fe2486e085007711e21dab3240252c67ebc337580d4
data/CHANGELOG.md CHANGED
@@ -1,9 +1,75 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.41.0] - 2026-09-23
4
+
5
+ - Approval decisions can now be granted for the current project through a
6
+ host-provided `project_grants` collaborator; hosts own its persistence and
7
+ project identity. Ask App Server supplies a workspace-scoped implementation.
8
+
9
+ ### Added
10
+
11
+ - Applying approval with `scope: :session` grants that tool for the session;
12
+ once/project approvals remain one-shot from Ask Agent's perspective. Grants
13
+ persist and restore with both `Session.load` and `SessionAdapter.resume`.
14
+ - `approval: { mode: ... }` — the permission mode (`:full_access`,
15
+ `:ask_before_changes`, `:read_only`) is forwarded to
16
+ `Ask::Permissions::ApprovalPolicy` when `Session#build_approval` wires the
17
+ policy, so sessions can run under a baseline mode alongside
18
+ `require_approval:` / `rules:` / grants.
19
+
20
+ ### Fixed
21
+
22
+ - **Approval / plan queue persistence restores without re-emitting and approves exactly once.**
23
+ `Session.persist!` stores JSON-safe queue snapshots (`approvals:`,
24
+ `plan_approvals:`); `Session.load` re-enables the queues only when the
25
+ blob carries pending actions and silently rebuilds pending-tool
26
+ registrations so a later approve/reject completes the original call once.
27
+ `SessionAdapter` snapshots carry the same state and resume restores it
28
+ without firing callbacks. Malformed snapshots (non-Hash, missing /
29
+ non-Array pendings, missing queue while pendings exist, non-empty target,
30
+ or `restore_pending` / `snapshot` failures) now raise a contextual
31
+ `Ask::Agent::Error` / `SessionAdapter::Error` instead of being silently
32
+ swallowed; nil snapshots and empty pending lists remain safe no-ops, and
33
+ queues predating the Permissions API persist as nil (documented
34
+ compatibility fallback that cannot strand actions). Framework-injected
35
+ tools (`load_skill`, `todo_write`, `exit_plan_mode`, memory tools,
36
+ `output_read`) are excluded from persisted tool lists, avoiding
37
+ `Session.load` reconstruction warnings.
38
+
39
+ ### Changed
40
+
41
+ - **Plan-mode tool gate delegates to `Ask::Permissions::PlanModePolicy`.**
42
+ `Session` now configures one shared policy (`allowed_tools:` from the
43
+ session's read-only tool list, `exit_tool: "exit_plan_mode"`) instead of
44
+ hand-rolling the allow/block check in `plan_mode_gate`, which now only
45
+ checks that plan mode is still active and delegates. The gate hook is
46
+ still installed only when plan mode is enabled, and approval still turns
47
+ the gate off. Exposes the configured policy as `Session#plan_mode_policy`.
48
+ - Unknown keys in the `approval:` hash now raise `ArgumentError` instead of
49
+ being silently dropped — a misspelled option previously disabled a safety
50
+ control with no signal. Accepted keys are unchanged: `queue:`,
51
+ `require_approval:`, `rules:`, `auto_approve:`, `session_grants:`,
52
+ `project_grants:`, and the new `mode:`.
53
+ ## [0.40.30] — 2026-09-23
54
+
3
55
  ### Added
4
56
 
5
57
  - **`Session#run(runtime_event_sink:)` forwards ask-runtime tool lifecycle events** through the agent loop and recursive turns to `ToolExecutor#execute_batch`.
6
58
 
59
+ ### Changed
60
+
61
+ - **Permission machinery extracted to the `ask-permissions` gem.**
62
+ `Ask::Agent::ApprovalQueue`, `Ask::Agent::Policies::ApprovalPolicy`,
63
+ `Ask::Agent::Policies::PermissionRules`, and
64
+ `Ask::Agent::Policies::Permissions` are removed from ask-agent; the same
65
+ classes now ship as `Ask::Permissions::ApprovalQueue`,
66
+ `Ask::Permissions::ApprovalPolicy`, `Ask::Permissions::PermissionRules`,
67
+ and `Ask::Permissions::Permissions` behind `require "ask/permissions"`.
68
+ Session keeps its agent-specific wiring — the `approval:` option still
69
+ builds the queue, wires on_approve/on_reject/on_submit callbacks, and
70
+ prepends the policy hook; the plan queue is unchanged apart from the
71
+ constant. Requires the new runtime dependency `ask-permissions >= 0.1.0`.
72
+
7
73
  ### Fixed
8
74
 
9
75
  - **`SessionAdapter.resume` surfaces every ask-session miss as
data/README.md CHANGED
@@ -120,6 +120,35 @@ under multiple OpenAI-compatible providers). A `provider:` passed to
120
120
  `Session.new` or declared in an agent `Definition` always wins over the global
121
121
  default.
122
122
 
123
+ ### Tool approval
124
+
125
+ `Session.new(approval: ...)` turns on human-in-the-loop approval: matching
126
+ tool calls queue on `session.approval_queue` instead of executing until
127
+ `approve(id)` / `reject(id)` is called. `approval: true` enables defaults; a
128
+ Hash accepts:
129
+
130
+ | Option | Purpose |
131
+ |---|---|
132
+ | `require_approval:` | tool names / regexps / `:all` that must be approved |
133
+ | `auto_approve:` | user-enabled rules keyed by tool name (pairs with `auto_approvable` tools) |
134
+ | `rules:` | an `Ask::Permissions::PermissionRules` block (`allow` / `ask` / `deny`) |
135
+ | `mode:` | baseline policy — `:full_access`, `:ask_before_changes`, or `:read_only` |
136
+ | `queue:` | a custom `Ask::Permissions::ApprovalQueue` |
137
+ | `session_grants:` / `project_grants:` | grant collaborators (session- and project-scoped approvals) |
138
+
139
+ ```ruby
140
+ session = Ask::Agent::Session.new(
141
+ model: "gpt-4o",
142
+ tools: [SendEmail],
143
+ approval: { mode: :read_only }
144
+ )
145
+ ```
146
+
147
+ `mode:` is forwarded to `Ask::Permissions::ApprovalPolicy`: `:full_access`
148
+ runs every tool without asking, `:ask_before_changes` queues tool calls for
149
+ approval, and `:read_only` refuses them outright. Unknown keys in the
150
+ `approval:` hash raise `ArgumentError` instead of being silently ignored.
151
+
123
152
  ## Durable sessions (ask-session)
124
153
 
125
154
  `Ask::Agent::SessionAdapter` bridges a session to an event-sourced
@@ -20,7 +20,7 @@ module Ask
20
20
 
21
21
  param :plan, type: :string, desc: "The plan you propose to execute", required: true
22
22
 
23
- # @param plan_queue [Ask::Agent::ApprovalQueue] queue carrying plan
23
+ # @param plan_queue [Ask::Permissions::ApprovalQueue] queue carrying plan
24
24
  # approvals; the session wires approve/reject callbacks
25
25
  # @param on_submit [Proc, nil] called with the plan text when the plan
26
26
  # is submitted (used to emit PlanProposed)
@@ -10,6 +10,12 @@ module Ask
10
10
  # loop registrations resurrecting ghost pending calls.
11
11
  RECENTLY_COMPLETED_MAX = 200
12
12
 
13
+ # Options accepted by the `approval:` hash. Unknown keys raise so a
14
+ # misspelled option can never silently disable a safety control.
15
+ APPROVAL_OPTIONS = %i[
16
+ queue require_approval rules auto_approve session_grants project_grants mode
17
+ ].freeze
18
+
13
19
  attr_reader :id, :chat, :tools, :turn_count, :created_at, :messages
14
20
  attr_reader :tool_calls_made, :total_input_tokens, :total_output_tokens, :total_cost
15
21
 
@@ -81,6 +87,7 @@ module Ask
81
87
  reflector: nil, telemetry: true, meta_agent: nil,
82
88
  agent_dir: nil, evaluator: nil, audit_log: nil,
83
89
  skills_disclosure: true, approval: nil,
90
+ session_grants: nil,
84
91
  tool_call_repair: nil, checkpoints: false,
85
92
  todos: false, plan_mode: false, memory: nil,
86
93
  memory_learning: false, offload_large_outputs: false,
@@ -88,6 +95,10 @@ module Ask
88
95
  **chat_options)
89
96
  @id = id || SecureRandom.uuid
90
97
  @agent_dir = agent_dir
98
+ @provided_session_grants = session_grants
99
+ @session_grants = nil
100
+ @project_grants = nil
101
+ @approval_policy = nil
91
102
  @max_turns = max_turns
92
103
  @max_tool_retries = max_tool_retries
93
104
  @parallel_tools = parallel_tools
@@ -166,7 +177,7 @@ module Ask
166
177
  else
167
178
  %w[read glob grep web_search]
168
179
  end
169
- @plan_queue = ApprovalQueue.new(
180
+ @plan_queue = Ask::Permissions::ApprovalQueue.new(
170
181
  on_approve: ->(action) { approve_plan(action) },
171
182
  on_reject: ->(action) { reject_plan(action) },
172
183
  # Same race closure as the tool approval queue: register the
@@ -201,7 +212,13 @@ module Ask
201
212
 
202
213
  # Plan gate runs before user hooks and the approval policy: in plan
203
214
  # mode, non-read-only tools are blocked outright (never queued).
215
+ # The allowed-tool decision lives in one shared PlanModePolicy;
216
+ # its #before_tool_call is hooked in only while plan mode is on.
204
217
  if @plan_mode
218
+ @plan_mode_policy = Ask::Permissions::PlanModePolicy.new(
219
+ allowed_tools: @plan_mode_read_only_tools,
220
+ exit_tool: "exit_plan_mode"
221
+ )
205
222
  @hooks = Hooks.new(
206
223
  before_tool: [method(:plan_mode_gate)] + Array(@hooks.instance_variable_get(:@before_tool)),
207
224
  after_tool: @hooks.instance_variable_get(:@after_tool)
@@ -270,11 +287,15 @@ module Ask
270
287
  # created without approval support. Use it to inspect pending actions
271
288
  # and approve/reject them.
272
289
  #
273
- # @return [Ask::Agent::ApprovalQueue, nil]
290
+ # @return [Ask::Permissions::ApprovalQueue, nil]
274
291
  attr_reader :approval_queue
275
- # @return [Ask::Agent::ApprovalQueue, nil] queue carrying plan
292
+ # @return [Ask::Permissions::ApprovalQueue, nil] queue carrying plan
276
293
  # approvals (only when plan mode is enabled)
277
294
  attr_reader :plan_queue
295
+ # @return [Ask::Permissions::PlanModePolicy, nil] the shared policy
296
+ # deciding which tools may run in plan mode (only when plan mode
297
+ # is enabled)
298
+ attr_reader :plan_mode_policy
278
299
  # @return [Ask::Agent::TodoList, nil] session task list (only when
279
300
  # todos are enabled)
280
301
  attr_reader :todo_list
@@ -287,6 +308,7 @@ module Ask
287
308
  # @return [Ask::Agent::ArtifactStore, nil] store for tool deliverables
288
309
  # (only when the +artifacts:+ option is enabled)
289
310
  attr_reader :artifact_store
311
+ attr_reader :session_grants, :approval_policy
290
312
 
291
313
  def run(message, tools: nil, reset: true, attachments: nil, runtime_event_sink: nil)
292
314
  raise "Session deleted" if @deleted
@@ -512,6 +534,10 @@ module Ask
512
534
 
513
535
  data = deep_symbolize_keys(data)
514
536
 
537
+ approvals_snapshot = data[:approvals]
538
+ plan_snapshot = data[:plan_approvals]
539
+ grants_snapshot = data[:session_grants]
540
+
515
541
  session = new(
516
542
  id: data[:id],
517
543
  model: data.dig(:metadata, :model),
@@ -531,9 +557,14 @@ module Ask
531
557
  state: adapter,
532
558
  # Checkpointing is restored automatically when the session has
533
559
  # checkpoints in the store; todos likewise when the snapshot has
534
- # a task list.
560
+ # a task list. Approval / plan queues are re-enabled only when the
561
+ # persisted blob carries pending actions — policy config (rules,
562
+ # require_approval lists) is not persisted, only queue state that
563
+ # the shared Permissions API supports.
535
564
  checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?,
536
- todos: !data[:todos].nil?
565
+ todos: !data[:todos].nil?,
566
+ approval: (approval_snapshot_pending?(approvals_snapshot) || grants_snapshot_present?(grants_snapshot)) ? true : nil,
567
+ plan_mode: approval_snapshot_pending?(plan_snapshot) ? true : false
537
568
  )
538
569
 
539
570
  data[:messages].each do |msg|
@@ -544,7 +575,8 @@ module Ask
544
575
  )
545
576
  end
546
577
  session.instance_variable_get(:@todo_list)&.restore(data[:todos])
547
-
578
+ session.send(:restore_persisted_approvals, approvals_snapshot, plan_snapshot, grants_snapshot)
579
+
548
580
  session.instance_variable_set(:@messages, session.chat.messages.dup)
549
581
  session
550
582
  end
@@ -633,7 +665,8 @@ module Ask
633
665
  state: @state,
634
666
  checkpoints: true,
635
667
  todos: @todos_enabled,
636
- plan_mode: @plan_mode
668
+ plan_mode: @plan_mode,
669
+ approval: (@approval_queue || self.class.grants_snapshot_present?(data[:session_grants])) ? true : nil
637
670
  )
638
671
  restore_into(forked, data)
639
672
  emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq))
@@ -701,13 +734,13 @@ end
701
734
  # phase; non-read-only tools are blocked until the plan is approved)
702
735
  def plan_mode? = @plan_mode
703
736
 
704
- # Before-tool gate active while in plan mode: only read-only tools
705
- # (and exit_plan_mode itself) run until a human approves the plan.
706
- def plan_mode_gate(tool_call, _context)
737
+ # Before-tool gate active while in plan mode: delegates the
738
+ # allowed-tool decision to the PlanModePolicy (read-only tools and
739
+ # exit_plan_mode itself) and proceeds once a human approves the plan.
740
+ def plan_mode_gate(tool_call, context)
707
741
  return { action: :proceed } unless @plan_mode
708
- return { action: :proceed } if @plan_mode_read_only_tools.include?(tool_call.name) || tool_call.name == "exit_plan_mode"
709
742
 
710
- { action: :block, reason: "Plan mode: only read-only tools until the plan is approved" }
743
+ @plan_mode_policy.before_tool_call(tool_call, context)
711
744
  end
712
745
 
713
746
  def approve_plan(action)
@@ -893,9 +926,19 @@ end
893
926
  #
894
927
  # `approval` accepts:
895
928
  # - true → queue with defaults
896
- # - a Hash → { require_approval:, auto_approve: } for the policy
897
- # - an ApprovalQueue → uses it, with policy options from
898
- # approval[:policy] if given
929
+ # - a Hash → { queue:, require_approval:, rules:,
930
+ # auto_approve:, session_grants:,
931
+ # project_grants:, mode: } — unknown keys
932
+ # raise; a nested `policy` option is not
933
+ # implemented
934
+ # - an ApprovalQueue → uses it directly: policy options are not
935
+ # read from the queue, so everything runs
936
+ # with defaults except the queue's own
937
+ # existing callbacks, which are preserved
938
+ #
939
+ # `mode:` forwards to Ask::Permissions::ApprovalPolicy — one of
940
+ # :full_access, :ask_before_changes, :read_only (nil = declaration-
941
+ # driven defaults).
899
942
  #
900
943
  # When enabled, an ApprovalPolicy hook is prepended to the session's
901
944
  # before_tool hooks so approval-required tools queue instead of running.
@@ -904,22 +947,41 @@ end
904
947
 
905
948
  policy_opts = approval.is_a?(Hash) ? approval : {}
906
949
 
907
- queue = if approval.is_a?(Ask::Agent::ApprovalQueue)
950
+ unknown = policy_opts.keys - APPROVAL_OPTIONS
951
+ unless unknown.empty?
952
+ label = unknown.size == 1 ? "option" : "options"
953
+ raise ArgumentError, "unknown approval #{label}: #{unknown.map(&:inspect).join(", ")}"
954
+ end
955
+
956
+ queue = if approval.is_a?(Ask::Permissions::ApprovalQueue)
908
957
  approval
909
- elsif policy_opts[:queue].is_a?(Ask::Agent::ApprovalQueue)
958
+ elsif policy_opts[:queue].is_a?(Ask::Permissions::ApprovalQueue)
910
959
  policy_opts[:queue]
911
960
  else
912
- Ask::Agent::ApprovalQueue.new(
961
+ Ask::Permissions::ApprovalQueue.new(
913
962
  auto_approve: policy_opts[:auto_approve],
914
963
  on_approve: ->(action) { apply_approved_action(action) },
915
964
  on_reject: ->(action) { reject_pending_action(action) }
916
965
  )
917
966
  end
918
967
 
919
- # Custom queues (subclasses, event-emitting wrappers) may come
920
- # without callbacks — wire the session's defaults so approvals
921
- # actually execute the tool call.
922
- queue.on_approve ||= ->(action) { apply_approved_action(action) }
968
+ custom_grants = @provided_session_grants || policy_opts[:session_grants]
969
+ unless custom_grants.nil? || custom_grants.is_a?(Ask::Permissions::SessionPermissionGrants)
970
+ raise ArgumentError, "session_grants must be an Ask::Permissions::SessionPermissionGrants, got #{custom_grants.class}"
971
+ end
972
+ @session_grants = custom_grants || Ask::Permissions::SessionPermissionGrants.new
973
+ @project_grants = policy_opts[:project_grants]
974
+ if @project_grants && !(%i[grant granted?].all? { |method| @project_grants.respond_to?(method) })
975
+ raise ArgumentError, "project_grants must respond to grant and granted?"
976
+ end
977
+
978
+ # Preserve a custom queue callback, but grant session scope before it
979
+ # executes. If no callback is supplied, keep the default executor.
980
+ previous_approve = queue.on_approve
981
+ queue.on_approve = lambda do |action|
982
+ apply_approval_scope(action)
983
+ previous_approve ? previous_approve.call(action) : apply_approved_action(action)
984
+ end
923
985
  queue.on_reject ||= ->(action) { reject_pending_action(action) }
924
986
  # Register the pending tool call the moment the action is queued —
925
987
  # before the auto-approval drain — so completions always match even
@@ -934,12 +996,16 @@ end
934
996
  })
935
997
  }
936
998
 
937
- policy = Ask::Agent::Policies::ApprovalPolicy.new(
999
+ policy = Ask::Permissions::ApprovalPolicy.new(
938
1000
  queue: queue,
939
1001
  require_approval: policy_opts[:require_approval],
940
1002
  rules: policy_opts[:rules],
941
- tools: @tools
1003
+ tools: @tools,
1004
+ session_grants: @session_grants,
1005
+ project_grants: @project_grants,
1006
+ mode: policy_opts[:mode]
942
1007
  )
1008
+ @approval_policy = policy
943
1009
 
944
1010
  # Prepend the approval gate so it runs before user hooks
945
1011
  @hooks = Hooks.new(
@@ -989,6 +1055,17 @@ end
989
1055
  end
990
1056
  end
991
1057
 
1058
+ def apply_approval_scope(action)
1059
+ return unless action.respond_to?(:resolution_scope) && action.tool_name
1060
+
1061
+ case action.resolution_scope
1062
+ when :session
1063
+ @session_grants&.grant(action.tool_name.to_s)
1064
+ when :project
1065
+ @project_grants&.grant(action.tool_name.to_s)
1066
+ end
1067
+ end
1068
+
992
1069
  # Notify the conversation that an action was rejected by the user.
993
1070
  def reject_pending_action(action)
994
1071
  complete_pending_tool(
@@ -1098,15 +1175,133 @@ end
1098
1175
  target.instance_variable_set(:@messages, target.chat.messages.dup)
1099
1176
  target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
1100
1177
  target.instance_variable_get(:@todo_list)&.restore(data[:todos])
1178
+ # Approval + grant restore for checkpoint rollback/fork is silent,
1179
+ # but malformed permission snapshots raise instead of being stranded.
1180
+ target.send(:restore_persisted_approvals, data[:approvals], data[:plan_approvals], data[:session_grants])
1101
1181
  end
1102
1182
 
1103
1183
  # User-supplied tools only. Framework-injected tools (the built-in
1104
- # load_skill tool) are re-created by resolve_tools on every session, so
1184
+ # load_skill tool, todo_write, exit_plan_mode, memory tools,
1185
+ # output_read) are re-created by resolve_tools on every session, so
1105
1186
  # persisting them would leak framework internals into user data — and
1106
- # they cannot be auto-instantiated on load anyway (LoadSkillTool needs
1107
- # a registry).
1187
+ # they cannot always be auto-instantiated on load anyway (LoadSkillTool
1188
+ # needs a registry, ExitPlanMode needs a plan_queue).
1108
1189
  def persisted_tools
1109
- @tools.reject { |t| t.is_a?(Ask::Skills::LoadSkillTool) }
1190
+ @tools.reject do |t|
1191
+ t.is_a?(Ask::Skills::LoadSkillTool) ||
1192
+ (defined?(TodoWrite) && t.is_a?(TodoWrite)) ||
1193
+ (defined?(ExitPlanMode) && t.is_a?(ExitPlanMode)) ||
1194
+ (defined?(MemoryWrite) && t.is_a?(MemoryWrite)) ||
1195
+ (defined?(MemorySearch) && t.is_a?(MemorySearch)) ||
1196
+ (defined?(OutputRead) && t.is_a?(OutputRead))
1197
+ end
1198
+ end
1199
+
1200
+ # JSON-safe v1 snapshot for a queue, or nil when there is no queue or
1201
+ # it predates the shared Permissions API (compatibility fallback:
1202
+ # nothing durable to persist, so nothing to restore — cannot strand
1203
+ # pendings because an unsupported queue never contributed durable
1204
+ # state). Raises Ask::Agent::Error with context when the queue
1205
+ # supports the API but snapshotting fails.
1206
+ def queue_snapshot_for(queue, queue_name = "approval")
1207
+ return nil unless queue
1208
+ return nil unless queue.respond_to?(:snapshot)
1209
+
1210
+ begin
1211
+ queue.snapshot
1212
+ rescue StandardError => e
1213
+ raise Ask::Agent::Error, "Failed to snapshot #{queue_name} queue (#{queue.class}): #{e.class}: #{e.message}"
1214
+ end
1215
+ end
1216
+
1217
+ # Restore persisted queue snapshots into this session's queues without
1218
+ # firing callbacks or emitting events. Pending-tool registrations are
1219
+ # rebuilt silently so a later approve/reject still completes the
1220
+ # original tool call exactly once.
1221
+ #
1222
+ # Compatibility fallback (cannot strand): a nil snapshot (approval off
1223
+ # or unsupported queue) and an empty pending_actions list are safe
1224
+ # no-ops. Any other state that would prevent a faithful restore —
1225
+ # non-Hash snapshot, missing/non-Array pendings, missing queue or
1226
+ # queue without restore support while pendings exist, a non-empty
1227
+ # target queue, or a restore_pending failure — raises
1228
+ # Ask::Agent::Error with queue context instead of silently dropping
1229
+ # pending actions.
1230
+ def restore_persisted_approvals(approvals_snapshot, plan_snapshot, grants_snapshot = nil)
1231
+ restore_queue_snapshot(@approval_queue, approvals_snapshot, "approval")
1232
+ restore_queue_snapshot(@plan_queue, plan_snapshot, "plan")
1233
+ restore_grants_snapshot(@session_grants, grants_snapshot)
1234
+ nil
1235
+ end
1236
+
1237
+ def restore_grants_snapshot(grants, snapshot)
1238
+ return if snapshot.nil?
1239
+ raise Ask::Agent::Error, "Cannot restore session grants: snapshot must be a Hash" unless snapshot.is_a?(Hash)
1240
+
1241
+ tools = snapshot[:granted_tools] || snapshot["granted_tools"]
1242
+ unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(String) && !tool.empty? }
1243
+ raise Ask::Agent::Error, "Cannot restore session grants: granted_tools must be an Array of non-empty Strings"
1244
+ end
1245
+ return if tools.empty?
1246
+ raise Ask::Agent::Error, "Cannot restore session grants: session has no grants store" unless grants
1247
+
1248
+ begin
1249
+ grants.restore_snapshot(snapshot)
1250
+ rescue StandardError => e
1251
+ raise Ask::Agent::Error, "Cannot restore session grants: #{e.class}: #{e.message}"
1252
+ end
1253
+ end
1254
+
1255
+ def restore_queue_snapshot(queue, snapshot, queue_name = "approval")
1256
+ return nil if snapshot.nil?
1257
+
1258
+ unless snapshot.is_a?(Hash)
1259
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: snapshot must be a Hash, got #{snapshot.class}"
1260
+ end
1261
+
1262
+ pendings = snapshot[:pending_actions] || snapshot["pending_actions"]
1263
+ if pendings.nil?
1264
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: snapshot missing pending_actions"
1265
+ end
1266
+ unless pendings.is_a?(Array)
1267
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: pending_actions must be an Array, got #{pendings.class}"
1268
+ end
1269
+ return nil if pendings.empty?
1270
+
1271
+ unless queue
1272
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: session has no #{queue_name} queue but snapshot carries #{pendings.size} pending action(s)"
1273
+ end
1274
+ unless queue.respond_to?(:restore_pending) && queue.respond_to?(:pending_actions)
1275
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: #{queue.class} does not support restore_pending"
1276
+ end
1277
+
1278
+ # restore_pending requires an empty queue — a non-empty target means
1279
+ # a second restore would duplicate or strand actions, so fail loudly
1280
+ # instead of silently dropping either side.
1281
+ if queue.respond_to?(:any_pending?) && queue.any_pending?
1282
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: target queue already holds pending actions"
1283
+ end
1284
+
1285
+ begin
1286
+ queue.restore_pending(snapshot)
1287
+ rescue StandardError => e
1288
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: #{e.class}: #{e.message}"
1289
+ end
1290
+ queue.pending_actions.each do |action|
1291
+ tool_call_id = action.tool_call_id
1292
+ next unless tool_call_id
1293
+
1294
+ @pending_mutex.synchronize do
1295
+ @pending_tools[tool_call_id] ||= {
1296
+ tool_name: action.tool_name,
1297
+ message: action.message || "Pending approval",
1298
+ status: "pending",
1299
+ tool_call_id: tool_call_id,
1300
+ action_id: action.id
1301
+ }
1302
+ end
1303
+ end
1304
+ nil
1110
1305
  end
1111
1306
 
1112
1307
  # Persist content blocks as their +to_h+ hashes so attachments
@@ -1134,6 +1329,14 @@ end
1134
1329
  }
1135
1330
  },
1136
1331
  todos: @todo_list&.to_h,
1332
+ # Durable permission state: JSON-safe v1 queue snapshots when the
1333
+ # queue supports the shared Permissions API (#snapshot). Nil when
1334
+ # approval is off or the queue predates the API (compatibility
1335
+ # fallback — nothing durable to persist, so nothing to strand).
1336
+ # Snapshot failures raise Ask::Agent::Error with queue context.
1337
+ approvals: queue_snapshot_for(@approval_queue, "approval"),
1338
+ plan_approvals: queue_snapshot_for(@plan_queue, "plan"),
1339
+ session_grants: @session_grants&.snapshot,
1137
1340
  metadata: {
1138
1341
  model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
1139
1342
  tools: persisted_tools.map { |t| t.class.name },
@@ -1245,6 +1448,22 @@ end
1245
1448
  end
1246
1449
  end
1247
1450
 
1451
+ # True when a persisted approval snapshot carries at least one pending
1452
+ # action. Used to decide whether Session.load should re-enable the
1453
+ # approval / plan queue — policy config itself is never persisted.
1454
+ def self.approval_snapshot_pending?(snapshot)
1455
+ return false unless snapshot.is_a?(Hash)
1456
+
1457
+ pendings = snapshot[:pending_actions] || snapshot["pending_actions"]
1458
+ pendings.is_a?(Array) && !pendings.empty?
1459
+ end
1460
+
1461
+ def self.grants_snapshot_present?(snapshot)
1462
+ return false unless snapshot.is_a?(Hash)
1463
+ tools = snapshot[:granted_tools] || snapshot["granted_tools"]
1464
+ tools.is_a?(Array) && !tools.empty?
1465
+ end
1466
+
1248
1467
  # Render the system context and apply it to the chat.
1249
1468
  def apply_system_context
1250
1469
  rendered = @system_context.render