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
|
@@ -218,7 +218,11 @@ class Plugin
|
|
|
218
218
|
|
|
219
219
|
def ended
|
|
220
220
|
status = @wait.value
|
|
221
|
-
reason =
|
|
221
|
+
reason = if status.nil?
|
|
222
|
+
"the server exited"
|
|
223
|
+
else
|
|
224
|
+
"the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
|
|
225
|
+
end
|
|
222
226
|
waiting, closing = @mutex.synchronize do
|
|
223
227
|
@dead ||= reason
|
|
224
228
|
[@pending.values, @closing]
|
|
@@ -652,7 +656,7 @@ class Plugin
|
|
|
652
656
|
|
|
653
657
|
notes = paths.map do |path|
|
|
654
658
|
images << { path: path, name: File.basename(path) }
|
|
655
|
-
"[image #{images.size}: #{File.basename(path)}
|
|
659
|
+
"[image #{images.size}: #{File.basename(path)}]"
|
|
656
660
|
end
|
|
657
661
|
[line, *notes].join("\n")
|
|
658
662
|
when "image"
|
|
@@ -661,11 +665,21 @@ class Plugin
|
|
|
661
665
|
next "[image: #{mime}, empty]" if bytes.empty?
|
|
662
666
|
|
|
663
667
|
images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
|
|
664
|
-
"[image #{images.size}: #{mime}
|
|
668
|
+
"[image #{images.size}: #{mime}]"
|
|
665
669
|
when "audio" then "[audio: #{block["mimeType"] || "unknown type"}]"
|
|
666
670
|
when "resource"
|
|
667
671
|
resource = block["resource"] || {}
|
|
668
|
-
|
|
672
|
+
mime = resource["mimeType"].to_s
|
|
673
|
+
blob = resource["blob"].to_s
|
|
674
|
+
if mime.start_with?("image/") && !blob.empty?
|
|
675
|
+
bytes = blob.unpack1("m")
|
|
676
|
+
next "[image: #{mime}, empty]" if bytes.empty?
|
|
677
|
+
|
|
678
|
+
images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
|
|
679
|
+
"[image #{images.size}: #{mime}]"
|
|
680
|
+
else
|
|
681
|
+
resource["text"] || "[resource: #{resource["uri"]}]"
|
|
682
|
+
end
|
|
669
683
|
when "resource_link" then "[resource link: #{block["uri"]}]"
|
|
670
684
|
else "[#{block["type"] || "unknown"} content]"
|
|
671
685
|
end
|
|
@@ -321,7 +321,8 @@ class SourceLinks
|
|
|
321
321
|
links.any? do |link|
|
|
322
322
|
in_target = start >= link[:target][0] && finish <= link[:target][1]
|
|
323
323
|
in_label = start >= link[:label][0] && finish <= link[:label][1]
|
|
324
|
-
in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish]
|
|
324
|
+
in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish],
|
|
325
|
+
case_insensitive: match.regexp.casefold?))
|
|
325
326
|
end
|
|
326
327
|
end
|
|
327
328
|
|
|
@@ -330,10 +331,12 @@ class SourceLinks
|
|
|
330
331
|
# the ref. So `[JIRA-1](…/JIRA-12)` is NOT skipped (the target names
|
|
331
332
|
# JIRA-12), while `[JIRA-123](…/JIRA-123)` is. Note this is stricter than
|
|
332
333
|
# the bare-text scan's `\b`, which treats `-` as a boundary: `…/JIRA-1-foo`
|
|
333
|
-
# would match there but not here.
|
|
334
|
-
|
|
334
|
+
# would match there but not here. A case_insensitive source compares
|
|
335
|
+
# without case too: `[jira-1](…/JIRA-1)` is skipped.
|
|
336
|
+
def target_names_ref?(target, ref, case_insensitive: false)
|
|
335
337
|
escaped = Regexp.escape(ref)
|
|
336
|
-
|
|
338
|
+
flags = case_insensitive ? Regexp::IGNORECASE : 0
|
|
339
|
+
target.match?(Regexp.new("(?<![A-Za-z0-9\\-])#{escaped}(?![A-Za-z0-9\\-])", flags))
|
|
337
340
|
end
|
|
338
341
|
|
|
339
342
|
# A ref glued to URL punctuation is part of a link too, even when the span
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
@@ -8,7 +8,7 @@ files:
|
|
|
8
8
|
source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
|
|
9
9
|
hooks:
|
|
10
10
|
source_links.rb:
|
|
11
|
-
sha256: sha256:
|
|
11
|
+
sha256: sha256:5a996b718b1db729305b893ed583b9e600e813570861d47e0b65a24fec36a35d
|
|
12
12
|
event: after_turn
|
|
13
13
|
on_error: log
|
|
14
14
|
priority: 90
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.20.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
|
|
9
9
|
config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -88,13 +88,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
88
88
|
| `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
|
|
89
89
|
| `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
|
|
90
90
|
| `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
|
|
91
|
-
| `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot
|
|
91
|
+
| `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
|
|
92
92
|
| `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
|
|
93
93
|
| `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
|
|
94
94
|
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
95
95
|
|
|
96
96
|
**Scope resolution for install/build:**
|
|
97
|
-
- CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`
|
|
97
|
+
- CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`MemoryPaths.scope_dir`).
|
|
98
98
|
|
|
99
99
|
**Example flows:**
|
|
100
100
|
```bash
|
|
@@ -28,8 +28,10 @@ module Samagotchi
|
|
|
28
28
|
def in_ui?(ui) = uis.nil? || uis.include?(ui)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# @param entries [Array<Entry>] taken as they are, in order (.from_listing)
|
|
32
|
+
def initialize(entries: [])
|
|
32
33
|
@entries = []
|
|
34
|
+
entries.each { |entry| add(entry) }
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# A UI without an Engine (attached) learns the session's commands from
|
|
@@ -39,8 +41,7 @@ module Samagotchi
|
|
|
39
41
|
# @param listing [Array<Hash>] #listing, with String or Symbol keys
|
|
40
42
|
# @return [Registry]
|
|
41
43
|
def self.from_listing(listing, base:)
|
|
42
|
-
registry = new
|
|
43
|
-
base.entries.each { |entry| registry.send(:add, entry) }
|
|
44
|
+
registry = new(entries: base.entries)
|
|
44
45
|
known = base.entries.map(&:name)
|
|
45
46
|
Array(listing).each do |item|
|
|
46
47
|
item = item.transform_keys(&:to_sym)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -107,6 +107,9 @@ module Samagotchi
|
|
|
107
107
|
# The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
|
|
108
108
|
# own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
|
|
109
109
|
Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
|
|
110
|
+
# Which rules vote: auto (the default) leaves out the rules tagged `modes: [strict]`; strict runs them all. An unknown
|
|
111
|
+
# value warns and is auto.
|
|
112
|
+
Entry.new(key: "guardrails.mode", yaml_path: %w[guardrails mode], type: :enum, default: "auto", expose: %i[config], enum_values: %w[auto strict]),
|
|
110
113
|
# Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
|
|
111
114
|
# active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
|
|
112
115
|
Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
|
|
@@ -26,9 +26,9 @@ module Samagotchi
|
|
|
26
26
|
usage = {} unless usage.is_a?(Hash)
|
|
27
27
|
|
|
28
28
|
# The token counts come from the same parser SessionMetrics uses.
|
|
29
|
-
counts = TokenUsage.from_payload(payload_hash)
|
|
30
|
-
prompt_tokens = counts
|
|
31
|
-
completion_tokens = counts
|
|
29
|
+
counts = TokenUsage.from_payload(payload_hash)
|
|
30
|
+
prompt_tokens = counts&.prompt_tokens
|
|
31
|
+
completion_tokens = counts&.completion_tokens
|
|
32
32
|
|
|
33
33
|
total_tokens = first_positive_integer(
|
|
34
34
|
usage["total_tokens"], usage[:total_tokens],
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -169,9 +169,10 @@ module Samagotchi
|
|
|
169
169
|
# kernel's dispatch): the built-ins, per Engine.
|
|
170
170
|
@tools = Tools::Builtins.registry
|
|
171
171
|
if @scratch
|
|
172
|
-
# A scratch session's children would outlive it
|
|
173
|
-
# would too (write and edit
|
|
174
|
-
|
|
172
|
+
# A scratch session's children would outlive it, and so would a
|
|
173
|
+
# note it left in a peer; its memories would too (write and edit
|
|
174
|
+
# into the memories: ScratchWrites).
|
|
175
|
+
[Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
|
|
175
176
|
@tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
|
|
176
177
|
end
|
|
177
178
|
# Likewise the slash commands its SessionCommands run.
|
|
@@ -614,6 +615,11 @@ module Samagotchi
|
|
|
614
615
|
Thread.current[key] = outer
|
|
615
616
|
end
|
|
616
617
|
|
|
618
|
+
# Whether an anytime command (/btw) still runs on its thread.
|
|
619
|
+
def anytime_running?
|
|
620
|
+
@lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
|
|
621
|
+
end
|
|
622
|
+
|
|
617
623
|
# Start an anytime command on its own thread (D8), one #shutdown waits
|
|
618
624
|
# for, so its command_ran is announced before the process leaves.
|
|
619
625
|
# @return [Thread]
|
|
@@ -2292,6 +2298,7 @@ module Samagotchi
|
|
|
2292
2298
|
# not an event of its own.
|
|
2293
2299
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2294
2300
|
|
|
2301
|
+
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2295
2302
|
emit_event(on_event, event)
|
|
2296
2303
|
watch.feed(**progress) if watch && progress
|
|
2297
2304
|
if event[:type] == :generation_completed
|
|
@@ -2301,6 +2308,19 @@ module Samagotchi
|
|
|
2301
2308
|
end
|
|
2302
2309
|
end
|
|
2303
2310
|
|
|
2311
|
+
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2312
|
+
# {decode_tps:, source:}, nil without one) and the session's running
|
|
2313
|
+
# token totals (`tokens:`, the snapshot's block): the web's info bar
|
|
2314
|
+
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2315
|
+
# so the event it gets next finds it closed.
|
|
2316
|
+
def with_generation_report(event)
|
|
2317
|
+
report = @metrics.finish_generation
|
|
2318
|
+
event.merge(speed: report.speed&.to_h, tokens: report.tokens)
|
|
2319
|
+
rescue StandardError
|
|
2320
|
+
event
|
|
2321
|
+
end
|
|
2322
|
+
private :with_generation_report
|
|
2323
|
+
|
|
2304
2324
|
# The turn's StreamWatch, or nil when no hook listens.
|
|
2305
2325
|
def stream_watch(cancel_controller)
|
|
2306
2326
|
return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
|
|
@@ -2316,8 +2336,8 @@ module Samagotchi
|
|
|
2316
2336
|
rescue StandardError
|
|
2317
2337
|
nil
|
|
2318
2338
|
end
|
|
2319
|
-
# Turn-scoped sink: receives the
|
|
2320
|
-
#
|
|
2339
|
+
# Turn-scoped sink: receives the event hash as the stream handler
|
|
2340
|
+
# passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
|
|
2321
2341
|
# kernel loop (same as KernelLoop's own handling).
|
|
2322
2342
|
if on_event
|
|
2323
2343
|
begin
|
|
@@ -70,10 +70,15 @@ module Samagotchi
|
|
|
70
70
|
@interface = value
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# The approval scopes a scratch session offers: nothing outlives it.
|
|
74
|
+
SCRATCH_SCOPES = %w[once session].freeze
|
|
75
|
+
|
|
73
76
|
# Where the approval store lives: beside Session's state dir
|
|
74
|
-
# ($XDG_STATE_HOME/samagotchi/guardrails/).
|
|
77
|
+
# ($XDG_STATE_HOME/samagotchi/guardrails/). A scratch session reads
|
|
78
|
+
# it but keeps its own session approvals in memory.
|
|
75
79
|
def state_dir=(state_dir)
|
|
76
80
|
@approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
|
|
81
|
+
@approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
|
|
77
82
|
@protected = nil
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -207,7 +212,8 @@ module Samagotchi
|
|
|
207
212
|
# A plugin tool is asked about by its label, as its row shows it.
|
|
208
213
|
label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
|
|
209
214
|
# verdict.call is the call that will run (a hook may have replaced it).
|
|
210
|
-
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call)
|
|
215
|
+
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
|
|
216
|
+
only: (SCRATCH_SCOPES if @scratch))
|
|
211
217
|
Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
|
|
212
218
|
end
|
|
213
219
|
|
|
@@ -273,7 +279,8 @@ module Samagotchi
|
|
|
273
279
|
@failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
274
280
|
end
|
|
275
281
|
Guardrails::Rules.new(rules + bundle_rules, disable: disable,
|
|
276
|
-
enabled: Samagotchi::Config.get("guardrails.enabled") != false
|
|
282
|
+
enabled: Samagotchi::Config.get("guardrails.enabled") != false,
|
|
283
|
+
mode: Samagotchi::Config.get("guardrails.mode") || "auto")
|
|
277
284
|
end
|
|
278
285
|
|
|
279
286
|
# The dry-run diff of an edit/write call for its approval; a preview
|
|
@@ -22,9 +22,11 @@ module Samagotchi
|
|
|
22
22
|
# approval[:tool] stays the raw name
|
|
23
23
|
# @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
|
|
24
24
|
# write: the card shows its diff, the question text one change line
|
|
25
|
+
# @param only [Array<String>, nil] offer no scope beyond these (a
|
|
26
|
+
# scratch session: once, session); "once" when none is left
|
|
25
27
|
# @return [Hash] open_question fields
|
|
26
|
-
def payload(verdict, label: nil, preview: nil)
|
|
27
|
-
scopes = offered_scopes(verdict)
|
|
28
|
+
def payload(verdict, label: nil, preview: nil, only: nil)
|
|
29
|
+
scopes = offered_scopes(verdict, only: only)
|
|
28
30
|
targets = verdict.targets
|
|
29
31
|
{
|
|
30
32
|
question: question_text(verdict, label: label, preview: preview),
|
|
@@ -42,6 +44,8 @@ module Samagotchi
|
|
|
42
44
|
args: args_for_card(targets),
|
|
43
45
|
cwd: targets&.cwd,
|
|
44
46
|
repo_root: targets&.repo_root,
|
|
47
|
+
# The repository's name (a worktree's main checkout's folder).
|
|
48
|
+
repo_name: targets&.repo_name,
|
|
45
49
|
branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
|
|
46
50
|
rule: verdict.rule,
|
|
47
51
|
source: verdict.source,
|
|
@@ -81,12 +85,16 @@ module Samagotchi
|
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# The rule's scopes; "rule" only when a rule id names what to approve.
|
|
84
|
-
def offered_scopes(verdict)
|
|
85
|
-
verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
|
|
88
|
+
def offered_scopes(verdict, only: nil)
|
|
89
|
+
scopes = verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
|
|
90
|
+
return scopes unless only
|
|
91
|
+
|
|
92
|
+
scopes &= only
|
|
93
|
+
scopes.empty? ? ["once"] : scopes
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def label(scope, verdict)
|
|
89
|
-
place = verdict.targets&.repo_root ? "in this repo" : "in this directory"
|
|
97
|
+
place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
|
|
90
98
|
case scope
|
|
91
99
|
when "once" then "Allow once"
|
|
92
100
|
when "session" then "Allow this call for the session"
|
|
@@ -123,9 +131,8 @@ module Samagotchi
|
|
|
123
131
|
what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
|
|
124
132
|
lines = ["#{tool}: #{what}"]
|
|
125
133
|
if targets
|
|
126
|
-
repo = targets.repo_root
|
|
127
134
|
branch = verdict.context&.branch(targets.cwd)
|
|
128
|
-
where =
|
|
135
|
+
where = targets.repo_root ? "repo #{targets.repo_name}#{", branch #{branch}" if branch}" : "not in a repo"
|
|
129
136
|
lines << " in #{targets.cwd} (#{where})"
|
|
130
137
|
end
|
|
131
138
|
who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
|
|
@@ -16,6 +16,10 @@ module Samagotchi
|
|
|
16
16
|
# this session;
|
|
17
17
|
# repo — this exact call in this repo (the cwd outside a repo);
|
|
18
18
|
# rule — anything this rule (from this source) asks about here.
|
|
19
|
+
# "This repo" is the repository (repo: its common git dir), so an
|
|
20
|
+
# approval holds in every worktree of it; repo_root, the worktree it
|
|
21
|
+
# was given in, is kept for showing, and an entry from before repo:
|
|
22
|
+
# still matches that exact folder.
|
|
19
23
|
# "once" is never stored. A file that doesn't parse is moved aside to
|
|
20
24
|
# approvals.json.corrupt-<UTC time>[-N], with a warning, and the store
|
|
21
25
|
# starts empty: it only means more asks, and nothing is overwritten.
|
|
@@ -45,34 +49,48 @@ module Samagotchi
|
|
|
45
49
|
# @param verdict [Verdict] an ask with its targets and context
|
|
46
50
|
# @return [Hash, nil] the first entry that allows it
|
|
47
51
|
def match(verdict)
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
self.class.find_in(read, verdict)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Store the verdict's allowed scope (not "once").
|
|
56
|
+
# @return [Hash, nil] the entry
|
|
57
|
+
def add(verdict, scope)
|
|
58
|
+
entry = self.class.entry_for(verdict, scope) or return nil
|
|
59
|
+
|
|
60
|
+
update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
|
|
61
|
+
entry
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param list [Array<Hash>] entries
|
|
65
|
+
# @return [Hash, nil] the first of +list+ that allows +verdict+
|
|
66
|
+
def self.find_in(list, verdict)
|
|
67
|
+
key = key_for(verdict)
|
|
68
|
+
place = place_for(verdict)
|
|
69
|
+
repo = verdict.targets&.repo
|
|
70
|
+
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
50
71
|
session_id = verdict.context&.session_id
|
|
51
|
-
|
|
72
|
+
list.find do |e|
|
|
52
73
|
case e["scope"]
|
|
53
74
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
54
|
-
when "repo" then e
|
|
55
|
-
when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
|
|
56
|
-
e["repo_root"] == place
|
|
75
|
+
when "repo" then here.(e) && e["key"] == key
|
|
76
|
+
when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.(e)
|
|
57
77
|
end
|
|
58
78
|
end
|
|
59
79
|
end
|
|
60
80
|
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
def add(verdict, scope)
|
|
81
|
+
# @return [Hash, nil] the entry that stores +scope+ for +verdict+; nil for "once"
|
|
82
|
+
def self.entry_for(verdict, scope)
|
|
64
83
|
return nil unless STORED_SCOPES.include?(scope)
|
|
65
84
|
|
|
66
85
|
entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
|
|
67
86
|
"rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
68
87
|
case scope
|
|
69
|
-
when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" =>
|
|
70
|
-
when "repo"
|
|
71
|
-
|
|
88
|
+
when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
89
|
+
when "repo"
|
|
90
|
+
entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
|
|
91
|
+
when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
|
|
72
92
|
end
|
|
73
|
-
entry.compact
|
|
74
|
-
update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
|
|
75
|
-
entry
|
|
93
|
+
entry.compact
|
|
76
94
|
end
|
|
77
95
|
|
|
78
96
|
# Remove entry +index+ (0-based, as #entries lists them).
|
|
@@ -167,6 +185,42 @@ module Samagotchi
|
|
|
167
185
|
AtomicFile.write(@path, JSON.pretty_generate(list))
|
|
168
186
|
end
|
|
169
187
|
end
|
|
188
|
+
|
|
189
|
+
# A scratch session's approvals: the store's entries still apply, and
|
|
190
|
+
# its own session approvals are kept in memory, so none outlives it
|
|
191
|
+
# (it offers no wider scope; one that comes anyway is not kept).
|
|
192
|
+
class InMemory
|
|
193
|
+
def initialize(store)
|
|
194
|
+
@store = store
|
|
195
|
+
@own = []
|
|
196
|
+
@mutex = Mutex.new
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def path = @store.path
|
|
200
|
+
|
|
201
|
+
# The store's entries, then this session's own.
|
|
202
|
+
def entries = @store.entries + @mutex.synchronize { @own.dup }
|
|
203
|
+
|
|
204
|
+
def match(verdict)
|
|
205
|
+
@mutex.synchronize { Approvals.find_in(@own, verdict) } || @store.match(verdict)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def add(verdict, scope)
|
|
209
|
+
return nil unless scope == "session"
|
|
210
|
+
|
|
211
|
+
entry = Approvals.entry_for(verdict, scope)
|
|
212
|
+
@mutex.synchronize { @own << entry unless @own.any? { |e| e.except("created_at") == entry.except("created_at") } }
|
|
213
|
+
entry
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Index as #entries lists them: the store's first, then its own.
|
|
217
|
+
def revoke(index)
|
|
218
|
+
stored = @store.entries.size
|
|
219
|
+
return @store.revoke(index) if index < stored
|
|
220
|
+
|
|
221
|
+
@mutex.synchronize { index - stored < @own.size ? @own.delete_at(index - stored) : nil }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
170
224
|
end
|
|
171
225
|
end
|
|
172
226
|
end
|
|
@@ -24,6 +24,11 @@ module Samagotchi
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def repo_root(dir = @cwd) = @git.root(dir)
|
|
27
|
+
# The git hooks dir of +dir+'s repo (core.hooksPath honoured), or nil.
|
|
28
|
+
def hooks_dir(dir = @cwd) = @git.hooks(dir)
|
|
29
|
+
# +dir+'s repository: its absolute common git dir (<root>/.git for a
|
|
30
|
+
# plain checkout; the same for every worktree of it), or nil.
|
|
31
|
+
def repo(dir = @cwd) = @git.common_dir(dir)
|
|
27
32
|
def branch(dir = @cwd) = @git.branch(dir)
|
|
28
33
|
|
|
29
34
|
# The hook event's context: hash.
|
|
@@ -41,6 +46,8 @@ module Samagotchi
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
def root(dir) = info(dir)[:root]
|
|
49
|
+
def hooks(dir) = info(dir)[:hooks]
|
|
50
|
+
def common_dir(dir) = info(dir)[:common_dir]
|
|
44
51
|
def branch(dir) = info(dir)[:branch]
|
|
45
52
|
|
|
46
53
|
private
|
|
@@ -53,16 +60,16 @@ module Samagotchi
|
|
|
53
60
|
def probe(dir)
|
|
54
61
|
return {} unless File.directory?(dir)
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# A repo with no commits yet has no HEAD: only the
|
|
59
|
-
unless status.success?
|
|
60
|
-
|
|
61
|
-
return {} unless status.success?
|
|
63
|
+
paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
|
|
64
|
+
out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
|
|
65
|
+
# A repo with no commits yet has no HEAD: only the paths answer.
|
|
66
|
+
out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
|
|
67
|
+
return {} unless status.success?
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{ root: lines[0],
|
|
69
|
+
lines = out.lines.map(&:strip)
|
|
70
|
+
# --git-path and --git-common-dir are relative to dir (core.hooksPath too).
|
|
71
|
+
{ root: lines[0], hooks: lines[1] && File.expand_path(lines[1], dir),
|
|
72
|
+
common_dir: lines[2] && File.expand_path(lines[2], dir), branch: lines[3] }
|
|
66
73
|
rescue SystemCallError
|
|
67
74
|
{}
|
|
68
75
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../config"
|
|
4
4
|
require_relative "../paths"
|
|
5
|
+
require_relative "../memory_paths"
|
|
5
6
|
require_relative "../hooks/loader"
|
|
6
7
|
require_relative "approval"
|
|
7
8
|
require_relative "protected_paths"
|
|
@@ -42,9 +43,12 @@ module Samagotchi
|
|
|
42
43
|
# Rules that ask about chi's own config and hooks: the core
|
|
43
44
|
# ProtectedPaths asks and the guardrails bundle's shell rule.
|
|
44
45
|
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
|
|
45
|
-
# A shell command naming chi's config, hooks or guardrails
|
|
46
|
-
#
|
|
46
|
+
# A shell command naming chi's config, hooks or guardrails as text
|
|
47
|
+
# (broad: a parent may not allow what only looks like one).
|
|
47
48
|
CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
|
|
49
|
+
# The guardrails bundle's shell-touches-chi, for a word it can't
|
|
50
|
+
# resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
|
|
51
|
+
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
|
|
48
52
|
|
|
49
53
|
module_function
|
|
50
54
|
|
|
@@ -101,10 +105,12 @@ module Samagotchi
|
|
|
101
105
|
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
|
|
102
106
|
end
|
|
103
107
|
|
|
104
|
-
# chi's config dir, the hooks dir
|
|
108
|
+
# chi's config dir, the hooks dir, the approval store's dir and the
|
|
109
|
+
# installed bundles' dir.
|
|
105
110
|
def chi_dirs
|
|
106
111
|
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
107
|
-
|
|
112
|
+
bundles = begin MemoryPaths.bundles_dir rescue nil end
|
|
113
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles]
|
|
108
114
|
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
109
115
|
end
|
|
110
116
|
|