samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require "yaml"
8
8
 
9
+ require_relative "iteration_limit"
9
10
  require_relative "config"
10
11
  require_relative "context_note"
11
12
  require_relative "context_status"
@@ -27,6 +28,7 @@ require_relative "llm/backend"
27
28
  require_relative "llm/openai_chat"
28
29
  require_relative "llm/turn_settings"
29
30
  require_relative "session"
31
+ require_relative "answer_tail"
30
32
  require_relative "archive_store"
31
33
  require_relative "session_observer"
32
34
  require_relative "tool_declarations"
@@ -83,7 +85,7 @@ module Samagotchi
83
85
  # @param profile [ModelProfile, Symbol, String, nil]
84
86
  # @param session_id [String, nil] resume an existing session
85
87
  # @param no_interrupt [Boolean] --no-interrupt: every turn runs with
86
- # NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
88
+ # IterationLimit.for(no_interrupt: true), whichever loop runs it
87
89
  # @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
88
90
  # @param model_typed [String, nil] the name the model was given as (a session's
89
91
  # model_typed: an alias), for the models: lookup; model_name by default
@@ -92,10 +94,6 @@ module Samagotchi
92
94
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
93
95
  # What memory_write answers in a scratch session.
94
96
  SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
95
- # A turn's iteration limit with no_interrupt (the worker's own
96
- # --no-interrupt turns pass the same).
97
- NO_INTERRUPT_MAX_ITERATIONS = 1000
98
-
99
97
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
100
98
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
101
99
  # delegate (a child would outlive it) nor plugin fork
@@ -243,7 +241,7 @@ module Samagotchi
243
241
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
244
242
  @resume_session = session_id ? Session.load(session_id) : nil
245
243
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
246
- thinking: -> { turn_thinking }, memories: memories,
244
+ thinking: -> { turn_thinking }, model: -> { model_identity }, memories: memories,
247
245
  muted_memory_names: @muted_memory_names)
248
246
  @session = nil
249
247
  @session_observer = SessionObserver.new
@@ -411,6 +409,18 @@ module Samagotchi
411
409
  model_ref_for(@effective_model_name)
412
410
  end
413
411
 
412
+ # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
413
+ # the ref, its host, the overlay key and, for a local llama.cpp host
414
+ # only, the model its /props names when that differs. Never a new
415
+ # request: only the /props answer profile resolution already cached.
416
+ def model_identity
417
+ target = @host_registry.resolve(@effective_model_name)
418
+ entry = target.entry
419
+ host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
420
+ SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
421
+ served: prompt_served_model(target), fallback_key: @fallback_model_key)
422
+ end
423
+
414
424
  # +name+'s resolved ref (ModelRef#ref).
415
425
  def model_ref_for(name)
416
426
  @host_registry.model_ref(name).ref
@@ -463,8 +473,8 @@ module Samagotchi
463
473
  def sync_model_key!
464
474
  @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
465
475
  typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
466
- fallback = typed_key == @model_key ? nil : typed_key
467
- @kernel.sync_model_key!(@model_key, fallback: fallback)
476
+ @fallback_model_key = typed_key == @model_key ? nil : typed_key
477
+ @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
468
478
  end
469
479
  private :sync_model_key!
470
480
 
@@ -904,7 +914,7 @@ module Samagotchi
904
914
  # else [nil, nil].
905
915
  # @return [Array(String, String), Array(nil, nil)]
906
916
  def served_model(probe: true)
907
- served_model_for(@metrics.snapshot, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
917
+ served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
908
918
  end
909
919
 
910
920
  # Read-only snapshot of the engine's view of the current session plus the
@@ -1069,7 +1079,10 @@ module Samagotchi
1069
1079
 
1070
1080
  # How the turn ended, on the session (Session#last_turn): the caller's
1071
1081
  # save puts it in the file the session hub watches.
1072
- def record_last_turn(session, outcome, seconds, origin)
1082
+ # @param limit [Integer, nil] the turn ran out of iterations at this
1083
+ # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1084
+ # answer the continue offer says so (ReplyWait, ParentReport "limit")
1085
+ def record_last_turn(session, outcome, seconds, origin, limit: nil)
1073
1086
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1074
1087
  source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1075
1088
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
@@ -1077,6 +1090,7 @@ module Samagotchi
1077
1090
  end
1078
1091
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1079
1092
  "seconds" => seconds.round(1), "origin" => source }
1093
+ session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1080
1094
  end
1081
1095
  private :record_last_turn
