samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "open3"
6
6
  require "rbconfig"
7
7
  require "rubygems/package"
8
+ require "time"
8
9
  require_relative "version"
9
10
  require_relative "redacted_inspect"
10
11
 
@@ -22,12 +23,14 @@ module Samagotchi
22
23
  end
23
24
 
24
25
  LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
26
+ RELEASES_URL = URI("https://rubygems.org/api/v1/versions/samagotchi.json")
25
27
  # Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
26
28
  LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
27
29
  TIMEOUT = 5
28
30
 
29
31
  # source is what `gem install` takes: the gem's name, or a .gem file.
30
32
  Latest = Struct.new(:version, :source, keyword_init: true)
33
+ Release = Data.define(:version, :created_at, :prerelease)
31
34
 
32
35
  # Runs a command without a shell; [output, success].
33
36
  class Runner
@@ -57,7 +60,7 @@ module Samagotchi
57
60
  local = @env[LOCAL_ENV].to_s
58
61
  return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
59
62
 
60
- version = JSON.parse(@fetcher.call.to_s)["version"].to_s
63
+ version = JSON.parse(fetch_body(LATEST_URL).to_s)["version"].to_s
61
64
  raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
62
65
 
63
66
  Latest.new(version: version, source: "samagotchi")
@@ -67,6 +70,29 @@ module Samagotchi
67
70
  raise Error, "#{e.class}: #{e.message}".lines.first.strip
68
71
  end
69
72
 
73
+ # @return [Array<Release>] rubygems releases, or the one local smoke gem
74
+ def releases
75
+ local = @env[LOCAL_ENV].to_s
76
+ return [local_release(local)] unless local.empty?
77
+
78
+ records = JSON.parse(fetch_body(RELEASES_URL).to_s)
79
+ raise Error, "rubygems.org answered without a releases list" unless records.is_a?(Array)
80
+
81
+ records.map do |record|
82
+ version = record["number"].to_s
83
+ created_at = record["created_at"].to_s
84
+ unless Gem::Version.correct?(version) && !created_at.empty? && [true, false].include?(record["prerelease"])
85
+ raise Error, "rubygems.org answered with an invalid release"
86
+ end
87
+
88
+ Release.new(version: version, created_at: Time.iso8601(created_at), prerelease: record["prerelease"])
89
+ end
90
+ rescue Error
91
+ raise
92
+ rescue StandardError => e
93
+ raise Error, "#{e.class}: #{e.message}".lines.first.strip
94
+ end
95
+
70
96
  def newer?(latest, current = VERSION)
71
97
  Gem::Version.new(latest.version) > Gem::Version.new(current)
72
98
  end
@@ -83,9 +109,20 @@ module Samagotchi
83
109
 
84
110
  private
85
111
 
86
- def fetch
87
- Net::HTTP.start(LATEST_URL.host, LATEST_URL.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
88
- request = Net::HTTP::Get.new(LATEST_URL)
112
+ def local_release(path)
113
+ version = Gem::Package.new(path).spec.version
114
+ Release.new(version: version.to_s, created_at: File.mtime(path), prerelease: version.prerelease?)
115
+ end
116
+
117
+ def fetch_body(url)
118
+ return @fetcher.call if @fetcher.arity.zero?
119
+
120
+ @fetcher.call(url)
121
+ end
122
+
123
+ def fetch(url = LATEST_URL)
124
+ Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
125
+ request = Net::HTTP::Get.new(url)
89
126
  request["User-Agent"] = USER_AGENT
90
127
  response = http.request(request)
91
128
  raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
@@ -45,8 +45,11 @@ module Samagotchi
45
45
  # @param system_one [#call] → SystemOne::Client or nil: the judgment client
46
46
  # (nil while `guardrails.system_one.mode` is off or no host is set)
47
47
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
48
+ # @param participant [#call] (author) → the participant's
49
+ # Participants::Entry (participants.json: their name, their grant's
50
+ # mode), nil for none
48
51
  def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
49
- notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {})
52
+ notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {}, participant: ->(_author) {})
50
53
  @scratch = scratch
51
54
  @hooks_lookup = hooks
52
55
  @tools_lookup = tools
