samagotchi 0.134.0 → 0.136.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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -14,8 +14,10 @@ require_relative "bridge_client/event_stream"
14
14
  require_relative "bridge_client/stream_request"
15
15
 
16
16
  module Samagotchi
17
- # Loaded on first use (a Verify turn): keeps the bundle test kit's load
18
- # budget (spec/samagotchi/testing/sandbox_spec.rb).
17
+ # Loaded on first use (any turn's input, a Verify or a Revise turn's
18
+ # anchor): keeps the bundle test kit's load budget
19
+ # (spec/samagotchi/testing/sandbox_spec.rb).
20
+ autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
19
21
  autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
20
22
 
21
23
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
@@ -192,16 +194,17 @@ module Samagotchi
192
194
  # owner; sent only when given (SessionManager.deliver_turn)
193
195
  # @param pending_limit [Integer, nil] with a participant's +author+: how
194
196
  # many of their turns may be running or waiting (429 too_many_turns past it)
197
+ # @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
198
+ # turn's draft: their own body keys ("verify", "revises")
195
199
  def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
196
- verify: nil)
200
+ anchors: nil)
197
201
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
198
202
  body[:no_interrupt] = true if no_interrupt
199
203
  body[:author] = author if author
200
204
  body[:pending_limit] = pending_limit if author && pending_limit
201
205
  body[:images] = images if images && !images.empty?
202
206
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
203
- anchor = VerifyAnchor.from(verify)
204
- body[:verify] = anchor.to_file if anchor
207
+ body.merge!(TurnAnchors.from(anchors).to_file) if anchors
205
208
  body[:deadline] = deadline
206
209
  post("turn", body, read_body: true)
207
210
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.134.0
3
+ version: 0.136.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -330,8 +330,6 @@ module Samagotchi
330
330
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli], lifecycle: :web_start),
331
331
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli], lifecycle: :web_start),
332
332
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
333
- # The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
334
- Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
335
333
  # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
336
334
  # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
337
335
  Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
@@ -15,6 +15,7 @@ require_relative "steer"
15
15
  require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
+ require_relative "revise_anchor"
18
19
  require_relative "verify_anchor"
19
20
  require_relative "web/participant_view"
20
21
  require_relative "verdict"
@@ -1631,10 +1632,11 @@ module Samagotchi
1631
1632
  seconds = turn.elapsed
1632
1633
  synchronize_events do
1633
1634
  kept, event = yield(seconds)
1634
- stamp_verify_anchor(kept, turn)
1635
+ revises = stamp_opening(kept, turn, outcome)
1635
1636
  replace_session_messages(turn.session, kept) if kept
1636
1637
  verdict = record_verdict(turn, outcome)
1637
1638
  event[:verdict] = verdict if verdict
1639
+ event[:revises] = revises.to_h if revises
1638
1640
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1639
1641
  turn.session.status = Session::STATUS_IDLE
1640
1642
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
@@ -1647,16 +1649,63 @@ module Samagotchi
1647
1649
  end
1648
1650
  private :end_turn
1649
1651
 
1650
- def stamp_verify_anchor(messages, turn)
1651
- return unless turn.verify && messages.is_a?(Array)
1652
+ # The turn's anchors, stamped on its opening message (the prompt, or a
1653
+ # wake turn's note) in the messages it keeps: display metadata, never
1654
+ # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
1655
+ # A Verify turn's claim however it ended; a Revise turn's draft only
1656
+ # once it completed with an answer (#revision_target). Returns the
1657
+ # stamped ReviseAnchor (the end event's revises), or nil.
1658
+ def stamp_opening(messages, turn, outcome)
1659
+ return unless messages.is_a?(Array)
1660
+
1661
+ revises = revision_target(messages, turn, outcome)
1662
+ fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
1663
+ return if fields.empty?
1652
1664
 
1653
1665
  opening = messages.reverse.find do |message|
