samagotchi 0.18.1 → 0.20.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shell_lex"
4
+
3
5
  module Samagotchi
4
6
  module Guardrails
5
7
  # Where a shell command runs a git subcommand that changes a checkout
@@ -13,154 +15,20 @@ module Samagotchi
13
15
  MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
14
16
  cherry-pick revert pull restore am].freeze
15
17
  STASH_READS = %w[list show].freeze
16
- # Longest first: "&&" before "&", ";;" before ";".
17
- OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
18
18
  PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
19
19
  # git global options that take the next word as their value.
20
20
  VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
21
- SUBST = "\0SUBST"
22
- REDIRECT = /\A\d*[<>]/
23
21
 
24
22
  # @param command [String] the shell command
25
23
  # @param cwd [String] where it starts (the call's cwd:, else the session's)
26
24
  # @param home [String] for ~, $HOME, ${HOME} and a bare cd
27
25
  # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
28
26
  def self.for(command, cwd:, home: Dir.home)
29
- Walk.new(cwd, home).run(simple_commands(lex(command.to_s)))
27
+ Walk.new(cwd, home).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
30
28
  rescue ArgumentError, EncodingError
31
29
  [] # a NUL byte, invalid UTF-8: the shell would refuse it too
32
30
  end
33
31
 
34
- # Words and operators ([:word, "x"] / [:op, "&&"]).
35
- def self.lex(text)
36
- Lexer.new(text).tokens
37
- end
38
-
39
- # The token stream split at operators into word lists; ( and ) become
40
- # :open / :close markers.
41
- def self.simple_commands(tokens)
42
- commands = [[]]
43
- tokens.each do |kind, value|
44
- if kind == :op
45
- commands << :open if value == "("
46
- commands << :close if value == ")"
47
- commands << []
48
- else
49
- commands.last << value
50
- end
51
- end
52
- commands.reject { |c| c.is_a?(Array) && c.empty? }
53
- end
54
-
55
- # A hand-written shell lexer: quotes, backslashes, comments, $(…) and
56
- # backticks (an opaque marker inside the word), redirections kept in
57
- # their word (2>&1 doesn't split on &), operators.
58
- class Lexer
59
- def initialize(text)
60
- @s = text
61
- @i = 0
62
- @tokens = []
63
- @word = +""
64
- @in_word = false
65
- end
66
-
67
- def tokens
68
- step while @i < @s.size
69
- flush
70
- @tokens
71
- end
72
-
73
- private
74
-
75
- def step
76
- c = @s[@i]
77
- case c
78
- when "\\" then add(@s[@i + 1].to_s, 2)
79
- when "'" then single_quote
80
- when '"' then double_quote
81
- when "`" then backtick
82
- when "$" then @s[@i + 1] == "(" ? substitution : add(c, 1)
83
- when "#" then @in_word ? add(c, 1) : comment
84
- when ">", "<" then redirection
85
- when " ", "\t" then flush && (@i += 1)
86
- else operator_or_char(c)
87
- end
88
- end
89
-
90
- def add(text, advance)
91
- @word << text
92
- @in_word = true
93
- @i += advance
94
- end
95
-
96
- def flush
97
- @tokens << [:word, @word.dup] if @in_word
98
- @word.clear
99
- @in_word = false
100
- true
101
- end
102
-
103
- def single_quote
104
- close = @s.index("'", @i + 1) || @s.size
105
- add(@s[(@i + 1)...close].to_s, close + 1 - @i)
106
- end
107
-
108
- def double_quote
109
- @i += 1
110
- @in_word = true
111
- while @i < @s.size && @s[@i] != '"'
112
- if @s[@i] == "\\" && @i + 1 < @s.size
113
- @word << @s[@i + 1]
114
- @i += 2
115
- else
116
- @word << @s[@i]
117
- @i += 1
118
- end
119
- end
120
- @i += 1
121
- end
122
-
123
- def backtick
124
- close = @s.index("`", @i + 1) || @s.size
125
- add(SUBST, close + 1 - @i)
126
- end
127
-
128
- def substitution
129
- depth = 0
130
- j = @i + 1
131
- loop do
132
- depth += 1 if @s[j] == "("
133
- depth -= 1 if @s[j] == ")"
134
- j += 1
135
- break if depth.zero? || j >= @s.size
136
- end
137
- add(SUBST, j - @i)
138
- end
139
-
140
- def comment
141
- @i = @s.index("\n", @i) || @s.size
142
- end
143
-
144
- # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
145
- def redirection
146
- add(@s[@i], 1)
147
- add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
148
- end
149
-
150
- def operator_or_char(char)
151
- op = OPERATORS.find { |o| @s[@i, o.size] == o }
152
- return add(char, 1) unless op
153
-
154
- if op == "&" && @word.match?(/\d*>\z/)
155
- add(op, 1)
156
- else
157
- flush
158
- @tokens << [:op, op]
159
- @i += op.size
160
- end
161
- end
162
- end
163
-
164
32
  # Follows the current dir through the simple commands and records
