samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "review_paths"
5
+ require_relative "shell_paths"
6
+ require_relative "shell_lex"
7
+
8
+ module Samagotchi
9
+ module Guardrails
10
+ # One shell command of a participant's pair turn, read the way
11
+ # PairBoundary needs it (the refusal words are PairBoundary's):
12
+ # - #refusal: why it may not run, nil when it may: chi, something left
13
+ # running, the environment or the keychain, git settings that run
14
+ # commands, a folder sweep, a secret (a path a word resolves to, a glob
15
+ # or a $var whose fixed part leads into one, SECRET_TEXT for the
16
+ # rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
17
+ # a command of its own, one it hands another interpreter (ruby -e,
18
+ # python <<EOF) by its text. A command it can't read is refused
19
+ # (UNREADABLE), never let through on a text match;
20
+ # - #outside_writes?: whether it writes outside the project (a
21
+ # redirection's target, tee's files, cp/mv/ln/install/rsync/ditto's
22
+ # destination), the tmp dirs and /dev/null aside; a target it can't
23
+ # resolve counts as outside.
24
+ # A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
25
+ # is not looked into: it's text being written, not run.
26
+ class PairCommand
27
+ # What runs a command on another's behalf, and the shell's keywords
28
+ # before a command: skipped to find the program.
29
+ WRAPPERS = %w[env exec command nohup time sudo nice xargs setsid timeout stdbuf caffeinate bundle ruby watch npx
30
+ { ! then do else elif if while until].freeze
31
+ # chi's own invocations a pair turn may run: they print and exit.
32
+ CHI_ALLOWED = [%w[--version], %w[--help]].freeze
33
+ # chi named in a script, a value or an argument.
34
+ CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}
35
+ SHELLS = %w[sh bash zsh dash fish ksh].freeze
36
+ # Other interpreters a script can be handed to (-e/-c, a heredoc).
37
+ INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
38
+ # Options whose next word is code or a command, for any program.
39
+ CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
40
+ # Programs that detach what they start.
41
+ DETACHERS = %w[nohup setsid disown daemonize].freeze
42
+ # Programs that print the environment or a keychain's secrets, whatever
43
+ # their arguments.
44
+ ENV_READERS = %w[printenv security].freeze
45
+ ENV_TEXT = %r{/proc/[^\s]*/environ}
46
+ # An interpreter's script that reads the environment or detaches.
47
+ SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
48
+ SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
49
+ # Programs that read a folder's whole tree: given home (or any folder
50
+ # above a denied one) they reach the secrets in it.
51
+ SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto].freeze
52
+ # git settings that make git run a command (or show one), and git
53
+ # config's scopes beyond the repository.
54
+ GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
55
+ GIT_WIDE_SCOPES = %w[--global --system].freeze
56
+ GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
57
+ # Writers whose last argument (or -t DIR) is the destination.
58
+ COPIERS = %w[cp mv ln install rsync ditto].freeze
59
+ REDIRECTS = %w[> >> &> &>> 1> 1>> 2> 2>>].freeze
60
+ REDIRECT_WORD = /\A\d*(?:&>>?|>>?)(?!&)(.+)\z/
61
+ DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty].freeze
62
+ GLOB = /[*?\[\]{}]/
63
+ # How many matches of a glob are checked against the denylist.
64
+ GLOB_LIMIT = 500
65
+ # How deep scripts handed to a shell are read; deeper is UNREADABLE.
66
+ MAX_DEPTH = 3
67
+
68
+ # The tmp dirs a pair turn writes into freely (neither asked nor
69
+ # denied): Dir.tmpdir, $TMPDIR, /tmp, /private/tmp.
70
+ def self.tmp_roots
71
+ [Dir.tmpdir, ENV.fetch("TMPDIR", nil), "/tmp", "/private/tmp"].compact.reject(&:empty?)
72
+ .map { |dir| ProtectedPaths.real(dir) }.uniq
73
+ end
74
+
75
+ # @param paths [ReviewPaths] the denylist
76
+ # @param root [String, nil] the project root (outside writes)
77
+ def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, tmp_roots: nil)
78
+ @tmp_roots = tmp_roots
79
+ @text = text.to_s
80
+ @cwd = cwd
81
+ @paths = paths
82
+ @home = home.to_s
83
+ @root = root.to_s
84
+ @depth = depth
85
+ end
86
+
87
+ # @return [String, nil] a PairBoundary refusal
88
+ def refusal
89
+ return @refusal if defined?(@refusal)
90
+
91
+ @refusal = read
92
+ rescue StandardError
93
+ @refusal = PairBoundary::UNREADABLE
94
+ end
95
+
96
+ # Whether it writes outside the project (asked about, never denied).
97
+ def outside_writes?
98
+ refusal
99
+ @targets.to_a.any? { |path| outside?(path) }
100
+ end
101
+
102
+ private
103
+
104
+ def read
105
+ return PairBoundary::UNREADABLE if @depth > MAX_DEPTH
106
+
107
+ tokens = ShellLex.lex(@text)
108
+ code = ShellLex.code_text(@text, keep_written: false)
109
+ subst = tokens.any? { |_, value| value.include?(ShellLex::SUBST) }
110
+ return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
111
+ return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
112
+ return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
113
+ return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
114
+
115
+ @targets = []
116
+ walk = ShellPaths::Walk.new(@cwd, @home, ENV)
117
+ ShellLex.simple_commands(tokens).each do |words|
118
+ next walk.step(words) if words.is_a?(Symbol)
119
+
120
+ @targets.concat(write_targets(words, walk))
121
+ reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
122
+ return reason if reason
123
+ end
124
+ nil
125
+ end
126
+
127
+ # A simple command's own refusal, by its program and arguments.
128
+ def words_refusal(words)
129
+ at = program_at(words)
130
+ program = at && File.basename(words[at])
131
+ args = at ? words[(at + 1)..] : []
132
+ return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
133
+ return PairBoundary::CHI if chi_in_values?(words)
134
+
135
+ upto = at ? words[0..at] : words
136
+ return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
137
+ return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
138
+ return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
139
+
140
+ nil
141
+ end
142
+
143
+ # chi in an assignment's value (FOO=chi, alias x='chi …') or in the
144
+ # word after an option that takes code or a command (-exec, -c, -e).
145
+ def chi_in_values?(words)
146
+ words.each_with_index.any? do |word, i|
147
+ value = word[/\A[A-Za-z_][\w-]*=(.*)\z/m, 1]
148
+ (value && value.match?(CHI_TEXT)) || (i.positive? && CODE_OPTIONS.include?(words[i - 1]) && word.match?(CHI_TEXT))
149
+ end
150
+ end
151
+
152
+ # The scripts the command hands on: a shell's (-c, eval, a heredoc or
153
+ # here-string) read as a command; an interpreter's (-e/-c, a heredoc)
154
+ # by its text.
155
+ def scripts_refusal(words)
156
+ limit = program_at(words) || (words.size - 1)
157
+ at = (0..limit).find { |i| runner?(words[i]) } or return nil
158
+ program = File.basename(words[at])
159
+ scripts(words, at).each do |script|
160
+ reason = if SHELLS.include?(program) || program == "eval"
161
+ PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root, depth: @depth + 1,
162
+ tmp_roots: @tmp_roots).refusal
163
+ elsif INTERPRETERS.include?(program)
164
+ script_text_refusal(script)
165
+ end
166
+ return reason if reason
167
+ end
168
+ nil
169
+ end
170
+
171
+ def runner?(word)
172
+ name = File.basename(word)
173
+ SHELLS.include?(name) || INTERPRETERS.include?(name) || name == "eval"
174
+ end
175
+
176
+ def scripts(words, at)
177
+ found = []
178
+ found << words[(at + 1)..].join(" ") if File.basename(words[at]) == "eval"
179
+ words.each_with_index do |word, i|
180
+ next if i.zero? || word == ShellLex::HEREDOC
181
+
182
+ previous = words[i - 1]
183
+ found << word if previous.start_with?("<<") || (i > at && %w[-c -e].include?(previous))
184
+ end
185
+ found
186
+ end
187
+
188
+ def script_text_refusal(script)
189
+ return PairBoundary::CHI if script.match?(CHI_TEXT)
190
+ return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
191
+ return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
192
+ return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
193
+
194
+ nil
195
+ end
196
+
197
+ # Where the program is in +words+: past assignments, wrappers, shell
198
+ # keywords and their options (and an option's value: ruby -I lib,
199
+ # timeout 10).
200
+ def program_at(words)
201
+ at = 0
202
+ at += 1 while at < words.size && skipped?(words, at)
203
+ at < words.size ? at : nil
204
+ end
205
+
206
+ def skipped?(words, at)
207
+ word = words[at]
208
+ return true if word.match?(/\A[A-Za-z_]\w*=/) || word.start_with?("-") || word.match?(/\A\d+[smhd]?\z/)
209
+ return true if WRAPPERS.include?(File.basename(word))
210
+
211
+ previous = at.positive? ? words[at - 1] : nil
212
+ !previous.nil? && previous.match?(/\A-[IrCen]\z/)
213
+ end
214
+
215
+ # screen -d…, tmux new -d: a session left running.
216
+ def detached_session?(program, args)
217
+ (program == "screen" && args.any? { |arg| arg.match?(/\A-\w*d/) }) ||
218
+ (program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
219
+ end
220
+
221
+ # printenv, security; set, export, declare and typeset that list
222
+ # (no assignment); launchctl getenv/export; ps with a BSD e (ps e, eww).
223
+ def env_reader?(program, args)
224
+ return true if ENV_READERS.include?(program)
225
+
226
+ listing = args.none? { |arg| !arg.start_with?("-", "+") }
227
+ case program
228
+ when "set" then args.empty?
229
+ when "export", "declare", "typeset" then listing
230
+ when "launchctl" then %w[getenv export].include?(args.first)
231
+ when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z/) }
232
+ else false
233
+ end
234
+ end
235
+
236
+ # env with no command after it prints the environment.
237
+ def bare_env?(words)
238
+ assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
239
+ at = words.index { |word| !assignment.call(word) }
240
+ return false unless at && File.basename(words[at]) == "env"
241
+
242
+ words[(at + 1)..].all?(&assignment)
243
+ end
244
+
245
+ # git config beyond the repository (--global, --system), or a setting
246
+ # that runs a command (core.hooksPath, core.fsmonitor, core.sshCommand,
247
+ # core.pager, core.editor, a "!" alias), set with git config or git -c.
248
+ def git_runs_commands?(args)
249
+ i = 0
250
+ while i < args.size && args[i].start_with?("-")
251
+ if args[i] == "-c"
252
+ return true if running_setting?(*args[i + 1].to_s.split("=", 2))
253
+
254
+ i += 2
255
+ elsif GIT_VALUE_OPTIONS.include?(args[i])
256
+ i += 2
257
+ else
258
+ i += 1
259
+ end
260
+ end
261
+ return false unless args[i] == "config"
262
+
263
+ rest = args[(i + 1)..]
264
+ return true if rest.any? { |arg| GIT_WIDE_SCOPES.include?(arg) }
265
+
266
+ key, value = rest.reject { |arg| arg.start_with?("-") }
267
+ running_setting?(key, value)
268
+ end
269
+
270
+ def running_setting?(key, value)
271
+ key = key.to_s.downcase
272
+ GIT_RUNNING_KEYS.include?(key) || (key.start_with?("alias.") && value.to_s.lstrip.start_with?("!"))
273
+ end
274
+
275
+ # The secrets a simple command's words reach: a resolved path on the
276
+ # denylist; a glob that matches one or whose fixed part leads into a
277
+ # denied dir; a $var word whose fixed part does; a sweeper given a
278
+ # folder above a denied one; SECRET_TEXT for a word it can't read.
279
+ def paths_refusal(words, resolved)
280
+ program_word = (at = program_at(words)) && File.basename(words[at])
281
+ resolved.each do |word, path|
282
+ if path
283
+ return PairBoundary::SECRET if @paths.secret_refusal([path])
284
+ return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
285
+ elsif unresolved_secret?(word) || (!data?(word) && word.match?(PairBoundary::SECRET_TEXT))
286
+ return PairBoundary::SECRET
287
+ end
288
+ end
289
+ nil
290
+ end
291
+
292
+ def data?(word) = word == ShellLex::HEREDOC
293
+
294
+ # A word Walk can't resolve: a glob checked by what it matches and by
295
+ # its fixed part, a $var by its fixed part (the text before the first
296
+ # glob character or $, with ~ and $HOME expanded). A script body
297
+ # (spaces, quotes), a data heredoc or a $(…) is left to the other checks.
298
+ def unresolved_secret?(word)
299
+ return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
300
+
301
+ expanded = word.sub(%r{\A~(?=/|\z)}, @home).sub(/\A\$(?:\{HOME\}|HOME\b)/, @home)
302
+ fixed = expanded[/\A[^*?\[\]{}$]*/]
303
+ dir = @cwd || Dir.pwd
304
+ if !expanded.include?("$") && expanded.match?(GLOB)
305
+ matches = Dir.glob(File.expand_path(expanded, dir)).first(GLOB_LIMIT)
306
+ return true unless @paths.secret_refusal(matches).nil?
307
+ end
308
+ return false if fixed.empty?
309
+
310
+ leads_into_denied?(File.expand_path(fixed, dir))
311
+ end
312
+
313
+ # Whether a path's text is the start of a denied root's (its parent,
314
+ # or part of its name: ~/.ss) or inside one.
315
+ def leads_into_denied?(prefix)
316
+ @paths.denied_roots.any? { |root| root.start_with?(prefix) || ShellPaths.within?(prefix, root) }
317
+ end
318
+
319
+ # Whether +path+ is a denied root's folder or one above it.
320
+ def above_denied?(path)
321
+ real = ProtectedPaths.real(path)
322
+ @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
323
+ end
324
+
325
+ # The paths a simple command writes (nil for one it can't resolve), from
326
+ # the dir it runs in (before a cd in it takes effect).
327
+ def write_targets(words, walk)
328
+ targets = []
329
+ words.each_with_index do |word, i|
330
+ if REDIRECTS.include?(word) && words[i + 1]
331
+ targets << walk.path(words[i + 1])
332
+ elsif (target = word[REDIRECT_WORD, 1]) && !word.start_with?("<")
333
+ targets << walk.path(target)
334
+ end
335
+ end
336
+ at = program_at(words) or return targets
337
+ program = File.basename(words[at])
338
+ args = words[(at + 1)..].reject { |w| REDIRECTS.include?(w) || w.match?(REDIRECT_WORD) }
339
+ plain = args.reject { |arg| arg.start_with?("-") }
340
+ if program == "tee"
341
+ targets.concat(plain.map { |arg| walk.path(arg) })
342
+ elsif COPIERS.include?(program)
343
+ dir = args[args.index("-t") + 1] if args.include?("-t")
344
+ dir ||= args.find { |arg| arg.start_with?("--target-directory=") }&.split("=", 2)&.last
345
+ dir ||= plain.last if plain.size >= 2
346
+ targets << walk.path(dir) if dir
347
+ end
348
+ targets
349
+ end
350
+
351
+ def outside?(path)
352
+ return true if path.nil?
353
+ return false if DEVICES.include?(path)
354
+
355
+ real = ProtectedPaths.real(path)
356
+ return false if tmp_roots.any? { |tmp| ShellPaths.within?(real, tmp) }
357
+ return true if @root.empty?
358
+
359
+ !ShellPaths.within?(real, ProtectedPaths.real(@root))
360
+ end
361
+
362
+ def tmp_roots = @tmp_roots ||= PairCommand.tmp_roots
363
+ end
364
+ end
365
+ end
@@ -14,8 +14,19 @@ module Samagotchi
14
14
  # a participant's turn; a review session's subjects (chi review, the