1654
- (message[:role] || message["role"]).to_s == "user" &&
1655
- (message[:turn_id] || message["turn_id"]).to_s == turn.id
1666
+ Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
1656
1667
  end
1657
- opening[:verify] = turn.verify.to_h if opening
1668
+ return unless opening
1669
+
1670
+ opening.merge!(fields)
1671
+ revises
1672
+ end
1673
+ private :stamp_opening
1674
+
1675
+ # The turn a Revise turn revised (D6: "this turn revised that one"):
1676
+ # only a completed turn with an answer (AnswerTail) has revised
1677
+ # anything. A draft no prompt in the messages carries is no target (a
1678
+ # stale page, a rolled-back turn); one a stamped turn already revised
1679
+ # is re-targeted to its stack's top, the newest version, which the
1680
+ # person meant ("this draft").
1681
+ def revision_target(messages, turn, outcome)
1682
+ return unless turn.revises && outcome == "completed"
1683
+
1684
+ slice = AnswerTail.turn_slice(messages, turn.id)
1685
+ return unless slice&.any? { |message| AnswerTail.answer?(message) }
1686
+
1687
+ target = turn.revises.turn_id
1688
+ seen = [turn.id]
1689
+ until seen.include?(target)
1690
+ return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
1691
+
1692
+ seen << target
1693
+ newer = messages.reverse.find do |message|
1694
+ Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
1695
+ end
1696
+ return ReviseAnchor.new(turn_id: target) unless newer
1697
+
1698
+ target = (newer[:turn_id] || newer["turn_id"]).to_s
1699
+ end
1700
+ reject_revise_anchor(turn.revises, :cycle)
1701
+ end
1702
+ private :revision_target
1703
+
1704
+ def reject_revise_anchor(anchor, reason)
1705
+ Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
1706
+ nil
1658
1707
  end
1659
- private :stamp_verify_anchor
1708
+ private :reject_revise_anchor
1660
1709
 
1661
1710
  def verified_anchor(session, anchor)
1662
1711
  return unless anchor
@@ -2150,8 +2199,9 @@ module Samagotchi
2150
2199
  # Continue that starts cold (after a model change).
2151
2200
  # +wall+: the wall time it started (MessageTime.now, once): the
2152
2201
  # :turn_started event's at and its user message's (and reminders') stamp.
2202
+ # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
2153
2203
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2154
- :settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
2204
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
2155
2205
  def resumed? = resumed ? true : false
2156
2206
 
2157
2207
  # The turn's boundary events carry the origin only when it names a
@@ -2215,6 +2265,9 @@ module Samagotchi
2215
2265
  # @param id [String, nil] the turn's id (a new uuid when nil): a caller
2216
2266
  # that put the turn's first message in the session already (a context
2217
2267
  # wake's note) names it with this id
2268
+ # @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
2269
+ # @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
2270
+ # the prompt at the turn's end, metadata only (#stamp_opening)
2218
2271
  #
2219
2272
  # An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
2220
2273
  # session as after a Stop (the prompt, the loop's finished tool
@@ -2226,9 +2279,11 @@ module Samagotchi
2226
2279
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2227
2280
  # one-line summary:.
2228
2281
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2229
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
2282
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, revises: nil,
2283
+ input_waiting: nil)
2230
2284
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2231
- continue: continue, id: id, verify: verify, input_waiting: input_waiting)
2285
+ continue: continue, id: id, verify: verify, revises: revises,
2286
+ input_waiting: input_waiting)
2232
2287
  if continue
2233
2288
  turn.resumed = resumed
2234
2289
  turn.resume_from = resume_from
@@ -2276,7 +2331,8 @@ module Samagotchi
2276
2331
  # The turn's state before anything can fail it: the session running,
2277
2332
  # the turn flag, its cancel controller and sink. The cross-thread state
2278
2333
  # is set here and cleared in #release_turn only.