165
33
  # where each mutating git runs.
166
34
  class Walk
@@ -197,7 +65,7 @@ module Samagotchi
197
65
  words.shift
198
66
  end
199
67
  verb = words.shift or return
200
- args = words.grep_v(REDIRECT)
68
+ args = words.grep_v(ShellLex::REDIRECT)
201
69
  case verb
202
70
  when "cd" then cd(args)
203
71
  when "pushd"
@@ -253,7 +121,7 @@ module Samagotchi
253
121
  # told from the text (a variable, a substitution, a relative path
254
122
  # against an unknown dir).
255
123
  def expand(arg, dir)
256
- return nil if arg.include?(SUBST)
124
+ return nil if arg.include?(ShellLex::SUBST)
257
125
 
258
126
  arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
259
127
  return nil if arg.include?("$") || arg.start_with?("~")
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # A small shell lexer for the guardrails' text checks (ShellGitDirs
6
+ # and the rules' shell matchers): words with quotes and
7
+ # backslashes undone, operators apart, comments dropped, and $(…) or
8
+ # backticks (quoted or not) as one opaque SUBST marker inside a word. It never runs
9
+ # anything, and it does its best on odd input (an unbalanced quote).
10
+ module ShellLex
11
+ # Longest first: "&&" before "&", ";;" before ";".
12
+ OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
13
+ # What a $(…) or `…` becomes inside a word.
14
+ SUBST = "\0SUBST"
15
+ # A word that is a redirection (>, >>, 2>&1, <).
16
+ REDIRECT = /\A\d*[<>]/
17
+
18
+ # Words and operators ([:word, "x", specials] / [:op, "&&"]). A word's
19
+ # specials are the characters the shell would act on in it, unquoted
20
+ # ("$" also when double-quoted): "$" an expansion, "{" / "}" a brace
21
+ # expansion, ">" / "<" a redirection.
22
+ def self.lex(text)
23
+ Lexer.new(text).tokens
24
+ end
25
+
26
+ # The token stream split at operators into word lists; ( and ) become
27
+ # :open / :close markers.
28
+ def self.simple_commands(tokens)
29
+ commands = [[]]
30
+ tokens.each do |kind, value|
31
+ if kind == :op
32
+ commands << :open if value == "("
33
+ commands << :close if value == ")"
34
+ commands << []
35
+ else
36
+ commands.last << value
37
+ end
38
+ end
39
+ commands.reject { |c| c.is_a?(Array) && c.empty? }
40
+ end
41
+
42
+ # A hand-written shell lexer: quotes, backslashes, comments, $(…) and
43
+ # backticks (an opaque marker inside the word), redirections kept in
44
+ # their word (2>&1 doesn't split on &), operators.
45
+ class Lexer
46
+ def initialize(text)
47
+ @s = text
48
+ @i = 0
49
+ @tokens = []
50
+ @word = +""
51
+ @specials = +""
52
+ @in_word = false
53
+ end
54
+
55
+ def tokens
56
+ step while @i < @s.size
57
+ flush
58
+ @tokens
59
+ end
60
+
61
+ private
62
+
63
+ def step
64
+ c = @s[@i]
65
+ case c
66
+ when "\\" then add(@s[@i + 1].to_s, 2)
67
+ when "'" then single_quote
68
+ when '"' then double_quote
69
+ when "`" then backtick
70
+ when "$" then @s[@i + 1] == "(" ? substitution : special(c)
71
+ when "#" then @in_word ? add(c, 1) : comment
72
+ when ">", "<" then redirection
73
+ when " ", "\t" then flush && (@i += 1)
74
+ else operator_or_char(c)
75
+ end
76
+ end
77
+
78
+ def add(text, advance)
79
+ @word << text
80
+ @in_word = true
81
+ @i += advance
82
+ end
83
+
84
+ # A character the shell acts on, kept in the word and noted.
85
+ def special(char)
86
+ @specials << char unless @specials.include?(char)
87
+ add(char, 1)
88
+ end
89
+
90
+ def flush
91
+ @tokens << [:word, @word.dup, @specials.dup] if @in_word
92
+ @word.clear
93
+ @specials.clear
94
+ @in_word = false
95
+ true
96
+ end
97
+
98
+ def single_quote
99
+ close = @s.index("'", @i + 1) || @s.size
100
+ add(@s[(@i + 1)...close].to_s, close + 1 - @i)
101
+ end
102
+
103
+ def double_quote
104
+ @i += 1
105
+ @in_word = true
106
+ while @i < @s.size && @s[@i] != '"'
107
+ if @s[@i] == "\\" && @i + 1 < @s.size
108
+ @word << @s[@i + 1]
109
+ @i += 2
110
+ elsif @s[@i] == "`" then backtick
111
+ elsif @s[@i] == "$" && @s[@i + 1] == "(" then substitution
112
+ elsif @s[@i] == "$" then special("$")
113
+ else
114
+ @word << @s[@i]
115
+ @i += 1
116
+ end
117
+ end
118
+ @i += 1
119
+ end
120
+
121
+ def backtick
122
+ close = @s.index("`", @i + 1) || @s.size
123
+ add(SUBST, close + 1 - @i)
124
+ end
125
+
126
+ def substitution
127
+ depth = 0
128
+ j = @i + 1
129
+ loop do
130
+ depth += 1 if @s[j] == "("
131
+ depth -= 1 if @s[j] == ")"
132
+ j += 1
133
+ break if depth.zero? || j >= @s.size
134
+ end
135
+ add(SUBST, j - @i)
136
+ end
137
+
138
+ def comment
139
+ @i = @s.index("\n", @i) || @s.size
140
+ end
141
+
142
+ # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
143
+ def redirection
144
+ special(@s[@i])
145
+ add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
146
+ end
147
+
148
+ def operator_or_char(char)
149
+ op = OPERATORS.find { |o| @s[@i, o.size] == o }
150
+ return special(char) if char == "{" || char == "}"
151
+ return add(char, 1) unless op
152
+
153
+ if op == "&" && @word.match?(/\d*>\z/)
154
+ add(op, 1)
155
+ else
156
+ flush
157
+ @tokens << [:op, op]
158
+ @i += op.size
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell_lex"
4
+ require_relative "protected_paths"
5
+
6
+ module Samagotchi
7
+ module Guardrails
8
+ # Whether a shell command names a path in one of some dirs (a rule's
9
+ # touches: chi_dirs): each word is read as a path, against the dir the
10
+ # command is in by then (cd, pushd/popd and ( … ) followed as in
11
+ # ShellGitDirs), with ~, $HOME and the XDG dirs expanded and symlinks
12
+ # resolved. A word names a dir when it is in it. Any path with a
13
+ # .git/hooks part counts too, outside the tmp dirs.
14
+ #
15
+ # A word it can't resolve (another $VAR, $(…), a glob, a relative path
16
+ # after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
17
+ # spaces, quotes, operators in it) falls back to a text match (+text+);
18
+ # so does the whole command when it holds a $(…) or backticks (the
19
+ # lexer keeps no body).
20
+ module ShellPaths
21
+ # A word that is a script rather than a path.
22
+ SCRIPT = /[\s;&|<>()'"`]/
23
+ GLOB = /[*?\[\]{}]/
24
+ HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
25
+ # $HOME, ${HOME} and the XDG dirs at a word's start.
26
+ EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
27
+ # Where XDG dirs default when unset.
28
+ XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
29
+ "XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
30
+ # A redirection's operator at a word's start (>>file, 2>file, <file).
31
+ REDIRECT_PREFIX = /\A\d*(?:&>>?|>>?&?|<)/
32
+
33
+ module_function
34
+
35
+ # @param command [String]
36
+ # @param dirs [Array<String>] absolute dirs (resolved here)
37
+ # @param text [Regexp] the fallback for a word that can't be resolved
38
+ # @param cwd [String, nil] where the command starts
39
+ # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
40
+ def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
41
+ roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
42
+ walk = Walk.new(cwd, home, env)
43
+ tokens = ShellLex.lex(command.to_s)
44
+ return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(text)
45
+
46
+ ShellLex.simple_commands(tokens).any? do |words|
47
+ walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
48
+ end
49
+ rescue ArgumentError, EncodingError
50
+ command.to_s.scrub.match?(text)
51
+ end
52
+
53
+ def names?(path, roots, tmp_roots)
54
+ real = ProtectedPaths.real(path)
55
+ return true if roots.any? { |root| within?(real, root) }
56
+
57
+ real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
58
+ end
59
+
60
+ def within?(path, root) = path == root || path.start_with?(File.join(root, ""))
61
+
62
+ # Follows the current dir and gives, per simple command, each word
63
+ # with its path (nil when it can't be resolved).
64
+ class Walk
65
+ def initialize(cwd, home, env)
66
+ @dir = cwd
67
+ @home = home
68
+ @env = env
69
+ @subshells = []
70
+ @pushed = []
71
+ end
72
+
73
+ # @param words [Array<String>, Symbol] a simple command, :open or :close
74
+ # @return [Array<[String, String|nil]>]
75
+ def step(words)
76
+ case words
77
+ when :open then @subshells << @dir
78
+ []
79
+ when :close then @dir = @subshells.pop || @dir
80
+ []
81
+ else command(words)
82
+ end
83
+ end
84
+
85
+ # An absolute path for +word+ against the current dir, or nil.
86
+ def path(word) = resolve(word, @dir)
87
+
88
+ private
89
+
90
+ def command(words)
91
+ verb = words.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) }
92
+ resolved = words.filter_map { |word| candidate(word) }.map { |word| [word, resolve(word, @dir)] }
93
+ cd_args = words.drop_while { |w| w != verb }.drop(1).grep_v(/\A-/)
94
+ case verb
95
+ when "cd" then @dir = cd_args.empty? ? @home : resolve(cd_args.first, @dir)
96
+ when "pushd"
97
+ @pushed << @dir
98
+ @dir = cd_args.first && resolve(cd_args.first, @dir)
99
+ when "popd" then @dir = @pushed.pop unless @pushed.empty?
100
+ end
101
+ resolved
102
+ end
103
+
104
+ # The part of +word+ that may be a path: an option's or an
105
+ # assignment's value, a redirection's target; nil for an option
106
+ # alone or an operator.
107
+ def candidate(word)
108
+ word = word.sub(REDIRECT_PREFIX, "")
109
+ word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
110
+ return nil if word.empty? || word == "-" || word.match?(/\A-[^\/]/)
111
+
112
+ word
113
+ end
114
+
115
+ # An absolute path for +word+ against +dir+, or nil.
116
+ def resolve(word, dir)
117
+ return nil if word.include?(ShellLex::SUBST) || word.match?(SCRIPT)
118
+
119
+ word = expand(word) or return nil
120
+ return nil if word.match?(GLOB)
121
+ return File.expand_path(word) if word.start_with?("/")
122
+
123
+ dir && File.expand_path(word, dir)
124
+ end
125
+
126
+ def expand(word)
127
+ word = word.sub(/\A~(?=\/|\z)/) { @home }
128
+ word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
129
+ return nil if word.include?("$") || word.start_with?("~")
130
+
131
+ word
132
+ end
133
+
134
+ def value(name)
135
+ return @home if name == "HOME"
136
+
137
+ set = @env[name].to_s
138
+ return set unless set.empty?
139
+
140
+ XDG_DEFAULTS[name] && File.join(@home, XDG_DEFAULTS[name])
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -6,6 +6,9 @@ require_relative "../log"
6
6
  require_relative "model_size"
