samagotchi 0.128.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +3 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -664,6 +664,19 @@ module Samagotchi
|
|
|
664
664
|
@turn_state.controller
|
|
665
665
|
end
|
|
666
666
|
|
|
667
|
+
# Whether a message a person sent waits for the running turn's next
|
|
668
|
+
# boundary (run_turn's input_waiting: probe): a task_wait returns on it
|
|
669
|
+
# (PeerView#input_waiting?). False outside a turn, for a turn given no
|
|
670
|
+
# probe, and when the probe raises (warned once a turn; the tool never
|
|
671
|
+
# fails for it).
|
|
672
|
+
def input_waiting?
|
|
673
|
+
probe = @turn_state.input_waiting
|
|
674
|
+
!!probe&.call
|
|
675
|
+
rescue StandardError => e
|
|
676
|
+
Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
|
|
677
|
+
false
|
|
678
|
+
end
|
|
679
|
+
|
|
667
680
|
# Cancel the currently running turn, if any.
|
|
668
681
|
# @param reason [Symbol] cancellation reason
|
|
669
682
|
# @return [Boolean] whether a cancellation was triggered
|
|
@@ -2132,6 +2145,9 @@ module Samagotchi
|
|
|
2132
2145
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
2133
2146
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
2134
2147
|
# by the agentic loop at iteration boundaries (see KernelLoop#run).
|
|
2148
|
+
# @param input_waiting [#call, nil] optional probe: true while a message
|
|
2149
|
+
# a person sent waits for the next boundary (it peeks, never drains);
|
|
2150
|
+
# a running task_wait returns on it (#input_waiting?). nil: never.
|
|
2135
2151
|
# @param continue [Boolean] resume the conversation without appending a
|
|
2136
2152
|
# user prompt (continue after the iteration limit, reminder turns);
|
|
2137
2153
|
# +prompt+ is ignored and :turn_started carries `continue: true`
|
|
@@ -2170,9 +2186,9 @@ module Samagotchi
|
|
|
2170
2186
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2171
2187
|
# one-line summary:.
|
|
2172
2188
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2173
|
-
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
|
|
2189
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
|
|
2174
2190
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2175
|
-
continue: continue, id: id, verify: verify)
|
|
2191
|
+
continue: continue, id: id, verify: verify, input_waiting: input_waiting)
|
|
2176
2192
|
if continue
|
|
2177
2193
|
turn.resumed = resumed
|
|
2178
2194
|
turn.resume_from = resume_from
|
|
@@ -2220,7 +2236,7 @@ module Samagotchi
|
|
|
2220
2236
|
# The turn's state before anything can fail it: the session running,
|
|
2221
2237
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2222
2238
|
# is set here and cleared in #release_turn only.
|
|
2223
|
-
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
|
|
2239
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
|
|
2224
2240
|
origin = TurnOrigin.of(origin)
|
|
2225
2241
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2226
2242
|
# author, boundary and cap), whoever started it.
|
|
@@ -2240,7 +2256,7 @@ module Samagotchi
|
|
|
2240
2256
|
# invalidated recap) while the model is working. A hook's notice goes
|
|
2241
2257
|
# where the turn's events go (the REPL renders only its sink; a
|
|
2242
2258
|
# worker's observers carry it to the bridge).
|
|
2243
|
-
@turn_state.begin!(controller: effective_controller, sink: on_event)
|
|
2259
|
+
@turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
|
|
2244
2260
|
# Drop a recap already in flight: the turn makes it stale.
|
|
2245
2261
|
@recap&.invalidate!
|
|
2246
2262
|
# The gate's context: who queued this turn, and git asked afresh.
|
|
@@ -8,7 +8,8 @@ module Samagotchi
|
|
|
8
8
|
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
9
9
|
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
10
10
|
# the controller is set from another thread and a tool gets no other
|
|
11
|
-
# way to see it.
|
|
11
|
+
# way to see it. input_waiting? is whether a person's message waits for
|
|
12
|
+
# the running turn's next boundary (task_wait returns on it).
|
|
12
13
|
PeerView = Struct.new(:engine) do
|
|
13
14
|
def session_id = engine.session&.id
|
|
14
15
|
def cwd = engine.session&.working_directory
|
|
@@ -16,6 +17,7 @@ module Samagotchi
|
|
|
16
17
|
def state_dir = engine.peer_state_dir
|
|
17
18
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
19
|
def cancel_reason = engine.active_cancel_controller&.reason
|
|
20
|
+
def input_waiting? = engine.input_waiting?
|
|
19
21
|
def relay = engine.relay_peer
|
|
20
22
|
def model_ref = engine.effective_model_ref
|
|
21
23
|
def thinking = engine.thinking_override
|
|
@@ -314,7 +314,7 @@ module Samagotchi
|
|
|
314
314
|
# one that passes every gate is allowed once without reaching them
|
|
315
315
|
# (docs/guardrails.md, "System 1").
|
|
316
316
|
judgment = begin
|
|
317
|
-
system_one_judge(payload)
|
|
317
|
+
system_one_judge(payload, targets: verdict.targets)
|
|
318
318
|
rescue LLM::RequestCancelled
|
|
319
319
|
return turn_stopped(verdict)
|
|
320
320
|
end
|
|
@@ -357,7 +357,7 @@ module Samagotchi
|
|
|
357
357
|
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
358
358
|
# so the failures are in the log too.
|
|
359
359
|
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|
|
360
|
-
def system_one_judge(payload)
|
|
360
|
+
def system_one_judge(payload, targets: nil)
|
|
361
361
|
return nil if Steer.participant_author?(turn_author)
|
|
362
362
|
|
|
363
363
|
client = @system_one.call
|
|
@@ -385,7 +385,7 @@ module Samagotchi
|
|
|
385
385
|
end
|
|
386
386
|
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
387
387
|
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
388
|
-
auto = system_one_auto_allow?(client, approval, p, entries)
|
|
388
|
+
auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
389
389
|
decision = system_one_decision(client, auto)
|
|
390
390
|
|
|
391
391
|
id = log.judgment(session_id: @session_lookup.call&.id,
|
|
@@ -417,13 +417,14 @@ module Samagotchi
|
|
|
417
417
|
# the rule's record. Any one that fails leaves the ask to the user. The
|
|
418
418
|
# entries are the judgment log read once for this ask.
|
|
419
419
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
420
|
-
|
|
420
|
+
# @param targets [Guardrails::Targets, nil] the call's, for its env:
|
|
421
|
+
def system_one_auto_allow?(client, approval, p, entries, targets: nil)
|
|
421
422
|
return false unless client.mode == "on"
|
|
422
423
|
|
|
423
424
|
rule = approval[:rule].to_s
|
|
424
425
|
return false if rule.empty?
|
|
425
426
|
return false unless system_one_rules.include?(rule)
|
|
426
|
-
return false if SystemOne::SelfProtect.refuse?(approval)
|
|
427
|
+
return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
|
|
427
428
|
|
|
428
429
|
threshold = Config.get("guardrails.system_one.threshold").to_f
|
|
429
430
|
return false if p.nil? || p.to_f < threshold
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../memory_paths"
|
|
6
|
+
require_relative "../hooks/loader"
|
|
7
|
+
require_relative "path_resolve"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# chi's own dirs, the sets the guardrails judge a path or a command's
|
|
12
|
+
# words against, as this process's environment and config.yml name them
|
|
13
|
+
# (read each call). Each surface keeps its set:
|
|
14
|
+
# .protected the config dir, the hooks dir, the approval store's
|
|
15
|
+
# dir, the installed bundles' dir, attached context: an
|
|
16
|
+
# ask only the user may answer (ParentApprovals.chi_files?)
|
|
17
|
+
# .touches .protected and the session repo's git hooks dir: a
|
|
18
|
+
# rule's touches: chi_dirs (Targets#touches_chi?)
|
|
19
|
+
# .self_protect .protected and the whole state dir, resolved: what
|
|
20
|
+
# System 1 never answers for (SelfProtect)
|
|
21
|
+
# .review the state dir, the config dir and a hooks dir
|
|
22
|
+
# configured outside it: what a participant's turn never
|
|
23
|
+
# reads (ReviewPaths)
|
|
24
|
+
# ProtectedPaths' deny/ask lists and ScratchWrites' memories dir are
|
|
25
|
+
# built by the wiring from the same places.
|
|
26
|
+
module ChiDirs
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# chi's config dir, the hooks dir, the approval store's dir, the
|
|
30
|
+
# installed bundles' dir and attached context.
|
|
31
|
+
def protected
|
|
32
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
33
|
+
bundles = begin MemoryPaths.bundles_dir rescue nil end
|
|
34
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
|
|
35
|
+
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# .protected (as ParentApprovals.chi_dirs, the name its callers and
|
|
39
|
+
# specs know) and the git hooks dir of +base+'s repo (+context+).
|
|
40
|
+
def touches(context, base)
|
|
41
|
+
require_relative "parent_approvals"
|
|
42
|
+
ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# .protected (as ParentApprovals.chi_dirs) and the whole state dir,
|
|
46
|
+
# resolved.
|
|
47
|
+
def self_protect
|
|
48
|
+
require_relative "parent_approvals"
|
|
49
|
+
[*ParentApprovals.chi_dirs, Paths.state_dir]
|
|
50
|
+
.map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# chi's state dir and config dir, as Paths has them now, and the hooks
|
|
54
|
+
# dir when config.yml puts it outside the config dir (hooks run as the
|
|
55
|
+
# owner: a participant reads none of them).
|
|
56
|
+
def review
|
|
57
|
+
config = File.join(Paths.config_home, Paths::APP_DIR)
|
|
58
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
59
|
+
hooks = nil if hooks.to_s.empty? || PathResolve.reals(hooks).any? { |real| PathResolve.within?(real, config) }
|
|
60
|
+
[Paths.state_dir, config, hooks].compact
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "outside"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "path_resolve"
|
|
5
5
|
require_relative "shell_lex"
|
|
6
6
|
require_relative "shell_paths"
|
|
7
7
|
require_relative "shared_git"
|
|
@@ -26,8 +26,11 @@ module Samagotchi
|
|
|
26
26
|
# share, from anywhere (SharedGit: config, update-ref, worktree add,
|
|
27
27
|
# stash clear, tags, another branch's delete).
|
|
28
28
|
# Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
|
|
29
|
-
# read-only commands never ask
|
|
30
|
-
#
|
|
29
|
+
# read-only commands never ask, nor do ones that only write into a tmp
|
|
30
|
+
# dir (> / >>, cp's destination, tee; Targets#read_only_but_tmp_writes?). Paths resolve under chi's env and the
|
|
31
|
+
# call's env: (Targets#shell_envs), and an env: that redefines the shell
|
|
32
|
+
# (BASH_ENV, CDPATH, …) always asks, from its own folder too. Text heuristics: sh
|
|
33
|
+
# -c bodies, scripts and $(…) aren't looked into.
|
|
31
34
|
class ChildBoundary
|
|
32
35
|
RULE = "child-boundary"
|
|
33
36
|
SOURCE = "core"
|
|
@@ -73,26 +76,33 @@ module Samagotchi
|
|
|
73
76
|
def shell_crosses?(targets, root)
|
|
74
77
|
return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
|
|
75
78
|
return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
|
|
76
|
-
|
|
79
|
+
# A redefined shell (BASH_ENV, BASH_FUNC_…) runs what no word says.
|
|
80
|
+
return true if targets.redefines_shell?
|
|
81
|
+
return false if targets.read_only? || targets.read_only_but_tmp_writes?(root: root)
|
|
77
82
|
return true if targets.cwd && Outside.outside?(targets.cwd, root: root)
|
|
78
83
|
|
|
79
|
-
names_other_checkout?(targets
|
|
84
|
+
names_other_checkout?(targets, root)
|
|
80
85
|
end
|
|
81
86
|
|
|
82
|
-
# Whether a word of
|
|
83
|
-
# repository (and not into root, which may sit inside the main one)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
# Whether a word of the command resolves into another checkout of the
|
|
88
|
+
# repository (and not into root, which may sit inside the main one),
|
|
89
|
+
# under chi's env or the call's env: (Targets#shell_envs).
|
|
90
|
+
def names_other_checkout?(targets, root)
|
|
91
|
+
real_root = PathResolve.real(root)
|
|
92
|
+
others = [*@worktrees.call, @common_dir.call].compact.map { |dir| PathResolve.real(dir) } - [real_root]
|
|
87
93
|
return false if others.empty?
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
walk.
|
|
92
|
-
|
|
95
|
+
commands = ShellLex.simple_commands(ShellLex.lex(targets.command.to_s))
|
|
96
|
+
targets.shell_envs.any? do |home, env|
|
|
97
|
+
walk = ShellPaths::Walk.new(targets.cwd, home, env)
|
|
98
|
+
commands.any? do |words|
|
|
99
|
+
walk.step(words).any? do |_word, path|
|
|
100
|
+
next false unless path
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
PathResolve.reals(path).any? do |real|
|
|
103
|
+
!PathResolve.under?(real, real_root) && others.any? { |dir| PathResolve.under?(real, dir) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
96
106
|
end
|
|
97
107
|
end
|
|
98
108
|
rescue ArgumentError, EncodingError
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require_relative "
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "path_resolve"
|
|
5
5
|
require_relative "../memory_bundle/index_sync"
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
@@ -16,31 +16,82 @@ module Samagotchi
|
|
|
16
16
|
# is itself in that tmp dir (a sandbox there still gets the check
|
|
17
17
|
# between its siblings).
|
|
18
18
|
# chi's state dir is not special: nothing there is the model's to write.
|
|
19
|
+
# A path whose landing place resolving can't tell (.resolve: a dangling
|
|
20
|
+
# symlink, a file with another hard link) is always outside.
|
|
19
21
|
module Outside
|
|
20
|
-
TMP_DIRS =
|
|
22
|
+
TMP_DIRS = PathResolve::TMP_DIRS
|
|
21
23
|
|
|
22
24
|
# @param path [String] absolute
|
|
23
25
|
# @param root [String] the session's repo root (or cwd)
|
|
24
26
|
def self.outside?(path, root:)
|
|
25
|
-
|
|
26
|
-
session =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
reals = resolve(path) or return true
|
|
28
|
+
session = PathResolve.real(root)
|
|
29
|
+
tmp = nil
|
|
30
|
+
reals.any? do |real|
|
|
31
|
+
next false if within?(real, session)
|
|
32
|
+
next false if MemoryBundle::IndexSync.memory_scope(path)
|
|
33
|
+
next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
true
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# Whether +path+ lands in the session's root every way it resolves
|
|
40
|
+
# (.resolve: never a dangling symlink, nor a file with another hard
|
|
41
|
+
# link). A pair turn's test for a path its command may name
|
|
42
|
+
# (PairCommand), with .tmp_write? for the rest.
|
|
43
|
+
# @param path [String] absolute
|
|
44
|
+
# @param root [String] the session's project root
|
|
45
|
+
def self.inside?(path, root:)
|
|
46
|
+
reals = resolve(path) or return false
|
|
47
|
+
session = PathResolve.real(root)
|
|
48
|
+
reals.all? { |real| within?(real, session) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether a shell command may write +path+ as a tmp file (the child
|
|
52
|
+
# boundary's tmp writes): it resolves (.resolve, every way) into a tmp
|
|
53
|
+
# dir the session's root isn't in.
|
|
54
|
+
# @param path [String] absolute
|
|
55
|
+
# @param root [String] the session's repo root (or cwd)
|
|
56
|
+
def self.tmp_write?(path, root:)
|
|
57
|
+
reals = resolve(path) or return false
|
|
58
|
+
session = PathResolve.real(root)
|
|
59
|
+
tmp = session_tmp_roots(session)
|
|
60
|
+
reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
|
|
41
61
|
end
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
# +path+ resolved (PathResolve.reals: the lexical answer and where
|
|
64
|
+
# the kernel lands, a `..` after a symlinked dir climbing out of its
|
|
65
|
+
# target), or nil when that doesn't say where a write lands: the path
|
|
66
|
+
# is or goes through a dangling symlink (a write would create its
|
|
67
|
+
# target; PathResolve.real follows it, the lexical answer stops at
|
|
68
|
+
# it, and either way the link's own place doesn't count as where the
|
|
69
|
+
# file is), or is a file with another hard link (a write would change
|
|
70
|
+
# that file too). Both outside? and tmp_write? read it, so neither
|
|
71
|
+
# counts such a path as inside or in tmp.
|
|
72
|
+
# @param path [String] absolute
|
|
73
|
+
# @return [Array<String>, nil]
|
|
74
|
+
def self.resolve(path)
|
|
75
|
+
reals = PathResolve.reals(path)
|
|
76
|
+
return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
|
|
77
|
+
return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
|
|
78
|
+
|
|
79
|
+
reals
|
|
80
|
+
rescue SystemCallError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The tmp dirs that exist, resolved (PathResolve.tmp_roots).
|
|
85
|
+
def self.tmp_roots = PathResolve.tmp_roots
|
|
86
|
+
|
|
87
|
+
# The tmp dirs +session+ (resolved) isn't in (PathResolve.tmp_roots).
|
|
88
|
+
def self.session_tmp_roots(session) = PathResolve.tmp_roots(session: session, roots: tmp_roots)
|
|
89
|
+
|
|
90
|
+
# Whether +path+ is +root+ or in it (PathResolve.under?): cased alike
|
|
91
|
+
# on a disk that folds case when +root+ exists (resolved, it has its
|
|
92
|
+
# on-disk case), by their exact text when it doesn't (a missing root
|
|
93
|
+
# keeps the case it was typed in).
|
|
94
|
+
def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
|
|
44
95
|
end
|
|
45
96
|
end
|
|
46
97
|
end
|
|
@@ -24,19 +24,27 @@ module Samagotchi
|
|
|
24
24
|
# credential files; project secret files) on every file tool's path,
|
|
25
25
|
# read or written (read, write, edit, a plugin's targets: paths), and
|
|
26
26
|
# on what a command does (execute, and any call whose targets carry
|
|
27
|
-
# a command: PairCommand, which also refuses chi
|
|
28
|
-
#
|
|
27
|
+
# a command: PairCommand, which also refuses chi and chi web on a
|
|
28
|
+
# loopback name, URL openers (open, xdg-open, a browser) and
|
|
29
|
+
# listeners (nc -l, socat …LISTEN, a static HTTP server), background
|
|
30
|
+
# and scheduled jobs, the environment and
|
|
31
|
+
# credential printers, git settings that run commands, home sweeps
|
|
32
|
+
# and links to them, and a program named by a $var or a glob); a
|
|
33
|
+
# write or edit of git's hooks (GIT_HOOKS);
|
|
29
34
|
# - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
|
|
30
35
|
# speaks or acts in the owner's name beyond this turn (a delegate, a
|
|
31
36
|
# reminder, a note to another session, a background task, a
|
|
32
|
-
# forgotten output, the session's loaded tools);
|
|
37
|
+
# forgotten output, the session's loaded tools); the tools that read
|
|
38
|
+
# the owner's background tasks (OWNER_TASK_READERS); and memory_write:
|
|
33
39
|
# a memory reaches the owner's later sessions;
|
|
34
40
|
# - ask_user_question: there is no one on the participant's side to
|
|
35
41
|
# answer it here; the model asks in its reply instead.
|
|
36
42
|
# And it asks the owner, always (Verdict#fresh_ask: no stored approval
|
|
37
|
-
# answers it, and the rules can't lower it), before a write
|
|
38
|
-
#
|
|
39
|
-
#
|
|
43
|
+
# answers it, and the rules can't lower it), before a write or an edit
|
|
44
|
+
# that may land outside the session's project root, or a command that
|
|
45
|
+
# may write there (failing closed: PairCommand#outside_writes?); a tmp
|
|
46
|
+
# dir the project isn't in aside (PairCommand.writable?). No project
|
|
47
|
+
# (the home folder or above it): every write asks.
|
|
40
48
|
# Everything else goes on to the owner's checks and rules as for the
|
|
41
49
|
# owner's turns.
|
|
42
50
|
class PairBoundary
|
|
@@ -45,6 +53,10 @@ module Samagotchi
|
|
|
45
53
|
OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
|
|
46
54
|
find_tools].freeze
|
|
47
55
|
OWNER_VOICED_PREFIX = "delegate_"
|
|
56
|
+
# The background task tools that read the owner's tasks (a pair turn
|
|
57
|
+
# starts none: task_create is owner-voiced, and its execute is never
|
|
58
|
+
# handed over).
|
|
59
|
+
OWNER_TASK_READERS = %w[task_wait task_get task_list].freeze
|
|
48
60
|
ASK_TOOL = "ask_user_question"
|
|
49
61
|
WRITE_TOOLS = %w[write edit].freeze
|
|
50
62
|
|
|
@@ -52,21 +64,55 @@ module Samagotchi
|
|
|
52
64
|
"only the owner can do."
|
|
53
65
|
SECRET = "a participant's turn can't read or write secrets or chi's own files"
|
|
54
66
|
OWNER_ONLY = "a participant's turn can't use %s: it acts in the owner's name beyond this turn"
|
|
67
|
+
OWNER_TASKS = "a participant's turn can't use %s: the background tasks are the owner's, and their output with them"
|
|
55
68
|
MEMORY_WRITE = "a participant's turn writes no memories: they reach the owner's later sessions; say in your " \
|
|
56
69
|
"reply what to remember"
|
|
57
70
|
ASK = "a participant's turn can't ask the user: ask in your reply"
|
|
58
71
|
OUTSIDE_WRITE = "a participant's turn writes outside the session's project"
|
|
72
|
+
NO_PROJECT = "a participant's turn writes, and this session's folder is the home folder or above it (or none): " \
|
|
73
|
+
"there is no project to keep it in"
|
|
59
74
|
CHI = "a participant's turn can't run chi: its commands act in the owner's name (sessions, shares, answers)"
|
|
60
75
|
BACKGROUND = "a participant's turn can't leave a command running in the background: it would outlive the turn"
|
|
61
76
|
ENVIRONMENT = "a participant's turn can't read the environment or the keychain: they hold the owner's secrets"
|
|
62
|
-
HOME_SWEEP = "a participant's turn can't search or
|
|
77
|
+
HOME_SWEEP = "a participant's turn can't search, copy or link a folder that holds secrets or chi's own files"
|
|
78
|
+
LINK = "a participant's turn makes a link: a path through it can lead out of the project, so the owner " \
|
|
79
|
+
"approves it"
|
|
63
80
|
GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
|
|
64
81
|
"(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
|
|
82
|
+
GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
|
|
83
|
+
# chi web on loopback asks the owner's token too (Web::App#token_gate,
|
|
84
|
+
# LAN mode); this text check stays as defense in depth: a loopback-only
|
|
85
|
+
# chi web still answers loopback as the owner.
|
|
86
|
+
CHI_WEB = "a participant's turn can't call chi web on this machine: it is the owner's (sessions, shares, answers)"
|
|
87
|
+
# A page opened in the owner's browser carries the owner's cookies
|
|
88
|
+
# (chi web's on 127.0.0.1 or the LAN address among them) to whatever
|
|
89
|
+
# it points at; a port listening here could catch them.
|
|
90
|
+
OPENER = "a participant's turn can't open a URL or a file in the owner's apps: a browser sends that page the " \
|
|
91
|
+
"owner's cookies"
|
|
92
|
+
LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
|
|
93
|
+
"owner's browser cookies"
|
|
94
|
+
VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
|
|
65
95
|
UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
|
|
66
96
|
"with the write tool"
|
|
67
97
|
# A pair turn's execute runs without the environment variables whose
|
|
68
98
|
# names say they hold a secret (Tools::Builtins, PairBoundary.scrubbed_env).
|
|
69
99
|
SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH/i
|
|
100
|
+
# chi web's port when config names none (Config's web.port default).
|
|
101
|
+
DEFAULT_WEB_PORT = 4567
|
|
102
|
+
|
|
103
|
+
# The ports chi web may answer on here: the one the running chi web
|
|
104
|
+
# recorded (Web::Running), config's web.port, and the default. A
|
|
105
|
+
# Bridge's port needs no entry: it answers only with its secret, in
|
|
106
|
+
# the worker's bridge.json under chi's state dir (denied).
|
|
107
|
+
# @return [Array<Integer>]
|
|
108
|
+
def self.web_ports
|
|
109
|
+
require_relative "../config"
|
|
110
|
+
require_relative "../web/running"
|
|
111
|
+
configured = Config.get("web.port").to_s.to_i
|
|
112
|
+
[Web::Running.port, (configured if configured.positive?), DEFAULT_WEB_PORT].compact.uniq
|
|
113
|
+
rescue StandardError, LoadError
|
|
114
|
+
[DEFAULT_WEB_PORT]
|
|
115
|
+
end
|
|
70
116
|
|
|
71
117
|
# The unsets a pair turn's execute adds to its environment: every
|
|
72
118
|
# variable of +env+ whose name SECRET_ENV_NAME matches.
|
|
@@ -92,14 +138,14 @@ module Samagotchi
|
|
|
92
138
|
# @param home [#call] → String, nil
|
|
93
139
|
# @param root [#call] → String, nil: the session's project root (the
|
|
94
140
|
# work tree top of its folder); a write outside it asks the owner
|
|
95
|
-
# (none: every write asks)
|
|
96
|
-
# @param
|
|
97
|
-
#
|
|
141
|
+
# (none, or the home folder or above it: every write asks)
|
|
142
|
+
# @param web_ports [#call] → Array<Integer>: chi web's ports here
|
|
143
|
+
# (.web_ports); a command naming one on a loopback name is denied
|
|
98
144
|
def initialize(author:, root: -> {}, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home },
|
|
99
|
-
|
|
145
|
+
web_ports: -> { PairBoundary.web_ports })
|
|
146
|
+
@web_ports = web_ports
|
|
100
147
|
@author = author
|
|
101
148
|
@root = root
|
|
102
|
-
@tmp_roots = tmp_roots
|
|
103
149
|
@denied_dirs = denied_dirs
|
|
104
150
|
@home = home
|
|
105
151
|
end
|
|
@@ -112,8 +158,8 @@ module Samagotchi
|
|
|
112
158
|
return verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
|
|
113
159
|
end
|
|
114
160
|
|
|
115
|
-
if
|
|
116
|
-
verdict.ask!(
|
|
161
|
+
if (reason = ask_reason(verdict))
|
|
162
|
+
verdict.ask!(reason, scopes: %w[once], rule: RULE, source: SOURCE, decided_by: "core")
|
|
117
163
|
verdict.fresh_ask = true
|
|
118
164
|
end
|
|
119
165
|
verdict
|
|
@@ -126,6 +172,7 @@ module Samagotchi
|
|
|
126
172
|
tool = call[:name].to_s
|
|
127
173
|
return ASK if tool == ASK_TOOL
|
|
128
174
|
return format(OWNER_ONLY, tool) if OWNER_VOICED.include?(tool) || tool.start_with?(OWNER_VOICED_PREFIX)
|
|
175
|
+
return format(OWNER_TASKS, tool) if OWNER_TASK_READERS.include?(tool)
|
|
129
176
|
return MEMORY_WRITE if tool == "memory_write"
|
|
130
177
|
|
|
131
178
|
targets = verdict.targets
|
|
@@ -133,37 +180,53 @@ module Samagotchi
|
|
|
133
180
|
|
|
134
181
|
paths = denylist
|
|
135
182
|
return SECRET if paths.secret_refusal(targets.paths.compact)
|
|
183
|
+
return GIT_HOOKS if WRITE_TOOLS.include?(tool) && targets.paths.compact.any? { |path| PairCommand.git_hook?(path) }
|
|
136
184
|
return command(targets, paths).refusal if targets.command
|
|
137
185
|
|
|
138
186
|
nil
|
|
139
187
|
end
|
|
140
188
|
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
189
|
+
# Why the owner is asked (fresh, once): a command that makes a link
|
|
190
|
+
# (LINK), a write that may land outside the project (OUTSIDE_WRITE, or
|
|
191
|
+
# NO_PROJECT without one); nil when nothing asks.
|
|
192
|
+
def ask_reason(verdict)
|
|
193
|
+
targets = verdict.targets
|
|
194
|
+
return LINK if targets&.command && command(targets, nil).links?
|
|
195
|
+
return nil unless outside_write?(verdict)
|
|
196
|
+
|
|
197
|
+
project_root ? OUTSIDE_WRITE : NO_PROJECT
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A write or edit whose path may land outside the project root, or a
|
|
201
|
+
# command that may write there (PairCommand#outside_writes?); a tmp
|
|
202
|
+
# dir the project isn't in aside (PairCommand.writable?).
|
|
144
203
|
def outside_write?(verdict)
|
|
145
204
|
targets = verdict.targets
|
|
146
205
|
return false unless targets
|
|
147
206
|
return command(targets, nil).outside_writes? if targets.command
|
|
148
207
|
return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
|
|
149
208
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
paths.any? do |path|
|
|
153
|
-
real = ProtectedPaths.real(path)
|
|
154
|
-
next false if @tmp_roots.call.any? { |tmp| ShellPaths.within?(real, tmp) }
|
|
155
|
-
|
|
156
|
-
root.empty? || !ShellPaths.within?(real, ProtectedPaths.real(root))
|
|
209
|
+
targets.paths.to_a.compact.any? do |path|
|
|
210
|
+
!PairCommand.writable?(File.expand_path(path), root: project_root, cwd: targets.cwd)
|
|
157
211
|
end
|
|
158
212
|
end
|
|
159
213
|
|
|
160
214
|
def command(targets, paths)
|
|
161
215
|
@command = nil unless @command&.first == targets
|
|
162
|
-
@command ||= [targets, PairCommand.new(targets.command, cwd: targets.cwd, home: home, root:
|
|
163
|
-
paths: paths || denylist,
|
|
216
|
+
@command ||= [targets, PairCommand.new(targets.command, cwd: targets.cwd, home: home, root: project_root,
|
|
217
|
+
paths: paths || denylist, web: web_text)]
|
|
164
218
|
@command.last
|
|
165
219
|
end
|
|
166
220
|
|
|
221
|
+
# The session's project root; nil without one, or when it is the home
|
|
222
|
+
# folder or above it (ReviewPaths.home_root?): then every write asks.
|
|
223
|
+
def project_root
|
|
224
|
+
root = @root.call.to_s
|
|
225
|
+
root.empty? || ReviewPaths.home_root?(root, home) ? nil : root
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def web_text = PairCommand.web_text(@web_ports.call, hostnames: PairCommand.hostnames)
|
|
229
|
+
|
|
167
230
|
def denylist = ReviewPaths.new(root: nil, denied_dirs: @denied_dirs.call, home: @home.call)
|
|
168
231
|
|
|
169
232
|
def home = @home.call.to_s
|