2279
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
2334
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
2335
+ input_waiting: nil)
2280
2336
  origin = TurnOrigin.of(origin)
2281
2337
  # A participant's Verify fresh child: every turn is theirs (their
2282
2338
  # author, boundary and cap), whoever started it.
@@ -2304,7 +2360,8 @@ module Samagotchi
2304
2360
 
2305
2361
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
2306
2362
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
2307
- id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify))
2363
+ id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
2364
+ revises: ReviseAnchor.from(revises))
2308
2365
  end
2309
2366
  private :begin_turn
2310
2367
 
@@ -330,9 +330,10 @@ module Samagotchi
330
330
  return verdict
331
331
  end
332
332
 
333
- answer = @ask.call(payload)
333
+ # The card and the session file carry the facts as the hash they were.
334
+ answer = @ask.call(payload.merge(approval: payload[:approval].to_h))
334
335
  system_one_label(judgment, answer, payload)
335
- Guardrails::Approval.settle(verdict, answer, payload[:approval][:scopes])
336
+ Guardrails::Approval.settle(verdict, answer, payload[:approval].scopes)
336
337
  end
337
338
 
338
339
  private
@@ -391,9 +392,9 @@ module Samagotchi
391
392
  decision = system_one_decision(client, auto)
392
393
 
393
394
  id = log.judgment(session_id: @session_lookup.call&.id,
394
- rule: approval[:rule], source: approval[:source], tool: approval[:tool],
395
+ rule: approval.rule, source: approval.source, tool: approval.tool,
395
396
  **SystemOne::ApprovalQuestion.call_fields(approval),
396
- repo_name: approval[:repo_name],
397
+ repo_name: approval.repo_name,
397
398
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
398
399
  latency_ms: latency_ms, decision: decision, origin: "live")
399
400
  # A judgment the log cannot hold is no judgment: the user is asked.
@@ -418,12 +419,13 @@ module Samagotchi
418
419
  # user opted in, the never-list and chi's own files, the probability, and
419
420
  # the rule's record. Any one that fails leaves the ask to the user. The
420
421
  # entries are the judgment log read once for this ask.
422
+ # @param approval [Guardrails::ApprovalFacts] the ask's facts
421
423
  # @param entries [Array<Hash>] SystemOne::Log#entries
422
424
  # @param targets [Guardrails::Targets, nil] the call's, for its env:
423
425
  def system_one_auto_allow?(client, approval, p, entries, targets: nil)
424
426
  return false unless client.mode == "on"
425
427
 
426
- rule = approval[:rule].to_s
428
+ rule = approval.rule.to_s
427
429
  return false if rule.empty?
428
430
  return false unless system_one_rules.include?(rule)
429
431
  return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
@@ -456,7 +458,7 @@ module Samagotchi
456
458
  id = judgment && judgment[:id]
457
459
  return nil if id.nil?
458
460
 
459
- scopes = Array(payload[:approval][:scopes])
461
+ scopes = Array(payload[:approval].scopes)
460
462
  if !answer.is_a?(Samagotchi::QuestionAnswer)
461
463
  label = "none"
462
464
  elsif answer.parent_agent?
@@ -6,6 +6,41 @@ require_relative "../question_answer"
6
6
 
7
7
  module Samagotchi
8
8
  module Guardrails