7
7
  require_relative "outside"
8
8
  require_relative "shell_git_dirs"
9
+ require_relative "read_only_shell"
10
+ require_relative "shell_paths"
11
+ require_relative "rm_targets"
9
12
 
10
13
  module Samagotchi
11
14
  module Guardrails
@@ -20,6 +23,11 @@ module Samagotchi
20
23
 
21
24
  attr_reader :tool, :command, :paths, :cwd, :repo_root
22
25
 
26
+ # @return [String, nil] the call's repository (Context#repo: its
27
+ # common git dir, shared by its worktrees): what a "this repo"
28
+ # approval is kept for
29
+ attr_reader :repo
30
+
23
31
  # @return [String] the session's repo root (the context cwd's; the cwd
24
32
  # outside a repo): what outside_repo measures from. repo_root is the
25
33
  # call's (its own cwd: for shell and plugin tools).
@@ -68,8 +76,10 @@ module Samagotchi
68
76
  entry = registry && registry[tool]
69
77
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
70
78
  end
71
- new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
72
- model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base)
79
+ new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
80
+ repo: context.repo(cwd), args: args,
81
+ model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
82
+ chi_dirs: -> { chi_dirs(context, base) })
73
83
  end
74
84
 
75
85
  # What a plugin tool's targets: callable says the call acts on:
