samagotchi 0.17.0 → 0.18.1
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 +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +9 -1
|
@@ -5,6 +5,8 @@ require "yaml"
|
|
|
5
5
|
require_relative "config"
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "guardrails"
|
|
8
|
+
require_relative "memory_bundle/manifest"
|
|
9
|
+
require_relative "version"
|
|
8
10
|
require_relative "hooks"
|
|
9
11
|
require_relative "edit_preview"
|
|
10
12
|
require_relative "tool_activity"
|
|
@@ -32,7 +34,10 @@ module Samagotchi
|
|
|
32
34
|
# @param model_name [#call] → String, nil: the effective model's bare name
|
|
33
35
|
# @param cancelled [#call] → Boolean, whether the running turn is cancelled
|
|
34
36
|
# @param ask [#call] (fields) → the answer; the Engine's question flow
|
|
35
|
-
|
|
37
|
+
# @param notify [#call] (message) → shows a guardrails line in every UI
|
|
38
|
+
# (a bundle's rules kept because it needs a newer chi)
|
|
39
|
+
def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
|
|
40
|
+
notify: ->(_message) {})
|
|
36
41
|
@scratch = scratch
|
|
37
42
|
@hooks_lookup = hooks
|
|
38
43
|
@tools_lookup = tools
|
|
@@ -41,6 +46,11 @@ module Samagotchi
|
|
|
41
46
|
@model_name_lookup = model_name
|
|
42
47
|
@cancelled_lookup = cancelled
|
|
43
48
|
@ask = ask
|
|
49
|
+
@notify = notify
|
|
50
|
+
# Bundle name → the rules last loaded from it whole, kept while the
|
|
51
|
+
# installed version needs a newer chi; [name, version] said so once.
|
|
52
|
+
@loaded_bundle_rules = {}
|
|
53
|
+
@kept_noticed = {}
|
|
44
54
|
@failures = Guardrails::LoadFailures.new
|
|
45
55
|
@rules_mutex = Mutex.new
|
|
46
56
|
@git = Guardrails::GitInfo.new
|
|
@@ -115,7 +125,9 @@ module Samagotchi
|
|
|
115
125
|
|
|
116
126
|
# Installed bundles' guardrails/*.yml, by bundle name then file name.
|
|
117
127
|
# A file that is missing, changed since install (sha256) or doesn't
|
|
118
|
-
# parse is a required load failure.
|
|
128
|
+
# parse is a required load failure. A bundle whose requires_chi this
|
|
129
|
+
# chi doesn't meet isn't read (its rules may use keys this chi doesn't
|
|
130
|
+
# know): the rules last loaded from it stay (#too_new_bundle_rules).
|
|
119
131
|
def bundle_rules
|
|
120
132
|
require_relative "memory_bundle/provenance"
|
|
121
133
|
rules = []
|
|
@@ -125,7 +137,13 @@ module Samagotchi
|
|
|
125
137
|
@failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
126
138
|
next
|
|
127
139
|
end
|
|
140
|
+
if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
|
|
141
|
+
rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
|
|
142
|
+
next
|
|
143
|
+
end
|
|
128
144
|
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
145
|
+
loaded = []
|
|
146
|
+
whole = true
|
|
129
147
|
data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
|
|
130
148
|
what = "rules #{basename} (bundle #{bundle_name})"
|
|
131
149
|
path = File.join(dir, basename.to_s)
|
|
@@ -139,12 +157,15 @@ module Samagotchi
|
|
|
139
157
|
doc = YAML.safe_load(File.read(path))
|
|
140
158
|
raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
|
|
141
159
|
|
|
142
|
-
|
|
160
|
+
loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
143
161
|
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
144
162
|
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
145
163
|
@failures.add(what, e.message, required: true, group: :rules)
|
|
164
|
+
whole = false
|
|
146
165
|
end
|
|
147
166
|
end
|
|
167
|
+
rules.concat(loaded)
|
|
168
|
+
@loaded_bundle_rules[bundle_name] = loaded if whole
|
|
148
169
|
end
|
|
149
170
|
rules
|
|
150
171
|
rescue StandardError => e
|
|
@@ -192,6 +213,38 @@ module Samagotchi
|
|
|
192
213
|
|
|
193
214
|
private
|
|
194
215
|
|
|
216
|
+
# The rules of a bundle that needs a newer chi: the ones last loaded
|
|
217
|
+
# from it, with one notice per bundle version (restart the session to
|
|
218
|
+
# load the new ones). Never loaded (a chi older than the bundle it
|
|
219
|
+
# starts with): none, and a required load failure, as the hook loader
|
|
220
|
+
# does for a guardrail hook, so the calls aren't quietly unguarded.
|
|
221
|
+
def too_new_bundle_rules(bundle_name, data, reason)
|
|
222
|
+
kept = @loaded_bundle_rules[bundle_name]
|
|
223
|
+
unless kept
|
|
224
|
+
Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
|
|
225
|
+
bundle: bundle_name)
|
|
226
|
+
@failures.add("rules (bundle #{bundle_name})", "#{reason}; update chi (chi update) or uninstall the bundle",
|
|
227
|
+
required: true, group: :rules)
|
|
228
|
+
return []
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
key = [bundle_name, data[:version].to_s]
|
|
232
|
+
return kept if @kept_noticed[key]
|
|
233
|
+
|
|
234
|
+
@kept_noticed[key] = true
|
|
235
|
+
Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
|
|
236
|
+
requires_chi: data[:requires_chi].to_s)
|
|
237
|
+
@notify.call(kept_notice(bundle_name, data))
|
|
238
|
+
kept
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def kept_notice(bundle_name, data)
|
|
242
|
+
id = @session_lookup.call&.id || "ID"
|
|
243
|
+
"bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
|
|
244
|
+
"its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
|
|
245
|
+
"(chi sessions stop #{id}, then chi --resume #{id})"
|
|
246
|
+
end
|
|
247
|
+
|
|
195
248
|
# [path, mtime, size] of config.yml and every installed bundle's
|
|
196
249
|
# manifest.json and guardrails/ file.
|
|
197
250
|
def rules_stamp
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "protected_paths"
|
|
5
|
+
require_relative "../memory_bundle/index_sync"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# What `outside_repo` means (a rule's path: and git:): a path is outside
|
|
10
|
+
# when it isn't in the session's repo root (the cwd outside a repo),
|
|
11
|
+
# with symlinks resolved on both sides. Never outside:
|
|
12
|
+
# - a memory's file (MemoryBundle::IndexSync.memory_scope): write/edit
|
|
13
|
+
# there is what memory_write does; index.md, the bundles dir and the
|
|
14
|
+
# rest of chi's config dir still count;
|
|
15
|
+
# - a tmp dir (Dir.tmpdir, /tmp, /var/tmp), unless the session's root
|
|
16
|
+
# is itself in that tmp dir (a sandbox there still gets the check
|
|
17
|
+
# between its siblings).
|
|
18
|
+
# chi's state dir is not special: nothing there is the model's to write.
|
|
19
|
+
module Outside
|
|
20
|
+
TMP_DIRS = %w[/tmp /var/tmp].freeze
|
|
21
|
+
|
|
22
|
+
# @param path [String] absolute
|
|
23
|
+
# @param root [String] the session's repo root (or cwd)
|
|
24
|
+
def self.outside?(path, root:)
|
|
25
|
+
real = ProtectedPaths.real(path)
|
|
26
|
+
session = ProtectedPaths.real(root)
|
|
27
|
+
return false if within?(real, session)
|
|
28
|
+
return false if MemoryBundle::IndexSync.memory_scope(path)
|
|
29
|
+
return false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
|
|
30
|
+
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The tmp dirs that exist, resolved.
|
|
35
|
+
def self.tmp_roots
|
|
36
|
+
[Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
|
|
37
|
+
File.realpath(dir) if File.directory?(dir)
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end.uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -5,6 +5,7 @@ require_relative "../paths"
|
|
|
5
5
|
require_relative "../hooks/loader"
|
|
6
6
|
require_relative "approval"
|
|
7
7
|
require_relative "protected_paths"
|
|
8
|
+
require_relative "parent_continue"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Guardrails
|
|
@@ -115,6 +116,7 @@ module Samagotchi
|
|
|
115
116
|
def message(reason, typed: false)
|
|
116
117
|
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
117
118
|
case reason
|
|
119
|
+
when :stop_only then ParentContinue.message
|
|
118
120
|
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
119
121
|
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
120
122
|
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Guardrails
|
|
7
|
+
# Whether a parent agent may answer Continue to a session's step-limit
|
|
8
|
+
# question (kind "continue", ContinueOffer): turn.parent_continue,
|
|
9
|
+
# config.yml only, default true. Continue grants no permission (every
|
|
10
|
+
# tool call still meets the guardrails), it spends time and tokens;
|
|
11
|
+
# false makes parents stop-only. Checked by `chi answer` before it posts
|
|
12
|
+
# and by the worker's question desk for an answer marked as chi
|
|
13
|
+
# answer's, the way ParentApprovals is.
|
|
14
|
+
module ParentContinue
|
|
15
|
+
KEY = "turn.parent_continue"
|
|
16
|
+
KIND = "continue"
|
|
17
|
+
CONTINUE = "Continue"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] this process's setting
|
|
22
|
+
def allowed?
|
|
23
|
+
Config.get(KEY) != false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Why a parent may not give this answer, or nil.
|
|
27
|
+
# @param pending [Hash] the pending question (symbol or string keys)
|
|
28
|
+
# @param selected [Array<String>] the selected labels
|
|
29
|
+
# @return [Symbol, nil] :stop_only
|
|
30
|
+
def refusal(pending, selected, allowed: allowed?)
|
|
31
|
+
return nil if allowed
|
|
32
|
+
return nil unless (pending[:kind] || pending["kind"]).to_s == KIND
|
|
33
|
+
|
|
34
|
+
Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message
|
|
38
|
+
"a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
|
|
39
|
+
"(--option Stop --text WHY), or tell your user it waits"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -14,6 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
15
15
|
# or "**/…" globs match the absolute path; others the path
|
|
16
16
|
# relative to the repo root, the cwd outside one)
|
|
17
|
+
# git: "outside_repo": a shell call runs a mutating git
|
|
18
|
+
# subcommand outside the session's repo (ShellGitDirs)
|
|
17
19
|
# models: "small" (Guardrails::ModelSize, guardrails.small_models),
|
|
18
20
|
# or a glob on the bare model name or the model key, or a
|
|
19
21
|
# list of them: the rule only votes for a model that matches
|
|
@@ -26,17 +28,19 @@ module Samagotchi
|
|
|
26
28
|
class Rules
|
|
27
29
|
class ParseError < StandardError; end
|
|
28
30
|
|
|
29
|
-
KEYS = %w[id tool command path models verdict reason scopes].freeze
|
|
31
|
+
KEYS = %w[id tool command path git models verdict reason scopes].freeze
|
|
32
|
+
MATCH_KEYS = %w[tool command path git].freeze
|
|
30
33
|
VERDICTS = %w[ask deny].freeze
|
|
31
34
|
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
32
35
|
|
|
33
|
-
Rule = Struct.new(:id, :tools, :command, :path, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
|
|
36
|
+
Rule = Struct.new(:id, :tools, :command, :path, :git, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
|
|
34
37
|
def matches?(targets)
|
|
35
38
|
return false unless targets
|
|
36
39
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
37
40
|
return false if tools && !tool_matches?(targets.tool)
|
|
38
41
|
return false if command && !(targets.command && command.match?(targets.command))
|
|
39
42
|
return false if path && !path_matches?(targets)
|
|
43
|
+
return false if git && !targets.git_outside_repo?
|
|
40
44
|
|
|
41
45
|
true
|
|
42
46
|
end
|
|
@@ -99,12 +103,12 @@ module Samagotchi
|
|
|
99
103
|
|
|
100
104
|
verdict = raw["verdict"].to_s
|
|
101
105
|
raise ParseError, "#{label}: verdict must be ask or deny (got #{verdict.inspect})" unless VERDICTS.include?(verdict)
|
|
102
|
-
unless
|
|
103
|
-
raise ParseError, "#{label}: give at least one of
|
|
106
|
+
unless MATCH_KEYS.any? { |k| raw.key?(k) }
|
|
107
|
+
raise ParseError, "#{label}: give at least one of #{MATCH_KEYS.join(", ")}"
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
|
|
107
|
-
path: path_of(raw["path"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
|
|
111
|
+
path: path_of(raw["path"], label), git: git_of(raw["git"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
|
|
108
112
|
reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -135,6 +139,14 @@ module Samagotchi
|
|
|
135
139
|
value
|
|
136
140
|
end
|
|
137
141
|
|
|
142
|
+
# Only "outside_repo" for now (room for globs later).
|
|
143
|
+
def self.git_of(value, label)
|
|
144
|
+
return nil if value.nil?
|
|
145
|
+
raise ParseError, "#{label}: git must be outside_repo" unless value == "outside_repo"
|
|
146
|
+
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
|
|
138
150
|
def self.models_of(value, label)
|
|
139
151
|
return nil if value.nil?
|
|
140
152
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# Where a shell command runs a git subcommand that changes a checkout
|
|
6
|
+
# (commit, add, reset, …): the directories, or :unknown for one that
|
|
7
|
+
# can't be read from the text ($VAR, $(…), `cd -`). Pure text: it
|
|
8
|
+
# follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
|
|
9
|
+
# and GIT_DIR/GIT_WORK_TREE. It catches honest mistakes (cd into
|
|
10
|
+
# another checkout and commit there); sh -c, scripts, aliases and
|
|
11
|
+
# $(…) bodies get through. Never raises on odd input.
|
|
12
|
+
module ShellGitDirs
|
|
13
|
+
MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
|
|
14
|
+
cherry-pick revert pull restore am].freeze
|
|
15
|
+
STASH_READS = %w[list show].freeze
|
|
16
|
+
# Longest first: "&&" before "&", ";;" before ";".
|
|
17
|
+
OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
|
|
18
|
+
PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
|
|
19
|
+
# git global options that take the next word as their value.
|
|
20
|
+
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
21
|
+
SUBST = "\0SUBST"
|
|
22
|
+
REDIRECT = /\A\d*[<>]/
|
|
23
|
+
|
|
24
|
+
# @param command [String] the shell command
|
|
25
|
+
# @param cwd [String] where it starts (the call's cwd:, else the session's)
|
|
26
|
+
# @param home [String] for ~, $HOME, ${HOME} and a bare cd
|
|
27
|
+
# @return [Array<String, Symbol>] absolute dirs and :unknown, unique
|
|
28
|
+
def self.for(command, cwd:, home: Dir.home)
|
|
29
|
+
Walk.new(cwd, home).run(simple_commands(lex(command.to_s)))
|
|
30
|
+
rescue ArgumentError, EncodingError
|
|
31
|
+
[] # a NUL byte, invalid UTF-8: the shell would refuse it too
|
|
32
|
+
end
|
|
33
|
+
|
|
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
|
+
# Follows the current dir through the simple commands and records
|
|
165
|
+
# where each mutating git runs.
|
|
166
|
+
class Walk
|
|
167
|
+
def initialize(cwd, home)
|
|
168
|
+
@dir = cwd
|
|
169
|
+
@home = home
|
|
170
|
+
@subshells = []
|
|
171
|
+
@pushed = []
|
|
172
|
+
@found = []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def run(commands)
|
|
176
|
+
commands.each do |words|
|
|
177
|
+
case words
|
|
178
|
+
when :open then @subshells << @dir
|
|
179
|
+
when :close then @dir = @subshells.pop || @dir
|
|
180
|
+
else simple(words.dup)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
@found.uniq
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def simple(words)
|
|
189
|
+
env = {}
|
|
190
|
+
while (word = words.first)
|
|
191
|
+
if word.match?(/\A[A-Za-z_]\w*=/)
|
|
192
|
+
key, value = word.split("=", 2)
|
|
193
|
+
env[key] = value
|
|
194
|
+
elsif !PREFIXES.include?(word)
|
|
195
|
+
break
|
|
196
|
+
end
|
|
197
|
+
words.shift
|
|
198
|
+
end
|
|
199
|
+
verb = words.shift or return
|
|
200
|
+
args = words.grep_v(REDIRECT)
|
|
201
|
+
case verb
|
|
202
|
+
when "cd" then cd(args)
|
|
203
|
+
when "pushd"
|
|
204
|
+
@pushed << @dir
|
|
205
|
+
@dir = args.first ? expand(args.first, @dir) : nil
|
|
206
|
+
when "popd" then @dir = @pushed.pop unless @pushed.empty?
|
|
207
|
+
else git(args, env) if verb == "git" || verb.end_with?("/git")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cd(args)
|
|
212
|
+
target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
|
|
213
|
+
@dir = if target.nil? then @home
|
|
214
|
+
elsif target == "-" then nil
|
|
215
|
+
else expand(target, @dir)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def git(args, env)
|
|
220
|
+
dir = @dir
|
|
221
|
+
work_tree = env["GIT_WORK_TREE"] && expand(env["GIT_WORK_TREE"], dir)
|
|
222
|
+
git_dir = env["GIT_DIR"] && expand(env["GIT_DIR"], dir)
|
|
223
|
+
sub = nil
|
|
224
|
+
until args.empty?
|
|
225
|
+
arg = args.shift
|
|
226
|
+
case arg
|
|
227
|
+
when *VALUE_OPTIONS
|
|
228
|
+
value = args.shift.to_s
|
|
229
|
+
dir = expand(value, dir) if arg == "-C"
|
|
230
|
+
git_dir = expand(value, dir) if arg == "--git-dir"
|
|
231
|
+
work_tree = expand(value, dir) if arg == "--work-tree"
|
|
232
|
+
when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
|
|
233
|
+
when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
|
|
234
|
+
when /\A-/ then next
|
|
235
|
+
else
|
|
236
|
+
sub = arg
|
|
237
|
+
break
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
return unless MUTATING.include?(sub)
|
|
241
|
+
return if sub == "stash" && STASH_READS.include?(args.first)
|
|
242
|
+
|
|
243
|
+
@found << (work_tree || repo_of(git_dir) || dir || :unknown)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def repo_of(git_dir)
|
|
247
|
+
return nil unless git_dir
|
|
248
|
+
|
|
249
|
+
File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# An absolute path for +arg+ against +dir+; nil when it can't be
|
|
253
|
+
# told from the text (a variable, a substitution, a relative path
|
|
254
|
+
# against an unknown dir).
|
|
255
|
+
def expand(arg, dir)
|
|
256
|
+
return nil if arg.include?(SUBST)
|
|
257
|
+
|
|
258
|
+
arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
|
|
259
|
+
return nil if arg.include?("$") || arg.start_with?("~")
|
|
260
|
+
return File.expand_path(arg) if arg.start_with?("/")
|
|
261
|
+
|
|
262
|
+
dir && File.expand_path(arg, dir)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -4,20 +4,27 @@ require_relative "../tools/tool_path"
|
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "model_size"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "outside"
|
|
8
|
+
require_relative "shell_git_dirs"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Guardrails
|
|
11
12
|
# What a tool call acts on, resolved the way the tools resolve it: the
|
|
12
13
|
# shell command, absolute paths (against Dir.pwd, as the file tools have
|
|
13
14
|
# no cwd:), the directory a command runs in, its repo root, and whether
|
|
14
|
-
# a path leaves the repo (the cwd
|
|
15
|
+
# a path leaves the session's repo (Outside: the context cwd's repo
|
|
16
|
+
# root, the cwd when there is no repo; not the call's own cwd:).
|
|
15
17
|
class Targets
|
|
16
18
|
SHELL_TOOLS = %w[execute task_create].freeze
|
|
17
19
|
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
18
20
|
|
|
19
21
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
20
22
|
|
|
23
|
+
# @return [String] the session's repo root (the context cwd's; the cwd
|
|
24
|
+
# outside a repo): what outside_repo measures from. repo_root is the
|
|
25
|
+
# call's (its own cwd: for shell and plugin tools).
|
|
26
|
+
attr_reader :session_root
|
|
27
|
+
|
|
21
28
|
# @return [String, nil] the effective model: its bare name (no host
|
|
22
29
|
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
23
30
|
attr_reader :model_name, :model_key
|
|
@@ -62,7 +69,7 @@ module Samagotchi
|
|
|
62
69
|
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
63
70
|
end
|
|
64
71
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
|
|
65
|
-
model_name: model_name, model_key: model_key)
|
|
72
|
+
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base)
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
# What a plugin tool's targets: callable says the call acts on:
|
|
@@ -102,7 +109,9 @@ module Samagotchi
|
|
|
102
109
|
nil
|
|
103
110
|
end
|
|
104
111
|
|
|
105
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil
|
|
112
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil,
|
|
113
|
+
session_root: nil)
|
|
114
|
+
@session_root = session_root || repo_root || cwd
|
|
106
115
|
@args = args
|
|
107
116
|
@model_name = model_name
|
|
108
117
|
@model_key = model_key
|
|
@@ -123,20 +132,28 @@ module Samagotchi
|
|
|
123
132
|
@small_model = ModelSize.small?(@model_name, @model_key)
|
|
124
133
|
end
|
|
125
134
|
|
|
126
|
-
# Whether any path is outside the repo
|
|
127
|
-
#
|
|
128
|
-
# write/edit there is what memory_write does, unasked; index.md, the
|
|
129
|
-
# bundles dir and the rest of chi's config dir still count.
|
|
135
|
+
# Whether any path is outside the session's repo (Outside.outside?:
|
|
136
|
+
# symlinks resolved; a memory's file and tmp dirs don't count).
|
|
130
137
|
def outside_repo?
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
@paths.any? { |p| Outside.outside?(p, root: @session_root) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Where a shell call runs mutating git (ShellGitDirs: dirs, and
|
|
142
|
+
# :unknown for one the text doesn't tell); [] for other tools.
|
|
143
|
+
def git_dirs
|
|
144
|
+
@git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether a shell call runs mutating git outside the session's repo
|
|
148
|
+
# (an :unknown dir doesn't count).
|
|
149
|
+
def git_outside_repo?
|
|
150
|
+
git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: @session_root) }
|
|
135
151
|
end
|
|
136
152
|
|
|
137
153
|
# The hook event's targets: hash.
|
|
138
154
|
def to_h
|
|
139
|
-
{ command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo
|
|
155
|
+
{ command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo?,
|
|
156
|
+
git_dirs: git_dirs.map(&:to_s) }
|
|
140
157
|
end
|
|
141
158
|
end
|
|
142
159
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "guardrails/verdict"
|
|
4
4
|
require_relative "guardrails/context"
|
|
5
|
+
require_relative "guardrails/outside"
|
|
6
|
+
require_relative "guardrails/shell_git_dirs"
|
|
5
7
|
require_relative "guardrails/targets"
|
|
6
8
|
require_relative "guardrails/model_size"
|
|
7
9
|
require_relative "guardrails/approval"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A turn's iteration limit (how many model ↔ tool rounds before the turn
|
|
8
|
+
# stops and offers to continue), one resolver for the Engine, the worker
|
|
9
|
+
# and the REPL.
|
|
10
|
+
module IterationLimit
|
|
11
|
+
KEY = "turn.max_iterations"
|
|
12
|
+
# A turn's limit unless turn.max_iterations says otherwise, and the
|
|
13
|
+
# loops' own default.
|
|
14
|
+
DEFAULT = 100
|
|
15
|
+
# A --no-interrupt / --non-interactive turn's, at least: nobody is there
|
|
16
|
+
# to answer a continue offer.
|
|
17
|
+
NO_INTERRUPT = 1000
|
|
18
|
+
|
|
19
|
+
# @param no_interrupt [Boolean] the turn runs with nobody to answer: the
|
|
20
|
+
# larger of NO_INTERRUPT and the configured limit, so a configured one
|
|
21
|
+
# above 1000 is never lowered
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def self.for(no_interrupt: false) = no_interrupt ? [NO_INTERRUPT, configured].max : configured
|
|
24
|
+
|
|
25
|
+
# turn.max_iterations (env SAMAGOTCHI_TURN_MAX_ITERATIONS); one below 1
|
|
26
|
+
# is DEFAULT, with a warning.
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.configured
|
|
29
|
+
value = Config.get(KEY)
|
|
30
|
+
return DEFAULT if value.nil?
|
|
31
|
+
return value if value.is_a?(Integer) && value >= 1
|
|
32
|
+
|
|
33
|
+
Log.warn(:config, "invalid_value", echo: "Warning: #{KEY} must be 1 or more, got #{value.inspect} — using #{DEFAULT}",
|
|
34
|
+
key: KEY)
|
|
35
|
+
DEFAULT
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|