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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # An ask as a question for the user (Engine#open_question), and the
8
+ # answer back as a verdict. The question text is plain and complete, so
9
+ # a UI that doesn't know approvals still shows everything; `approval:`
10
+ # carries the same facts for UIs that render them richer. Answers map
11
+ # to scopes by index, never by label.
12
+ module Approval
13
+ KIND = "approval"
14
+ HEADER = "Approve tool call?"
15
+ DENY = "Deny"
16
+
17
+ module_function
18
+
19
+ # @param verdict [Verdict] an ask, with its targets and context
20
+ # @param label [String, nil] a plugin tool's label ("chrome:
21
+ # screenshot"): the question names the tool by it, as its row does;
22
+ # approval[:tool] stays the raw name
23
+ # @return [Hash] open_question fields
24
+ def payload(verdict, label: nil)
25
+ scopes = offered_scopes(verdict)
26
+ targets = verdict.targets
27
+ {
28
+ question: question_text(verdict, label: label),
29
+ options: scopes.map { |scope| label(scope, verdict) } + [DENY],
30
+ header: HEADER,
31
+ multi_select: false,
32
+ allow_freeform: true,
33
+ kind: KIND,
34
+ approval: {
35
+ tool: targets&.tool || verdict.call[:name].to_s,
36
+ label: label,
37
+ command: targets&.command,
38
+ paths: targets && !targets.paths.empty? ? targets.paths : nil,
39
+ cwd: targets&.cwd,
40
+ repo_root: targets&.repo_root,
41
+ branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
42
+ rule: verdict.rule,
43
+ source: verdict.source,
44
+ reason: verdict.reason,
45
+ scopes: scopes
46
+ }.compact
47
+ }
48
+ end
49
+
50
+ # Settle +verdict+ from open_question's result.
51
+ # @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
52
+ # @param scopes [Array<String>] the offered scopes, in option order
53
+ # @return [Verdict]
54
+ def settle(verdict, answer, scopes)
55
+ unless answer.is_a?(Hash) && !answer[:error]
56
+ return verdict.settle!(:deny, decided_by: "user", note: "The approval was cancelled.")
57
+ end
58
+
59
+ index = Array(answer[:selected_indices]).first
60
+ if index && index < scopes.size
61
+ verdict.settle!(:allow, decided_by: "user")
62
+ verdict.scope = scopes[index]
63
+ return verdict
64
+ end
65
+
66
+ freeform = answer[:freeform].to_s.strip
67
+ note = freeform.empty? ? "The user declined this call." : "The user declined this call: #{freeform.inspect}."
68
+ verdict.settle!(:deny, decided_by: "user", note: note)
69
+ end
70
+
71
+ # The rule's scopes; "rule" only when a rule id names what to approve.
72
+ def offered_scopes(verdict)
73
+ verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
74
+ end
75
+
76
+ def label(scope, verdict)
77
+ place = verdict.targets&.repo_root ? "in this repo" : "in this directory"
78
+ case scope
79
+ when "once" then "Allow once"
80
+ when "session" then "Allow this call for the session"
81
+ when "repo" then "Allow this call #{place}"
82
+ when "rule" then "Allow rule #{verdict.rule} #{place}"
83
+ end
84
+ end
85
+
86
+ # How much of a plugin tool's arguments the question shows.
87
+ ARGS_CHARS = 300
88
+
89
+ # A plugin tool's arguments (Targets#args), as the question shows
90
+ # them and an approval is keyed by, when its targets name no command
91
+ # or path (an MCP tool): `a=20 b="x y"`, keys sorted, values as JSON.
92
+ # "" for none (chi's own tools).
93
+ # @param limit [Integer, nil] cut to this many characters (with …)
94
+ def args_text(args, limit: nil)
95
+ return "" unless args.is_a?(Hash) && !args.empty?
96
+
97
+ text = args.sort_by { |key, _| key.to_s }.map do |key, value|
98
+ "#{key}=#{value.is_a?(String) && value.match?(/\A[^\s"=]+\z/) ? value : JSON.generate(value)}"
99
+ end.join(" ")
100
+ limit && text.length > limit ? "#{text[0, limit - 1]}…" : text
101
+ end
102
+
103
+ # execute: git push origin main
104
+ # in /path/to/repo (repo samagotchi, branch main)
105
+ # why: git push publishes commits (rule git-push, bundle guardrails)
106
+ def question_text(verdict, label: nil)
107
+ targets = verdict.targets
108
+ tool = label || targets&.tool || verdict.call[:name].to_s
109
+ what = targets&.command || (targets && targets.paths.join(", "))
110
+ what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
111
+ lines = ["#{tool}: #{what}"]
112
+ if targets
113
+ repo = targets.repo_root
114
+ branch = verdict.context&.branch(targets.cwd)
115
+ where = repo ? "repo #{File.basename(repo)}#{", branch #{branch}" if branch}" : "not in a repo"
116
+ lines << " in #{targets.cwd} (#{where})"
117
+ end
118
+ who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
119
+ reason = verdict.reason.to_s.strip
120
+ lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
121
+ lines.join("\n")
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "../log"
7
+ require_relative "approval"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # The user's stored approvals: one JSON file under the state dir,
12
+ # written aside and renamed under an exclusive flock (several chi
13
+ # processes share it). An entry relaxes an ask, never a deny:
14
+ # session — this exact call (tool + normalized command / paths) in
15
+ # this session;
16
+ # repo — this exact call in this repo (the cwd outside a repo);
17
+ # rule — anything this rule (from this source) asks about here.
18
+ # "once" is never stored. A file that doesn't parse is moved aside to
19
+ # approvals.json.corrupt-<UTC time>[-N], with a warning, and the store
20
+ # starts empty: it only means more asks, and nothing is overwritten.
21
+ class Approvals
22
+ FILE = "approvals.json"
23
+ STORED_SCOPES = %w[session repo rule].freeze
24
+
25
+ # @param sessions_dir [String] Session's state dir
26
+ # ($XDG_STATE_HOME/samagotchi/sessions): the store sits beside it,
27
+ # not among the sessions
28
+ def self.dir_for(sessions_dir) = File.join(File.dirname(sessions_dir), "guardrails")
29
+
30
+ attr_reader :path
31
+
32
+ def initialize(dir:, warn: ->(msg) { Log.warn(:guardrails, "approvals_unreadable", echo: msg) })
33
+ @dir = dir
34
+ @path = File.join(dir, FILE)
35
+ @warn = warn
36
+ @warned = false
37
+ end
38
+
39
+ # @return [Array<Hash>] the entries (string keys), oldest first
40
+ def entries
41
+ read
42
+ end
43
+
44
+ # @param verdict [Verdict] an ask with its targets and context
45
+ # @return [Hash, nil] the first entry that allows it
46
+ def match(verdict)
47
+ key = self.class.key_for(verdict)
48
+ place = self.class.place_for(verdict)
49
+ session_id = verdict.context&.session_id
50
+ read.find do |e|
51
+ case e["scope"]
52
+ when "session" then session_id && e["session_id"] == session_id && e["key"] == key
53
+ when "repo" then e["repo_root"] == place && e["key"] == key
54
+ when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
55
+ e["repo_root"] == place
56
+ end
57
+ end
58
+ end
59
+
60
+ # Store the verdict's allowed scope (not "once").
61
+ # @return [Hash, nil] the entry
62
+ def add(verdict, scope)
63
+ return nil unless STORED_SCOPES.include?(scope)
64
+
65
+ entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
66
+ "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
67
+ case scope
68
+ when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => self.class.key_for(verdict))
69
+ when "repo" then entry.merge!("repo_root" => self.class.place_for(verdict), "key" => self.class.key_for(verdict))
70
+ when "rule" then entry["repo_root"] = self.class.place_for(verdict)
71
+ end
72
+ entry.compact!
73
+ update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
74
+ entry
75
+ end
76
+
77
+ # Remove entry +index+ (0-based, as #entries lists them).
78
+ # @return [Hash, nil] the removed entry
79
+ def revoke(index)
80
+ removed = nil
81
+ update { |list| removed = list.delete_at(index) if index >= 0 && index < list.size }
82
+ removed
83
+ end
84
+
85
+ # tool + the normalized command, or the sorted paths; for a plugin
86
+ # tool with neither (an MCP tool), its arguments, so "this call" is
87
+ # this call.
88
+ def self.key_for(verdict)
89
+ t = verdict.targets
90
+ return "#{verdict.call[:name]}:" unless t
91
+ return "#{t.tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
92
+ return "#{t.tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
93
+
94
+ "#{t.tool}:#{t.paths.sort.join("\n")}"
95
+ end
96
+
97
+ def self.place_for(verdict)
98
+ t = verdict.targets
99
+ t&.repo_root || t&.cwd || verdict.context&.cwd
100
+ end
101
+
102
+ private
103
+
104
+ # @param locked [Boolean] the caller holds the store's lock
105
+ def read(locked: false)
106
+ return [] unless File.exist?(@path)
107
+
108
+ parse(File.read(@path))
109
+ rescue JSON::ParserError, SystemCallError => e
110
+ aside = begin
111
+ locked ? set_aside : with_lock { set_aside }
112
+ rescue SystemCallError
113
+ nil
114
+ end
115
+ return read(locked: locked) if aside == :readable
116
+
117
+ unless @warned
118
+ @warned = true
119
+ where = aside ? "moved it to #{aside}" : "left it in place"
120
+ @warn.call("[samagotchi:guardrails] #{@path} is unreadable (#{e.message}); #{where}, no stored approvals apply")
121
+ end
122
+ []
123
+ end
124
+
125
+ def parse(text)
126
+ data = JSON.parse(text)
127
+ raise JSON::ParserError, "not a list" unless data.is_a?(Array)
128
+
129
+ data.select { |e| e.is_a?(Hash) }
130
+ end
131
+
132
+ # Under the lock: rename the file aside if it is still unreadable.
133
+ # @return [String, :readable] where it went; :readable when another
134
+ # process replaced or removed it meanwhile
135
+ def set_aside
136
+ return :readable unless File.exist?(@path)
137
+
138
+ begin
139
+ parse(File.read(@path))
140
+ return :readable
141
+ rescue JSON::ParserError, SystemCallError
142
+ nil
143
+ end
144
+ base = "#{@path}.corrupt-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}"
145
+ # A second one within the same second must not replace the first
146
+ # (we hold the lock, so nobody else takes the name meanwhile).
147
+ aside = base
148
+ n = 1
149
+ aside = "#{base}-#{n += 1}" while File.exist?(aside)
150
+ File.rename(@path, aside)
151
+ aside
152
+ end
153
+
154
+ def with_lock
155
+ FileUtils.mkdir_p(@dir)
156
+ File.open(File.join(@dir, "approvals.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
157
+ lock.flock(File::LOCK_EX)
158
+ yield
159
+ end
160
+ end
161
+
162
+ def update
163
+ with_lock do
164
+ list = read(locked: true)
165
+ yield list
166
+ tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
167
+ begin
168
+ File.write(tmp, JSON.pretty_generate(list))
169
+ File.rename(tmp, @path)
170
+ ensure
171
+ FileUtils.rm_f(tmp)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # Where a tool call runs and on whose behalf: the process cwd, the
8
+ # session, the host (interface) and who queued the turn (origin). Repo
9
+ # root and branch come from git, asked once per directory per turn.
10
+ class Context
11
+ # :repl — the plain REPL answers on the turn thread;
12
+ # :worker — a shared-session worker (attached TUI / web answer);
13
+ # :non_interactive — nobody can answer (-p --non-interactive, specs).
14
+ INTERFACES = %i[repl worker non_interactive].freeze
15
+
16
+ attr_reader :cwd, :session_id, :interface, :origin
17
+
18
+ def initialize(cwd: Dir.pwd, session_id: nil, interface: :non_interactive, origin: nil, git: GitInfo.new)
19
+ @cwd = cwd
20
+ @session_id = session_id
21
+ @interface = interface
22
+ @origin = origin
23
+ @git = git
24
+ end
25
+
26
+ def repo_root(dir = @cwd) = @git.root(dir)
27
+ def branch(dir = @cwd) = @git.branch(dir)
28
+
29
+ # The hook event's context: hash.
30
+ def to_h
31
+ { cwd: @cwd, repo_root: repo_root, branch: branch, session_id: @session_id,
32
+ interface: @interface, origin: @origin }
33
+ end
34
+ end
35
+
36
+ # `git rev-parse` per directory, memoized (one GitInfo per turn).
37
+ class GitInfo
38
+ def initialize
39
+ @cache = {}
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ def root(dir) = info(dir)[:root]
44
+ def branch(dir) = info(dir)[:branch]
45
+
46
+ private
47
+
48
+ def info(dir)
49
+ dir = File.expand_path(dir.to_s)
50
+ @mutex.synchronize { @cache[dir] ||= probe(dir) }
51
+ end
52
+
53
+ def probe(dir)
54
+ return {} unless File.directory?(dir)
55
+
56
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD")
57
+ lines = out.lines.map(&:strip)
58
+ # A repo with no commits yet has no HEAD: only the root answers.
59
+ unless status.success?
60
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel")
61
+ return {} unless status.success?
62
+
63
+ lines = [out.strip]
64
+ end
65
+ { root: lines[0], branch: lines[1] }
66
+ rescue SystemCallError
67
+ {}
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verdict"
4
+ require_relative "context"
5
+ require_relative "targets"
6
+ require_relative "approvals"
7
+ require_relative "protected_paths"
8
+ require_relative "../log"
9
+
10
+ module Samagotchi
11
+ module Guardrails
12
+ # Decides whether a tool call may run. The voters are before_tool_call
13
+ # hooks: through event[:guardrail] (#deny!, #ask!) or the legacy flag
14
+ # (event[:blocked] = true, optional event[:block_reason]). The flag is
15
+ # folded into the verdict after each hook, so a later hook can't undo a
16
+ # deny. A hook may replace event[:call]; the verdict carries the final
17
+ # call.
18
+ class Gate
19
+ # @param hooks_lookup [#call] returns the Hooks::Registry (or nil);
20
+ # read per call, since the Engine sets the kernel's hooks after
21
+ # the kernel is built.
22
+ # @param context_lookup [#call] returns the Context for this call
23
+ # (the Engine's: session, interface, origin, per-turn git cache)
24
+ # @param model_key_lookup [#call] the model key (memory overlays)
25
+ # @param approver [#call, nil] settles an ask (Engine#request_approval);
26
+ # without one an ask is denied
27
+ # @param approvals_lookup [#call] returns the Approvals store (or nil)
28
+ # @param checks_lookup [#call] core checks (#check(verdict)) run on the
29
+ # final call after the hooks: protected paths, then the rules
30
+ # @param cancelled_lookup [#call] true once the turn was cancelled (a
31
+ # hook's stop_turn): the rest of a batch is denied without a vote
32
+ # @param tools_lookup [#call] returns the Tools::Registry (or nil): a
33
+ # plugin tool's targets: say what its call acts on
34
+ def initialize(hooks_lookup, context_lookup: -> { Context.new }, model_key_lookup: -> {}, approver: nil,
35
+ approvals_lookup: -> {}, checks_lookup: -> { [] }, cancelled_lookup: -> { false },
36
+ tools_lookup: -> {})
37
+ @hooks_lookup = hooks_lookup
38
+ @context_lookup = context_lookup
39
+ @model_key_lookup = model_key_lookup
40
+ @approver = approver
41
+ @approvals_lookup = approvals_lookup
42
+ @checks_lookup = checks_lookup
43
+ @cancelled_lookup = cancelled_lookup
44
+ @tools_lookup = tools_lookup
45
+ end
46
+
47
+ # @param call [Hash] the parsed tool call
48
+ # @param iteration [Integer]
49
+ # @param params [String] the call's one-line preview
50
+ # @return [Verdict]
51
+ def evaluate(call, iteration:, params:)
52
+ context = @context_lookup.call
53
+ verdict = Verdict.new(call: call)
54
+ if @cancelled_lookup.call
55
+ verdict.deny!("the turn was stopped", decided_by: "core")
56
+ verdict.context = context
57
+ verdict.targets = targets_for(call, context)
58
+ return verdict
59
+ end
60
+ before = { type: :before_tool_call, iteration: iteration, call: call.dup, params: params,
61
+ blocked: false, block_reason: nil, guardrail: verdict,
62
+ context: context.to_h, targets: targets_for(call, context).to_h }
63
+ fire_each(:before_tool_call, before) do |event|
64
+ verdict.legacy_deny!(event[:block_reason]) if event[:blocked]
65
+ event[:blocked] = verdict.deny?
66
+ event[:block_reason] = verdict.reason if verdict.deny?
67
+ end
68
+ verdict.call = before[:call] || call
69
+ verdict.context = context
70
+ verdict.targets = targets_for(verdict.call, context)
71
+ Array(@checks_lookup.call).each { |check| check.check(verdict) }
72
+ verdict
73
+ end
74
+
75
+ # Settle an ask: the approver asks the user; with none, or one that
76
+ # fails, it is denied.
77
+ # @return [Verdict]
78
+ # A stored approval that covers the call allows it without asking; an
79
+ # answer that allows it beyond this once is stored.
80
+ def settle_ask(verdict)
81
+ approvals = @approvals_lookup.call
82
+ if (entry = stored(approvals, verdict))
83
+ verdict.settle!(:allow, decided_by: "approval")
84
+ verdict.scope = entry["scope"]
85
+ return verdict
86
+ end
87
+ return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it.") unless @approver
88
+
89
+ @approver.call(verdict)
90
+ return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it.") if verdict.ask?
91
+
92
+ remember(approvals, verdict)
93
+ verdict
94
+ rescue StandardError => e
95
+ verdict.settle!(:deny, decided_by: "core", note: "The approval failed (#{e.class}: #{e.message}).")
96
+ end
97
+
98
+ private
99
+
100
+ def stored(approvals, verdict)
101
+ approvals&.match(verdict)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ # A store that can't be written only means the next call asks again.
107
+ def remember(approvals, verdict)
108
+ return unless approvals && verdict.allow? && verdict.scope
109
+
110
+ approvals.add(verdict, verdict.scope)
111
+ rescue StandardError => e
112
+ Log.warn(:guardrails, "approval_store_failed", echo: "[samagotchi:guardrails] could not store the approval: #{e.class}: #{e.message}", error: e.class.name)
113
+ end
114
+
115
+ def targets_for(call, context)
116
+ Targets.for(call, context, model_key: @model_key_lookup.call, registry: @tools_lookup.call)
117
+ end
118
+
119
+ # Registry#fire_each rescues a raising hook itself.
120
+ def fire_each(name, event, &after_each)
121
+ @hooks_lookup.call&.fire_each(name, event, &after_each)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # What failed to load at Engine start: hooks, bundle hook checksums,
6
+ # rule files. A required one makes the gate deny every tool call (a
7
+ # guardrail that silently vanished is the failure this guards against);
8
+ # the rest are only announced.
9
+ class LoadFailures
10
+ Failure = Struct.new(:what, :reason, :required, keyword_init: true)
11
+
12
+ def initialize
13
+ @list = []
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # @param what [String] e.g. "hook guard.rb (config)"
18
+ def add(what, reason, required:)
19
+ @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required) }
20
+ end
21
+
22
+ def list = @mutex.synchronize { @list.dup }
23
+ def any? = !list.empty?
24
+ def required = list.select(&:required)
25
+
26
+ # A core check: deny every call while a required guardrail is missing.
27
+ def check(verdict)
28
+ first = required.first
29
+ return verdict unless first
30
+
31
+ verdict.deny!("required guardrail #{first.what} failed to load: #{first.reason}",
32
+ rule: "guardrail-load", source: "core", decided_by: "core")
33
+ end
34
+
35
+ # One line for the UIs, or nil.
36
+ def message
37
+ items = list
38
+ return nil if items.empty?
39
+
40
+ text = items.map { |f| "#{f.what} failed to load (#{f.reason})" }.join("; ")
41
+ text += ". Every tool call is denied until it is fixed" if items.any?(&:required)
42
+ text
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # Core checks on what the file tools (write, edit, memory_write) touch,
6
+ # after the hooks and before the rules. Paths are compared with
7
+ # symlinks resolved (on the longest existing parent).
8
+ # deny: the approval store's dir and installed bundles (.bundles/:
9
+ # hooks, rules, manifests); nothing legitimate writes there
10
+ # through the file tools.
11
+ # ask (once / session): config.yml and the plain hooks dir. The
12
+ # system bundle's config protocol edits config.yml with
13
+ # write/edit, so this keeps the user in the loop without
14
+ # breaking it.
15
+ # execute can still write all of them (the guardrails bundle adds a
16
+ # text match for that).
17
+ class ProtectedPaths
18
+ FILE_TOOLS = %w[write edit memory_write].freeze
19
+ SOURCE = "core"
20
+
21
+ # @param store_dir [String] <state dir>/guardrails
22
+ # @param bundles_dir [String] <memories>/.bundles
23
+ # @param config_path [String] config.yml
24
+ # @param hooks_dir [String] the plain hooks dir
25
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:)
26
+ @deny = [
27
+ ["guardrail-store", store_dir, "the approval store belongs to the user"],
28
+ ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"]
29
+ ]
30
+ @ask = [
31
+ ["chi-config", config_path, "changes chi's config.yml"],
32
+ ["chi-hooks", hooks_dir, "changes chi's hooks"]
33
+ ]
34
+ end
35
+
36
+ # Vote on +verdict+ (its targets).
37
+ def check(verdict)
38
+ targets = verdict.targets
39
+ return verdict unless targets && FILE_TOOLS.include?(targets.tool)
40
+
41
+ paths = targets.paths.map { |p| self.class.real(p) }
42
+ @deny.each do |rule, root, reason|
43
+ next unless paths.any? { |p| self.class.within?(p, root) }
44
+
45
+ verdict.deny!(reason, rule: rule, source: SOURCE, decided_by: "core")
46
+ end
47
+ @ask.each do |rule, root, reason|
48
+ next unless paths.any? { |p| self.class.within?(p, root) }
49
+
50
+ verdict.ask!(reason, scopes: %w[once session], rule: rule, source: SOURCE, decided_by: "core")
51
+ end
52
+ verdict
53
+ end
54
+
55
+ # +path+ with symlinks resolved on its longest existing parent.
56
+ def self.real(path)
57
+ path = File.expand_path(path)
58
+ rest = []
59
+ current = path
60
+ until File.exist?(current) || current == File.dirname(current)
61
+ rest.unshift(File.basename(current))
62
+ current = File.dirname(current)
63
+ end
64
+ File.join(File.realpath(current), *rest)
65
+ rescue SystemCallError
66
+ path
67
+ end
68
+
69
+ def self.within?(path, root)
70
+ return false if root.nil? || root.to_s.empty?
71
+
72
+ root = real(root.to_s.chomp("/"))
73
+ path == root || path.start_with?(File.join(root, ""))
74
+ end
75
+ end
76
+ end
77
+ end