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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- 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(
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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),
|
|
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
|
|
130
|
-
# boundary comes before both
|
|
131
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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:
|
|
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
|