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,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
|