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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. 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