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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
|