samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
@@ -5,6 +5,8 @@ require "yaml"
5
5
  require_relative "config"
6
6
  require_relative "log"
7
7
  require_relative "guardrails"
8
+ require_relative "memory_bundle/manifest"
9
+ require_relative "version"
8
10
  require_relative "hooks"
9
11
  require_relative "edit_preview"
10
12
  require_relative "tool_activity"
@@ -32,7 +34,10 @@ module Samagotchi
32
34
  # @param model_name [#call] → String, nil: the effective model's bare name
33
35
  # @param cancelled [#call] → Boolean, whether the running turn is cancelled
34
36
  # @param ask [#call] (fields) → the answer; the Engine's question flow
35
- def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {})
37
+ # @param notify [#call] (message) → shows a guardrails line in every UI
38
+ # (a bundle's rules kept because it needs a newer chi)
39
+ def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
40
+ notify: ->(_message) {})
36
41
  @scratch = scratch
37
42
  @hooks_lookup = hooks
38
43
  @tools_lookup = tools
@@ -41,6 +46,11 @@ module Samagotchi
41
46
  @model_name_lookup = model_name
42
47
  @cancelled_lookup = cancelled
43
48
  @ask = ask
49
+ @notify = notify
50
+ # Bundle name → the rules last loaded from it whole, kept while the
51
+ # installed version needs a newer chi; [name, version] said so once.
52
+ @loaded_bundle_rules = {}
53
+ @kept_noticed = {}
44
54
  @failures = Guardrails::LoadFailures.new
45
55
  @rules_mutex = Mutex.new
46
56
  @git = Guardrails::GitInfo.new
@@ -115,7 +125,9 @@ module Samagotchi
115
125
 
116
126
  # Installed bundles' guardrails/*.yml, by bundle name then file name.
117
127
  # A file that is missing, changed since install (sha256) or doesn't
118
- # parse is a required load failure.
128
+ # parse is a required load failure. A bundle whose requires_chi this
129
+ # chi doesn't meet isn't read (its rules may use keys this chi doesn't
130
+ # know): the rules last loaded from it stay (#too_new_bundle_rules).
119
131
  def bundle_rules
120
132
  require_relative "memory_bundle/provenance"
121
133
  rules = []
@@ -125,7 +137,13 @@ module Samagotchi
125
137
  @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
126
138
  next
127
139
  end
140
+ if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
141
+ rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
142
+ next
143
+ end
128
144
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
145
+ loaded = []
146
+ whole = true
129
147
  data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
130
148
  what = "rules #{basename} (bundle #{bundle_name})"
131
149
  path = File.join(dir, basename.to_s)
@@ -139,12 +157,15 @@ module Samagotchi
139
157
  doc = YAML.safe_load(File.read(path))
140
158
  raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
141
159
 
142
- rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
160
+ loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
143
161
  rescue Guardrails::Rules::ParseError, Psych::Exception => e
144
162
  Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
145
163
  @failures.add(what, e.message, required: true, group: :rules)
164
+ whole = false
146
165
  end
147
166
  end
167
+ rules.concat(loaded)
168
+ @loaded_bundle_rules[bundle_name] = loaded if whole
148
169
  end
149
170
  rules
150
171
  rescue StandardError => e
@@ -192,6 +213,38 @@ module Samagotchi
192
213
 
193
214
  private
194
215
 
216
+ # The rules of a bundle that needs a newer chi: the ones last loaded
217
+ # from it, with one notice per bundle version (restart the session to
218
+ # load the new ones). Never loaded (a chi older than the bundle it
219
+ # starts with): none, and a required load failure, as the hook loader
220
+ # does for a guardrail hook, so the calls aren't quietly unguarded.
221
+ def too_new_bundle_rules(bundle_name, data, reason)
222
+ kept = @loaded_bundle_rules[bundle_name]
223
+ unless kept
224
+ Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
225
+ bundle: bundle_name)
226
+ @failures.add("rules (bundle #{bundle_name})", "#{reason}; update chi (chi update) or uninstall the bundle",
227
+ required: true, group: :rules)
228
+ return []
229
+ end
230
+
231
+ key = [bundle_name, data[:version].to_s]
232
+ return kept if @kept_noticed[key]
233
+
234
+ @kept_noticed[key] = true
235
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
236
+ requires_chi: data[:requires_chi].to_s)
237
+ @notify.call(kept_notice(bundle_name, data))
238
+ kept
239
+ end
240
+
241
+ def kept_notice(bundle_name, data)
242
+ id = @session_lookup.call&.id || "ID"
243
+ "bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
244
+ "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
245
+ "(chi sessions stop #{id}, then chi --resume #{id})"
246
+ end
247
+
195
248
  # [path, mtime, size] of config.yml and every installed bundle's
196
249
  # manifest.json and guardrails/ file.
197
250
  def rules_stamp
@@ -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
@@ -4,20 +4,27 @@ require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../log"
6
6
  require_relative "model_size"
7
- require_relative "../memory_bundle/index_sync"
7
+ require_relative "outside"
8
+ require_relative "shell_git_dirs"
8
9
 
9
10
  module Samagotchi
10
11
  module Guardrails
