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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. 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 = Verdict.new(call: call)
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: false, block_reason: nil, guardrail: verdict,
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.command && command.match?(targets.command))
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, SUBST when an unquoted tag's body holds $(…) or
12
- # backticks, else the body text itself (a script, as sh -c '…' gives).
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 cat or tee read as data becomes.
22
- HEREDOC = "\0HEREDOC"
23
- # Commands that read a heredoc as data (when not piped on).
24
- DATA_SINKS = %w[cat tee].freeze
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 = /\A\d*[<>]/
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 is cat or tee
361
- # and not piped into another command.
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
- verb = command_word(@tokens[start...at])
366
- next_op = @tokens[at..].find { |t| t[0] == :op }
367
- DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
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
- # The command word among the words before a heredoc's: the NAME=
371
- # words and the redirections dropped, and with a redirection written
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
- event_sym = hook.event.to_sym
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
- plugin = instantiate(bundle_name, basename, file, settings: settings)
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 and
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)