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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
@@ -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
- def system_one_auto_allow?(client, approval, p, entries)
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 "protected_paths"
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. Text heuristics: sh -c bodies, scripts
30
- # and $(…) aren't looked into.
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
- return false if targets.read_only?
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.command, targets.cwd, root)
84
+ names_other_checkout?(targets, root)
80
85
  end
81
86
 
82
- # Whether a word of +command+ resolves into another checkout of the
83
- # repository (and not into root, which may sit inside the main one).
84
- def names_other_checkout?(command, cwd, root)
85
- real_root = ProtectedPaths.real(root)
86
- others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
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
- walk = ShellPaths::Walk.new(cwd, Dir.home, ENV)
90
- ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
91
- walk.step(words).any? do |_word, path|
92
- next false unless path
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
- real = ProtectedPaths.real(path)
95
- !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
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 "tmpdir"
4
- require_relative "protected_paths"
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 = %w[/tmp /var/tmp].freeze
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
- real = ProtectedPaths.real(path)
26
- session = ProtectedPaths.real(root)
27
- return false if within?(real, session)
28
- return false if MemoryBundle::IndexSync.memory_scope(path)
29
- return false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
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
- true
35
+ true
36
+ end
32
37
  end
33
38
 
34
- # The tmp dirs that exist, resolved.
35
- def self.tmp_roots
36
- [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
37
- File.realpath(dir) if File.directory?(dir)
38
- rescue SystemCallError
39
- nil
40
- end.uniq
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
- def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
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, background jobs,
28
- # the environment, git settings that run commands and home sweeps);
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); and memory_write:
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, an edit or a
38
- # command that writes outside the session's project root (the tmp dirs
39
- # aside).
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 copy a folder that holds secrets or chi's own files"
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 tmp_roots [#call] → Array<String>: where a write neither asks
97
- # nor is denied (PairCommand.tmp_roots)
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
- tmp_roots: -> { PairCommand.tmp_roots })
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 outside_write?(verdict)
116
- verdict.ask!(OUTSIDE_WRITE, scopes: %w[once], rule: RULE, source: SOURCE, decided_by: "core")
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
- # A write or edit whose path is outside the project root (as given or
142
- # as it resolves), or a command that writes there (PairCommand); the
143
- # tmp dirs aside.
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
- paths = targets.paths.to_a.compact
151
- root = @root.call.to_s
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: @root.call,
163
- paths: paths || denylist, tmp_roots: @tmp_roots.call)]
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