@@ -92,6 +102,13 @@ module Samagotchi
92
102
  none
93
103
  end
94
104
 
105
+ # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
106
+ # git hooks dir.
107
+ def self.chi_dirs(context, base)
108
+ require_relative "parent_approvals"
109
+ ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
110
+ end
111
+
95
112
  def self.absolute(path, base)
96
113
  path = Tools::ToolPath.normalize(path)
97
114
  path.empty? ? nil : File.expand_path(path, base)
@@ -109,8 +126,11 @@ module Samagotchi
109
126
  nil
110
127
  end
111
128
 
112
- def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil,
113
- session_root: nil)
129
+ # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
130
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, model_name: nil, model_key: nil,
131
+ session_root: nil, chi_dirs: nil)
132
+ @repo = repo
133
+ @chi_dirs = chi_dirs
114
134
  @session_root = session_root || repo_root || cwd
115
135
  @args = args
116
136
  @model_name = model_name
@@ -124,6 +144,18 @@ module Samagotchi
124
144
 
125
145
  def shell? = SHELL_TOOLS.include?(@tool)
126
146
 
147
+ # The repository's name: its main checkout's folder (the folder
148
+ # holding the common .git dir; a bare repo's name less .git), else
149
+ # the repo root's folder; nil outside a repo.
150
+ def repo_name
151
+ if @repo
152
+ base = File.basename(@repo)
153
+ base == ".git" ? File.basename(File.dirname(@repo)) : base.delete_suffix(".git")
154
+ elsif @repo_root
155
+ File.basename(@repo_root)
156
+ end
157
+ end
158
+
127
159
  # Whether the effective model is a small one (guardrails.small_models,