1082
1096
 
@@ -1089,14 +1103,15 @@ module Samagotchi
1089
1103
  # returns [the messages to keep (nil: leave the session's), the end
1090
1104
  # event]. +save:+ also saves the session (a failed turn: a worker exits
1091
1105
  # after it). The disk writes come after the step: every emitter waits
1092
- # for the event lock.
1093
- def end_turn(turn, outcome, save: false)
1106
+ # for the event lock. +exhausted:+ the turn ran out of iterations
1107
+ # (its limit goes in last_turn).
1108
+ def end_turn(turn, outcome, save: false, exhausted: false)
1094
1109
  seconds = turn.elapsed
1095
1110
  synchronize_events do
1096
1111
  kept, event = yield(seconds)
1097
1112
  replace_session_messages(turn.session, kept) if kept
1098
1113
  turn.session.status = Session::STATUS_IDLE
1099
- record_last_turn(turn.session, outcome, seconds, turn.origin)
1114
+ record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
1100
1115
  emit_event(turn.on_event, turn.tag(event))
1101
1116
  end
1102
1117
  turn.ended = true
@@ -1242,6 +1257,19 @@ module Samagotchi
1242
1257
  @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
1243
1258
  end
1244
1259
 
1260
+ # Publish a standing question that blocks nobody (QuestionDesk#post: a
1261
+ # worker's step-limit question).
1262
+ # @return [Hash, nil] the pending question, nil while another is open
1263
+ def post_question(fields, on_answer:, on_superseded_close: nil)
1264
+ @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
1265
+ end
1266
+
1267
+ # Withdraw the standing question (QuestionDesk#withdraw).
1268
+ # @return [Boolean]
1269
+ def withdraw_question(reason, id: nil)
1270
+ @question_desk.withdraw(reason, id: id)
1271
+ end
1272
+
1245
1273
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1246
1274
  # @return [Hash] normalized answer
1247
1275
  def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
@@ -1262,6 +1290,7 @@ module Samagotchi
1262
1290
 
1263
1291
  # Cancel the pending question (QuestionDesk#cancel).
1264
1292
  # @return [Boolean] whether it was cancelled
1293
+ # @raise [QuestionDesk::NotDismissable] +id+ names a standing question
1265
1294
  def cancel_question(reason = "user", id: nil)
1266
1295
  @question_desk.cancel(reason, id: id)
1267
1296
  end
@@ -1307,6 +1336,7 @@ module Samagotchi
1307
1336
  def state_dir = engine.peer_state_dir
1308
1337
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1309
1338
  def relay = engine.relay_peer
1339
+ def model_ref = engine.effective_model_ref
1310
1340
  end
1311
1341
 
1312
1342
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -1401,8 +1431,9 @@ module Samagotchi
1401
1431
  # set on the kernel in #generate.
1402
1432
  # +id+: names the turn on :turn_started (its metrics record's id) and on
1403
1433
  # its saved prompt, so a UI pairs the two whatever left the conversation.
1434
+ # +limit+: the iteration limit #generate ran it with.
1404
1435
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1405
- :settings, :id) do
1436
+ :settings, :id, :limit) do
1406
1437
  # The turn's boundary events carry the origin only when there is one,
1407
1438
  # so payloads stay unchanged for callers that don't pass it.
1408
1439
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1420,7 +1451,7 @@ module Samagotchi
1420
1451
  # @param session [Session] the session to operate on
1421
1452
  # @param prompt [String] user input
1422
1453
  # @param on_event [Proc, nil] receives event hashes
1423
- # @param max_iterations [Integer] max kernel iterations
1454
+ # @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
1424
1455
  # @param cancel_controller [CancellationController, nil]
1425
1456
  # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1426
1457
  # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
@@ -1449,7 +1480,7 @@ module Samagotchi
1449
1480
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1450
1481
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1451
1482
  # one-line summary:.
1452
- def run_turn(session, prompt, on_event: nil, max_iterations: 100, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1483
+ def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1453
1484
  images: [])
1454
1485
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1455
1486
  continue: continue)
@@ -1653,9 +1684,10 @@ module Samagotchi
1653
1684
  # turn's model, not the last native one.
1654
1685
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
1655
1686
 