11
12
  # What a tool call acts on, resolved the way the tools resolve it: the
12
13
  # shell command, absolute paths (against Dir.pwd, as the file tools have
13
14
  # no cwd:), the directory a command runs in, its repo root, and whether
14
- # a path leaves the repo (the cwd when there is no repo).
15
+ # a path leaves the session's repo (Outside: the context cwd's repo
16
+ # root, the cwd when there is no repo; not the call's own cwd:).
15
17
  class Targets
16
18
  SHELL_TOOLS = %w[execute task_create].freeze
17
19
  PATH_TOOLS = %w[write edit read memory_write].freeze
18
20
 
19
21
  attr_reader :tool, :command, :paths, :cwd, :repo_root
20
22
 
23
+ # @return [String] the session's repo root (the context cwd's; the cwd
24
+ # outside a repo): what outside_repo measures from. repo_root is the
25
+ # call's (its own cwd: for shell and plugin tools).
26
+ attr_reader :session_root
27
+
21
28
  # @return [String, nil] the effective model: its bare name (no host
22
29
  # prefix) and its key (ModelOverlay.key_for), for a rule's models:
23
30
  attr_reader :model_name, :model_key
@@ -62,7 +69,7 @@ module Samagotchi
62
69
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
63
70
  end
64
71
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
65
- model_name: model_name, model_key: model_key)
72
+ model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base)
66
73
  end
67
74
 
68
75
  # What a plugin tool's targets: callable says the call acts on:
@@ -102,7 +109,9 @@ module Samagotchi
102
109
  nil
103
110
  end
104
111
 
105
- def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil)
112
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil,
113
+ session_root: nil)
114
+ @session_root = session_root || repo_root || cwd
106
115
  @args = args
107
116
  @model_name = model_name
108
117
  @model_key = model_key
@@ -123,20 +132,28 @@ module Samagotchi
123
132
  @small_model = ModelSize.small?(@model_name, @model_key)
124
133
  end
125
134
 
126
- # Whether any path is outside the repo root (the cwd outside a repo).
127
- # A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
128
- # write/edit there is what memory_write does, unasked; index.md, the
129
- # bundles dir and the rest of chi's config dir still count.
135
+ # Whether any path is outside the session's repo (Outside.outside?:
136
+ # symlinks resolved; a memory's file and tmp dirs don't count).
130
137
  def outside_repo?
131
- root = @repo_root || @cwd
132
- @paths.any? do |p|
133
- p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
134
- end
138
+ @paths.any? { |p| Outside.outside?(p, root: @session_root) }
139
+ end
140
+
141
+ # Where a shell call runs mutating git (ShellGitDirs: dirs, and
142
+ # :unknown for one the text doesn't tell); [] for other tools.
143
+ def git_dirs
144
+ @git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
145
+ end
146
+
147
+ # Whether a shell call runs mutating git outside the session's repo
148
+ # (an :unknown dir doesn't count).
149
+ def git_outside_repo?
150
+ git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: @session_root) }
135
151
  end
136
152
 
137
153
  # The hook event's targets: hash.
138
154
  def to_h
139
- { command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo? }
155
+ { command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo?,
156
+ git_dirs: git_dirs.map(&:to_s) }
140
157
  end
141
158
  end
142
159
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "guardrails/verdict"
4
4
  require_relative "guardrails/context"
5
+ require_relative "guardrails/outside"
6
+ require_relative "guardrails/shell_git_dirs"
5
7
  require_relative "guardrails/targets"
6
8
  require_relative "guardrails/model_size"
7
9
  require_relative "guardrails/approval"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # A turn's iteration limit (how many model ↔ tool rounds before the turn
8
+ # stops and offers to continue), one resolver for the Engine, the worker
9
+ # and the REPL.
10
+ module IterationLimit
11
+ KEY = "turn.max_iterations"
12
+ # A turn's limit unless turn.max_iterations says otherwise, and the
13
+ # loops' own default.
14
+ DEFAULT = 100
15
+ # A --no-interrupt / --non-interactive turn's, at least: nobody is there
16
+ # to answer a continue offer.
17
+ NO_INTERRUPT = 1000
18
+
19
+ # @param no_interrupt [Boolean] the turn runs with nobody to answer: the
20
+ # larger of NO_INTERRUPT and the configured limit, so a configured one
21
+ # above 1000 is never lowered
22
+ # @return [Integer]
23
+ def self.for(no_interrupt: false) = no_interrupt ? [NO_INTERRUPT, configured].max : configured
24
+
25
+ # turn.max_iterations (env SAMAGOTCHI_TURN_MAX_ITERATIONS); one below 1
26
+ # is DEFAULT, with a warning.
27
+ # @return [Integer]
28
+ def self.configured
29
+ value = Config.get(KEY)
30
+ return DEFAULT if value.nil?
31
+ return value if value.is_a?(Integer) && value >= 1
32
+
33
+ Log.warn(:config, "invalid_value", echo: "Warning: #{KEY} must be 1 or more, got #{value.inspect} — using #{DEFAULT}",
34
+ key: KEY)
35
+ DEFAULT
36
+ end
37
+ end
38
+ end