128
160
  # read when first asked: once per call's targets).
129
161
  def small_model?
@@ -144,6 +176,35 @@ module Samagotchi
144
176
  @git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
145
177
  end
146
178
 
179
+ # Whether the command only reads (ReadOnlyShell); false without one.
180
+ def read_only?
181
+ return @read_only if defined?(@read_only)
182
+
183
+ @read_only = !@command.nil? && ReadOnlyShell.read_only?(@command)
184
+ end
185
+
186
+ # Whether the command names a path in chi's own dirs (ShellPaths:
187
+ # resolved paths; the text CHI_SHELL_TEXT for a word that can't be
188
+ # resolved); false without a command.
189
+ def touches_chi?
190
+ return @touches_chi if defined?(@touches_chi)
191
+
192
+ @touches_chi = !@command.nil? && begin
193
+ require_relative "parent_approvals"
194
+ ShellPaths.touches?(@command, dirs: @chi_dirs ? @chi_dirs.call : ParentApprovals.chi_dirs,
195
+ text: ParentApprovals::CHI_SHELL_TEXT, cwd: @cwd, tmp_roots: Outside.tmp_roots)
196
+ end
197
+ end
198
+
199
+ # Whether an rm -rf in the command reaches outside the tmp dirs
200
+ # (RmTargets; a tmp dir the session's root is in doesn't count as one).
201
+ def rm_outside_tmp?
202
+ return @rm_outside_tmp if defined?(@rm_outside_tmp)
203
+
204
+ @rm_outside_tmp = !@command.nil? &&
205
+ RmTargets.outside_tmp?(@command, cwd: @cwd, tmp_roots: Outside.tmp_roots, session_root: @session_root)
206
+ end
207
+
147
208
  # Whether a shell call runs mutating git outside the session's repo
148
209
  # (an :unknown dir doesn't count).
149
210
  def git_outside_repo?
@@ -3,7 +3,9 @@
3
3
  require_relative "guardrails/verdict"
4
4
  require_relative "guardrails/context"
5
5
  require_relative "guardrails/outside"
6
+ require_relative "guardrails/shell_lex"
6
7
  require_relative "guardrails/shell_git_dirs"
8
+ require_relative "guardrails/read_only_shell"
7
9
  require_relative "guardrails/targets"
8
10
  require_relative "guardrails/model_size"
9
11
  require_relative "guardrails/approval"
@@ -257,8 +257,10 @@ module Samagotchi
257
257
  event[:hook] = label if event.is_a?(Hash)
258
258
  begin
259
259
  hook_proc.call(event)
260
- rescue StandardError
260
+ rescue StandardError => e
261
261
  # A failing hook must not break the turn.
262
+ Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
263
+ hook: label, event: name.to_s)
262
264
  end
263
265
  end
264
266
  end
@@ -284,8 +286,10 @@ module Samagotchi
284
286
  event[:hook] = label if event.is_a?(Hash)
285
287
  begin
286
288
  hook_proc.call(event)
287
- rescue StandardError
289
+ rescue StandardError => e
288
290
  # A failing hook must not break the turn.
291
+ Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
292
+ hook: label, event: name.to_s)
289
293
  end
290
294
  yield event
291
295
  end