@@ -58,6 +61,8 @@ module Samagotchi
58
61
  @notify = notify
59
62
  @system_one = system_one
60
63
  @cancel_controller = cancel_controller
64
+ @participant = participant
65
+ @turn_mode = nil
61
66
  # Bundle name → the rules last loaded from it whole, kept while the
62
67
  # installed version needs a newer chi; [name, version] said so once.
63
68
  @loaded_bundle_rules = {}
@@ -96,12 +101,24 @@ module Samagotchi
96
101
  end
97
102
 
98
103
  # A turn starts: its origin (Engine#run_turn's TurnOrigin; nil or a
99
- # Hash is read as one, TurnOrigin.of), and the git facts read afresh.
104
+ # Hash is read as one, TurnOrigin.of), the git facts read afresh, and
105
+ # a participant's turn's mode decided once for the whole turn
106
+ # (#turn_mode).
100
107
  def begin_turn(origin)
101
108
  @origin = TurnOrigin.of(origin)
102
109
  @git = Guardrails::GitInfo.new
110
+ author = turn_author
111
+ @turn_entry = author && @participant.call(author)
112
+ @turn_mode = author && ParticipantMode.for_turn(@session_lookup.call, @turn_entry)
103
113
  end
104
114
 
115
+ # The running participant turn's mode (ParticipantMode.for_turn: :pair
116
+ # only in a working session for a participant invited to act), decided
117
+ # at its start; nil for the owner's turns. The boundary in #checks, the
118
+ # Engine's stamp and mid-turn stop, and ToolRunner read it.
119
+ # @return [Symbol, nil]
120
+ attr_reader :turn_mode
121
+
105
122
  # Who wrote the running turn when it isn't the session's owner (its
106
123
  # origin's participant author); nil for the owner's turns and every
107
124
  # other origin.
@@ -126,23 +143,34 @@ module Samagotchi
126
143
 
127
144
  # The gate's core checks, in order. A delegate child's boundary comes
128
145
  # before the rules, so its ask keeps the child-boundary rule id (and
129
- # its once/session scopes) when a strict rule asks too. The review
130
- # boundary comes before both: a participant's review turn is denied
131
- # what it may not do, never asked about it.
146
+ # its once/session scopes) when a strict rule asks too. The
147
+ # participant's boundary comes before both, by the session's
148
+ # ParticipantMode: a review turn's (ReviewBoundary) or a pair turn's
149
+ # (PairBoundary). Either denies what a participant's turn may not do,
150
+ # never asks about it; both are no-ops for the owner's turns.
132
151
  def checks
133
152
  rules = self.rules
