samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verdict"
|
|
4
|
+
require_relative "targets"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Guardrails
|
|
8
|
+
# Declarative rules from config.yml's `guardrails:` section (and, later,
|
|
9
|
+
# installed bundles). All the fields a rule gives must match:
|
|
10
|
+
# tool: a tool name, a list, or "shell" (execute + task_create);
|
|
11
|
+
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*")
|
|
12
|
+
# command: a Ruby regex on a shell tool's command
|
|
13
|
+
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
14
|
+
# or "**/…" globs match the absolute path; others the path
|
|
15
|
+
# relative to the repo root, the cwd outside one)
|
|
16
|
+
# verdict ask|deny, reason, scopes (for an ask; default all).
|
|
17
|
+
# `guardrails.disable` switches single rules off (#parse_disable).
|
|
18
|
+
# A rule that doesn't parse raises ParseError: the Engine then denies
|
|
19
|
+
# every call (a rule set that silently vanished is what this guards
|
|
20
|
+
# against). Unknown keys are errors for the same reason (a typo).
|
|
21
|
+
class Rules
|
|
22
|
+
class ParseError < StandardError; end
|
|
23
|
+
|
|
24
|
+
KEYS = %w[id tool command path verdict reason scopes].freeze
|
|
25
|
+
VERDICTS = %w[ask deny].freeze
|
|
26
|
+
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
27
|
+
|
|
28
|
+
Rule = Struct.new(:id, :tools, :command, :path, :verdict, :reason, :scopes, :source, keyword_init: true) do
|
|
29
|
+
def matches?(targets)
|
|
30
|
+
return false unless targets
|
|
31
|
+
return false if tools && !tool_matches?(targets.tool)
|
|
32
|
+
return false if command && !(targets.command && command.match?(targets.command))
|
|
33
|
+
return false if path && !path_matches?(targets)
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tool_matches?(name)
|
|
39
|
+
tools.any? { |tool| Rules.glob?(tool) ? File.fnmatch(tool, name.to_s, File::FNM_EXTGLOB) : tool == name }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def path_matches?(targets)
|
|
43
|
+
return targets.outside_repo? if path == "outside_repo"
|
|
44
|
+
|
|
45
|
+
base = targets.repo_root || targets.cwd
|
|
46
|
+
glob = path.start_with?("~") ? File.expand_path(path) : path
|
|
47
|
+
targets.paths.any? do |p|
|
|
48
|
+
candidate = glob.start_with?("/", "**") ? p : relative(p, base)
|
|
49
|
+
candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def relative(path, base)
|
|
54
|
+
prefix = File.join(base, "")
|
|
55
|
+
path.start_with?(prefix) ? path.delete_prefix(prefix) : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param list [Array<Hash>, nil] the YAML rules
|
|
60
|
+
# @param source [String] "config", "bundle <name>"
|
|
61
|
+
# @return [Array<Rule>]
|
|
62
|
+
def self.parse(list, source:)
|
|
63
|
+
return [] if list.nil?
|
|
64
|
+
raise ParseError, "rules must be a list" unless list.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
list.each_with_index.map { |raw, idx| parse_rule(raw, idx, source) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.parse_rule(raw, idx, source)
|
|
70
|
+
raise ParseError, "rule #{idx + 1} is not a mapping" unless raw.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
raw = raw.transform_keys(&:to_s)
|
|
73
|
+
id = raw["id"].to_s.strip
|
|
74
|
+
label = id.empty? ? "rule #{idx + 1}" : "rule #{id}"
|
|
75
|
+
unknown = raw.keys - KEYS
|
|
76
|
+
raise ParseError, "#{label}: unknown key(s) #{unknown.join(", ")}" unless unknown.empty?
|
|
77
|
+
raise ParseError, "#{label}: id is required" if id.empty?
|
|
78
|
+
|
|
79
|
+
verdict = raw["verdict"].to_s
|
|
80
|
+
raise ParseError, "#{label}: verdict must be ask or deny (got #{verdict.inspect})" unless VERDICTS.include?(verdict)
|
|
81
|
+
unless %w[tool command path].any? { |k| raw.key?(k) }
|
|
82
|
+
raise ParseError, "#{label}: give at least one of tool, command, path"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
|
|
86
|
+
path: path_of(raw["path"], label), verdict: verdict.to_sym,
|
|
87
|
+
reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.tools_of(value, label)
|
|
91
|
+
return nil if value.nil?
|
|
92
|
+
|
|
93
|
+
names = Array(value).map(&:to_s)
|
|
94
|
+
raise ParseError, "#{label}: tool must be a name or a list of names" if names.empty? || names.any?(&:empty?)
|
|
95
|
+
|
|
96
|
+
names.flat_map { |n| n == "shell" ? Targets::SHELL_TOOLS : [n] }.uniq
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether a rule's tool name is a glob.
|
|
100
|
+
def self.glob?(name) = name.match?(/[*?\[{]/)
|
|
101
|
+
|
|
102
|
+
def self.regex_of(value, label)
|
|
103
|
+
return nil if value.nil?
|
|
104
|
+
|
|
105
|
+
Regexp.new(value.to_s)
|
|
106
|
+
rescue RegexpError => e
|
|
107
|
+
raise ParseError, "#{label}: command is not a valid regex: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.path_of(value, label)
|
|
111
|
+
return nil if value.nil?
|
|
112
|
+
raise ParseError, "#{label}: path must be a string" unless value.is_a?(String) && !value.empty?
|
|
113
|
+
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.scopes_of(value, label)
|
|
118
|
+
return nil if value.nil?
|
|
119
|
+
|
|
120
|
+
scopes = Array(value).map(&:to_s)
|
|
121
|
+
bad = scopes - Verdict::SCOPES
|
|
122
|
+
raise ParseError, "#{label}: unknown scope(s) #{bad.join(", ")}" unless bad.empty?
|
|
123
|
+
|
|
124
|
+
scopes
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# config.yml's `guardrails.disable`: rule ids ("git-rebase", any
|
|
128
|
+
# source) or "<bundle>:<id>" (that bundle's rule only).
|
|
129
|
+
# @return [Array<String>]
|
|
130
|
+
def self.parse_disable(value)
|
|
131
|
+
return [] if value.nil?
|
|
132
|
+
|
|
133
|
+
ids = Array(value)
|
|
134
|
+
unless ids.all? { |id| id.is_a?(String) && !id.strip.empty? }
|
|
135
|
+
raise ParseError, "disable must be a list of rule ids (id or bundle:id)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
ids.map(&:strip)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
attr_reader :rules
|
|
142
|
+
|
|
143
|
+
# @param rules [Array<Rule>] in order: config first, then bundles by name
|
|
144
|
+
# @param enabled [Boolean] false: no rules, and hooks' asks are dropped
|
|
145
|
+
# @param disable [Array<String>] from #parse_disable: rules that don't vote
|
|
146
|
+
def initialize(rules = [], enabled: true, disable: [])
|
|
147
|
+
@rules = rules
|
|
148
|
+
@enabled = enabled
|
|
149
|
+
@disable = disable
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def enabled? = @enabled
|
|
153
|
+
|
|
154
|
+
def disabled?(rule)
|
|
155
|
+
@disable.any? { |entry| disables?(entry, rule) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The disable entries no loaded rule has (a typo, or an uninstalled bundle).
|
|
159
|
+
def unmatched_disables
|
|
160
|
+
@disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A core check: every matching rule votes (strictest wins).
|
|
164
|
+
def check(verdict)
|
|
165
|
+
return verdict unless @enabled
|
|
166
|
+
|
|
167
|
+
@rules.each do |rule|
|
|
168
|
+
next if disabled?(rule)
|
|
169
|
+
next unless rule.matches?(verdict.targets)
|
|
170
|
+
|
|
171
|
+
if rule.verdict == :deny
|
|
172
|
+
verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
173
|
+
else
|
|
174
|
+
verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
verdict
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def disables?(entry, rule)
|
|
181
|
+
bundle, id = entry.include?(":") ? entry.split(":", 2) : [nil, entry]
|
|
182
|
+
id == rule.id && (bundle.nil? || rule.source == "bundle #{bundle}")
|
|
183
|
+
end
|
|
184
|
+
private :disables?
|
|
185
|
+
|
|
186
|
+
# A core check that runs right after the hooks: with guardrails
|
|
187
|
+
# disabled, a hook's ask is dropped (a deny still applies).
|
|
188
|
+
def hook_asks
|
|
189
|
+
rules = self
|
|
190
|
+
Object.new.tap do |check|
|
|
191
|
+
check.define_singleton_method(:check) do |verdict|
|
|
192
|
+
verdict.drop_ask! if !rules.enabled? && verdict.ask? && verdict.decided_by == "hook"
|
|
193
|
+
verdict
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tools/tool_path"
|
|
4
|
+
require_relative "../tools/memory"
|
|
5
|
+
require_relative "../log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# What a tool call acts on, resolved the way the tools resolve it: the
|
|
10
|
+
# shell command, absolute paths (against Dir.pwd, as the file tools have
|
|
11
|
+
# no cwd:), the directory a command runs in, its repo root, and whether
|
|
12
|
+
# a path leaves the repo (the cwd when there is no repo).
|
|
13
|
+
class Targets
|
|
14
|
+
SHELL_TOOLS = %w[execute task_create].freeze
|
|
15
|
+
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
18
|
+
|
|
19
|
+
# @return [Hash, nil] a plugin tool's arguments (call[:args]), for an
|
|
20
|
+
# approval of a call whose targets name no command or path; nil for
|
|
21
|
+
# chi's own tools
|
|
22
|
+
attr_reader :args
|
|
23
|
+
|
|
24
|
+
# @param call [Hash] the parsed tool call
|
|
25
|
+
# @param context [Context]
|
|
26
|
+
# @param model_key [String, nil] for a memory_write model overlay
|
|
27
|
+
# @param registry [Tools::Registry, nil] the session's tools: a plugin
|
|
28
|
+
# tool's entry says what it acts on (its targets:)
|
|
29
|
+
def self.for(call, context, model_key: nil, registry: nil)
|
|
30
|
+
tool = call[:name].to_s
|
|
31
|
+
base = context.cwd
|
|
32
|
+
command = nil
|
|
33
|
+
paths = []
|
|
34
|
+
cwd = base
|
|
35
|
+
args = nil
|
|
36
|
+
case tool
|
|
37
|
+
when *SHELL_TOOLS
|
|
38
|
+
command = call[:content].to_s
|
|
39
|
+
given = call[:cwd].to_s.strip
|
|
40
|
+
cwd = File.expand_path(given, base) unless given.empty?
|
|
41
|
+
when "write", "edit"
|
|
42
|
+
paths << absolute(call[:path], base)
|
|
43
|
+
when "read"
|
|
44
|
+
paths << absolute(call[:content], base)
|
|
45
|
+
when "memory_write"
|
|
46
|
+
paths << memory_path(call, model_key)
|
|
47
|
+
else
|
|
48
|
+
given = plugin_targets(call, registry)
|
|
49
|
+
command = given[:command]
|
|
50
|
+
given_cwd = given[:cwd].to_s.strip
|
|
51
|
+
cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
|
|
52
|
+
paths.concat(given[:paths].map { |path| absolute(path, cwd) })
|
|
53
|
+
entry = registry && registry[tool]
|
|
54
|
+
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
55
|
+
end
|
|
56
|
+
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# What a plugin tool's targets: callable says the call acts on:
|
|
60
|
+
# {command:, paths:, cwd:}, each optional; a callable that raises or
|
|
61
|
+
# gives something else counts as nothing (and is logged).
|
|
62
|
+
def self.plugin_targets(call, registry)
|
|
63
|
+
entry = registry && registry[call[:name].to_s]
|
|
64
|
+
none = { command: nil, paths: [], cwd: nil }
|
|
65
|
+
return none unless entry && !entry.core? && entry.targets
|
|
66
|
+
|
|
67
|
+
given = entry.targets.call(call)
|
|
68
|
+
return none unless given.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
given = given.transform_keys(&:to_sym)
|
|
71
|
+
command = given[:command].to_s
|
|
72
|
+
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd] }
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
|
|
75
|
+
msg: "#{call[:name]} targets failed: #{e.message}")
|
|
76
|
+
none
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.absolute(path, base)
|
|
80
|
+
path = Tools::ToolPath.normalize(path)
|
|
81
|
+
path.empty? ? nil : File.expand_path(path, base)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The file memory_write would write; nil when the call is invalid.
|
|
85
|
+
def self.memory_path(call, model_key)
|
|
86
|
+
entry = call[:path].to_s.strip
|
|
87
|
+
return nil if entry.empty? || call[:scope].to_s.strip.empty?
|
|
88
|
+
|
|
89
|
+
dir = Tools::MemoryRead.memories_dir(Tools::MemoryRead.normalize_scope(call[:scope]))
|
|
90
|
+
overlay = call[:current_model_only].to_s.strip.downcase == "true" && model_key
|
|
91
|
+
File.expand_path(overlay ? "#{entry}.#{model_key}.md" : "#{entry}.md", dir)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil)
|
|
97
|
+
@args = args
|
|
98
|
+
@tool = tool
|
|
99
|
+
@command = command
|
|
100
|
+
@paths = paths
|
|
101
|
+
@cwd = cwd
|
|
102
|
+
@repo_root = repo_root
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def shell? = SHELL_TOOLS.include?(@tool)
|
|
106
|
+
|
|
107
|
+
# Whether any path is outside the repo root (the cwd outside a repo).
|
|
108
|
+
def outside_repo?
|
|
109
|
+
root = @repo_root || @cwd
|
|
110
|
+
@paths.any? { |p| p != root && !p.start_with?(File.join(root, "")) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The hook event's targets: hash.
|
|
114
|
+
def to_h
|
|
115
|
+
{ command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo? }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# The gate's verdict for one tool call. Voters (hooks now, rules later)
|
|
6
|
+
# call #deny! or #ask!; the strictest vote wins (deny > ask > allow) and
|
|
7
|
+
# a vote never relaxes it, so a deny is sticky. Of two equal votes the
|
|
8
|
+
# first one stays.
|
|
9
|
+
class Verdict
|
|
10
|
+
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
11
|
+
SCOPES = %w[once session repo rule].freeze
|
|
12
|
+
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
13
|
+
|
|
14
|
+
attr_reader :decision, :reason, :rule, :source, :scopes, :decided_by
|
|
15
|
+
# The call to dispatch; a hook may have replaced the original one.
|
|
16
|
+
attr_accessor :call
|
|
17
|
+
# The Context and the final call's Targets (set by the Gate).
|
|
18
|
+
attr_accessor :context, :targets
|
|
19
|
+
# The scope the user allowed an ask for ("once", "session", …).
|
|
20
|
+
attr_accessor :scope
|
|
21
|
+
|
|
22
|
+
def initialize(call:)
|
|
23
|
+
@call = call
|
|
24
|
+
@decision = :allow
|
|
25
|
+
@reason = nil
|
|
26
|
+
@rule = nil
|
|
27
|
+
@source = nil
|
|
28
|
+
@scopes = SCOPES
|
|
29
|
+
@decided_by = nil
|
|
30
|
+
@legacy = false
|
|
31
|
+
@note = nil
|
|
32
|
+
@voter = nil
|
|
33
|
+
@advice = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param rule [String, nil] the rule id, when a rule decided
|
|
37
|
+
# @param source [String, nil] where the rule came from ("config", "bundle x")
|
|
38
|
+
# @param advice [String, nil] what the model should do instead, in
|
|
39
|
+
# place of the fixed "Do not retry it…" tail (a hook that rejects a
|
|
40
|
+
# call so the model retries it corrected)
|
|
41
|
+
def deny!(reason, rule: nil, source: nil, decided_by: "hook", advice: nil)
|
|
42
|
+
@advice = advice.to_s.strip.empty? ? nil : advice.to_s.strip if vote(:deny, reason, rule: rule, source: source, decided_by: decided_by)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param scopes [Array<String>, nil] which approval scopes the user may
|
|
47
|
+
# pick (default all of SCOPES)
|
|
48
|
+
def ask!(reason, scopes: nil, rule: nil, source: nil, decided_by: "hook")
|
|
49
|
+
return self unless vote(:ask, reason, rule: rule, source: source, decided_by: decided_by)
|
|
50
|
+
|
|
51
|
+
picked = Array(scopes).map(&:to_s) & SCOPES
|
|
52
|
+
@scopes = picked.empty? ? SCOPES : picked
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A before_tool_call hook set the old event[:blocked] flag. Its model
|
|
57
|
+
# text stays "blocked by guardrail: <reason>".
|
|
58
|
+
def legacy_deny!(reason)
|
|
59
|
+
@legacy = true if vote(:deny, reason, decided_by: "hook")
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Back to allow (guardrails disabled: a hook's ask doesn't count).
|
|
64
|
+
def drop_ask!
|
|
65
|
+
return self unless ask?
|
|
66
|
+
|
|
67
|
+
@decision = :allow
|
|
68
|
+
@reason = @rule = @source = @decided_by = @voter = @advice = nil
|
|
69
|
+
@scopes = SCOPES
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The user (or the lack of one) settled an ask.
|
|
74
|
+
# @param note [String, nil] for a deny, what the model is told about
|
|
75
|
+
# the user ("The user declined.", "No one to approve it.")
|
|
76
|
+
def settle!(decision, decided_by: "user", note: nil)
|
|
77
|
+
@decision = decision
|
|
78
|
+
@decided_by = decided_by
|
|
79
|
+
@note = note
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def allow? = @decision == :allow
|
|
84
|
+
def ask? = @decision == :ask
|
|
85
|
+
def deny? = @decision == :deny
|
|
86
|
+
def legacy? = @legacy
|
|
87
|
+
|
|
88
|
+
# What the model gets for a deny (after the "[tool] Error: " prefix):
|
|
89
|
+
# who decided, why, and not to route around it (or the voter's own
|
|
90
|
+
# advice). A user's deny leads with the user's answer, so it doesn't
|
|
91
|
+
# read as the rule refusing.
|
|
92
|
+
def deny_text
|
|
93
|
+
reason = @reason.to_s.strip
|
|
94
|
+
reason += "." unless reason.empty? || reason.match?(/[.!?]\z/)
|
|
95
|
+
parts = if @decided_by == "user" && @note
|
|
96
|
+
[@note, "It needed approval (#{decider}):", reason]
|
|
97
|
+
else
|
|
98
|
+
["denied by guardrail (#{decider}):", reason, @note || "The user was not asked."]
|
|
99
|
+
end
|
|
100
|
+
(parts << (@advice || DO_NOT_RETRY)).reject(&:empty?).join(" ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# For the activity entry; an allowed ask notes how ("approved (repo)",
|
|
104
|
+
# "approved earlier (session)").
|
|
105
|
+
def to_activity
|
|
106
|
+
note = if allow? && @scope
|
|
107
|
+
"approved#{" earlier" if @decided_by == "approval"} (#{@scope})"
|
|
108
|
+
end
|
|
109
|
+
{ rule: @rule, verdict: @decision.to_s, decided_by: @decided_by, scope: @scope, note: note }.compact
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def decider
|
|
115
|
+
return [("rule #{@rule}" if @rule), @source].compact.join(", ") if @rule || @source
|
|
116
|
+
|
|
117
|
+
@voter || "hook"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def vote(decision, reason, decided_by:, rule: nil, source: nil)
|
|
121
|
+
return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision)
|
|
122
|
+
|
|
123
|
+
@decision = decision
|
|
124
|
+
@reason = reason.to_s.strip
|
|
125
|
+
@rule = rule&.to_s
|
|
126
|
+
@source = source&.to_s
|
|
127
|
+
@decided_by = decided_by
|
|
128
|
+
@voter = decided_by
|
|
129
|
+
@legacy = false
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/verdict"
|
|
4
|
+
require_relative "guardrails/context"
|
|
5
|
+
require_relative "guardrails/targets"
|
|
6
|
+
require_relative "guardrails/approval"
|
|
7
|
+
require_relative "guardrails/approvals"
|
|
8
|
+
require_relative "guardrails/protected_paths"
|
|
9
|
+
require_relative "guardrails/load_failures"
|
|
10
|
+
require_relative "guardrails/rules"
|
|
11
|
+
require_relative "guardrails/gate"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# Tool guardrails: the verdict every model tool call gets before it is
|
|
15
|
+
# dispatched. ToolRunner asks the Gate; hooks and rules vote.
|
|
16
|
+
module Guardrails
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
9
|
+
# evaluated inside `Samagotchi::Bundles::<BundleName>` so that two bundles
|
|
10
|
+
# shipping a file with the same basename (e.g. `guardrails.rb`) cannot collide
|
|
11
|
+
# on a top-level constant.
|
|
12
|
+
module Bundles; end
|
|
13
|
+
|
|
14
|
+
module Hooks
|
|
15
|
+
# Loads bundle-owned hook plugins into a Registry.
|
|
16
|
+
#
|
|
17
|
+
# Unlike +Hooks::Loader+ (which `require`s a file and resolves a top-level
|
|
18
|
+
# constant), this loader `module_eval`s each plugin file inside a
|
|
19
|
+
# per-bundle namespace module. This gives:
|
|
20
|
+
# - zero class-name collisions across bundles
|
|
21
|
+
# - a clean uninstall path (`Registry#unregister_bundle`)
|
|
22
|
+
#
|
|
23
|
+
# Each plugin must define a class whose PascalCase name matches the file
|
|
24
|
+
# basename and whose instances respond to +call(event)+.
|
|
25
|
+
#
|
|
26
|
+
# A "fail_closed" before_tool_call hook is required: if it is missing,
|
|
27
|
+
# fails to load, or its file's sha256 differs from the one recorded at
|
|
28
|
+
# install, the failure is reported and the gate denies every tool call.
|
|
29
|
+
# Any hook whose sha256 differs is not loaded (reinstall the bundle
|
|
30
|
+
# after editing a hook by hand).
|
|
31
|
+
#
|
|
32
|
+
# The +on_error+ policy is applied per hook at fire time:
|
|
33
|
+
# - "fail_closed" (on :before_tool_call): a raising guardrail sets
|
|
34
|
+
# event[:blocked] = true — the tool call is prevented (fail-closed).
|
|
35
|
+
# - "log": warn to stderr and continue.
|
|
36
|
+
# - "skip" (default): silently continue.
|
|
37
|
+
module BundleLoader
|
|
38
|
+
class << self
|
|
39
|
+
# Load all hook plugins for a bundle into the given registry.
|
|
40
|
+
#
|
|
41
|
+
# @param bundle_name [String]
|
|
42
|
+
# @param hooks_dir [String] directory containing the bundle's .rb hook files
|
|
43
|
+
# @param metadata [Hash] basename => { event:, on_error:, priority: }
|
|
44
|
+
# @param registry [Samagotchi::Hooks::Registry]
|
|
45
|
+
# @param failures [Guardrails::LoadFailures, nil] collects hooks that
|
|
46
|
+
# failed to load
|
|
47
|
+
# @param settings [Hash] the bundle's section of config.yml
|
|
48
|
+
# `bundles:` (string keys); a hook class whose initialize takes an
|
|
49
|
+
# argument gets it
|
|
50
|
+
# @return [Integer] number of hooks successfully registered
|
|
51
|
+
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
|
|
52
|
+
return 0 unless metadata.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
loaded = 0
|
|
55
|
+
metadata.each do |raw_basename, raw_meta|
|
|
56
|
+
basename = raw_basename.to_s
|
|
57
|
+
next if basename.empty?
|
|
58
|
+
|
|
59
|
+
meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
|
|
60
|
+
event = meta.(:event).to_s
|
|
61
|
+
# A hook with no declared event cannot be auto-registered.
|
|
62
|
+
next if event.empty?
|
|
63
|
+
|
|
64
|
+
event_sym = event.to_sym
|
|
65
|
+
on_error = (meta.(:on_error) || "skip").to_s
|
|
66
|
+
priority = (meta.(:priority) || 100).to_i
|
|
67
|
+
fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
|
|
68
|
+
what = "hook #{basename} (bundle #{bundle_name})"
|
|
69
|
+
file = hooks_dir && File.join(hooks_dir, basename)
|
|
70
|
+
unless file && File.exist?(file)
|
|
71
|
+
failures&.add(what, "the file is missing", required: true) if fail_closed
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
75
|
+
Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
|
|
76
|
+
failures&.add(what, mismatch, required: fail_closed)
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
plugin = instantiate(bundle_name, basename, file, settings: settings)
|
|
82
|
+
|
|
83
|
+
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
|
|
84
|
+
begin
|
|
85
|
+
plugin.call(event)
|
|
86
|
+
rescue Exception => e
|
|
87
|
+
handle_error(bundle_name, basename, on_error, fail_closed, event, e)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
loaded += 1
|
|
91
|
+
rescue Exception => e
|
|
92
|
+
Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
|
|
93
|
+
failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
loaded
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Why the file doesn't match the sha256 recorded at install, or nil.
|
|
100
|
+
# With none recorded, only a required hook counts as a mismatch.
|
|
101
|
+
def sha_mismatch(file, recorded, required:)
|
|
102
|
+
expected = recorded.to_s.sub(/\Asha256:/, "")
|
|
103
|
+
return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
|
|
104
|
+
|
|
105
|
+
actual = Digest::SHA256.hexdigest(File.binread(file))
|
|
106
|
+
return nil if actual == expected
|
|
107
|
+
|
|
108
|
+
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Evaluate a plugin file inside the bundle's namespace module and
|
|
112
|
+
# return an instance that responds to #call. A class whose
|
|
113
|
+
# initialize takes an argument gets the settings (one Hash, string
|
|
114
|
+
# keys); one that takes none is built bare.
|
|
115
|
+
def instantiate(bundle_name, basename, file, settings: {})
|
|
116
|
+
ns = namespace_for(bundle_name)
|
|
117
|
+
content = File.read(file)
|
|
118
|
+
ns.module_eval(content, file, 1)
|
|
119
|
+
class_name = File.basename(basename, ".rb").split("_").map(&:capitalize).join
|
|
120
|
+
klass = ns.const_get(class_name, false)
|
|
121
|
+
instance = Hooks.build_plugin(klass, settings)
|
|
122
|
+
raise ArgumentError, "plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
|
|
123
|
+
instance
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The (lazily created) namespace module for a bundle.
|
|
127
|
+
# Sanitization is made injective by appending a short digest so that
|
|
128
|
+
# `my-bundle` and `my_bundle` do not collide.
|
|
129
|
+
def namespace_for(bundle_name)
|
|
130
|
+
raw = bundle_name.to_s
|
|
131
|
+
base = raw.gsub(/[^A-Za-z0-9_]/, "_")
|
|
132
|
+
base = "B_#{base}" unless base.match?(/\A[A-Z]/)
|
|
133
|
+
digest = Digest::MD5.hexdigest(raw)[0, 4]
|
|
134
|
+
ns_name = "#{base}_#{digest}"
|
|
135
|
+
if Samagotchi::Bundles.const_defined?(ns_name.to_sym, false)
|
|
136
|
+
Samagotchi::Bundles.const_get(ns_name.to_sym)
|
|
137
|
+
else
|
|
138
|
+
Samagotchi::Bundles.const_set(ns_name.to_sym, Module.new)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Apply the per-hook on_error policy when a plugin raises.
|
|
143
|
+
def handle_error(bundle_name, basename, on_error, fail_closed, event, error)
|
|
144
|
+
if fail_closed && event.is_a?(Hash)
|
|
145
|
+
event[:blocked] = true
|
|
146
|
+
reason = "guardrail #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}"
|
|
147
|
+
event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
|
|
148
|
+
existing = event[:block_reason].to_s
|
|
149
|
+
event[:block_reason] = existing.empty? ? reason : "#{existing}; #{reason}"
|
|
150
|
+
elsif on_error == "log"
|
|
151
|
+
Log.warn(:hooks, "bundle_hook_failed", echo: "[samagotchi:hooks] #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}", bundle: bundle_name, hook: basename.to_s, error: error.class.name)
|
|
152
|
+
end
|
|
153
|
+
# "skip" (and fail_closed on non-veto events) is silent.
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|