15
15
  # participant page's Plan) go through the same check.
16
16
  class ReviewPaths
17
- # Home folders that hold keys and tokens, never read in a review turn.
18
- HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
17
+ # Home folders that hold keys and tokens, never read in a review turn
18
+ # (nor read or written in a pair turn).
19
+ # A pair turn neither reads nor writes these either: the shell's and
20
+ # git's config folders, the user's launch agents and ~/.local/bin (what
21
+ # runs at login or comes first on PATH).
22
+ HOME_SECRET_DIRS = %w[.ssh .gnupg .aws .config/gh .config/fish .config/git Library/LaunchAgents .local/bin].freeze
23
+ # Home files that start a shell, hold credentials or keep what was
24
+ # typed: a pair turn neither reads nor writes them (a review turn reads
25
+ # only inside the project anyway).
26
+ HOME_SECRET_FILES = %w[.zshrc .zshenv .zprofile .zlogin .bashrc .bash_profile .bash_login .profile .gitconfig
27
+ .netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
28
+ .node_repl_history .psql_history .mysql_history .irb_history .lesshst
29
+ .local/share/fish/fish_history].freeze
19
30
  # Project files that hold secrets, by a path part's name: .env*,
20
31
  # netrc, keys and certificates, anything named credentials, package
21
32
  # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
