samagotchi 0.134.0 → 0.136.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 +78 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +43 -38
- data/docs/configuration.md +11 -9
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/web-frontend.md +263 -0
- data/docs/internals/working-set.md +2 -2
- data/docs/sessions.md +5 -3
- data/docs/testing.md +36 -6
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/engine.rb +69 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
- data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
- data/lib/samagotchi/guardrails/pair_command.rb +387 -78
- data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
- data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +13 -15
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -18
- data/lib/samagotchi/web/message_display.rb +33 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +100 -31
- data/lib/samagotchi/web/public/app.js +451 -367
- data/lib/samagotchi/web/public/bubbles.js +30 -10
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/chip_popover.js +2 -2
- data/lib/samagotchi/web/public/composer.css +8 -0
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +10 -6
- data/lib/samagotchi/web/public/index.html +105 -53
- data/lib/samagotchi/web/public/info_bar.js +11 -12
- data/lib/samagotchi/web/public/info_bar_model.js +8 -10
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/level_chip.js +33 -95
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +378 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +9 -5
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/settings_chip.js +212 -0
- data/lib/samagotchi/web/public/settings_panel.js +121 -0
- data/lib/samagotchi/web/public/shell_command.js +26 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_settings.js +25 -88
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/thinking_chip.js +7 -45
- data/lib/samagotchi/web/public/time_gaps.js +5 -2
- data/lib/samagotchi/web/public/timing.js +15 -5
- data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
- data/lib/samagotchi/web/public/turn_events.js +3 -3
- data/lib/samagotchi/web/public/turn_html.js +20 -16
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +6 -3
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +15 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -14,8 +14,10 @@ require_relative "bridge_client/event_stream"
|
|
|
14
14
|
require_relative "bridge_client/stream_request"
|
|
15
15
|
|
|
16
16
|
module Samagotchi
|
|
17
|
-
# Loaded on first use (a Verify
|
|
18
|
-
# budget
|
|
17
|
+
# Loaded on first use (any turn's input, a Verify or a Revise turn's
|
|
18
|
+
# anchor): keeps the bundle test kit's load budget
|
|
19
|
+
# (spec/samagotchi/testing/sandbox_spec.rb).
|
|
20
|
+
autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
|
|
19
21
|
autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
|
|
20
22
|
|
|
21
23
|
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
@@ -192,16 +194,17 @@ module Samagotchi
|
|
|
192
194
|
# owner; sent only when given (SessionManager.deliver_turn)
|
|
193
195
|
# @param pending_limit [Integer, nil] with a participant's +author+: how
|
|
194
196
|
# many of their turns may be running or waiting (429 too_many_turns past it)
|
|
197
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
198
|
+
# turn's draft: their own body keys ("verify", "revises")
|
|
195
199
|
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
|
|
196
|
-
|
|
200
|
+
anchors: nil)
|
|
197
201
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
198
202
|
body[:no_interrupt] = true if no_interrupt
|
|
199
203
|
body[:author] = author if author
|
|
200
204
|
body[:pending_limit] = pending_limit if author && pending_limit
|
|
201
205
|
body[:images] = images if images && !images.empty?
|
|
202
206
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
203
|
-
|
|
204
|
-
body[:verify] = anchor.to_file if anchor
|
|
207
|
+
body.merge!(TurnAnchors.from(anchors).to_file) if anchors
|
|
205
208
|
body[:deadline] = deadline
|
|
206
209
|
post("turn", body, read_body: true)
|
|
207
210
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -330,8 +330,6 @@ module Samagotchi
|
|
|
330
330
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli], lifecycle: :web_start),
|
|
331
331
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli], lifecycle: :web_start),
|
|
332
332
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
|
|
333
|
-
# The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
|
|
334
|
-
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
335
333
|
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
336
334
|
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
337
335
|
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "steer"
|
|
|
15
15
|
require_relative "turn_note"
|
|
16
16
|
require_relative "turn_endings"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "revise_anchor"
|
|
18
19
|
require_relative "verify_anchor"
|
|
19
20
|
require_relative "web/participant_view"
|
|
20
21
|
require_relative "verdict"
|
|
@@ -1631,10 +1632,11 @@ module Samagotchi
|
|
|
1631
1632
|
seconds = turn.elapsed
|
|
1632
1633
|
synchronize_events do
|
|
1633
1634
|
kept, event = yield(seconds)
|
|
1634
|
-
|
|
1635
|
+
revises = stamp_opening(kept, turn, outcome)
|
|
1635
1636
|
replace_session_messages(turn.session, kept) if kept
|
|
1636
1637
|
verdict = record_verdict(turn, outcome)
|
|
1637
1638
|
event[:verdict] = verdict if verdict
|
|
1639
|
+
event[:revises] = revises.to_h if revises
|
|
1638
1640
|
emit_working_set(turn.on_event, turn.session, turn.session.messages)
|
|
1639
1641
|
turn.session.status = Session::STATUS_IDLE
|
|
1640
1642
|
record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
|
|
@@ -1647,16 +1649,63 @@ module Samagotchi
|
|
|
1647
1649
|
end
|
|
1648
1650
|
private :end_turn
|
|
1649
1651
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
+
# The turn's anchors, stamped on its opening message (the prompt, or a
|
|
1653
|
+
# wake turn's note) in the messages it keeps: display metadata, never
|
|
1654
|
+
# in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
|
|
1655
|
+
# A Verify turn's claim however it ended; a Revise turn's draft only
|
|
1656
|
+
# once it completed with an answer (#revision_target). Returns the
|
|
1657
|
+
# stamped ReviseAnchor (the end event's revises), or nil.
|
|
1658
|
+
def stamp_opening(messages, turn, outcome)
|
|
1659
|
+
return unless messages.is_a?(Array)
|
|
1660
|
+
|
|
1661
|
+
revises = revision_target(messages, turn, outcome)
|
|
1662
|
+
fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
|
|
1663
|
+
return if fields.empty?
|
|
1652
1664
|
|
|
1653
1665
|
opening = messages.reverse.find do |message|
|
|
1654
|
-
(message[:
|
|
1655
|
-
(message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1666
|
+
Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1656
1667
|
end
|
|
1657
|
-
|
|
1668
|
+
return unless opening
|
|
1669
|
+
|
|
1670
|
+
opening.merge!(fields)
|
|
1671
|
+
revises
|
|
1672
|
+
end
|
|
1673
|
+
private :stamp_opening
|
|
1674
|
+
|
|
1675
|
+
# The turn a Revise turn revised (D6: "this turn revised that one"):
|
|
1676
|
+
# only a completed turn with an answer (AnswerTail) has revised
|
|
1677
|
+
# anything. A draft no prompt in the messages carries is no target (a
|
|
1678
|
+
# stale page, a rolled-back turn); one a stamped turn already revised
|
|
1679
|
+
# is re-targeted to its stack's top, the newest version, which the
|
|
1680
|
+
# person meant ("this draft").
|
|
1681
|
+
def revision_target(messages, turn, outcome)
|
|
1682
|
+
return unless turn.revises && outcome == "completed"
|
|
1683
|
+
|
|
1684
|
+
slice = AnswerTail.turn_slice(messages, turn.id)
|
|
1685
|
+
return unless slice&.any? { |message| AnswerTail.answer?(message) }
|
|
1686
|
+
|
|
1687
|
+
target = turn.revises.turn_id
|
|
1688
|
+
seen = [turn.id]
|
|
1689
|
+
until seen.include?(target)
|
|
1690
|
+
return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
|
|
1691
|
+
|
|
1692
|
+
seen << target
|
|
1693
|
+
newer = messages.reverse.find do |message|
|
|
1694
|
+
Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
|
|
1695
|
+
end
|
|
1696
|
+
return ReviseAnchor.new(turn_id: target) unless newer
|
|
1697
|
+
|
|
1698
|
+
target = (newer[:turn_id] || newer["turn_id"]).to_s
|
|
1699
|
+
end
|
|
1700
|
+
reject_revise_anchor(turn.revises, :cycle)
|
|
1701
|
+
end
|
|
1702
|
+
private :revision_target
|
|
1703
|
+
|
|
1704
|
+
def reject_revise_anchor(anchor, reason)
|
|
1705
|
+
Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1706
|
+
nil
|
|
1658
1707
|
end
|
|
1659
|
-
private :
|
|
1708
|
+
private :reject_revise_anchor
|
|
1660
1709
|
|
|
1661
1710
|
def verified_anchor(session, anchor)
|
|
1662
1711
|
return unless anchor
|
|
@@ -2150,8 +2199,9 @@ module Samagotchi
|
|
|
2150
2199
|
# Continue that starts cold (after a model change).
|
|
2151
2200
|
# +wall+: the wall time it started (MessageTime.now, once): the
|
|
2152
2201
|
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2202
|
+
# +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
|
|
2153
2203
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2154
|
-
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
|
|
2204
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
|
|
2155
2205
|
def resumed? = resumed ? true : false
|
|
2156
2206
|
|
|
2157
2207
|
# The turn's boundary events carry the origin only when it names a
|
|
@@ -2215,6 +2265,9 @@ module Samagotchi
|
|
|
2215
2265
|
# @param id [String, nil] the turn's id (a new uuid when nil): a caller
|
|
2216
2266
|
# that put the turn's first message in the session already (a context
|
|
2217
2267
|
# wake's note) names it with this id
|
|
2268
|
+
# @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
|
|
2269
|
+
# @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
|
|
2270
|
+
# the prompt at the turn's end, metadata only (#stamp_opening)
|
|
2218
2271
|
#
|
|
2219
2272
|
# An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
|
|
2220
2273
|
# session as after a Stop (the prompt, the loop's finished tool
|
|
@@ -2226,9 +2279,11 @@ module Samagotchi
|
|
|
2226
2279
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2227
2280
|
# one-line summary:.
|
|
2228
2281
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2229
|
-
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil,
|
|
2282
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, revises: nil,
|
|
2283
|
+
input_waiting: nil)
|
|
2230
2284
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2231
|
-
continue: continue, id: id, verify: verify,
|
|
2285
|
+
continue: continue, id: id, verify: verify, revises: revises,
|
|
2286
|
+
input_waiting: input_waiting)
|
|
2232
2287
|
if continue
|
|
2233
2288
|
turn.resumed = resumed
|
|
2234
2289
|
turn.resume_from = resume_from
|
|
@@ -2276,7 +2331,8 @@ module Samagotchi
|
|
|
2276
2331
|
# The turn's state before anything can fail it: the session running,
|
|
2277
2332
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2278
2333
|
# is set here and cleared in #release_turn only.
|
|
2279
|
-
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil,
|
|
2334
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
|
|
2335
|
+
input_waiting: nil)
|
|
2280
2336
|
origin = TurnOrigin.of(origin)
|
|
2281
2337
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2282
2338
|
# author, boundary and cap), whoever started it.
|
|
@@ -2304,7 +2360,8 @@ module Samagotchi
|
|
|
2304
2360
|
|
|
2305
2361
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
2306
2362
|
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
2307
|
-
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify)
|
|
2363
|
+
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
|
|
2364
|
+
revises: ReviseAnchor.from(revises))
|
|
2308
2365
|
end
|
|
2309
2366
|
private :begin_turn
|
|
2310
2367
|
|
|
@@ -330,9 +330,10 @@ module Samagotchi
|
|
|
330
330
|
return verdict
|
|
331
331
|
end
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
# The card and the session file carry the facts as the hash they were.
|
|
334
|
+
answer = @ask.call(payload.merge(approval: payload[:approval].to_h))
|
|
334
335
|
system_one_label(judgment, answer, payload)
|
|
335
|
-
Guardrails::Approval.settle(verdict, answer, payload[:approval]
|
|
336
|
+
Guardrails::Approval.settle(verdict, answer, payload[:approval].scopes)
|
|
336
337
|
end
|
|
337
338
|
|
|
338
339
|
private
|
|
@@ -391,9 +392,9 @@ module Samagotchi
|
|
|
391
392
|
decision = system_one_decision(client, auto)
|
|
392
393
|
|
|
393
394
|
id = log.judgment(session_id: @session_lookup.call&.id,
|
|
394
|
-
rule: approval
|
|
395
|
+
rule: approval.rule, source: approval.source, tool: approval.tool,
|
|
395
396
|
**SystemOne::ApprovalQuestion.call_fields(approval),
|
|
396
|
-
repo_name: approval
|
|
397
|
+
repo_name: approval.repo_name,
|
|
397
398
|
p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
|
|
398
399
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
399
400
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
@@ -418,12 +419,13 @@ module Samagotchi
|
|
|
418
419
|
# user opted in, the never-list and chi's own files, the probability, and
|
|
419
420
|
# the rule's record. Any one that fails leaves the ask to the user. The
|
|
420
421
|
# entries are the judgment log read once for this ask.
|
|
422
|
+
# @param approval [Guardrails::ApprovalFacts] the ask's facts
|
|
421
423
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
422
424
|
# @param targets [Guardrails::Targets, nil] the call's, for its env:
|
|
423
425
|
def system_one_auto_allow?(client, approval, p, entries, targets: nil)
|
|
424
426
|
return false unless client.mode == "on"
|
|
425
427
|
|
|
426
|
-
rule = approval
|
|
428
|
+
rule = approval.rule.to_s
|
|
427
429
|
return false if rule.empty?
|
|
428
430
|
return false unless system_one_rules.include?(rule)
|
|
429
431
|
return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
|
|
@@ -456,7 +458,7 @@ module Samagotchi
|
|
|
456
458
|
id = judgment && judgment[:id]
|
|
457
459
|
return nil if id.nil?
|
|
458
460
|
|
|
459
|
-
scopes = Array(payload[:approval]
|
|
461
|
+
scopes = Array(payload[:approval].scopes)
|
|
460
462
|
if !answer.is_a?(Samagotchi::QuestionAnswer)
|
|
461
463
|
label = "none"
|
|
462
464
|
elsif answer.parent_agent?
|
|
@@ -6,6 +6,41 @@ require_relative "../question_answer"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
module Guardrails
|
|
9
|
+
# An ask's facts, as Approval.payload builds them (its approval:) and
|
|
10
|
+
# the question card shows them: the tool (tool, acts_as, label), what
|
|
11
|
+
# it acts on (command, paths, args, memory), where (cwd, repo_root,
|
|
12
|
+
# repo_name, branch), who asked and why (rule, source, reason), the
|
|
13
|
+
# offered scopes, an edit's preview (EditPreview.for) and, for a
|
|
14
|
+
# participant's turn, who asks (author, author_name, asked_by).
|
|
15
|
+
#
|
|
16
|
+
# In this process it is passed as this value; it crosses to the UIs and
|
|
17
|
+
# the session file (PendingQuestion's approval) as #to_h, the hash it
|
|
18
|
+
# always was: symbol keys, in member order, unset fields left out. A
|
|
19
|
+
# saved or sent one (string or symbol keys, missing fields, a key we
|
|
20
|
+
# don't know dropped) comes back through .from_h. memory and preview
|
|
21
|
+
# stay as given (string keys inside after a load).
|
|
22
|
+
ApprovalFacts = Data.define(:tool, :acts_as, :label, :command, :paths, :args, :memory, :cwd, :repo_root,
|
|
23
|
+
:repo_name, :branch, :rule, :source, :reason, :scopes, :preview, :author,
|
|
24
|
+
:author_name, :asked_by) do
|
|
25
|
+
def initialize(**fields)
|
|
26
|
+
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param raw [ApprovalFacts, Hash, nil] facts as built, saved or sent
|
|
30
|
+
# @return [ApprovalFacts, nil] nil for anything that is not facts
|
|
31
|
+
def self.from_h(raw)
|
|
32
|
+
return raw if raw.is_a?(ApprovalFacts)
|
|
33
|
+
return nil unless raw.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
new(**members.to_h { |m| [m, raw.key?(m) ? raw[m] : raw[m.to_s]] })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The card's and the session file's shape: symbol keys, in member
|
|
39
|
+
# order, unset fields left out.
|
|
40
|
+
# @return [Hash{Symbol => Object}]
|
|
41
|
+
def to_h = super.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
9
44
|
# An ask as a question for the user (Engine#open_question), and the
|
|
10
45
|
# answer back as a verdict. The question text is plain and complete, so
|
|
11
46
|
# a UI that doesn't know approvals still shows everything; `approval:`
|
|
@@ -42,9 +77,10 @@ module Samagotchi
|
|
|
42
77
|
# their turn asks: it offers once only (PARTICIPANT_SCOPES), and the
|
|
43
78
|
# card names them (approval author:, author_name:, the header)
|
|
44
79
|
# @param author_name [String, nil] their name (participants.json)
|
|
45
|
-
# @return [Hash] open_question fields
|
|
46
|
-
# (
|
|
47
|
-
#
|
|
80
|
+
# @return [Hash] open_question fields, with approval: an ApprovalFacts
|
|
81
|
+
# (its caller hands open_question approval.to_h); the caller adds
|
|
82
|
+
# user_only: (ParentApprovals.protected_facts? on approval:, a file
|
|
83
|
+
# that requires this one)
|
|
48
84
|
def payload(verdict, label: nil, preview: nil, only: nil, author: nil, author_name: nil)
|
|
49
85
|
asker = asker(author, author_name)
|
|
50
86
|
only = PARTICIPANT_SCOPES if asker
|
|
@@ -60,7 +96,7 @@ module Samagotchi
|
|
|
60
96
|
allow_freeform: true,
|
|
61
97
|
effects: effects(scopes),
|
|
62
98
|
kind: KIND,
|
|
63
|
-
approval:
|
|
99
|
+
approval: ApprovalFacts.new(
|
|
64
100
|
tool: targets&.tool || verdict.call[:name].to_s,
|
|
65
101
|
# The tool the call acts as (an MCP tool behind call_tool).
|
|
66
102
|
acts_as: targets&.acts_as,
|
|
@@ -86,7 +122,7 @@ module Samagotchi
|
|
|
86
122
|
author: (author.to_s if asker),
|
|
87
123
|
author_name: (author_name_for(author_name) if asker),
|
|
88
124
|
asked_by: (format(ASKED_BY, asker) if asker)
|
|
89
|
-
|
|
125
|
+
)
|
|
90
126
|
}
|
|
91
127
|
end
|
|
92
128
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "shell_lex"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Guardrails
|
|
5
7
|
# Programs that write outside the folder they run in with no word of
|
|
@@ -61,9 +63,10 @@ module Samagotchi
|
|
|
61
63
|
"gh" => [[%w[auth], %w[login logout refresh setup-git switch]], [%w[config], %w[set]],
|
|
62
64
|
[%w[extension extensions ext], %w[install upgrade remove]], [%w[alias], %w[set delete import]]],
|
|
63
65
|
# the global config and a launchd/cron schedule; git lfs's global filters
|
|
64
|
-
|
|
66
|
+
# (git's first group is its subcommand: .git_outside?)
|
|
67
|
+
"git" => [[%w[maintenance], %w[register start unregister stop]], [%w[lfs], %w[install uninstall update]]],
|
|
65
68
|
"git-maintenance" => [[%w[register start unregister stop]]],
|
|
66
|
-
"git-lfs" => [[%w[install uninstall]]],
|
|
69
|
+
"git-lfs" => [[%w[install uninstall update]]],
|
|
67
70
|
"scalar" => [[%w[register unregister clone reconfigure delete]]]
|
|
68
71
|
}.freeze
|
|
69
72
|
# program => the verbs (its first argument) that only read: every
|
|
@@ -89,9 +92,9 @@ module Samagotchi
|
|
|
89
92
|
|
|
90
93
|
module_function
|
|
91
94
|
|
|
92
|
-
# The table's name for +program+ (
|
|
95
|
+
# The table's name for +program+ (its word's ShellLex.program_name).
|
|
93
96
|
def name(program)
|
|
94
|
-
base =
|
|
97
|
+
base = ShellLex.program_name(program)
|
|
95
98
|
ALIASES.each { |pattern, to| return to if base.match?(pattern) }
|
|
96
99
|
base
|
|
97
100
|
end
|
|
@@ -108,6 +111,7 @@ module Samagotchi
|
|
|
108
111
|
return bundle_config_outside?(args) if program == "bundle" && args.first == "config"
|
|
109
112
|
|
|
110
113
|
entries = BY_VERB[program] or return false
|
|
114
|
+
return git_outside?(entries, args, assigned) if program == "git"
|
|
111
115
|
return false unless entries.any? { |groups| groups.all? { |group| group?(group, args, assigned) } }
|
|
112
116
|
return true unless LOCAL_INSTALLERS.include?(program)
|
|
113
117
|
return true if args.include?("--user")
|
|
@@ -115,6 +119,17 @@ module Samagotchi
|
|
|
115
119
|
!(local || args.any? { |arg| arg.match?(PLACE_OPTIONS) })
|
|
116
120
|
end
|
|
117
121
|
|
|
122
|
+
# git's entries match from its subcommand on (PairCommand.git_command_starts:
|
|
123
|
+
# git -C . maintenance start), the first group holding the subcommand,
|
|
124
|
+
# not its words anywhere (git add maintenance start, git commit -m lfs -m install).
|
|
125
|
+
def git_outside?(entries, args, assigned)
|
|
126
|
+
PairCommand.git_command_starts(args).grep(Integer).any? do |i|
|
|
127
|
+
entries.any? do |first, *rest|
|
|
128
|
+
first.include?(args[i]) && rest.all? { |group| group?(group, args[(i + 1)..], assigned) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
118
133
|
def group?(group, args, assigned)
|
|
119
134
|
group == :global ? global?(args, assigned) : args.intersect?(group)
|
|
120
135
|
end
|
|
@@ -97,6 +97,8 @@ module Samagotchi
|
|
|
97
97
|
"owner's cookies"
|
|
98
98
|
LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
|
|
99
99
|
"owner's browser cookies"
|
|
100
|
+
NET = "a participant's turn runs a command chi can't read for sure (git or chi behind a program it doesn't " \
|
|
101
|
+
"know, or a $(…) kept in a value the shell expands later), so the owner approves it"
|
|
100
102
|
VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
|
|
101
103
|
UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
|
|
102
104
|
"with the write tool"
|
|
@@ -195,13 +197,15 @@ module Samagotchi
|
|
|
195
197
|
|
|
196
198
|
# Why the owner is asked (fresh, once): a command that makes a link
|
|
197
199
|
# (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
|
|
198
|
-
# config (MISE_CONFIG), a
|
|
200
|
+
# config (MISE_CONFIG), a command chi can't read for sure (NET:
|
|
201
|
+
# PairCommand#net_ask?), a write that may land outside the project (OUTSIDE_WRITE, or
|
|
199
202
|
# NO_PROJECT without one); nil when nothing asks.
|
|
200
203
|
def ask_reason(verdict)
|
|
201
204
|
targets = verdict.targets
|
|
202
205
|
return LINK if targets&.command && command(targets, nil).links?
|
|
203
206
|
return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
|
|
204
207
|
return MISE_CONFIG if mise_config_write?(verdict)
|
|
208
|
+
return NET if targets&.command && command(targets, nil).net_ask?
|
|
205
209
|
return nil unless outside_write?(verdict)
|
|
206
210
|
|
|
207
211
|
project_root ? OUTSIDE_WRITE : NO_PROJECT
|