samagotchi 0.128.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 +82 -1
- data/docs/architecture.md +12 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +150 -25
- data/docs/internals/participants.md +17 -3
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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 +191 -8
- 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 +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- 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/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +1 -1
|
@@ -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
|