@@ -67,13 +78,34 @@ module Samagotchi
67
78
  nil
68
79
  end
69
80
 
70
- private
81
+ # SECRET when one of +paths+ is on the read denylist (as given or as
82
+ # it resolves), wherever it is; nil otherwise. A pair turn's check
83
+ # (PairBoundary): it reads outside the project too, never these.
84
+ # @param paths [Array<String>] absolute, or relative to the cwd
85
+ # @return [Symbol, nil]
86
+ def secret_refusal(paths)
87
+ paths.each do |path|
88
+ given = File.expand_path(path)
89
+ return SECRET if secret?(given) || secret?(ProtectedPaths.real(given))
90
+ end
91
+ nil
92
+ end
93
+
94
+ # The denylist's dirs and home files, absolute (as given and as they
95
+ # resolve): a pair turn's command check asks whether a word it can't
96
+ # resolve (a glob, a $var) may lead into one.
97
+ # @return [Array<String>]
98
+ def denied_roots
99
+ @denied_roots ||= begin
100
+ roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
101
+ roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
102
+ (roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
103
+ end
104
+ end
71
105
 
106
+ # Whether +path+ (absolute) is on the read denylist.
72
107
  def secret?(path)
73
- dirs = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
74
- dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
75
- dirs += HOME_SECRET_DIRS.map { |dir| File.join(@home, dir) } unless @home.empty?
76
- return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
108
+ return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
77
109
 
78
110
  parts = path.split(File::SEPARATOR)
79
111
  return true if parts.any? { |part| SECRET_FILE.match?(part) }
@@ -82,6 +114,8 @@ module Samagotchi
82
114
  git_config?(parts)
83
115
  end
84
116
 
117
+ private
118
+
85
119
  # A config file anywhere under a .git dir (.git/config,
86
120
  # .git/modules/<sub>/config, a worktree's config.worktree).
87
121
  def git_config?(parts)
@@ -87,8 +87,10 @@ module Samagotchi
87
87
  # as data (DataReaders) cut out, the <<TAG word and the terminator line
88
88
  # kept as written. This is what a rule's command: reads. A text the
89
89
  # lexer isn't sure of (unterminated) comes back as it was, and so does
90
- # one it can't read: the safe default.
91
- def self.code_text(text)
90
+ # one it can't read: the safe default. +keep_written+ false cuts every
91
+ # data body, a cat or tee writing it to a file too (a pair turn's check:
92
+ # text written to a file is data; where it goes is checked apart).
93
+ def self.code_text(text, keep_written: true)
92
94
  lexer = Lexer.new(text)
93
95
  tokens = lexer.tokens
94
96
  return text if lexer.unterminated?
@@ -104,7 +106,7 @@ module Samagotchi
104
106
 
105
107
  span = lexer.spans[i]
106
108
  cut = if bodies.include?(span)
107
- body_cut(text, span, words, redirected) if value == HEREDOC
109
+ body_cut(text, span, words, redirected, keep_written) if value == HEREDOC
108
110
  elsif value == SUBST
109
111
  substitution_cut(text, tokens, i, span, words, position)
110
112
  end
@@ -139,8 +141,8 @@ module Samagotchi
139
141
  # cat or tee writing a file may be run later (D1), and so may one
140
142
  # printing after an earlier exec sent the shell's output to a file
141
143
  # (+redirected+).
142
- def self.body_cut(text, span, words, redirected)
143
- return nil unless cut_body?(words, redirected)
144
+ def self.body_cut(text, span, words, redirected, keep_written = true)
145
+ return nil if keep_written && !cut_body?(words, redirected)
144
146
 
145
147
  [span, terminator_line(text[span])]
146
148
  end
@@ -41,13 +41,22 @@ module Samagotchi
41
41
  # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
42
42
  def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
43
43
  roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
44
+ any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
45
+ end
46
+
47
+ # Whether a word of +command+ names a path the block holds for (each
48
+ # word resolved as #touches? resolves it); a word that can't be
49
+ # resolved, a command with a $(…) or backticks, or one that doesn't
50
+ # lex, by the text match +text+ instead.
51
+ # @yieldparam path [String] an absolute path (not resolved for symlinks)
52
+ def any_path?(command, text:, cwd:, home: Dir.home, env: ENV)
44
53
  walk = Walk.new(cwd, home, env)
45
54
  tokens = ShellLex.lex(command.to_s)
46
55
  code = ShellLex.code_text(command.to_s)
47
56
  return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
48
57
 
49
58
  ShellLex.simple_commands(tokens).any? do |words|
50
- walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
59
+ walk.step(words).any? { |word, path| path ? yield(path) : word.match?(text) }
51
60
  end
52
61
  rescue ArgumentError, EncodingError
53
62
  command.to_s.scrub.match?(text)
@@ -38,6 +38,13 @@ module Samagotchi
38
38
  # System 1's judgment that decided an ask, when it did: {id:, p:}.
39
39
  attr_accessor :judgment
40
40
 
41
+ # An ask no stored approval answers (Gate#settle_ask): the one a
42
+ # participant's pair turn gets before a write outside the project
43
+ # (PairBoundary), which the owner decides each time.
44
+ attr_writer :fresh_ask
45
+
46
+ def fresh_ask? = @fresh_ask == true
47
+
41
48
  def initialize(call:)
42
49
  @call = call
43
50
  @decision = :allow
@@ -14,6 +14,7 @@ require_relative "guardrails/protected_paths"
14
14
  require_relative "guardrails/scratch_writes"
15
15
  require_relative "guardrails/child_boundary"
16
16
  require_relative "guardrails/review_boundary"
17
+ require_relative "guardrails/pair_boundary"
17
18
  require_relative "guardrails/load_failures"
18
19
  require_relative "guardrails/rules"
19
20
  require_relative "guardrails/gate"
@@ -66,6 +66,7 @@ module Samagotchi
66
66
  parent_cannot_allow: "answered by a parent that could not allow",
67
67
  unanswered: "unanswered",
68
68
  no_approval: "no approval card",
69
+ participant: "asked by a participant's turn",
69
70
  no_question_id: "no question id"
70
71
  }.freeze
