samagotchi 0.79.0 → 0.81.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 +59 -1
- data/README.md +7 -5
- data/docs/architecture.md +9 -1
- data/docs/cli.md +32 -7
- data/docs/configuration.md +10 -4
- data/docs/guardrails.md +26 -3
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +149 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +9 -1
- data/docs/testing.md +51 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +93 -16
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +68 -39
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +61 -9
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +141 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +165 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +20 -15
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +24 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# Which shell commands read their stdin as data and never run it: cat and
|
|
6
|
+
# tee (to stdout), and a git or gh command taking its message from stdin.
|
|
7
|
+
# Pure word predicates over one simple command's words (the lexer's
|
|
8
|
+
# ShellLex). Everything else is an unknown consumer and stays matched.
|
|
9
|
+
module DataReaders
|
|
10
|
+
# Commands that print what they read.
|
|
11
|
+
SINKS = %w[cat tee].freeze
|
|
12
|
+
# What a heredoc body that a data reader reads becomes (the same value
|
|
13
|
+
# as ShellLex::HEREDOC).
|
|
14
|
+
HEREDOC = "\0HEREDOC"
|
|
15
|
+
# A word that is a redirection (>, >>, 2>&1, <; the same as
|
|
16
|
+
# ShellLex::REDIRECT).
|
|
17
|
+
REDIRECT = /\A\d*[<>]/
|
|
18
|
+
# git's global options that take the next word.
|
|
19
|
+
GIT_OPTIONS_WITH_VALUE = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
20
|
+
# A >& target that names an fd instead of a file: a number or -.
|
|
21
|
+
FD_TARGET = /\A(\d+|-)\z/
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The command word among a simple command's words: the NAME= words and
|
|
26
|
+
# the redirections dropped, and with a redirection written with a space
|
|
27
|
+
# (> /tmp/x) its target word too. nil when there is none.
|
|
28
|
+
def command_word(words)
|
|
29
|
+
index = command_index(words)
|
|
30
|
+
words[index] if index
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Where command_word's word is in +words+ (nil when there is none).
|
|
34
|
+
def command_index(words)
|
|
35
|
+
i = 0
|
|
36
|
+
while i < words.size
|
|
37
|
+
word = words[i]
|
|
38
|
+
if word.match?(REDIRECT)
|
|
39
|
+
i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
|
|
40
|
+
elsif !word.match?(/\A[A-Za-z_]\w*=/)
|
|
41
|
+
return i
|
|
42
|
+
end
|
|
43
|
+
i += 1
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether a cat or tee command sends its output to a file (a file
|
|
49
|
+
# written can be run later): a stdout redirection whose target isn't
|
|
50
|
+
# /dev/null, a dangling >, or a tee argument that is no option,
|
|
51
|
+
# redirection, heredoc or /dev/null.
|
|
52
|
+
def writes_file?(words)
|
|
53
|
+
return true if stdout_file?(words)
|
|
54
|
+
|
|
55
|
+
File.basename(command_word(words).to_s) == "tee" && tee_file?(words)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether one of +words+ sends stdout to a file: a >, >>, 1>, 1>>, >& or
|
|
59
|
+
# 1>& redirection (its target in the word, or in the next word when the
|
|
60
|
+
# operator ends it) whose target names a file and isn't /dev/null. A dup
|
|
61
|
+
# to an fd writes no file: >&2, >&-, 2>&1, and a spaced ">& 2" or
|
|
62
|
+
# ">& -". A >& with any other target does write one: bash reads
|
|
63
|
+
# ">& file" and ">&file" as "&>file".
|
|
64
|
+
def stdout_file?(words)
|
|
65
|
+
words.each_with_index do |word, i|
|
|
66
|
+
match = word.match(/\A1?(>>?)(&?)(.*)\z/m)
|
|
67
|
+
next unless match
|
|
68
|
+
|
|
69
|
+
amp = match[2]
|
|
70
|
+
target = match[3]
|
|
71
|
+
target = words[i + 1] if target.empty? # "> file", ">& 2"
|
|
72
|
+
return true if target.nil? # a dangling > or >&
|
|
73
|
+
next if target == "/dev/null"
|
|
74
|
+
next if !amp.empty? && target.match?(FD_TARGET)
|
|
75
|
+
|
|
76
|
+
return true
|
|
77
|
+
end
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether a tee command writes a file of its own (an argument that
|
|
82
|
+
# isn't an option, a redirection, a heredoc, a body or /dev/null).
|
|
83
|
+
def tee_file?(words)
|
|
84
|
+
verb = command_index(words)
|
|
85
|
+
return false unless verb
|
|
86
|
+
|
|
87
|
+
i = verb + 1
|
|
88
|
+
while i < words.size
|
|
89
|
+
word = words[i]
|
|
90
|
+
if word.match?(REDIRECT)
|
|
91
|
+
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
return true if filename_word?(word)
|
|
95
|
+
|
|
96
|
+
i += 1
|
|
97
|
+
end
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether a tee argument names a file: not an option, a heredoc's tag,
|
|
102
|
+
# a body word (HEREDOC, SUBST, a script) or /dev/null.
|
|
103
|
+
def filename_word?(word)
|
|
104
|
+
!word.start_with?("-", "<<") && !word.include?("\0") && !word.include?("\n") && word != "/dev/null"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether a simple command reads its stdin as a text file it never
|
|
108
|
+
# runs: cat or tee, git commit/tag -F - (--file -), gh pr|issue … -F -
|
|
109
|
+
# (--body-file -) or gh release … -F - (--notes-file -).
|
|
110
|
+
def data_reader?(words)
|
|
111
|
+
args = arguments(words)
|
|
112
|
+
verb = File.basename(args.first.to_s)
|
|
113
|
+
return true if SINKS.include?(verb)
|
|
114
|
+
|
|
115
|
+
case verb
|
|
116
|
+
when "git" then git_reader?(args)
|
|
117
|
+
when "gh" then gh_reader?(args)
|
|
118
|
+
else false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether word +index+ of a simple command is the value of a message
|
|
123
|
+
# option of a data reader: git commit/tag -m/--message, gh pr|issue …
|
|
124
|
+
# -b/--body/-t/--title, gh release … -n/--notes/-t/--title. A
|
|
125
|
+
# "$(cat <<'EOF' … EOF)" there is a message, not code.
|
|
126
|
+
def message_arg?(words, index)
|
|
127
|
+
return false unless index.positive?
|
|
128
|
+
|
|
129
|
+
option = words[index - 1]
|
|
130
|
+
args = arguments(words)
|
|
131
|
+
case File.basename(args.first.to_s)
|
|
132
|
+
when "git" then git_message_option?(args, option)
|
|
133
|
+
when "gh" then gh_message_option?(args, option)
|
|
134
|
+
else false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A simple command's arguments: the NAME= words, the redirections (with
|
|
139
|
+
# their spaced targets), the <<TAG words and the heredoc bodies left
|
|
140
|
+
# out.
|
|
141
|
+
def arguments(words)
|
|
142
|
+
args = []
|
|
143
|
+
i = 0
|
|
144
|
+
while i < words.size
|
|
145
|
+
word = words[i]
|
|
146
|
+
if word.match?(REDIRECT)
|
|
147
|
+
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
148
|
+
next
|
|
149
|
+
end
|
|
150
|
+
args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
args
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether a word is a heredoc's <<TAG or one of its bodies.
|
|
157
|
+
def heredoc_word?(word)
|
|
158
|
+
word.match?(REDIRECT) || word.include?("\0") || word.include?("\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The words after git's global options ([-C dir …] commit), nil when
|
|
162
|
+
# they run out.
|
|
163
|
+
def git_arguments(args)
|
|
164
|
+
i = 1
|
|
165
|
+
i += GIT_OPTIONS_WITH_VALUE.include?(args[i]) ? 2 : 1 while i < args.size && args[i].start_with?("-")
|
|
166
|
+
args[i..]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def git_reader?(args)
|
|
170
|
+
rest = git_arguments(args)
|
|
171
|
+
return false unless rest
|
|
172
|
+
|
|
173
|
+
subcommand, *opts = rest
|
|
174
|
+
return false unless %w[commit tag].include?(subcommand)
|
|
175
|
+
|
|
176
|
+
stdin_file?(opts, "-F", "--file")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def gh_reader?(args)
|
|
180
|
+
case args[1]
|
|
181
|
+
when "pr", "issue"
|
|
182
|
+
%w[create comment edit review].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--body-file")
|
|
183
|
+
when "release"
|
|
184
|
+
%w[create edit].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--notes-file")
|
|
185
|
+
else
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether +opts+ take their text from stdin: "-F -", "--file -",
|
|
191
|
+
# "-F-" or "--file=-".
|
|
192
|
+
def stdin_file?(opts, short, long)
|
|
193
|
+
opts.each_cons(2).any? { |a, b| [short, long].include?(a) && b == "-" } ||
|
|
194
|
+
opts.include?("#{short}-") || opts.include?("#{long}=-")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def git_message_option?(args, option)
|
|
198
|
+
rest = git_arguments(args)
|
|
199
|
+
rest && %w[commit tag].include?(rest.first) && %w[-m --message].include?(option)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def gh_message_option?(args, option)
|
|
203
|
+
case args[1]
|
|
204
|
+
when "pr", "issue"
|
|
205
|
+
%w[create comment edit review].include?(args[2]) && %w[-b --body -t --title].include?(option)
|
|
206
|
+
when "release"
|
|
207
|
+
%w[create edit].include?(args[2]) && %w[-n --notes -t --title].include?(option)
|
|
208
|
+
else
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -49,10 +49,14 @@ module Samagotchi
|
|
|
49
49
|
# @param call [Hash] the parsed tool call
|
|
50
50
|
# @param iteration [Integer]
|
|
51
51
|
# @param params [String] the call's one-line preview
|
|
52
|
+
# @param verdict [Verdict, nil] the one the voters vote on (the test
|
|
53
|
+
# kit's recording one, maybe with a vote already in); default a new
|
|
54
|
+
# one for +call+
|
|
52
55
|
# @return [Verdict]
|
|
53
|
-
def evaluate(call, iteration:, params:)
|
|
56
|
+
def evaluate(call, iteration:, params:, verdict: nil)
|
|
54
57
|
context = @context_lookup.call
|
|
55
|
-
verdict
|
|
58
|
+
verdict ||= Verdict.new(call: call)
|
|
59
|
+
verdict.call = call
|
|
56
60
|
if @cancelled_lookup.call
|
|
57
61
|
verdict.deny!("the turn was stopped", decided_by: "core")
|
|
58
62
|
verdict.context = context
|
|
@@ -60,7 +64,7 @@ module Samagotchi
|
|
|
60
64
|
return verdict
|
|
61
65
|
end
|
|
62
66
|
before = { type: :before_tool_call, iteration: iteration, call: call.dup, params: params,
|
|
63
|
-
blocked:
|
|
67
|
+
blocked: verdict.deny?, block_reason: (verdict.reason if verdict.deny?), guardrail: verdict,
|
|
64
68
|
context: context.to_h, targets: targets_for(call, context).to_h }
|
|
65
69
|
fire_each(:before_tool_call, before) do |event|
|
|
66
70
|
verdict.legacy_deny!(event[:block_reason]) if event[:blocked]
|
|
@@ -13,7 +13,9 @@ module Samagotchi
|
|
|
13
13
|
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
|
|
14
14
|
# matches the call's name or the tool a plugin tool's call
|
|
15
15
|
# acts as (Targets#acts_as: an MCP tool behind call_tool)
|
|
16
|
-
# command: a Ruby regex on a shell tool's command
|
|
16
|
+
# command: a Ruby regex on a shell tool's command, the body of a
|
|
17
|
+
# heredoc its command only reads as data left out
|
|
18
|
+
# (Targets#rule_command, ShellLex.code_text)
|
|
17
19
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
18
20
|
# or "**/…" globs match the absolute path; others the path
|
|
19
21
|
# relative to the repo root, the cwd outside one)
|
|
@@ -61,7 +63,7 @@ module Samagotchi
|
|
|
61
63
|
return false unless targets
|
|
62
64
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
63
65
|
return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
|
|
64
|
-
return false if command && !(targets.
|
|
66
|
+
return false if command && !(targets.rule_command && command.match?(targets.rule_command))
|
|
65
67
|
return false if path && !path_matches?(targets)
|
|
66
68
|
return false if git && !targets.git_outside_repo?
|
|
67
69
|
return false if touches && !targets.touches_chi?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "data_readers"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Guardrails
|
|
5
7
|
# A small shell lexer for the guardrails' text checks (ShellGitDirs
|
|
@@ -8,8 +10,9 @@ module Samagotchi
|
|
|
8
10
|
# backticks (quoted or not) as one opaque SUBST marker inside a word. A
|
|
9
11
|
# heredoc's body (<<EOF, <<'EOF', <<-EOF) is one word after its <<TAG
|
|
10
12
|
# word and lexing goes on after the terminator line: HEREDOC when cat or
|
|
11
|
-
# tee read it as data
|
|
12
|
-
#
|
|
13
|
+
# tee read it as data (when the command ends at a plain separator,
|
|
14
|
+
# outside any ( or { group), SUBST when an unquoted tag's body holds $(…)
|
|
15
|
+
# or backticks, else the body text itself (a script, as sh -c '…' gives).
|
|
13
16
|
# An unterminated heredoc is no heredoc: the rest is lexed as commands.
|
|
14
17
|
# It never runs anything, and it does its best on odd input (an
|
|
15
18
|
# unbalanced quote).
|
|
@@ -18,12 +21,15 @@ module Samagotchi
|
|
|
18
21
|
OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
|
|
19
22
|
# What a $(…) or `…` becomes inside a word.
|
|
20
23
|
SUBST = "\0SUBST"
|
|
21
|
-
# What a heredoc body that
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
# What a heredoc body that a data reader reads as data becomes (its
|
|
25
|
+
# command ends at a plain separator, outside any ( or { group).
|
|
26
|
+
HEREDOC = DataReaders::HEREDOC
|
|
27
|
+
# The operators that may follow a data heredoc's command; any other
|
|
28
|
+
# (|, |&, &, (, ), ;;) may hand its output on, and & also starts
|
|
29
|
+
# &>file.
|
|
30
|
+
DATA_ENDS = ["\n", ";", "&&", "||"].freeze
|
|
25
31
|
# A word that is a redirection (>, >>, 2>&1, <).
|
|
26
|
-
REDIRECT =
|
|
32
|
+
REDIRECT = DataReaders::REDIRECT
|
|
27
33
|
|
|
28
34
|
# Words and operators ([:word, "x", specials] / [:op, "&&"]). A word's
|
|
29
35
|
# specials are the characters the shell would act on in it, unquoted
|
|
@@ -57,6 +63,146 @@ module Samagotchi
|
|
|
57
63
|
commands.reject { |c| c.is_a?(Array) && c.empty? }
|
|
58
64
|
end
|
|
59
65
|
|
|
66
|
+
# Whether an ( or { group is still open at token +at+: a pipe after
|
|
67
|
+
# the group's ) or } takes what the group prints. The counters never go
|
|
68
|
+
# below 0 (a case pattern's "x)" closes nothing); ((…)) arithmetic and
|
|
69
|
+
# <(…) / >(…) count as open parens (the conservative reading).
|
|
70
|
+
def self.in_group?(tokens, at)
|
|
71
|
+
parens = 0
|
|
72
|
+
braces = 0
|
|
73
|
+
tokens[0...at].each do |kind, value|
|
|
74
|
+
if kind == :op
|
|
75
|
+
parens += 1 if value == "("
|
|
76
|
+
parens -= 1 if value == ")" && parens.positive?
|
|
77
|
+
elsif value == "{"
|
|
78
|
+
braces += 1
|
|
79
|
+
elsif value == "}" && braces.positive?
|
|
80
|
+
braces -= 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
parens.positive? || braces.positive?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# +text+ as code: the lines of each heredoc body its command only reads
|
|
87
|
+
# as data (DataReaders) cut out, the <<TAG word and the terminator line
|
|
88
|
+
# kept as written. This is what a rule's command: reads. A text the
|
|
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)
|
|
92
|
+
lexer = Lexer.new(text)
|
|
93
|
+
tokens = lexer.tokens
|
|
94
|
+
return text if lexer.unterminated?
|
|
95
|
+
|
|
96
|
+
bodies = lexer.heredoc_spans.map { |doc| doc[:span] }
|
|
97
|
+
cuts = []
|
|
98
|
+
each_command(tokens) do |indices|
|
|
99
|
+
words = indices.map { |i| tokens[i][1] }
|
|
100
|
+
indices.each_with_index do |i, position|
|
|
101
|
+
kind, value, = tokens[i]
|
|
102
|
+
next unless kind == :word
|
|
103
|
+
|
|
104
|
+
span = lexer.spans[i]
|
|
105
|
+
cut = if bodies.include?(span)
|
|
106
|
+
body_cut(text, span, words) if value == HEREDOC
|
|
107
|
+
elsif value == SUBST
|
|
108
|
+
substitution_cut(text, tokens, i, span, words, position)
|
|
109
|
+
end
|
|
110
|
+
cuts << cut if cut
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
|
|
114
|
+
out[span] = replacement
|
|
115
|
+
end
|
|
116
|
+
rescue ArgumentError, EncodingError
|
|
117
|
+
text
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The token indices of each simple command, its operators left out.
|
|
121
|
+
def self.each_command(tokens)
|
|
122
|
+
current = []
|
|
123
|
+
tokens.each_with_index do |(kind, _value, *_rest), i|
|
|
124
|
+
if kind == :op
|
|
125
|
+
yield current unless current.empty?
|
|
126
|
+
current = []
|
|
127
|
+
else
|
|
128
|
+
current << i
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
yield current unless current.empty?
|
|
132
|
+
end
|
|
133
|
+
private_class_method :each_command
|
|
134
|
+
|
|
135
|
+
# [span, what it becomes] for a data heredoc's body at +span+ (its
|
|
136
|
+
# command's +words+), or nil when the body stays in the code text: a
|
|
137
|
+
# cat or tee writing a file may be run later (D1).
|
|
138
|
+
def self.body_cut(text, span, words)
|
|
139
|
+
return nil unless cut_body?(words)
|
|
140
|
+
|
|
141
|
+
[span, terminator_line(text[span])]
|
|
142
|
+
end
|
|
143
|
+
private_class_method :body_cut
|
|
144
|
+
|
|
145
|
+
# Whether a body a data reader reads leaves the code text: a cat or tee
|
|
146
|
+
# sending it to a file keeps it (D1); a body any other command reads as
|
|
147
|
+
# data is one the lexer's data_sink? approved.
|
|
148
|
+
def self.cut_body?(words)
|
|
149
|
+
verb = DataReaders.command_word(words)
|
|
150
|
+
return true unless DataReaders::SINKS.include?(File.basename(verb.to_s))
|
|
151
|
+
|
|
152
|
+
!DataReaders.writes_file?(words)
|
|
153
|
+
end
|
|
154
|
+
private_class_method :cut_body?
|
|
155
|
+
|
|
156
|
+
# [span, what it becomes] for the body of a "$(cat <<'EOF' … EOF)" given
|
|
157
|
+
# as a message (-m, --body, …) of a data reader (D2), or nil: eval,
|
|
158
|
+
# bash -c, a command piped on and a body that runs stay.
|
|
159
|
+
def self.substitution_cut(text, tokens, index, span, words, position)
|
|
160
|
+
return nil unless data_end?(tokens, index)
|
|
161
|
+
return nil if in_group?(tokens, index)
|
|
162
|
+
return nil unless DataReaders.message_arg?(words, position)
|
|
163
|
+
|
|
164
|
+
raw = text[span]
|
|
165
|
+
dollar = raw.start_with?("\"$(") ? 1 : 0
|
|
166
|
+
return nil unless raw[dollar, 2] == "$("
|
|
167
|
+
|
|
168
|
+
inner = raw[(dollar + 2)..]
|
|
169
|
+
inner_lexer = Lexer.new(inner)
|
|
170
|
+
first = inner_lexer.tokens.first(5)
|
|
171
|
+
return nil unless message_heredoc?(first)
|
|
172
|
+
|
|
173
|
+
inner_span = inner_lexer.spans[2]
|
|
174
|
+
return nil unless inner_span
|
|
175
|
+
|
|
176
|
+
start = span.begin + dollar + 2
|
|
177
|
+
cut = ((start + inner_span.begin)...(start + inner_span.end))
|
|
178
|
+
[cut, terminator_line(text[cut])]
|
|
179
|
+
end
|
|
180
|
+
private_class_method :substitution_cut
|
|
181
|
+
|
|
182
|
+
# Whether the command holding token +index+ ends at nil or a plain
|
|
183
|
+
# separator (not piped on: what it prints is not run).
|
|
184
|
+
def self.data_end?(tokens, index)
|
|
185
|
+
next_op = (tokens[(index + 1)..] || []).find { |token| token[0] == :op }
|
|
186
|
+
next_op.nil? || DATA_ENDS.include?(next_op[1])
|
|
187
|
+
end
|
|
188
|
+
private_class_method :data_end?
|
|
189
|
+
|
|
190
|
+
# Whether the tokens inside a $(…) are cat <<TAG and a data body, and
|
|
191
|
+
# the substitution closes right after.
|
|
192
|
+
def self.message_heredoc?(first)
|
|
193
|
+
first.size == 5 && first[0][0..1] == [:word, "cat"] &&
|
|
194
|
+
first[1][0] == :word && first[1][1].start_with?("<<") &&
|
|
195
|
+
first[2][0..1] == [:word, HEREDOC] && first[3] == [:op, "\n"] && first[4] == [:op, ")"]
|
|
196
|
+
end
|
|
197
|
+
private_class_method :message_heredoc?
|
|
198
|
+
|
|
199
|
+
# +raw+ (a body and its terminator line) as its terminator line alone.
|
|
200
|
+
def self.terminator_line(raw)
|
|
201
|
+
line = raw.rindex("\n")
|
|
202
|
+
line ? raw[(line + 1)..] : raw
|
|
203
|
+
end
|
|
204
|
+
private_class_method :terminator_line
|
|
205
|
+
|
|
60
206
|
# A hand-written shell lexer: quotes, backslashes, comments, $(…) and
|
|
61
207
|
# backticks (an opaque marker inside the word), redirections kept in
|
|
62
208
|
# their word (2>&1 doesn't split on &), operators.
|
|
@@ -357,32 +503,24 @@ module Samagotchi
|
|
|
357
503
|
body
|
|
358
504
|
end
|
|
359
505
|
|
|
360
|
-
# Whether the command the heredoc at token +at+ is in
|
|
361
|
-
#
|
|
506
|
+
# Whether the command the heredoc at token +at+ is in reads it as data
|
|
507
|
+
# (DataReaders.data_reader?: cat, tee, git commit -F -, gh …), ends at
|
|
508
|
+
# a plain separator (DATA_ENDS: not piped on, backgrounded or handed to
|
|
509
|
+
# a process substitution) and sits outside any ( or { group (whose
|
|
510
|
+
# output a pipe after the group's ) or } would take).
|
|
362
511
|
def data_sink?(at)
|
|
363
512
|
start = at
|
|
364
513
|
start -= 1 while start.positive? && @tokens[start - 1][0] == :word
|
|
365
|
-
|
|
366
|
-
next_op =
|
|
367
|
-
|
|
514
|
+
following = @tokens[at..] || []
|
|
515
|
+
next_op = following.index { |t| t[0] == :op }
|
|
516
|
+
ends = next_op.nil? || DATA_ENDS.include?(following[next_op][1])
|
|
517
|
+
words = @tokens[start...at].map { |t| t[1] }
|
|
518
|
+
words += @tokens[at...(next_op ? at + next_op : @tokens.size)].map { |t| t[1] }
|
|
519
|
+
DataReaders.data_reader?(words) && ends && !in_group?(at)
|
|
368
520
|
end
|
|
369
521
|
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
# with a space (> /tmp/x) its target word too.
|
|
373
|
-
def command_word(words)
|
|
374
|
-
i = 0
|
|
375
|
-
while i < words.size
|
|
376
|
-
word = words[i][1]
|
|
377
|
-
if word.match?(REDIRECT)
|
|
378
|
-
i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
|
|
379
|
-
elsif !word.match?(/\A[A-Za-z_]\w*=/)
|
|
380
|
-
return word
|
|
381
|
-
end
|
|
382
|
-
i += 1
|
|
383
|
-
end
|
|
384
|
-
nil
|
|
385
|
-
end
|
|
522
|
+
# Whether an ( or { group is still open at token +at+.
|
|
523
|
+
def in_group?(at) = ShellLex.in_group?(@tokens, at)
|
|
386
524
|
end
|
|
387
525
|
end
|
|
388
526
|
end
|
|
@@ -8,6 +8,7 @@ require_relative "../log"
|
|
|
8
8
|
require_relative "model_size"
|
|
9
9
|
require_relative "outside"
|
|
10
10
|
require_relative "shell_git_dirs"
|
|
11
|
+
require_relative "shell_lex"
|
|
11
12
|
require_relative "read_only_shell"
|
|
12
13
|
require_relative "shell_paths"
|
|
13
14
|
require_relative "rm_targets"
|
|
@@ -246,6 +247,19 @@ module Samagotchi
|
|
|
246
247
|
|
|
247
248
|
def shell? = SHELL_TOOLS.include?(@tool)
|
|
248
249
|
|
|
250
|
+
# What a rule's command: reads: the shell command with the body of each
|
|
251
|
+
# heredoc its command only reads as data left out (ShellLex.code_text);
|
|
252
|
+
# a plugin tool's own command as given (its text may not be shell).
|
|
253
|
+
# Approvals, the approval question and hooks keep #command.
|
|
254
|
+
def rule_command
|
|
255
|
+
return @rule_command if defined?(@rule_command)
|
|
256
|
+
|
|
257
|
+
@rule_command = if @command.nil? then nil
|
|
258
|
+
elsif shell? then ShellLex.code_text(@command)
|
|
259
|
+
else @command
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
249
263
|
# The repository's name: its main checkout's folder (the folder
|
|
250
264
|
# holding the common .git dir; a bare repo's name less .git), else
|
|
251
265
|
# the repo root's folder; nil outside a repo.
|
|
@@ -70,10 +70,7 @@ module Samagotchi
|
|
|
70
70
|
# A hook with no declared event cannot be auto-registered.
|
|
71
71
|
next if hook.event.empty?
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
on_error = hook.on_error
|
|
75
|
-
priority = hook.priority
|
|
76
|
-
fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
|
|
73
|
+
fail_closed = hook.on_error == "fail_closed" && hook.event == "before_tool_call"
|
|
77
74
|
what = "hook #{basename} (bundle #{bundle_name})"
|
|
78
75
|
file = hooks_dir && File.join(hooks_dir, basename)
|
|
79
76
|
unless file && File.exist?(file)
|
|
@@ -87,15 +84,7 @@ module Samagotchi
|
|
|
87
84
|
end
|
|
88
85
|
|
|
89
86
|
begin
|
|
90
|
-
|
|
91
|
-
policy = if fail_closed then :deny
|
|
92
|
-
elsif on_error == "log" then :log
|
|
93
|
-
else :skip
|
|
94
|
-
end
|
|
95
|
-
label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
|
|
96
|
-
handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
|
|
97
|
-
fields: { bundle: bundle_name }) { |event| plugin.call(event) }
|
|
98
|
-
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
|
|
87
|
+
load_hook_file(bundle_name, basename, file, hook, registry, settings: settings)
|
|
99
88
|
loaded += 1
|
|
100
89
|
rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
|
|
101
90
|
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
@@ -107,6 +96,31 @@ module Samagotchi
|
|
|
107
96
|
loaded
|
|
108
97
|
end
|
|
109
98
|
|
|
99
|
+
# Build one hook file and register it in +registry+ under its
|
|
100
|
+
# event, priority and error policy (a fail_closed before_tool_call
|
|
101
|
+
# one denies when it raises). No missing-file, sha or requires_chi
|
|
102
|
+
# check: #load makes them; the test kit loads a bundle's source
|
|
103
|
+
# this way.
|
|
104
|
+
# @param hook [MemoryBundle::BundleHook] its manifest entry
|
|
105
|
+
# @param namespace [Module, nil] the module the file is evaluated
|
|
106
|
+
# in (default: the bundle's, #namespace_for)
|
|
107
|
+
# @raise [Exception] what loading or building the file raised;
|
|
108
|
+
# nothing is registered then
|
|
109
|
+
def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil)
|
|
110
|
+
event_sym = hook.event.to_sym
|
|
111
|
+
fail_closed = hook.on_error == "fail_closed" && event_sym == :before_tool_call
|
|
112
|
+
plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
|
|
113
|
+
policy = if fail_closed then :deny
|
|
114
|
+
elsif hook.on_error == "log" then :log
|
|
115
|
+
else :skip
|
|
116
|
+
end
|
|
117
|
+
label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
|
|
118
|
+
handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
|
|
119
|
+
fields: { bundle: bundle_name }) { |event| plugin.call(event) }
|
|
120
|
+
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
110
124
|
# Why the file doesn't match the sha256 recorded at install, or nil.
|
|
111
125
|
# With none recorded, only a required hook counts as a mismatch.
|
|
112
126
|
def sha_mismatch(file, recorded, required:)
|
|
@@ -119,12 +133,13 @@ module Samagotchi
|
|
|
119
133
|
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
120
134
|
end
|
|
121
135
|
|
|
122
|
-
# Evaluate a plugin file inside the bundle's namespace module
|
|
136
|
+
# Evaluate a plugin file inside the bundle's namespace module (or
|
|
137
|
+
# +namespace+) and
|
|
123
138
|
# return an instance that responds to #call. A class whose
|
|
124
139
|
# initialize takes an argument gets the settings (one Hash, string
|
|
125
140
|
# keys); one that takes none is built bare.
|
|
126
|
-
def instantiate(bundle_name, basename, file, settings: {})
|
|
127
|
-
ns = namespace_for(bundle_name)
|
|
141
|
+
def instantiate(bundle_name, basename, file, settings: {}, namespace: nil)
|
|
142
|
+
ns = namespace || namespace_for(bundle_name)
|
|
128
143
|
content = File.read(file)
|
|
129
144
|
ns.module_eval(content, file, 1)
|
|
130
145
|
klass = Hooks.class_for(basename, ns)
|