9
+ # An ask's facts, as Approval.payload builds them (its approval:) and
10
+ # the question card shows them: the tool (tool, acts_as, label), what
11
+ # it acts on (command, paths, args, memory), where (cwd, repo_root,
12
+ # repo_name, branch), who asked and why (rule, source, reason), the
13
+ # offered scopes, an edit's preview (EditPreview.for) and, for a
14
+ # participant's turn, who asks (author, author_name, asked_by).
15
+ #
16
+ # In this process it is passed as this value; it crosses to the UIs and
17
+ # the session file (PendingQuestion's approval) as #to_h, the hash it
18
+ # always was: symbol keys, in member order, unset fields left out. A
19
+ # saved or sent one (string or symbol keys, missing fields, a key we
20
+ # don't know dropped) comes back through .from_h. memory and preview
21
+ # stay as given (string keys inside after a load).
22
+ ApprovalFacts = Data.define(:tool, :acts_as, :label, :command, :paths, :args, :memory, :cwd, :repo_root,
23
+ :repo_name, :branch, :rule, :source, :reason, :scopes, :preview, :author,
24
+ :author_name, :asked_by) do
25
+ def initialize(**fields)
26
+ super(**members.to_h { |m| [m, nil] }, **fields)
27
+ end
28
+
29
+ # @param raw [ApprovalFacts, Hash, nil] facts as built, saved or sent
30
+ # @return [ApprovalFacts, nil] nil for anything that is not facts
31
+ def self.from_h(raw)
32
+ return raw if raw.is_a?(ApprovalFacts)
33
+ return nil unless raw.is_a?(Hash)
34
+
35
+ new(**members.to_h { |m| [m, raw.key?(m) ? raw[m] : raw[m.to_s]] })
36
+ end
37
+
38
+ # The card's and the session file's shape: symbol keys, in member
39
+ # order, unset fields left out.
40
+ # @return [Hash{Symbol => Object}]
41
+ def to_h = super.compact
42
+ end
43
+
9
44
  # An ask as a question for the user (Engine#open_question), and the
10
45
  # answer back as a verdict. The question text is plain and complete, so
11
46
  # a UI that doesn't know approvals still shows everything; `approval:`
@@ -42,9 +77,10 @@ module Samagotchi
42
77
  # their turn asks: it offers once only (PARTICIPANT_SCOPES), and the
43
78
  # card names them (approval author:, author_name:, the header)
44
79
  # @param author_name [String, nil] their name (participants.json)
45
- # @return [Hash] open_question fields; its caller adds user_only:
46
- # (ParentApprovals.protected_facts? on approval:, a file that
47
- # requires this one)
80
+ # @return [Hash] open_question fields, with approval: an ApprovalFacts
81
+ # (its caller hands open_question approval.to_h); the caller adds
82
+ # user_only: (ParentApprovals.protected_facts? on approval:, a file
83
+ # that requires this one)
48
84
  def payload(verdict, label: nil, preview: nil, only: nil, author: nil, author_name: nil)
49
85
  asker = asker(author, author_name)
50
86
  only = PARTICIPANT_SCOPES if asker
@@ -60,7 +96,7 @@ module Samagotchi
60
96
  allow_freeform: true,
61
97
  effects: effects(scopes),
62
98
  kind: KIND,
63
- approval: {
99
+ approval: ApprovalFacts.new(
64
100
  tool: targets&.tool || verdict.call[:name].to_s,
65
101
  # The tool the call acts as (an MCP tool behind call_tool).
66
102
  acts_as: targets&.acts_as,
@@ -86,7 +122,7 @@ module Samagotchi
86
122
  author: (author.to_s if asker),
87
123
  author_name: (author_name_for(author_name) if asker),
88
124
  asked_by: (format(ASKED_BY, asker) if asker)
89
- }.compact
125
+ )
90
126
  }
91
127
  end
92
128
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shell_lex"
4
+
3
5
  module Samagotchi
4
6
  module Guardrails
5
7
  # Programs that write outside the folder they run in with no word of
@@ -61,9 +63,10 @@ module Samagotchi
61
63
  "gh" => [[%w[auth], %w[login logout refresh setup-git switch]], [%w[config], %w[set]],
62
64
  [%w[extension extensions ext], %w[install upgrade remove]], [%w[alias], %w[set delete import]]],
63
65
  # the global config and a launchd/cron schedule; git lfs's global filters
