samagotchi 0.19.0 → 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 +83 -1
- data/docs/architecture.md +6 -0
- data/docs/cli.md +14 -3
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bundle_command.rb +22 -4
- 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/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- 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 +16 -2
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- 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/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -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_metrics.rb +224 -19
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +34 -15
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +26 -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 +2 -1
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Fields: id, tool (a name, a list, or shell = execute + task_create),
|
|
7
7
|
# command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
|
|
8
|
-
# a shell call runs commit/add/reset/… in another checkout),
|
|
9
|
-
#
|
|
8
|
+
# a shell call runs commit/add/reset/… in another checkout), touches
|
|
9
|
+
# (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
|
|
10
|
+
# (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
|
|
11
|
+
# match), modes (strict: only with guardrails.mode: strict; untagged rules
|
|
12
|
+
# ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
|
|
13
|
+
# session, repo, rule; default all).
|
|
10
14
|
# [^;&|\n]* keeps a match inside one command of a chain; the git rules match
|
|
11
15
|
# the subcommand after optional global options (git -C dir push).
|
|
12
16
|
rules:
|
|
@@ -31,9 +35,11 @@ rules:
|
|
|
31
35
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
|
|
32
36
|
verdict: ask
|
|
33
37
|
reason: git branch -D deletes a branch even if it is not merged
|
|
38
|
+
# Strict mode only: a rebase rewrites local history, which the reflog keeps.
|
|
34
39
|
- id: git-rebase
|
|
35
40
|
tool: shell
|
|
36
41
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
|
|
42
|
+
modes: [strict]
|
|
37
43
|
verdict: ask
|
|
38
44
|
reason: git rebase rewrites history
|
|
39
45
|
- id: git-history-rewrite
|
|
@@ -44,10 +50,13 @@ rules:
|
|
|
44
50
|
|
|
45
51
|
# ── deleting ───────────────────────────────────────────────────────────
|
|
46
52
|
# rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
|
|
47
|
-
# (a text heuristic; rm -rf build/ in the repo is not asked)
|
|
53
|
+
# (a text heuristic; rm -rf build/ in the repo is not asked), unless
|
|
54
|
+
# every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
|
|
55
|
+
# /tmp/* and a link out of it still ask).
|
|
48
56
|
- id: rm-rf-wide
|
|
49
57
|
tool: shell
|
|
50
58
|
command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
|
|
59
|
+
rm: outside_tmp
|
|
51
60
|
verdict: ask
|
|
52
61
|
reason: rm -rf on a path outside the working tree
|
|
53
62
|
|
|
@@ -65,10 +74,13 @@ rules:
|
|
|
65
74
|
|
|
66
75
|
# ── work outside this session's repo, and git hooks ───────────────────
|
|
67
76
|
# outside_repo: not in the session's repo (its folder outside a repo);
|
|
68
|
-
# tmp dirs and memory files don't count.
|
|
77
|
+
# tmp dirs and memory files don't count. Strict mode only, as is
|
|
78
|
+
# git-outside-repo; chi's own config and hooks still ask (core protected
|
|
79
|
+
# paths, shell-touches-chi).
|
|
69
80
|
- id: write-outside-repo
|
|
70
81
|
tool: [write, edit]
|
|
71
82
|
path: outside_repo
|
|
83
|
+
modes: [strict]
|
|
72
84
|
verdict: ask
|
|
73
85
|
reason: writes outside this session's repo
|
|
74
86
|
# cd/git -C into another checkout, then commit, add, reset, checkout,
|
|
@@ -80,6 +92,7 @@ rules:
|
|
|
80
92
|
- id: git-outside-repo
|
|
81
93
|
tool: shell
|
|
82
94
|
git: outside_repo
|
|
95
|
+
modes: [strict]
|
|
83
96
|
verdict: ask
|
|
84
97
|
scopes: [once, session, rule]
|
|
85
98
|
reason: runs git that changes another checkout, outside this session's repo
|
|
@@ -90,9 +103,15 @@ rules:
|
|
|
90
103
|
reason: git hooks run code on every git command
|
|
91
104
|
|
|
92
105
|
# ── chi's own config, hooks, rules and approvals, from the shell ───────
|
|
106
|
+
# The real dirs (config.yml's dir, hooks, the approval store, installed
|
|
107
|
+
# bundles, this repo's git hooks), not a look-alike under /tmp or chi's
|
|
108
|
+
# own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
|
|
109
|
+
# $(…), a glob) is matched as text. A command that only reads them (ls,
|
|
110
|
+
# cat, rg, sed -n) isn't asked about.
|
|
93
111
|
- id: shell-touches-chi
|
|
94
112
|
tool: shell
|
|
95
|
-
|
|
113
|
+
touches: chi_dirs
|
|
114
|
+
skip_read_only: true
|
|
96
115
|
verdict: ask
|
|
97
116
|
reason: the command names chi's config, hooks, guardrails or git hooks
|
|
98
117
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
|
|
8
|
-
|
|
7
|
+
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
|
+
# skip_read_only:, touches:, rm: and modes: need 0.20.0
|
|
9
|
+
requires_chi: ">= 0.20.0"
|
|
9
10
|
files:
|
|
10
11
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
|
|
11
11
|
requires_chi: ">= 0.8.0"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
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
|
@@ -2298,6 +2298,7 @@ module Samagotchi
|
|
|
2298
2298
|
# not an event of its own.
|
|
2299
2299
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2300
2300
|
|
|
2301
|
+
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2301
2302
|
emit_event(on_event, event)
|
|
2302
2303
|
watch.feed(**progress) if watch && progress
|
|
2303
2304
|
if event[:type] == :generation_completed
|
|
@@ -2307,6 +2308,19 @@ module Samagotchi
|
|
|
2307
2308
|
end
|
|
2308
2309
|
end
|
|
2309
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
|
+
|
|
2310
2324
|
# The turn's StreamWatch, or nil when no hook listens.
|
|
2311
2325
|
def stream_watch(cancel_controller)
|
|
2312
2326
|
return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
|
|
@@ -2322,8 +2336,8 @@ module Samagotchi
|
|
|
2322
2336
|
rescue StandardError
|
|
2323
2337
|
nil
|
|
2324
2338
|
end
|
|
2325
|
-
# Turn-scoped sink: receives the
|
|
2326
|
-
#
|
|
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
|
|
2327
2341
|
# kernel loop (same as KernelLoop's own handling).
|
|
2328
2342
|
if on_event
|
|
2329
2343
|
begin
|
|
@@ -279,7 +279,8 @@ module Samagotchi
|
|
|
279
279
|
@failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
280
280
|
end
|
|
281
281
|
Guardrails::Rules.new(rules + bundle_rules, disable: disable,
|
|
282
|
-
enabled: Samagotchi::Config.get("guardrails.enabled") != false
|
|
282
|
+
enabled: Samagotchi::Config.get("guardrails.enabled") != false,
|
|
283
|
+
mode: Samagotchi::Config.get("guardrails.mode") || "auto")
|
|
283
284
|
end
|
|
284
285
|
|
|
285
286
|
# The dry-run diff of an edit/write call for its approval; a preview
|
|
@@ -44,6 +44,8 @@ module Samagotchi
|
|
|
44
44
|
args: args_for_card(targets),
|
|
45
45
|
cwd: targets&.cwd,
|
|
46
46
|
repo_root: targets&.repo_root,
|
|
47
|
+
# The repository's name (a worktree's main checkout's folder).
|
|
48
|
+
repo_name: targets&.repo_name,
|
|
47
49
|
branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
|
|
48
50
|
rule: verdict.rule,
|
|
49
51
|
source: verdict.source,
|
|
@@ -92,7 +94,7 @@ module Samagotchi
|
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
def label(scope, verdict)
|
|
95
|
-
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"
|
|
96
98
|
case scope
|
|
97
99
|
when "once" then "Allow once"
|
|
98
100
|
when "session" then "Allow this call for the session"
|
|
@@ -129,9 +131,8 @@ module Samagotchi
|
|
|
129
131
|
what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
|
|
130
132
|
lines = ["#{tool}: #{what}"]
|
|
131
133
|
if targets
|
|
132
|
-
repo = targets.repo_root
|
|
133
134
|
branch = verdict.context&.branch(targets.cwd)
|
|
134
|
-
where =
|
|
135
|
+
where = targets.repo_root ? "repo #{targets.repo_name}#{", branch #{branch}" if branch}" : "not in a repo"
|
|
135
136
|
lines << " in #{targets.cwd} (#{where})"
|
|
136
137
|
end
|
|
137
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.
|
|
@@ -62,13 +66,14 @@ module Samagotchi
|
|
|
62
66
|
def self.find_in(list, verdict)
|
|
63
67
|
key = key_for(verdict)
|
|
64
68
|
place = place_for(verdict)
|
|
69
|
+
repo = verdict.targets&.repo
|
|
70
|
+
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
65
71
|
session_id = verdict.context&.session_id
|
|
66
72
|
list.find do |e|
|
|
67
73
|
case e["scope"]
|
|
68
74
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
69
|
-
when "repo" then e
|
|
70
|
-
when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
|
|
71
|
-
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)
|
|
72
77
|
end
|
|
73
78
|
end
|
|
74
79
|
end
|
|
@@ -81,8 +86,9 @@ module Samagotchi
|
|
|
81
86
|
"rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
82
87
|
case scope
|
|
83
88
|
when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
84
|
-
when "repo"
|
|
85
|
-
|
|
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)
|
|
86
92
|
end
|
|
87
93
|
entry.compact
|
|
88
94
|
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
|
|