134
- boundary = ParticipantMode.of(@session_lookup.call) == :review ? review_boundary : nil
135
- [@failures, boundary, rules.hook_asks, protected_paths,
153
+ [@failures, participant_boundary, rules.hook_asks, protected_paths,
136
154
  (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
137
155
  end
138
156
 
139
- # A participant's turn's boundary (Guardrails::ReviewBoundary): in
140
- # every session, a no-op unless the running turn's author is a
141
- # participant's.
157
+ # The boundary for a participant's turn in this session (#checks).
158
+ def participant_boundary
159
+ @turn_mode == ParticipantMode::PAIR ? pair_boundary : review_boundary
160
+ end
161
+
162
+ # A participant's review turn's boundary (Guardrails::ReviewBoundary): a
163
+ # no-op unless the running turn's author is a participant's.
142
164
  def review_boundary
143
165
  @review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
144
166
  end
145
167
 
168
+ # A participant's pair turn's boundary (Guardrails::PairBoundary): a
169
+ # no-op unless the running turn's author is a participant's.
170
+ def pair_boundary
171
+ @pair_boundary ||= Guardrails::PairBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
172
+ end
173
+
146
174
  # The delegate child's boundary (Guardrails::ChildBoundary); nil for
147
175
  # any other session.
148
176
  def child_boundary
@@ -264,8 +292,12 @@ module Samagotchi
264
292
  label = verdict.targets&.label ||
265
293
  ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
266
294
  # verdict.call is the call that will run (a hook may have replaced it).
295
+ # A participant's turn's ask names them and offers once only
296
+ # (Approval.payload's author:), whichever session it is.
297
+ author = turn_author
267
298
  payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
268
- only: (SCRATCH_SCOPES if @scratch))
299
+ only: (SCRATCH_SCOPES if @scratch), author: author,
300
+ author_name: (@turn_entry&.name if author))
269
301
  # A call on chi's own config, hooks or guardrails is the user's to
270
302
  # allow, never a parent agent's (Guardrails::ParentPolicy).
271
303
  payload[:user_only] = Guardrails::ParentApprovals.protected_facts?(payload[:approval])
@@ -311,7 +343,8 @@ module Samagotchi
311
343
  # System 1's judgment of an ask, written to the log before the user is
312
344
  # asked, with the decision it led to: "auto" (it answered for the user),
313
345
  # "asked" (on mode, the user decides) or "audit" (audit mode). A
314
- # participant's review turn is never judged, and no client (mode off, no
346
+ # participant's turn (review or pair) is never judged, so never
347
+ # auto-allowed: its asks go to the owner. No client (mode off, no
315
348
  # host) means no line at all. A judgment that fails is logged with p nil,
316
349
  # so the failures are in the log too.
317
350
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
@@ -14,6 +14,16 @@ module Samagotchi
14
14
  module Approval
15
15
  KIND = "approval"
16
16
  HEADER = "Approve tool call?"
17
+ # A participant's turn's ask (author:): who asks, in the header and as
18
+ # the question's last line, so a UI that doesn't know the field still
19
+ # says it.
20
+ PARTICIPANT_HEADER = "%s's turn asks: approve tool call?"
21
+ # Who asks, in the plain question (indented) and as the card's line
22
+ # (approval asked_by:): the one wording every UI shows.
23
+ ASKED_BY = "asked by: %s's turn (a participant, not you); Allow once only"
24
+ # The only scope a participant's ask offers: no session, repo or rule
25
+ # approval is made from a participant's call.
26
+ PARTICIPANT_SCOPES = %w[once].freeze
17
27
  DENY = "Deny"
18
28
  ONCE = "Allow once"
19
29
 
@@ -28,16 +38,24 @@ module Samagotchi
28
38
  # write: the card shows its diff, the question text one change line
29
39
  # @param only [Array<String>, nil] offer no scope beyond these (a
30
40
  # scratch session: once, session); "once" when none is left
41
+ # @param author [String, nil] a participant's author ("p:<id>") when
42
+ # their turn asks: it offers once only (PARTICIPANT_SCOPES), and the
43
+ # card names them (approval author:, author_name:, the header)
44
+ # @param author_name [String, nil] their name (participants.json)
31
45
  # @return [Hash] open_question fields; its caller adds user_only:
32
46
  # (ParentApprovals.protected_facts? on approval:, a file that
33
47
  # requires this one)
34
- def payload(verdict, label: nil, preview: nil, only: nil)
48
+ def payload(verdict, label: nil, preview: nil, only: nil, author: nil, author_name: nil)
49
+ asker = asker(author, author_name)
50
+ only = PARTICIPANT_SCOPES if asker
35
51
  scopes = offered_scopes(verdict, only: only)
36
52
  targets = verdict.targets
53
+ question = question_text(verdict, label: label, preview: preview)
54
+ question = "#{question}\n #{format(ASKED_BY, asker)}" if asker
37
55
  {
38
- question: question_text(verdict, label: label, preview: preview),
56
+ question: question,
39
57
  options: scopes.map { |scope| label(scope, verdict) } + [DENY],
40
- header: HEADER,
58
+ header: asker ? format(PARTICIPANT_HEADER, asker) : HEADER,
41
59
  multi_select: false,
42
60
  allow_freeform: true,
43
61
  effects: effects(scopes),
@@ -63,11 +81,31 @@ module Samagotchi
63
81
  source: verdict.source,
64
82
  reason: verdict.reason,
65
83
  scopes: scopes,
66
- preview: preview
84
+ preview: preview,
85
+ # A participant's turn asks (nil for the owner's).
86
+ author: (author.to_s if asker),
87
+ author_name: (author_name_for(author_name) if asker),
88
+ asked_by: (format(ASKED_BY, asker) if asker)
67
89
  }.compact
68
90
  }