64
- "git" => [[%w[maintenance], %w[register start unregister stop]], [%w[lfs], %w[install uninstall]]],
66
+ # (git's first group is its subcommand: .git_outside?)
67
+ "git" => [[%w[maintenance], %w[register start unregister stop]], [%w[lfs], %w[install uninstall update]]],
65
68
  "git-maintenance" => [[%w[register start unregister stop]]],
66
- "git-lfs" => [[%w[install uninstall]]],
69
+ "git-lfs" => [[%w[install uninstall update]]],
67
70
  "scalar" => [[%w[register unregister clone reconfigure delete]]]
68
71
  }.freeze
69
72
  # program => the verbs (its first argument) that only read: every
@@ -89,9 +92,9 @@ module Samagotchi
89
92
 
90
93
  module_function
91
94
 
92
- # The table's name for +program+ (a word's basename).
95
+ # The table's name for +program+ (its word's ShellLex.program_name).
93
96
  def name(program)
94
- base = File.basename(program.to_s)
97
+ base = ShellLex.program_name(program)
95
98
  ALIASES.each { |pattern, to| return to if base.match?(pattern) }
96
99
  base
97
100
  end
@@ -108,6 +111,7 @@ module Samagotchi
108
111
  return bundle_config_outside?(args) if program == "bundle" && args.first == "config"
109
112
 
110
113
  entries = BY_VERB[program] or return false
114
+ return git_outside?(entries, args, assigned) if program == "git"
111
115
  return false unless entries.any? { |groups| groups.all? { |group| group?(group, args, assigned) } }
112
116
  return true unless LOCAL_INSTALLERS.include?(program)
113
117
  return true if args.include?("--user")
@@ -115,6 +119,17 @@ module Samagotchi
115
119
  !(local || args.any? { |arg| arg.match?(PLACE_OPTIONS) })
116
120
  end
117
121
 
122
+ # git's entries match from its subcommand on (PairCommand.git_command_starts:
123
+ # git -C . maintenance start), the first group holding the subcommand,
124
+ # not its words anywhere (git add maintenance start, git commit -m lfs -m install).
125
+ def git_outside?(entries, args, assigned)
126
+ PairCommand.git_command_starts(args).grep(Integer).any? do |i|
127
+ entries.any? do |first, *rest|
128
+ first.include?(args[i]) && rest.all? { |group| group?(group, args[(i + 1)..], assigned) }
129
+ end
130
+ end
131
+ end
132
+
118
133
  def group?(group, args, assigned)
119
134
  group == :global ? global?(args, assigned) : args.intersect?(group)
120
135
  end
@@ -97,6 +97,8 @@ module Samagotchi
97
97
  "owner's cookies"
98
98
  LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
99
99
  "owner's browser cookies"
100
+ NET = "a participant's turn runs a command chi can't read for sure (git or chi behind a program it doesn't " \
101
+ "know, or a $(…) kept in a value the shell expands later), so the owner approves it"
100
102
  VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
101
103
  UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
102
104
  "with the write tool"
@@ -195,13 +197,15 @@ module Samagotchi
195
197
 
196
198
  # Why the owner is asked (fresh, once): a command that makes a link
197
199
  # (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
198
- # config (MISE_CONFIG), a write that may land outside the project (OUTSIDE_WRITE, or
200
+ # config (MISE_CONFIG), a command chi can't read for sure (NET:
201
+ # PairCommand#net_ask?), a write that may land outside the project (OUTSIDE_WRITE, or
199
202
  # NO_PROJECT without one); nil when nothing asks.
200
203
  def ask_reason(verdict)
201
204
  targets = verdict.targets
202
205
  return LINK if targets&.command && command(targets, nil).links?
203
206
  return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
204
207
  return MISE_CONFIG if mise_config_write?(verdict)
208
+ return NET if targets&.command && command(targets, nil).net_ask?
205
209
  return nil unless outside_write?(verdict)
206
210
 
207
211
  project_root ? OUTSIDE_WRITE : NO_PROJECT