1687
+ turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
1656
1688
  backend.complete(
1657
1689
  messages: turn.messages,
1658
- max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
1690
+ max_iterations: turn.limit,
1659
1691
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
1660
1692
  cancel_controller: turn.controller,
1661
1693
  model_name: bare_for_backend,
@@ -1692,7 +1724,7 @@ module Samagotchi
1692
1724
  # after_turn hooks run below and may present the answer (the web
1693
1725
  # holds its pop until it knows).
1694
1726
  display_pending = !canceled && @hooks.any?(:after_turn)
1695
- end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
1727
+ end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
1696
1728
  kept = kept_messages(turn, result, seconds)
1697
1729
  if canceled
1698
1730
  [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
@@ -1955,6 +1987,15 @@ module Samagotchi
1955
1987
  # rollback after Ctrl-C). Each replaces the array rather than mutating it,
1956
1988
  # so the recap's lock-free snapshot never sees a half-applied edit.
1957
1989
 
1990
+ # The answer a UI shows last (AnswerTail), a copy of that one message
1991
+ # only: walked back from the end, never a #messages_checkpoint of the
1992
+ # whole conversation (the Bridge's GET tail). +turn_id+: that turn's
1993
+ # answer (AnswerTail.find); unknown or nil: the newest.
1994
+ # @return [Hash, nil]
1995
+ def last_answer_message(turn_id: nil)
1996
+ AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
1997
+ end
1998
+
1958
1999
  # @return [Array<Hash>] a copy of the current session's messages to hand
1959
2000
  # back to #rollback_to later
1960
2001
  def messages_checkpoint
@@ -2125,7 +2166,8 @@ module Samagotchi
2125
2166
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2126
2167
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2127
2168
  },
2128
- model_name: -> { @session&.model_name || @effective_model_name },
2169
+ model_name: -> { effective_model_ref },
2170
+ model_key: -> { @model_key },
2129
2171
  state_dir: -> { session_state_dir },
2130
2172
  scratch: -> { @scratch }
2131
2173
  )
@@ -2320,6 +2362,18 @@ module Samagotchi
2320
2362
  [nil, nil]
2321
2363
  end
2322
2364
 
2365
+ # What a local native host's cached /props names, when it isn't the
2366
+ # model asked for; nil for a chat or remote host, or before any probe.
2367
+ def prompt_served_model(target)
2368
+ entry = target.entry
2369
+ return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
2370
+
2371
+ served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
2372
+ served if ServedModel.differs?(target.bare_model, served)
2373
+ rescue StandardError
2374
+ nil
2375
+ end
2376
+
2323
2377
  # ── Prompt profile ─────────────────────────────────────────────────────────
2324
2378
 