71
72
 
@@ -200,13 +201,17 @@ module Samagotchi
200
201
 
201
202
  # One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
202
203
  # :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
203
- # :no_approval, :no_question_id), or nil for an entry that is not a
204
+ # :no_approval, :participant, :no_question_id), or nil for an entry that is not a
204
205
  # question at all.
205
206
  def ask_of(entry, session_id, judged, sessions_dir)
206
207
  return nil unless entry.is_a?(Hash) && entry[:type] == "question"
207
208
 
208
209
  pending = entry[:pending_question]
209
210
  return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
211
+ # A participant's turn's ask is never System 1's to judge, live or here
212
+ # (GuardrailWiring#system_one_judge): no record of it is the user's
213
+ # rule-level decision (it offered once only).
214
+ return :participant if pending[:approval][:author]
210
215
 
211
216
  question_id = pending[:id].to_s
212
217
  return :no_question_id if question_id.empty?
@@ -67,6 +67,10 @@ module Samagotchi
67
67
 
68
68
  def reminder_store = @kernel.reminder_store
69
69
  def peers = @kernel.peers
70
+
71
+ # The running participant turn's mode (KernelLoop#participant_mode:
72
+ # :pair, :review), nil for the owner's turns.
73
+ def participant_mode = @kernel.participant_mode&.call
70
74
  def model_key = @kernel.model_key
