samagotchi 0.127.0 → 0.129.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 +106 -1
- data/docs/architecture.md +15 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +155 -25
- data/docs/internals/participants.md +23 -5
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +56 -0
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
- data/lib/samagotchi/guardrails/outside.rb +54 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
- data/lib/samagotchi/guardrails/pair_command.rb +261 -66
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
- data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
- data/lib/samagotchi/guardrails/review_paths.rb +22 -8
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
- data/lib/samagotchi/guardrails/targets.rb +128 -24
- data/lib/samagotchi/session_manager.rb +13 -14
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
- 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/own_turns.rb +81 -0
- data/lib/samagotchi/verify_anchor.rb +8 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- data/lib/samagotchi/web/participant_review.rb +4 -2
- data/lib/samagotchi/web/public/annotate_bar.js +14 -2
- data/lib/samagotchi/web/public/app.js +25 -5
- data/lib/samagotchi/web/public/composer.css +1 -0
- data/lib/samagotchi/web/public/notify.js +2 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +18 -7
- data/lib/samagotchi/web/public/participant_model.js +3 -2
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/question_card.js +4 -1
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -2
- data/lib/samagotchi/web/public/verdict_card.js +18 -3
- data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
- data/lib/samagotchi/web/public/verify_card.css +1 -0
- data/lib/samagotchi/web/public/verify_preset.js +6 -0
- data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
- data/lib/samagotchi/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +2 -1
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "atomic_file"
|
|
|
11
11
|
require_relative "bridge_secret"
|
|
12
12
|
require_relative "delivery"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
|
+
require_relative "verify_anchor"
|
|
14
15
|
|
|
15
16
|
require_relative "bridge/bounded_queue"
|
|
16
17
|
require_relative "bridge/card_store"
|
|
@@ -633,6 +634,7 @@ module Samagotchi
|
|
|
633
634
|
# turn (Guardrails::PairBoundary); a worker without it runs it as a
|
|
634
635
|
# review turn, and chi web says so (Delivery::PAIR_FEATURE).
|
|
635
636
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
637
|
+
VerifyAnchor::FEATURE,
|
|
636
638
|
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
|
|
637
639
|
|
|
638
640
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
@@ -88,7 +88,8 @@ module Samagotchi
|
|
|
88
88
|
def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
|
|
89
89
|
return nil unless sidecar
|
|
90
90
|
|
|
91
|
-
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout
|
|
91
|
+
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout,
|
|
92
|
+
features: sidecar.features)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
@@ -111,6 +112,9 @@ module Samagotchi
|
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
attr_reader :session_id, :port, :host
|
|
115
|
+
# @return [Array<String>] what the worker's sidecar named (WorkerSidecar#features)
|
|
116
|
+
# as this client was made from it; empty for one made without a sidecar
|
|
117
|
+
attr_reader :features
|
|
114
118
|
|
|
115
119
|
# Which worker this client talks to, for EventStream: a rediscovered
|
|
116
120
|
# client with another key is a new worker. A Bridge's port, and a
|
|
@@ -122,7 +126,9 @@ module Samagotchi
|
|
|
122
126
|
# with every request (nil: a worker from before secrets, which asks for
|
|
123
127
|
# none); from the worker's sidecar (.for_sidecar), never typed
|
|
124
128
|
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
125
|
-
|
|
129
|
+
# @param features [Array<String>] the sidecar's (#features)
|
|
130
|
+
def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT, features: [])
|
|
131
|
+
@features = Array(features).freeze
|
|
126
132
|
@session_id = session_id
|
|
127
133
|
@port = port
|
|
128
134
|
@secret = secret
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1620,6 +1620,61 @@ module Samagotchi
|
|
|
1620
1620
|
end
|
|
1621
1621
|
private :stamp_verify_anchor
|
|
1622
1622
|
|
|
1623
|
+
def verified_anchor(session, anchor)
|
|
1624
|
+
return unless anchor
|
|
1625
|
+
|
|
1626
|
+
source = verify_anchor_source_session(session, anchor)
|
|
1627
|
+
return unless source
|
|
1628
|
+
|
|
1629
|
+
turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
|
|
1630
|
+
return reject_verify_anchor(anchor, :turn_missing) unless turn
|
|
1631
|
+
|
|
1632
|
+
reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
|
|
1633
|
+
return reject_verify_anchor(anchor, :reply_missing) unless reply
|
|
1634
|
+
|
|
1635
|
+
quote_text = verify_comparison_text(anchor.quote)
|
|
1636
|
+
answer_text = verify_comparison_text(reply[:content] || reply["content"])
|
|
1637
|
+
return reject_verify_anchor(anchor, :quote_missing) if quote_text.empty? || !answer_text.include?(quote_text)
|
|
1638
|
+
|
|
1639
|
+
anchor
|
|
1640
|
+
end
|
|
1641
|
+
private :verified_anchor
|
|
1642
|
+
|
|
1643
|
+
def verify_anchor_source_session(session, anchor)
|
|
1644
|
+
return session unless session.verify_fresh
|
|
1645
|
+
|
|
1646
|
+
fresh = session.verify_fresh
|
|
1647
|
+
return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
|
|
1648
|
+
return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
|
|
1649
|
+
|
|
1650
|
+
Session.load(session.parent_id, state_dir: session_state_dir)
|
|
1651
|
+
rescue ArgumentError, SystemCallError, IOError => e
|
|
1652
|
+
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
|
|
1653
|
+
error: e.class.name)
|
|
1654
|
+
nil
|
|
1655
|
+
end
|
|
1656
|
+
private :verify_anchor_source_session
|
|
1657
|
+
|
|
1658
|
+
def verify_comparison_text(text)
|
|
1659
|
+
OutputFormatter.strip_markup(text)
|
|
1660
|
+
.gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
|
|
1661
|
+
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
|
|
1662
|
+
.gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
|
|
1663
|
+
.gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
|
|
1664
|
+
.gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
|
|
1665
|
+
.gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
|
|
1666
|
+
.tr("|", " ") # a table row's cells; the page copies them without the pipes
|
|
1667
|
+
.gsub(/\s+/, " ")
|
|
1668
|
+
.strip
|
|
1669
|
+
end
|
|
1670
|
+
private :verify_comparison_text
|
|
1671
|
+
|
|
1672
|
+
def reject_verify_anchor(anchor, reason)
|
|
1673
|
+
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1674
|
+
nil
|
|
1675
|
+
end
|
|
1676
|
+
private :reject_verify_anchor
|
|
1677
|
+
|
|
1623
1678
|
def record_verdict(turn, outcome)
|
|
1624
1679
|
return unless outcome == "completed" && turn.verify
|
|
1625
1680
|
|
|
@@ -2170,6 +2225,7 @@ module Samagotchi
|
|
|
2170
2225
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2171
2226
|
# author, boundary and cap), whoever started it.
|
|
2172
2227
|
origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
|
|
2228
|
+
verify = verified_anchor(session, VerifyAnchor.from(verify))
|
|
2173
2229
|
# Track the active session for recap and status snapshot.
|
|
2174
2230
|
@session = session
|
|
2175
2231
|
# status is turn state: running now, idle again before the turn's end
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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)
|
|
85
91
|
real_root = ProtectedPaths.real(root)
|
|
86
92
|
others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.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
|
+
ProtectedPaths.reals(path).any? do |real|
|
|
103
|
+
!ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
96
106
|
end
|
|
97
107
|
end
|
|
98
108
|
rescue ArgumentError, EncodingError
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
3
4
|
require "tmpdir"
|
|
4
5
|
require_relative "protected_paths"
|
|
5
6
|
require_relative "../memory_bundle/index_sync"
|
|
@@ -16,19 +17,67 @@ module Samagotchi
|
|
|
16
17
|
# is itself in that tmp dir (a sandbox there still gets the check
|
|
17
18
|
# between its siblings).
|
|
18
19
|
# chi's state dir is not special: nothing there is the model's to write.
|
|
20
|
+
# A path whose landing place resolving can't tell (.resolve: a dangling
|
|
21
|
+
# symlink, a file with another hard link) is always outside.
|
|
19
22
|
module Outside
|
|
20
23
|
TMP_DIRS = %w[/tmp /var/tmp].freeze
|
|
21
24
|
|
|
22
25
|
# @param path [String] absolute
|
|
23
26
|
# @param root [String] the session's repo root (or cwd)
|
|
24
27
|
def self.outside?(path, root:)
|
|
25
|
-
|
|
28
|
+
reals = resolve(path) or return true
|
|
26
29
|
session = ProtectedPaths.real(root)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
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 = ProtectedPaths.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 = ProtectedPaths.real(root)
|
|
59
|
+
reals.all? { |real| tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# +path+ resolved (ProtectedPaths.reals: the lexical answer and where
|
|
63
|
+
# the kernel lands, a `..` after a symlinked dir climbing out of its
|
|
64
|
+
# target), or nil when that doesn't say where a write lands: the path
|
|
65
|
+
# is or goes through a dangling symlink (a write would create its
|
|
66
|
+
# target; ProtectedPaths.real follows it, the lexical answer stops at
|
|
67
|
+
# it, and either way the link's own place doesn't count as where the
|
|
68
|
+
# file is), or is a file with another hard link (a write would change
|
|
69
|
+
# that file too). Both outside? and tmp_write? read it, so neither
|
|
70
|
+
# counts such a path as inside or in tmp.
|
|
71
|
+
# @param path [String] absolute
|
|
72
|
+
# @return [Array<String>, nil]
|
|
73
|
+
def self.resolve(path)
|
|
74
|
+
reals = ProtectedPaths.reals(path)
|
|
75
|
+
return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
|
|
76
|
+
return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
|
|
77
|
+
|
|
78
|
+
reals
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
nil
|
|
32
81
|
end
|
|
33
82
|
|
|
34
83
|
# The tmp dirs that exist, resolved.
|
|
@@ -24,19 +24,25 @@ 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, background and scheduled jobs, the environment and
|
|
29
|
+
# credential printers, git settings that run commands, home sweeps
|
|
30
|
+
# and links to them, and a program named by a $var or a glob); a
|
|
31
|
+
# write or edit of git's hooks (GIT_HOOKS);
|
|
29
32
|
# - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
|
|
30
33
|
# speaks or acts in the owner's name beyond this turn (a delegate, a
|
|
31
34
|
# reminder, a note to another session, a background task, a
|
|
32
|
-
# forgotten output, the session's loaded tools);
|
|
35
|
+
# forgotten output, the session's loaded tools); the tools that read
|
|
36
|
+
# the owner's background tasks (OWNER_TASK_READERS); and memory_write:
|
|
33
37
|
# a memory reaches the owner's later sessions;
|
|
34
38
|
# - ask_user_question: there is no one on the participant's side to
|
|
35
39
|
# answer it here; the model asks in its reply instead.
|
|
36
40
|
# 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
|
-
#
|
|
41
|
+
# answers it, and the rules can't lower it), before a write or an edit
|
|
42
|
+
# that may land outside the session's project root, or a command that
|
|
43
|
+
# may write there (failing closed: PairCommand#outside_writes?); a tmp
|
|
44
|
+
# dir the project isn't in aside (PairCommand.writable?). No project
|
|
45
|
+
# (the home folder or above it): every write asks.
|
|
40
46
|
# Everything else goes on to the owner's checks and rules as for the
|
|
41
47
|
# owner's turns.
|
|
42
48
|
class PairBoundary
|
|
@@ -45,6 +51,10 @@ module Samagotchi
|
|
|
45
51
|
OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
|
|
46
52
|
find_tools].freeze
|
|
47
53
|
OWNER_VOICED_PREFIX = "delegate_"
|
|
54
|
+
# The background task tools that read the owner's tasks (a pair turn
|
|
55
|
+
# starts none: task_create is owner-voiced, and its execute is never
|
|
56
|
+
# handed over).
|
|
57
|
+
OWNER_TASK_READERS = %w[task_wait task_get task_list].freeze
|
|
48
58
|
ASK_TOOL = "ask_user_question"
|
|
49
59
|
WRITE_TOOLS = %w[write edit].freeze
|
|
50
60
|
|
|
@@ -52,21 +62,46 @@ module Samagotchi
|
|
|
52
62
|
"only the owner can do."
|
|
53
63
|
SECRET = "a participant's turn can't read or write secrets or chi's own files"
|
|
54
64
|
OWNER_ONLY = "a participant's turn can't use %s: it acts in the owner's name beyond this turn"
|
|
65
|
+
OWNER_TASKS = "a participant's turn can't use %s: the background tasks are the owner's, and their output with them"
|
|
55
66
|
MEMORY_WRITE = "a participant's turn writes no memories: they reach the owner's later sessions; say in your " \
|
|
56
67
|
"reply what to remember"
|
|
57
68
|
ASK = "a participant's turn can't ask the user: ask in your reply"
|
|
58
69
|
OUTSIDE_WRITE = "a participant's turn writes outside the session's project"
|
|
70
|
+
NO_PROJECT = "a participant's turn writes, and this session's folder is the home folder or above it (or none): " \
|
|
71
|
+
"there is no project to keep it in"
|
|
59
72
|
CHI = "a participant's turn can't run chi: its commands act in the owner's name (sessions, shares, answers)"
|
|
60
73
|
BACKGROUND = "a participant's turn can't leave a command running in the background: it would outlive the turn"
|
|
61
74
|
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
|
|
75
|
+
HOME_SWEEP = "a participant's turn can't search, copy or link a folder that holds secrets or chi's own files"
|
|
76
|
+
LINK = "a participant's turn makes a link: a path through it can lead out of the project, so the owner " \
|
|
77
|
+
"approves it"
|
|
63
78
|
GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
|
|
64
79
|
"(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
|
|
80
|
+
GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
|
|
81
|
+
CHI_WEB = "a participant's turn can't call chi web on this machine: on loopback it answers as the owner " \
|
|
82
|
+
"(sessions, shares, answers)"
|
|
83
|
+
VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
|
|
65
84
|
UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
|
|
66
85
|
"with the write tool"
|
|
67
86
|
# A pair turn's execute runs without the environment variables whose
|
|
68
87
|
# names say they hold a secret (Tools::Builtins, PairBoundary.scrubbed_env).
|
|
69
88
|
SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH/i
|
|
89
|
+
# chi web's port when config names none (Config's web.port default).
|
|
90
|
+
DEFAULT_WEB_PORT = 4567
|
|
91
|
+
|
|
92
|
+
# The ports chi web may answer on here: the one the running chi web
|
|
93
|
+
# recorded (Web::Running), config's web.port, and the default. A
|
|
94
|
+
# Bridge's port needs no entry: it answers only with its secret, in
|
|
95
|
+
# the worker's bridge.json under chi's state dir (denied).
|
|
96
|
+
# @return [Array<Integer>]
|
|
97
|
+
def self.web_ports
|
|
98
|
+
require_relative "../config"
|
|
99
|
+
require_relative "../web/running"
|
|
100
|
+
configured = Config.get("web.port").to_s.to_i
|
|
101
|
+
[Web::Running.port, (configured if configured.positive?), DEFAULT_WEB_PORT].compact.uniq
|
|
102
|
+
rescue StandardError, LoadError
|
|
103
|
+
[DEFAULT_WEB_PORT]
|
|
104
|
+
end
|
|
70
105
|
|
|
71
106
|
# The unsets a pair turn's execute adds to its environment: every
|
|
72
107
|
# variable of +env+ whose name SECRET_ENV_NAME matches.
|
|
@@ -92,14 +127,14 @@ module Samagotchi
|
|
|
92
127
|
# @param home [#call] → String, nil
|
|
93
128
|
# @param root [#call] → String, nil: the session's project root (the
|
|
94
129
|
# work tree top of its folder); a write outside it asks the owner
|
|
95
|
-
# (none: every write asks)
|
|
96
|
-
# @param
|
|
97
|
-
#
|
|
130
|
+
# (none, or the home folder or above it: every write asks)
|
|
131
|
+
# @param web_ports [#call] → Array<Integer>: chi web's ports here
|
|
132
|
+
# (.web_ports); a command naming one on a loopback name is denied
|
|
98
133
|
def initialize(author:, root: -> {}, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home },
|
|
99
|
-
|
|
134
|
+
web_ports: -> { PairBoundary.web_ports })
|
|
135
|
+
@web_ports = web_ports
|
|
100
136
|
@author = author
|
|
101
137
|
@root = root
|
|
102
|
-
@tmp_roots = tmp_roots
|
|
103
138
|
@denied_dirs = denied_dirs
|
|
104
139
|
@home = home
|
|
105
140
|
end
|
|
@@ -112,8 +147,8 @@ module Samagotchi
|
|
|
112
147
|
return verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
|
|
113
148
|
end
|
|
114
149
|
|
|
115
|
-
if
|
|
116
|
-
verdict.ask!(
|
|
150
|
+
if (reason = ask_reason(verdict))
|
|
151
|
+
verdict.ask!(reason, scopes: %w[once], rule: RULE, source: SOURCE, decided_by: "core")
|
|
117
152
|
verdict.fresh_ask = true
|
|
118
153
|
end
|
|
119
154
|
verdict
|
|
@@ -126,6 +161,7 @@ module Samagotchi
|
|
|
126
161
|
tool = call[:name].to_s
|
|
127
162
|
return ASK if tool == ASK_TOOL
|
|
128
163
|
return format(OWNER_ONLY, tool) if OWNER_VOICED.include?(tool) || tool.start_with?(OWNER_VOICED_PREFIX)
|
|
164
|
+
return format(OWNER_TASKS, tool) if OWNER_TASK_READERS.include?(tool)
|
|
129
165
|
return MEMORY_WRITE if tool == "memory_write"
|
|
130
166
|
|
|
131
167
|
targets = verdict.targets
|
|
@@ -133,37 +169,53 @@ module Samagotchi
|
|
|
133
169
|
|
|
134
170
|
paths = denylist
|
|
135
171
|
return SECRET if paths.secret_refusal(targets.paths.compact)
|
|
172
|
+
return GIT_HOOKS if WRITE_TOOLS.include?(tool) && targets.paths.compact.any? { |path| PairCommand.git_hook?(path) }
|
|
136
173
|
return command(targets, paths).refusal if targets.command
|
|
137
174
|
|
|
138
175
|
nil
|
|
139
176
|
end
|
|
140
177
|
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
178
|
+
# Why the owner is asked (fresh, once): a command that makes a link
|
|
179
|
+
# (LINK), a write that may land outside the project (OUTSIDE_WRITE, or
|
|
180
|
+
# NO_PROJECT without one); nil when nothing asks.
|
|
181
|
+
def ask_reason(verdict)
|
|
182
|
+
targets = verdict.targets
|
|
183
|
+
return LINK if targets&.command && command(targets, nil).links?
|
|
184
|
+
return nil unless outside_write?(verdict)
|
|
185
|
+
|
|
186
|
+
project_root ? OUTSIDE_WRITE : NO_PROJECT
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A write or edit whose path may land outside the project root, or a
|
|
190
|
+
# command that may write there (PairCommand#outside_writes?); a tmp
|
|
191
|
+
# dir the project isn't in aside (PairCommand.writable?).
|
|
144
192
|
def outside_write?(verdict)
|
|
145
193
|
targets = verdict.targets
|
|
146
194
|
return false unless targets
|
|
147
195
|
return command(targets, nil).outside_writes? if targets.command
|
|
148
196
|
return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
|
|
149
197
|
|
|
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))
|
|
198
|
+
targets.paths.to_a.compact.any? do |path|
|
|
199
|
+
!PairCommand.writable?(File.expand_path(path), root: project_root, cwd: targets.cwd)
|
|
157
200
|
end
|
|
158
201
|
end
|
|
159
202
|
|
|
160
203
|
def command(targets, paths)
|
|
161
204
|
@command = nil unless @command&.first == targets
|
|
162
|
-
@command ||= [targets, PairCommand.new(targets.command, cwd: targets.cwd, home: home, root:
|
|
163
|
-
paths: paths || denylist,
|
|
205
|
+
@command ||= [targets, PairCommand.new(targets.command, cwd: targets.cwd, home: home, root: project_root,
|
|
206
|
+
paths: paths || denylist, web: web_text)]
|
|
164
207
|
@command.last
|
|
165
208
|
end
|
|
166
209
|
|
|
210
|
+
# The session's project root; nil without one, or when it is the home
|
|
211
|
+
# folder or above it (ReviewPaths.home_root?): then every write asks.
|
|
212
|
+
def project_root
|
|
213
|
+
root = @root.call.to_s
|
|
214
|
+
root.empty? || ReviewPaths.home_root?(root, home) ? nil : root
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def web_text = PairCommand.web_text(@web_ports.call, hostnames: PairCommand.hostnames)
|
|
218
|
+
|
|
167
219
|
def denylist = ReviewPaths.new(root: nil, denied_dirs: @denied_dirs.call, home: @home.call)
|
|
168
220
|
|
|
169
221
|
def home = @home.call.to_s
|