2325
2379
  def resolve_profile
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "protected_paths"
5
+ require_relative "../memory_bundle/index_sync"
6
+
7
+ module Samagotchi
8
+ module Guardrails
9
+ # What `outside_repo` means (a rule's path: and git:): a path is outside
10
+ # when it isn't in the session's repo root (the cwd outside a repo),
11
+ # with symlinks resolved on both sides. Never outside:
12
+ # - a memory's file (MemoryBundle::IndexSync.memory_scope): write/edit
13
+ # there is what memory_write does; index.md, the bundles dir and the
14
+ # rest of chi's config dir still count;
15
+ # - a tmp dir (Dir.tmpdir, /tmp, /var/tmp), unless the session's root
16
+ # is itself in that tmp dir (a sandbox there still gets the check
17
+ # between its siblings).
18
+ # chi's state dir is not special: nothing there is the model's to write.
19
+ module Outside
20
+ TMP_DIRS = %w[/tmp /var/tmp].freeze
21
+
22
+ # @param path [String] absolute
23
+ # @param root [String] the session's repo root (or cwd)
24
+ def self.outside?(path, root:)
25
+ real = ProtectedPaths.real(path)
26
+ session = ProtectedPaths.real(root)
27
+ return false if within?(real, session)
28
+ return false if MemoryBundle::IndexSync.memory_scope(path)
29
+ return false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
30
+
31
+ true
32
+ end
33
+
34
+ # The tmp dirs that exist, resolved.
35
+ def self.tmp_roots
36
+ [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
37
+ File.realpath(dir) if File.directory?(dir)
38
+ rescue SystemCallError
39
+ nil
40
+ end.uniq
41
+ end
42
+
43
+ def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
44
+ end
45
+ end
46
+ end
@@ -5,6 +5,7 @@ require_relative "../paths"
5
5
  require_relative "../hooks/loader"
6
6
  require_relative "approval"
7
7
  require_relative "protected_paths"
8
+ require_relative "parent_continue"
8
9
 
9
10
  module Samagotchi
10
11
  module Guardrails
@@ -115,6 +116,7 @@ module Samagotchi
115
116
  def message(reason, typed: false)
116
117
  deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
117
118
  case reason
119
+ when :stop_only then ParentContinue.message
118
120
  when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
119
121
  when :off then "allowing a tool call is up to the user: #{deny}"
120
122
  else "only Allow once (#{KEY}: once) can be given here: #{deny}"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # Whether a parent agent may answer Continue to a session's step-limit
8
+ # question (kind "continue", ContinueOffer): turn.parent_continue,
9
+ # config.yml only, default true. Continue grants no permission (every
10
+ # tool call still meets the guardrails), it spends time and tokens;
11
+ # false makes parents stop-only. Checked by `chi answer` before it posts
12
+ # and by the worker's question desk for an answer marked as chi
13
+ # answer's, the way ParentApprovals is.
14
+ module ParentContinue
15
+ KEY = "turn.parent_continue"
16
+ KIND = "continue"
17
+ CONTINUE = "Continue"
18
+
19
+ module_function
20
+
21
+ # @return [Boolean] this process's setting
22
+ def allowed?
23
+ Config.get(KEY) != false
24
+ end
25
+
26
+ # Why a parent may not give this answer, or nil.
27
+ # @param pending [Hash] the pending question (symbol or string keys)
28
+ # @param selected [Array<String>] the selected labels
29
+ # @return [Symbol, nil] :stop_only
30
+ def refusal(pending, selected, allowed: allowed?)
31
+ return nil if allowed
32
+ return nil unless (pending[:kind] || pending["kind"]).to_s == KIND
33
+
34
+ Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
35
+ end
36
+
37
+ def message
38
+ "a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
39
+ "(--option Stop --text WHY), or tell your user it waits"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -14,6 +14,8 @@ module Samagotchi
14
14
  # path: "outside_repo", or a glob on the resolved paths (absolute
15
15
  # or "**/…" globs match the absolute path; others the path
16
16
  # relative to the repo root, the cwd outside one)
17
+ # git: "outside_repo": a shell call runs a mutating git
18
+ # subcommand outside the session's repo (ShellGitDirs)
17
19
  # models: "small" (Guardrails::ModelSize, guardrails.small_models),
18
20
  # or a glob on the bare model name or the model key, or a
19
21
  # list of them: the rule only votes for a model that matches
@@ -26,17 +28,19 @@ module Samagotchi
26
28
  class Rules
27
29
  class ParseError < StandardError; end
28
30
 
29
- KEYS = %w[id tool command path models verdict reason scopes].freeze
31
+ KEYS = %w[id tool command path git models verdict reason scopes].freeze
32
+ MATCH_KEYS = %w[tool command path git].freeze
30
33
  VERDICTS = %w[ask deny].freeze
31
34
  GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
32
35
 
33
- Rule = Struct.new(:id, :tools, :command, :path, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
36
+ Rule = Struct.new(:id, :tools, :command, :path, :git, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
34
37
  def matches?(targets)
35
38
  return false unless targets
36
39
  return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
37
40
  return false if tools && !tool_matches?(targets.tool)
38
41
  return false if command && !(targets.command && command.match?(targets.command))
39
42
  return false if path && !path_matches?(targets)
43
+ return false if git && !targets.git_outside_repo?
40
44
 
41
45
  true
42
46
  end
@@ -99,12 +103,12 @@ module Samagotchi
99
103
 
100
104
  verdict = raw["verdict"].to_s
101
105
  raise ParseError, "#{label}: verdict must be ask or deny (got #{verdict.inspect})" unless VERDICTS.include?(verdict)
102
- unless %w[tool command path].any? { |k| raw.key?(k) }
103
- raise ParseError, "#{label}: give at least one of tool, command, path"
106
+ unless MATCH_KEYS.any? { |k| raw.key?(k) }
107
+ raise ParseError, "#{label}: give at least one of #{MATCH_KEYS.join(", ")}"
104
108
  end
105
109
 
106
110
  Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
107
- path: path_of(raw["path"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
111
+ path: path_of(raw["path"], label), git: git_of(raw["git"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
108
112
  reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
109
113
  end
110
114
 
@@ -135,6 +139,14 @@ module Samagotchi
135
139
  value
136
140
  end
137
141
 
142
+ # Only "outside_repo" for now (room for globs later).
143
+ def self.git_of(value, label)
144
+ return nil if value.nil?
145
+ raise ParseError, "#{label}: git must be outside_repo" unless value == "outside_repo"
146
+
147
+ value
148
+ end
149
+
138
150
  def self.models_of(value, label)
139
151
  return nil if value.nil?
140
152
 
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # Where a shell command runs a git subcommand that changes a checkout
6
+ # (commit, add, reset, …): the directories, or :unknown for one that
7
+ # can't be read from the text ($VAR, $(…), `cd -`). Pure text: it
8
+ # follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
9
+ # and GIT_DIR/GIT_WORK_TREE. It catches honest mistakes (cd into
10
+ # another checkout and commit there); sh -c, scripts, aliases and
11
+ # $(…) bodies get through. Never raises on odd input.
12
+ module ShellGitDirs
13
+ MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
14
+ cherry-pick revert pull restore am].freeze
15
+ STASH_READS = %w[list show].freeze
16
+ # Longest first: "&&" before "&", ";;" before ";".
17
+ OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
18
+ PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
19
+ # git global options that take the next word as their value.
20
+ VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
21
+ SUBST = "\0SUBST"
22
+ REDIRECT = /\A\d*[<>]/
23
+
24
+ # @param command [String] the shell command
25
+ # @param cwd [String] where it starts (the call's cwd:, else the session's)
26
+ # @param home [String] for ~, $HOME, ${HOME} and a bare cd
27
+ # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
28
+ def self.for(command, cwd:, home: Dir.home)
29
+ Walk.new(cwd, home).run(simple_commands(lex(command.to_s)))
30
+ rescue ArgumentError, EncodingError
31
+ [] # a NUL byte, invalid UTF-8: the shell would refuse it too
32
+ end
33
+
34
+ # Words and operators ([:word, "x"] / [:op, "&&"]).
35
+ def self.lex(text)
36
+ Lexer.new(text).tokens
37
+ end
38
+
39
+ # The token stream split at operators into word lists; ( and ) become
40
+ # :open / :close markers.
41
+ def self.simple_commands(tokens)
42
+ commands = [[]]
43
+ tokens.each do |kind, value|
44
+ if kind == :op
45
+ commands << :open if value == "("
46
+ commands << :close if value == ")"
47
+ commands << []
48
+ else
49
+ commands.last << value
50
+ end
51
+ end
52
+ commands.reject { |c| c.is_a?(Array) && c.empty? }
53
+ end
54
+
55
+ # A hand-written shell lexer: quotes, backslashes, comments, $(…) and
56
+ # backticks (an opaque marker inside the word), redirections kept in
57
+ # their word (2>&1 doesn't split on &), operators.
58
+ class Lexer
59
+ def initialize(text)
60
+ @s = text
61
+ @i = 0
62
+ @tokens = []
63
+ @word = +""
64
+ @in_word = false
65
+ end
66
+
67
+ def tokens
68
+ step while @i < @s.size
69
+ flush
70
+ @tokens
71
+ end
72
+
73
+ private
74
+
75
+ def step
76
+ c = @s[@i]
77
+ case c
78
+ when "\\" then add(@s[@i + 1].to_s, 2)
79
+ when "'" then single_quote
80
+ when '"' then double_quote
81
+ when "`" then backtick
82
+ when "$" then @s[@i + 1] == "(" ? substitution : add(c, 1)
83
+ when "#" then @in_word ? add(c, 1) : comment
84
+ when ">", "<" then redirection
85
+ when " ", "\t" then flush && (@i += 1)
86
+ else operator_or_char(c)
87
+ end
88
+ end
89
+
90
+ def add(text, advance)
91
+ @word << text
92
+ @in_word = true
93
+ @i += advance
94
+ end
95
+
96
+ def flush
97
+ @tokens << [:word, @word.dup] if @in_word
98
+ @word.clear
99
+ @in_word = false
100
+ true
101
+ end
102
+
103
+ def single_quote
104
+ close = @s.index("'", @i + 1) || @s.size
105
+ add(@s[(@i + 1)...close].to_s, close + 1 - @i)
106
+ end
107
+
108
+ def double_quote
109
+ @i += 1
110
+ @in_word = true
111
+ while @i < @s.size && @s[@i] != '"'
112
+ if @s[@i] == "\\" && @i + 1 < @s.size
113
+ @word << @s[@i + 1]
114
+ @i += 2
115
+ else
116
+ @word << @s[@i]
117
+ @i += 1
118
+ end
119
+ end
120
+ @i += 1
121
+ end
122
+
123
+ def backtick
124
+ close = @s.index("`", @i + 1) || @s.size
125
+ add(SUBST, close + 1 - @i)
126
+ end
127
+
128
+ def substitution
129
+ depth = 0
130
+ j = @i + 1
131
+ loop do
132
+ depth += 1 if @s[j] == "("
133
+ depth -= 1 if @s[j] == ")"
134
+ j += 1
135
+ break if depth.zero? || j >= @s.size
136
+ end
137
+ add(SUBST, j - @i)
138
+ end
139
+
140
+ def comment
141
+ @i = @s.index("\n", @i) || @s.size
142
+ end
143
+
144
+ # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
145
+ def redirection
146
+ add(@s[@i], 1)
147
+ add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
148
+ end
149
+
150
+ def operator_or_char(char)
151
+ op = OPERATORS.find { |o| @s[@i, o.size] == o }
152
+ return add(char, 1) unless op
153
+
154
+ if op == "&" && @word.match?(/\d*>\z/)
155
+ add(op, 1)
156
+ else
157
+ flush
158
+ @tokens << [:op, op]
159
+ @i += op.size
160
+ end
161
+ end
162
+ end
163
+
164
+ # Follows the current dir through the simple commands and records
165
+ # where each mutating git runs.
166
+ class Walk
167
+ def initialize(cwd, home)
168
+ @dir = cwd
169
+ @home = home
170
+ @subshells = []
171
+ @pushed = []
172
+ @found = []
173
+ end
174
+
175
+ def run(commands)
176
+ commands.each do |words|
177
+ case words
178
+ when :open then @subshells << @dir
179
+ when :close then @dir = @subshells.pop || @dir
180
+ else simple(words.dup)
181
+ end
182
+ end
183
+ @found.uniq
184
+ end
185
+
186
+ private
187
+
188
+ def simple(words)
189
+ env = {}
190
+ while (word = words.first)
191
+ if word.match?(/\A[A-Za-z_]\w*=/)
192
+ key, value = word.split("=", 2)
193
+ env[key] = value
194
+ elsif !PREFIXES.include?(word)
195
+ break
196
+ end
197
+ words.shift
198
+ end
199
+ verb = words.shift or return
200
+ args = words.grep_v(REDIRECT)
201
+ case verb
202
+ when "cd" then cd(args)
203
+ when "pushd"
204
+ @pushed << @dir
205
+ @dir = args.first ? expand(args.first, @dir) : nil
206
+ when "popd" then @dir = @pushed.pop unless @pushed.empty?
207
+ else git(args, env) if verb == "git" || verb.end_with?("/git")
208
+ end
209
+ end
210
+
211
+ def cd(args)
212
+ target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
213
+ @dir = if target.nil? then @home
214
+ elsif target == "-" then nil
215
+ else expand(target, @dir)
216
+ end
217
+ end
218
+
219
+ def git(args, env)
220
+ dir = @dir
221
+ work_tree = env["GIT_WORK_TREE"] && expand(env["GIT_WORK_TREE"], dir)
222
+ git_dir = env["GIT_DIR"] && expand(env["GIT_DIR"], dir)
223
+ sub = nil
224
+ until args.empty?
225
+ arg = args.shift
226
+ case arg
227
+ when *VALUE_OPTIONS
228
+ value = args.shift.to_s
229
+ dir = expand(value, dir) if arg == "-C"
230
+ git_dir = expand(value, dir) if arg == "--git-dir"
231
+ work_tree = expand(value, dir) if arg == "--work-tree"
232
+ when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
233
+ when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
234
+ when /\A-/ then next
235
+ else
236
+ sub = arg
237
+ break
238
+ end
239
+ end
240
+ return unless MUTATING.include?(sub)
241
+ return if sub == "stash" && STASH_READS.include?(args.first)
242
+
243
+ @found << (work_tree || repo_of(git_dir) || dir || :unknown)
244
+ end
245
+
246
+ def repo_of(git_dir)
247
+ return nil unless git_dir
248
+
249
+ File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
250
+ end
251
+
252
+ # An absolute path for +arg+ against +dir+; nil when it can't be
253
+ # told from the text (a variable, a substitution, a relative path
254
+ # against an unknown dir).
255
+ def expand(arg, dir)
256
+ return nil if arg.include?(SUBST)
257
+
258
+ arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
259
+ return nil if arg.include?("$") || arg.start_with?("~")
260
+ return File.expand_path(arg) if arg.start_with?("/")
261
+
262
+ dir && File.expand_path(arg, dir)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end