samagotchi 0.134.0 → 0.135.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +39 -34
  6. data/docs/configuration.md +1 -2
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/web-frontend.md +262 -0
  11. data/docs/sessions.md +2 -0
  12. data/docs/testing.md +27 -2
  13. data/lib/samagotchi/bridge.rb +11 -6
  14. data/lib/samagotchi/bridge_client.rb +8 -5
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +0 -2
  17. data/lib/samagotchi/engine.rb +69 -12
  18. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  19. data/lib/samagotchi/guardrails/approval.rb +41 -5
  20. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  22. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  24. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  25. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  26. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  28. data/lib/samagotchi/guardrails_command.rb +13 -10
  29. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  30. data/lib/samagotchi/message_parts.rb +1 -1
  31. data/lib/samagotchi/revise_anchor.rb +33 -0
  32. data/lib/samagotchi/session_inbox.rb +20 -12
  33. data/lib/samagotchi/session_manager.rb +13 -11
  34. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  35. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  36. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  37. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  38. data/lib/samagotchi/tools/execute.rb +6 -6
  39. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  40. data/lib/samagotchi/tools/read.rb +5 -5
  41. data/lib/samagotchi/turn_anchors.rb +49 -0
  42. data/lib/samagotchi/verify_anchor.rb +5 -5
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +2 -14
  45. data/lib/samagotchi/web/message_display.rb +17 -1
  46. data/lib/samagotchi/web/participant_access.rb +2 -2
  47. data/lib/samagotchi/web/participant_view.rb +8 -0
  48. data/lib/samagotchi/web/public/activity.js +1 -1
  49. data/lib/samagotchi/web/public/annotations.js +1 -1
  50. data/lib/samagotchi/web/public/answer_fold.js +3 -3
  51. data/lib/samagotchi/web/public/app.js +447 -340
  52. data/lib/samagotchi/web/public/bubbles.js +3 -3
  53. data/lib/samagotchi/web/public/cards_place.js +4 -2
  54. data/lib/samagotchi/web/public/composer.css +8 -0
  55. data/lib/samagotchi/web/public/context_chips.js +9 -6
  56. data/lib/samagotchi/web/public/data.js +4 -2
  57. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  58. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  59. data/lib/samagotchi/web/public/history_reveal.js +8 -4
  60. data/lib/samagotchi/web/public/index.html +38 -9
  61. data/lib/samagotchi/web/public/latest.js +46 -0
  62. data/lib/samagotchi/web/public/lists_view.js +15 -13
  63. data/lib/samagotchi/web/public/live_timing.js +2 -0
  64. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  65. data/lib/samagotchi/web/public/method_api.js +14 -0
  66. data/lib/samagotchi/web/public/notifications.js +4 -11
  67. data/lib/samagotchi/web/public/participant.js +11 -6
  68. data/lib/samagotchi/web/public/participant_names.js +1 -1
  69. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  70. data/lib/samagotchi/web/public/project_picker.js +18 -22
  71. data/lib/samagotchi/web/public/question_cards.js +2 -2
  72. data/lib/samagotchi/web/public/restore.js +2 -0
  73. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  74. data/lib/samagotchi/web/public/scope.js +12 -20
  75. data/lib/samagotchi/web/public/send_turn.js +29 -16
  76. data/lib/samagotchi/web/public/session_actions.js +15 -14
  77. data/lib/samagotchi/web/public/session_fields.js +152 -0
  78. data/lib/samagotchi/web/public/session_payload.js +35 -0
  79. data/lib/samagotchi/web/public/session_state.js +2 -0
  80. data/lib/samagotchi/web/public/session_store.js +191 -0
  81. data/lib/samagotchi/web/public/stage_model.js +4 -4
  82. data/lib/samagotchi/web/public/stage_view.js +11 -16
  83. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  84. data/lib/samagotchi/web/public/task_stop.js +1 -1
  85. data/lib/samagotchi/web/public/timing.js +2 -2
  86. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +27 -92
  87. data/lib/samagotchi/web/public/turn_events.js +2 -2
  88. data/lib/samagotchi/web/public/turn_html.js +5 -3
  89. data/lib/samagotchi/web/public/turn_model.js +6 -6
  90. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  91. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  92. data/lib/samagotchi/web/server.rb +4 -4
  93. data/lib/samagotchi/web/session_control.rb +2 -2
  94. data/lib/samagotchi/worker.rb +9 -9
  95. data/lib/samagotchi/worker_inbound.rb +6 -5
  96. metadata +12 -3
  97. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -330,9 +330,10 @@ module Samagotchi
330
330
  return verdict
331
331
  end
332
332
 
333
- answer = @ask.call(payload)
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][:scopes])
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[:rule], source: approval[:source], tool: approval[:tool],
395
+ rule: approval.rule, source: approval.source, tool: approval.tool,
395
396
  **SystemOne::ApprovalQuestion.call_fields(approval),
396
- repo_name: approval[:repo_name],
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[:rule].to_s
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][:scopes])
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; its caller adds user_only:
46
- # (ParentApprovals.protected_facts? on approval:, a file that
47
- # requires this one)
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
- }.compact
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
- "git" => [[%w[maintenance], %w[register start unregister stop]], [%w[lfs], %w[install uninstall]]],
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+ (a word's basename).
95
+ # The table's name for +program+ (its word's ShellLex.program_name).
93
96
  def name(program)
94
- base = File.basename(program.to_s)
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 write that may land outside the project (OUTSIDE_WRITE, or
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