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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- 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),
|
|
72
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|