71
75
 
72
76
  # memory_read with the session's mutes applied: a blank name (the index)
@@ -179,6 +183,21 @@ module Samagotchi
179
183
  # Whether the current turn belongs to a participant, so pre-gate
180
184
  # refusals can defer to ReviewBoundary.
181
185
  attr_accessor :participant_turn
186
+ # The running participant turn's mode (a callable → :pair, :review or
187
+ # nil: GuardrailWiring#turn_mode): ToolRunner reads it. The Engine sets it.
188
+ attr_accessor :participant_mode
189
+ # Called before each generation, before the before_generation hook (a
190
+ # callable, nil: none): the Engine's check that may stop the turn (a
191
+ # pair turn past its participant's cap, Engine#guard_participant_spend).
192
+ attr_accessor :generation_guard
193
+
194
+ # The generation guard, if any; one that raises never breaks the turn.
195
+ def guard_generation
196
+ @generation_guard&.call
197
+ rescue StandardError => e
198
+ Log.warn(:turn, "generation_guard_failed", error: "#{e.class}: #{e.message}")
199
+ nil
200
+ end
182
201
 
183
202
  def participant_turn? = @participant_turn&.call == true
184
203
  # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
@@ -426,6 +445,7 @@ module Samagotchi
426
445
  def generate(turn, request)
427
446
  # Before the generation starts: a hook that waits (a question) isn't
428
447
  # the generation's time, and a Stop there leaves none started.
448
+ guard_generation
429
449
  fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
430
450
  raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
431
451
 
@@ -389,6 +389,9 @@ module Samagotchi
389
389
  nil
390
390
  end
391
391
 
392
+ # The kernel's generation guard (KernelLoop#guard_generation).
393
+ def guard_generation = @kernel.respond_to?(:guard_generation) ? @kernel.guard_generation : nil
394
+
392
395
  # A hook failing must not break the turn (as in ToolRunner).
393
396
  def fire_hook(name, event)
394
397
  @kernel.hooks&.fire(name, event)
@@ -727,6 +730,7 @@ module Samagotchi
727
730
  observe_context(iteration, window)
728
731
  # Before the generation starts: a hook that waits (a question)
729
732
  # isn't the generation's time, and a Stop there leaves none started.
733
+ @loop.guard_generation
730
734
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
731
735
  return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
732
736