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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/docs/architecture.md +15 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +155 -25
  6. data/docs/internals/participants.md +23 -5
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bridge_client.rb +8 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +56 -0
  14. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  15. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  16. data/lib/samagotchi/guardrails/outside.rb +54 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  18. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  20. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  21. data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
  22. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  23. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  24. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  25. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  26. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  28. data/lib/samagotchi/guardrails/targets.rb +128 -24
  29. data/lib/samagotchi/session_manager.rb +13 -14
  30. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  31. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  33. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  35. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  36. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  38. data/lib/samagotchi/verify_anchor.rb +8 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/app.rb +9 -7
  41. data/lib/samagotchi/web/participant_review.rb +4 -2
  42. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  43. data/lib/samagotchi/web/public/app.js +25 -5
  44. data/lib/samagotchi/web/public/composer.css +1 -0
  45. data/lib/samagotchi/web/public/notify.js +2 -1
  46. data/lib/samagotchi/web/public/participant.html +3 -2
  47. data/lib/samagotchi/web/public/participant.js +18 -7
  48. data/lib/samagotchi/web/public/participant_model.js +3 -2
  49. data/lib/samagotchi/web/public/participant_names.js +2 -2
  50. data/lib/samagotchi/web/public/question_card.js +4 -1
  51. data/lib/samagotchi/web/public/session_state.js +2 -0
  52. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  53. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  54. data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
  55. data/lib/samagotchi/web/public/verify_card.css +1 -0
  56. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  57. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  58. data/lib/samagotchi/worker.rb +82 -32
  59. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  60. data/lib/samagotchi/worker_link.rb +13 -5
  61. metadata +2 -1
@@ -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
- def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT)
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.127.0
3
+ version: 0.129.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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
- 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
@@ -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)
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
- 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
+ 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
- real = ProtectedPaths.real(path)
28
+ reals = resolve(path) or return true
26
29
  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) }
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
- true
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, 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, 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); and memory_write:
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, an edit or a
38
- # command that writes outside the session's project root (the tmp dirs
39
- # aside).
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 copy a folder that holds secrets or chi's own files"
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 tmp_roots [#call] → Array<String>: where a write neither asks
97
- # nor is denied (PairCommand.tmp_roots)
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
- tmp_roots: -> { PairCommand.tmp_roots })
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 outside_write?(verdict)
116
- verdict.ask!(OUTSIDE_WRITE, scopes: %w[once], rule: RULE, source: SOURCE, decided_by: "core")
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
- # 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.
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
- 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))
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: @root.call,
163
- paths: paths || denylist, tmp_roots: @tmp_roots.call)]
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