69
91
  end
70
92
 
93
+ # How a participant's ask names them: their cleaned name, else their
94
+ # id; nil for the owner's turns.
95
+ def asker(author, author_name)
96
+ require_relative "../steer"
97
+ return nil unless Steer.participant_author?(author)
98
+
99
+ name = author_name_for(author_name)
100
+ name || Steer.participant_id(author.to_s)
101
+ end
102
+
103
+ def author_name_for(author_name)
104
+ require_relative "../participant_name"
105
+ name = ParticipantName.clean(author_name)
106
+ name.empty? ? nil : name
107
+ end
108
+
71
109
  # Each option's effect for a parent's answer (ParentPolicy): once
72
110
  # permits one call, a wider scope more, Deny denies.
73
111
  def effects(scopes)
@@ -85,7 +85,7 @@ module Samagotchi
85
85
  # answer that allows it beyond this once is stored.
86
86
  def settle_ask(verdict)
87
87
  approvals = @approvals_lookup.call
88
- if (entry = stored(approvals, verdict))
88
+ if !verdict.fresh_ask? && (entry = stored(approvals, verdict))
89
89
  verdict.settle!(:allow, decided_by: "approval")
90
90
  verdict.scope = entry["scope"]
91
91
  return verdict
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "review_paths"
4
+ require_relative "shell_paths"
5
+ require_relative "shell_lex"
6
+ require_relative "pair_command"
7
+ require_relative "parent_approvals"
8
+ require_relative "../steer"
9
+
10
+ module Samagotchi
11
+ module Guardrails
12
+ # A participant's pair turn (ParticipantMode :pair, a working session):
13
+ # it acts with the session's tools under the owner's guardrails, and
14
+ # their asks go to the owner (GuardrailWiring#request_approval). This
15
+ # check sits in ReviewBoundary's seat (GuardrailWiring#checks), on in
16
+ # every guardrails mode and with guardrails.enabled: false, active only
17
+ # while the running turn's author is a participant. An accident guard
18
+ # for a trusted team, not containment. It denies, never asks (the
19
+ # owner's approval would let the participant's text reach what only the
20
+ # owner should), on:
21
+ # - secrets: ReviewPaths' denylist (chi's state and config dirs, the
22
+ # web token, grants and every participants.json among them; home
23
+ # secret dirs and files: keys, shell startup files, histories,
24
+ # credential files; project secret files) on every file tool's path,
25
+ # read or written (read, write, edit, a plugin's targets: paths), and
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);
29
+ # - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
30
+ # speaks or acts in the owner's name beyond this turn (a delegate, a
31
+ # reminder, a note to another session, a background task, a
32
+ # forgotten output, the session's loaded tools); and memory_write:
33
+ # a memory reaches the owner's later sessions;
34
+ # - ask_user_question: there is no one on the participant's side to
35
+ # answer it here; the model asks in its reply instead.
36
+ # 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).
40
+ # Everything else goes on to the owner's checks and rules as for the
41
+ # owner's turns.
42
+ class PairBoundary
43
+ RULE = "pair-boundary"
44
+ SOURCE = "core"
45
+ OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
46
+ find_tools].freeze
47
+ OWNER_VOICED_PREFIX = "delegate_"
48
+ ASK_TOOL = "ask_user_question"
49
+ WRITE_TOOLS = %w[write edit].freeze
50
+
51
+ ADVICE = "This is a participant's turn: work with this session's other tools, and say in your reply what " \
52
+ "only the owner can do."
53
+ SECRET = "a participant's turn can't read or write secrets or chi's own files"
54
+ OWNER_ONLY = "a participant's turn can't use %s: it acts in the owner's name beyond this turn"
55
+ MEMORY_WRITE = "a participant's turn writes no memories: they reach the owner's later sessions; say in your " \
56
+ "reply what to remember"
57
+ ASK = "a participant's turn can't ask the user: ask in your reply"
58
+ OUTSIDE_WRITE = "a participant's turn writes outside the session's project"
59
+ CHI = "a participant's turn can't run chi: its commands act in the owner's name (sessions, shares, answers)"
60
+ BACKGROUND = "a participant's turn can't leave a command running in the background: it would outlive the turn"
61
+ 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"
63
+ GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
64
+ "(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
65
+ UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
66
+ "with the write tool"
67
+ # A pair turn's execute runs without the environment variables whose
68
+ # names say they hold a secret (Tools::Builtins, PairBoundary.scrubbed_env).
69
+ SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH/i
70
+
71
+ # The unsets a pair turn's execute adds to its environment: every
72
+ # variable of +env+ whose name SECRET_ENV_NAME matches.
73
+ # @return [Hash{String => nil}]
74
+ def self.scrubbed_env(env = ENV) = env.keys.grep(SECRET_ENV_NAME).to_h { |name| [name, nil] }
75
+
76
+ # What a command word that can't be resolved to a path is matched
77
+ # with: chi's own dirs (ParentApprovals::CHI_TEXT, the state dir,
78
+ # the web's grants and token), the home secret dirs, and the secret
79
+ # file names ReviewPaths::SECRET_FILE has.
80
+ SECRET_TEXT = Regexp.union(
81
+ ParentApprovals::CHI_TEXT,
82
+ %r{\.local/state/samagotchi|web-grants|grants\.json|web-token},
83
+ %r{(?:\A|[\s/'"=])\.(?:ssh|gnupg|aws|kube|docker)(?![\w.-])},
84
+ %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|[._]netrc|\.npmrc|\.pypirc|\.git-credentials)(?![\w-])},
85
+ /\bid_(?:rsa|dsa|ecdsa|ed25519)\b|credentials|\.(?:pem|p12|pfx|tfstate)\b/
86
+ )
87
+
88
+ # @param author [#call] → String, nil: the running turn's author
89
+ # (GuardrailWiring#turn_author); the check is off unless it is a
90
+ # participant's (Steer.participant_author?)
91
+ # @param denied_dirs [#call] → Array<String>: chi's own dirs
92
+ # @param home [#call] → String, nil
93
+ # @param root [#call] → String, nil: the session's project root (the
94
+ # 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)
98
+ def initialize(author:, root: -> {}, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home },
99
+ tmp_roots: -> { PairCommand.tmp_roots })
100
+ @author = author
101
+ @root = root
102
+ @tmp_roots = tmp_roots
103
+ @denied_dirs = denied_dirs
104
+ @home = home
105
+ end
106
+
107
+ # Vote on +verdict+ (its call and targets).
108
+ def check(verdict)
109
+ return verdict unless Steer.participant_author?(@author.call)
110
+
111
+ if (reason = refusal(verdict))
112
+ return verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
113
+ end
114
+
115
+ if outside_write?(verdict)
116
+ verdict.ask!(OUTSIDE_WRITE, scopes: %w[once], rule: RULE, source: SOURCE, decided_by: "core")
117
+ verdict.fresh_ask = true
118
+ end
119
+ verdict
120
+ end
121
+
122
+ private
123
+
124
+ def refusal(verdict)
125
+ call = verdict.call || {}
126
+ tool = call[:name].to_s
127
+ return ASK if tool == ASK_TOOL
128
+ return format(OWNER_ONLY, tool) if OWNER_VOICED.include?(tool) || tool.start_with?(OWNER_VOICED_PREFIX)
129
+ return MEMORY_WRITE if tool == "memory_write"
130
+
131
+ targets = verdict.targets
132
+ return nil unless targets
133
+
134
+ paths = denylist
135
+ return SECRET if paths.secret_refusal(targets.paths.compact)
136
+ return command(targets, paths).refusal if targets.command
137
+
138
+ nil
139
+ end
140
+
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.
144
+ def outside_write?(verdict)
145
+ targets = verdict.targets
146
+ return false unless targets
147
+ return command(targets, nil).outside_writes? if targets.command
148
+ return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
149
+
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))
157
+ end
158
+ end
159
+
160
+ def command(targets, paths)
161
+ @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)]
164
+ @command.last
165
+ end
166
+
167
+ def denylist = ReviewPaths.new(root: nil, denied_dirs: @denied_dirs.call, home: @home.call)
168
+
169
+ def home = @home.call.to_s
170
+ end
171
+ end
172
+ end