samagotchi 0.121.1 → 0.123.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +4 -1
  4. data/docs/architecture.md +19 -7
  5. data/docs/cli.md +32 -1
  6. data/docs/configuration.md +55 -5
  7. data/docs/guardrails.md +119 -7
  8. data/docs/internals/engine.md +11 -2
  9. data/docs/internals/participants.md +76 -20
  10. data/docs/internals/prompt-caching.md +19 -7
  11. data/docs/internals/web-push.md +7 -3
  12. data/docs/memory.md +33 -0
  13. data/docs/plugins.md +3 -2
  14. data/docs/sessions.md +14 -4
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  16. data/lib/samagotchi/bridge.rb +8 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
  18. data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  21. data/lib/samagotchi/config.rb +11 -0
  22. data/lib/samagotchi/delivery.rb +7 -0
  23. data/lib/samagotchi/engine.rb +289 -12
  24. data/lib/samagotchi/event_fanout.rb +0 -1
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +46 -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/log_line.rb +1 -1
  40. data/lib/samagotchi/memory_orphans.rb +11 -0
  41. data/lib/samagotchi/participant_mode.rb +85 -0
  42. data/lib/samagotchi/participants.rb +7 -5
  43. data/lib/samagotchi/plugin/api.rb +7 -4
  44. data/lib/samagotchi/process_group.rb +13 -0
  45. data/lib/samagotchi/review_apply.rb +10 -2
  46. data/lib/samagotchi/review_start.rb +42 -10
  47. data/lib/samagotchi/run_options.rb +1 -0
  48. data/lib/samagotchi/session.rb +14 -1
  49. data/lib/samagotchi/session_access/contract.rb +1 -1
  50. data/lib/samagotchi/session_access/local.rb +5 -2
  51. data/lib/samagotchi/session_access/remote.rb +32 -12
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
  53. data/lib/samagotchi/session_manager.rb +21 -7
  54. data/lib/samagotchi/session_participants.rb +18 -3
  55. data/lib/samagotchi/session_setup.rb +8 -4
  56. data/lib/samagotchi/sessions_command.rb +1 -1
  57. data/lib/samagotchi/share_command.rb +2 -2
  58. data/lib/samagotchi/steer.rb +13 -6
  59. data/lib/samagotchi/system_one/client.rb +16 -7
  60. data/lib/samagotchi/system_one/log.rb +25 -5
  61. data/lib/samagotchi/system_prompt.rb +20 -4
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  63. data/lib/samagotchi/terminal_ui.rb +2 -1
  64. data/lib/samagotchi/tool_runner.rb +11 -1
  65. data/lib/samagotchi/toolkit.rb +27 -8
  66. data/lib/samagotchi/toolkit_binding.rb +149 -7
  67. data/lib/samagotchi/toolkit_command.rb +15 -3
  68. data/lib/samagotchi/toolkit_report_command.rb +320 -0
  69. data/lib/samagotchi/toolkit_suggest.rb +377 -0
  70. data/lib/samagotchi/tools/builtins.rb +14 -1
  71. data/lib/samagotchi/tools/memory.rb +1 -1
  72. data/lib/samagotchi/update_command.rb +7 -6
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +12 -13
  75. data/lib/samagotchi/web/grants.rb +17 -5
  76. data/lib/samagotchi/web/message_display.rb +6 -1
  77. data/lib/samagotchi/web/participant_access.rb +4 -1
  78. data/lib/samagotchi/web/participant_review.rb +8 -4
  79. data/lib/samagotchi/web/participant_view.rb +12 -4
  80. data/lib/samagotchi/web/participants_admin.rb +10 -6
  81. data/lib/samagotchi/web/principal.rb +3 -4
  82. data/lib/samagotchi/web/public/app.js +34 -10
  83. data/lib/samagotchi/web/public/bubbles.js +3 -2
  84. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  85. data/lib/samagotchi/web/public/participant.html +1 -1
  86. data/lib/samagotchi/web/public/participant.js +12 -13
  87. data/lib/samagotchi/web/public/participant_model.js +20 -8
  88. data/lib/samagotchi/web/public/participant_names.js +29 -8
  89. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  90. data/lib/samagotchi/web/public/question_card.js +12 -2
  91. data/lib/samagotchi/web/public/question_cards.js +7 -0
  92. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  93. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  94. data/lib/samagotchi/web/public/turn_events.js +7 -5
  95. data/lib/samagotchi/web/public/turn_html.js +2 -2
  96. data/lib/samagotchi/web/public/turn_view.js +6 -4
  97. data/lib/samagotchi/web/public/update_notice.js +31 -2
  98. data/lib/samagotchi/web/push/notifier.rb +28 -1
  99. data/lib/samagotchi/web/server.rb +7 -3
  100. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  101. data/lib/samagotchi/web/session_control.rb +4 -2
  102. data/lib/samagotchi/web/session_hub.rb +24 -3
  103. data/lib/samagotchi/web/start_dir.rb +10 -5
  104. data/lib/samagotchi/web/update_check.rb +179 -0
  105. data/lib/samagotchi/worker.rb +60 -8
  106. data/lib/samagotchi/worker_link.rb +1 -0
  107. metadata +8 -1
@@ -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
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "review_paths"
5
+ require_relative "shell_paths"
6
+ require_relative "shell_lex"
7
+
8
+ module Samagotchi
9
+ module Guardrails
10
+ # One shell command of a participant's pair turn, read the way
11
+ # PairBoundary needs it (the refusal words are PairBoundary's):
12
+ # - #refusal: why it may not run, nil when it may: chi, something left
13
+ # running, the environment or the keychain, git settings that run
14
+ # commands, a folder sweep, a secret (a path a word resolves to, a glob
15
+ # or a $var whose fixed part leads into one, SECRET_TEXT for the
16
+ # rest); a script it hands a shell (sh -c, eval, a heredoc) is read as
17
+ # a command of its own, one it hands another interpreter (ruby -e,
18
+ # python <<EOF) by its text. A command it can't read is refused
19
+ # (UNREADABLE), never let through on a text match;
20
+ # - #outside_writes?: whether it writes outside the project (a
21
+ # redirection's target, tee's files, cp/mv/ln/install/rsync/ditto's
22
+ # destination), the tmp dirs and /dev/null aside; a target it can't
23
+ # resolve counts as outside.
24
+ # A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
25
+ # is not looked into: it's text being written, not run.
26
+ class PairCommand
27
+ # What runs a command on another's behalf, and the shell's keywords
28
+ # before a command: skipped to find the program.
29
+ WRAPPERS = %w[env exec command nohup time sudo nice xargs setsid timeout stdbuf caffeinate bundle ruby watch npx
30
+ { ! then do else elif if while until].freeze
31
+ # chi's own invocations a pair turn may run: they print and exit.
32
+ CHI_ALLOWED = [%w[--version], %w[--help]].freeze
33
+ # chi named in a script, a value or an argument.
34
+ CHI_TEXT = %r{(?:\A|[\s;&|(`'"=])(?:[^\s;&|(`'"=]*/)?chi(?=\s|\z|[;&|)`'"])}
35
+ SHELLS = %w[sh bash zsh dash fish ksh].freeze
36
+ # Other interpreters a script can be handed to (-e/-c, a heredoc).
37
+ INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
38
+ # Options whose next word is code or a command, for any program.
39
+ CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
40
+ # Programs that detach what they start.
41
+ DETACHERS = %w[nohup setsid disown daemonize].freeze
42
+ # Programs that print the environment or a keychain's secrets, whatever
43
+ # their arguments.
44
+ ENV_READERS = %w[printenv security].freeze
45
+ ENV_TEXT = %r{/proc/[^\s]*/environ}
46
+ # An interpreter's script that reads the environment or detaches.
47
+ SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
48
+ SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
49
+ # Programs that read a folder's whole tree: given home (or any folder
50
+ # above a denied one) they reach the secrets in it.
51
+ SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto].freeze
52
+ # git settings that make git run a command (or show one), and git
53
+ # config's scopes beyond the repository.
54
+ GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
55
+ GIT_WIDE_SCOPES = %w[--global --system].freeze
56
+ GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
57
+ # Writers whose last argument (or -t DIR) is the destination.
58
+ COPIERS = %w[cp mv ln install rsync ditto].freeze
59
+ REDIRECTS = %w[> >> &> &>> 1> 1>> 2> 2>>].freeze
60
+ REDIRECT_WORD = /\A\d*(?:&>>?|>>?)(?!&)(.+)\z/
61
+ DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty].freeze
62
+ GLOB = /[*?\[\]{}]/
63
+ # How many matches of a glob are checked against the denylist.
64
+ GLOB_LIMIT = 500
65
+ # How deep scripts handed to a shell are read; deeper is UNREADABLE.
66
+ MAX_DEPTH = 3
67
+
68
+ # The tmp dirs a pair turn writes into freely (neither asked nor
69
+ # denied): Dir.tmpdir, $TMPDIR, /tmp, /private/tmp.
70
+ def self.tmp_roots
71
+ [Dir.tmpdir, ENV.fetch("TMPDIR", nil), "/tmp", "/private/tmp"].compact.reject(&:empty?)
72
+ .map { |dir| ProtectedPaths.real(dir) }.uniq
73
+ end
74
+
75
+ # @param paths [ReviewPaths] the denylist
76
+ # @param root [String, nil] the project root (outside writes)
77
+ def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, tmp_roots: nil)
78
+ @tmp_roots = tmp_roots
79
+ @text = text.to_s
80
+ @cwd = cwd
81
+ @paths = paths
82
+ @home = home.to_s
83
+ @root = root.to_s
84
+ @depth = depth
85
+ end
86
+
87
+ # @return [String, nil] a PairBoundary refusal
88
+ def refusal
89
+ return @refusal if defined?(@refusal)
90
+
91
+ @refusal = read
92
+ rescue StandardError
93
+ @refusal = PairBoundary::UNREADABLE
94
+ end
95
+
96
+ # Whether it writes outside the project (asked about, never denied).
97
+ def outside_writes?
98
+ refusal
99
+ @targets.to_a.any? { |path| outside?(path) }
100
+ end
101
+
102
+ private
103
+
104
+ def read
105
+ return PairBoundary::UNREADABLE if @depth > MAX_DEPTH
106
+
107
+ tokens = ShellLex.lex(@text)
108
+ code = ShellLex.code_text(@text, keep_written: false)
109
+ subst = tokens.any? { |_, value| value.include?(ShellLex::SUBST) }
110
+ return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
111
+ return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
112
+ return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
113
+ return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
114
+
115
+ @targets = []
116
+ walk = ShellPaths::Walk.new(@cwd, @home, ENV)
117
+ ShellLex.simple_commands(tokens).each do |words|
118
+ next walk.step(words) if words.is_a?(Symbol)
119
+
120
+ @targets.concat(write_targets(words, walk))
121
+ reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
122
+ return reason if reason
123
+ end
124
+ nil
125
+ end
126
+
127
+ # A simple command's own refusal, by its program and arguments.
128
+ def words_refusal(words)
129
+ at = program_at(words)
130
+ program = at && File.basename(words[at])
131
+ args = at ? words[(at + 1)..] : []
132
+ return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
133
+ return PairBoundary::CHI if chi_in_values?(words)
134
+
135
+ upto = at ? words[0..at] : words
136
+ return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
137
+ return PairBoundary::ENVIRONMENT if env_reader?(program, args) || bare_env?(words)
138
+ return PairBoundary::GIT_CONFIG if program == "git" && git_runs_commands?(args)
139
+
140
+ nil
141
+ end
142
+
143
+ # chi in an assignment's value (FOO=chi, alias x='chi …') or in the
144
+ # word after an option that takes code or a command (-exec, -c, -e).
145
+ def chi_in_values?(words)
146
+ words.each_with_index.any? do |word, i|
147
+ value = word[/\A[A-Za-z_][\w-]*=(.*)\z/m, 1]
148
+ (value && value.match?(CHI_TEXT)) || (i.positive? && CODE_OPTIONS.include?(words[i - 1]) && word.match?(CHI_TEXT))
149
+ end
150
+ end
151
+
152
+ # The scripts the command hands on: a shell's (-c, eval, a heredoc or
153
+ # here-string) read as a command; an interpreter's (-e/-c, a heredoc)
154
+ # by its text.
155
+ def scripts_refusal(words)
156
+ limit = program_at(words) || (words.size - 1)
157
+ at = (0..limit).find { |i| runner?(words[i]) } or return nil
158
+ program = File.basename(words[at])
159
+ scripts(words, at).each do |script|
160
+ reason = if SHELLS.include?(program) || program == "eval"
161
+ PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root, depth: @depth + 1,
162
+ tmp_roots: @tmp_roots).refusal
163
+ elsif INTERPRETERS.include?(program)
164
+ script_text_refusal(script)
165
+ end
166
+ return reason if reason
167
+ end
168
+ nil
169
+ end
170
+
171
+ def runner?(word)
172
+ name = File.basename(word)
173
+ SHELLS.include?(name) || INTERPRETERS.include?(name) || name == "eval"
174
+ end
175
+
176
+ def scripts(words, at)
177
+ found = []
178
+ found << words[(at + 1)..].join(" ") if File.basename(words[at]) == "eval"
179
+ words.each_with_index do |word, i|
180
+ next if i.zero? || word == ShellLex::HEREDOC
181
+
182
+ previous = words[i - 1]
183
+ found << word if previous.start_with?("<<") || (i > at && %w[-c -e].include?(previous))
184
+ end
185
+ found
186
+ end
187
+
188
+ def script_text_refusal(script)
189
+ return PairBoundary::CHI if script.match?(CHI_TEXT)
190
+ return PairBoundary::SECRET if script.match?(PairBoundary::SECRET_TEXT)
191
+ return PairBoundary::ENVIRONMENT if script.match?(SCRIPT_ENV_TEXT) || script.match?(ENV_TEXT)
192
+ return PairBoundary::BACKGROUND if script.match?(SCRIPT_BACKGROUND_TEXT)
193
+
194
+ nil
195
+ end
196
+
197
+ # Where the program is in +words+: past assignments, wrappers, shell
198
+ # keywords and their options (and an option's value: ruby -I lib,
199
+ # timeout 10).
200
+ def program_at(words)
201
+ at = 0
202
+ at += 1 while at < words.size && skipped?(words, at)
203
+ at < words.size ? at : nil
204
+ end
205
+
206
+ def skipped?(words, at)
207
+ word = words[at]
208
+ return true if word.match?(/\A[A-Za-z_]\w*=/) || word.start_with?("-") || word.match?(/\A\d+[smhd]?\z/)
209
+ return true if WRAPPERS.include?(File.basename(word))
210
+
211
+ previous = at.positive? ? words[at - 1] : nil
212
+ !previous.nil? && previous.match?(/\A-[IrCen]\z/)
213
+ end
214
+
215
+ # screen -d…, tmux new -d: a session left running.
216
+ def detached_session?(program, args)
217
+ (program == "screen" && args.any? { |arg| arg.match?(/\A-\w*d/) }) ||
218
+ (program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
219
+ end
220
+
221
+ # printenv, security; set, export, declare and typeset that list
222
+ # (no assignment); launchctl getenv/export; ps with a BSD e (ps e, eww).
223
+ def env_reader?(program, args)
224
+ return true if ENV_READERS.include?(program)
225
+
226
+ listing = args.none? { |arg| !arg.start_with?("-", "+") }
227
+ case program
228
+ when "set" then args.empty?
229
+ when "export", "declare", "typeset" then listing
230
+ when "launchctl" then %w[getenv export].include?(args.first)
231
+ when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z/) }
232
+ else false
233
+ end
234
+ end
235
+
236
+ # env with no command after it prints the environment.
237
+ def bare_env?(words)
238
+ assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
239
+ at = words.index { |word| !assignment.call(word) }
240
+ return false unless at && File.basename(words[at]) == "env"
241
+
242
+ words[(at + 1)..].all?(&assignment)
243
+ end
244
+
245
+ # git config beyond the repository (--global, --system), or a setting
246
+ # that runs a command (core.hooksPath, core.fsmonitor, core.sshCommand,
247
+ # core.pager, core.editor, a "!" alias), set with git config or git -c.
248
+ def git_runs_commands?(args)
249
+ i = 0
250
+ while i < args.size && args[i].start_with?("-")
251
+ if args[i] == "-c"
252
+ return true if running_setting?(*args[i + 1].to_s.split("=", 2))
253
+
254
+ i += 2
255
+ elsif GIT_VALUE_OPTIONS.include?(args[i])
256
+ i += 2
257
+ else
258
+ i += 1
259
+ end
260
+ end
261
+ return false unless args[i] == "config"
262
+
263
+ rest = args[(i + 1)..]
264
+ return true if rest.any? { |arg| GIT_WIDE_SCOPES.include?(arg) }
265
+
266
+ key, value = rest.reject { |arg| arg.start_with?("-") }
267
+ running_setting?(key, value)
268
+ end
269
+
270
+ def running_setting?(key, value)
271
+ key = key.to_s.downcase
272
+ GIT_RUNNING_KEYS.include?(key) || (key.start_with?("alias.") && value.to_s.lstrip.start_with?("!"))
273
+ end
274
+
275
+ # The secrets a simple command's words reach: a resolved path on the
276
+ # denylist; a glob that matches one or whose fixed part leads into a
277
+ # denied dir; a $var word whose fixed part does; a sweeper given a
278
+ # folder above a denied one; SECRET_TEXT for a word it can't read.
279
+ def paths_refusal(words, resolved)
280
+ program_word = (at = program_at(words)) && File.basename(words[at])
281
+ resolved.each do |word, path|
282
+ if path
283
+ return PairBoundary::SECRET if @paths.secret_refusal([path])
284
+ return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
285
+ elsif unresolved_secret?(word) || (!data?(word) && word.match?(PairBoundary::SECRET_TEXT))
286
+ return PairBoundary::SECRET
287
+ end
288
+ end
289
+ nil
290
+ end
291
+
292
+ def data?(word) = word == ShellLex::HEREDOC
293
+
294
+ # A word Walk can't resolve: a glob checked by what it matches and by
295
+ # its fixed part, a $var by its fixed part (the text before the first
296
+ # glob character or $, with ~ and $HOME expanded). A script body
297
+ # (spaces, quotes), a data heredoc or a $(…) is left to the other checks.
298
+ def unresolved_secret?(word)
299
+ return false if word.include?("\0") || word.match?(ShellPaths::SCRIPT)
300
+
301
+ expanded = word.sub(%r{\A~(?=/|\z)}, @home).sub(/\A\$(?:\{HOME\}|HOME\b)/, @home)
302
+ fixed = expanded[/\A[^*?\[\]{}$]*/]
303
+ dir = @cwd || Dir.pwd
304
+ if !expanded.include?("$") && expanded.match?(GLOB)
305
+ matches = Dir.glob(File.expand_path(expanded, dir)).first(GLOB_LIMIT)
306
+ return true unless @paths.secret_refusal(matches).nil?
307
+ end
308
+ return false if fixed.empty?
309
+
310
+ leads_into_denied?(File.expand_path(fixed, dir))
311
+ end
312
+
313
+ # Whether a path's text is the start of a denied root's (its parent,
314
+ # or part of its name: ~/.ss) or inside one.
315
+ def leads_into_denied?(prefix)
316
+ @paths.denied_roots.any? { |root| root.start_with?(prefix) || ShellPaths.within?(prefix, root) }
317
+ end
318
+
319
+ # Whether +path+ is a denied root's folder or one above it.
320
+ def above_denied?(path)
321
+ real = ProtectedPaths.real(path)
322
+ @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
323
+ end
324
+
325
+ # The paths a simple command writes (nil for one it can't resolve), from
326
+ # the dir it runs in (before a cd in it takes effect).
327
+ def write_targets(words, walk)
328
+ targets = []
329
+ words.each_with_index do |word, i|
330
+ if REDIRECTS.include?(word) && words[i + 1]
331
+ targets << walk.path(words[i + 1])
332
+ elsif (target = word[REDIRECT_WORD, 1]) && !word.start_with?("<")
333
+ targets << walk.path(target)
334
+ end
335
+ end
336
+ at = program_at(words) or return targets
337
+ program = File.basename(words[at])
338
+ args = words[(at + 1)..].reject { |w| REDIRECTS.include?(w) || w.match?(REDIRECT_WORD) }
339
+ plain = args.reject { |arg| arg.start_with?("-") }
340
+ if program == "tee"
341
+ targets.concat(plain.map { |arg| walk.path(arg) })
342
+ elsif COPIERS.include?(program)
343
+ dir = args[args.index("-t") + 1] if args.include?("-t")
344
+ dir ||= args.find { |arg| arg.start_with?("--target-directory=") }&.split("=", 2)&.last
345
+ dir ||= plain.last if plain.size >= 2
346
+ targets << walk.path(dir) if dir
347
+ end
348
+ targets
349
+ end
350
+
351
+ def outside?(path)
352
+ return true if path.nil?
353
+ return false if DEVICES.include?(path)
354
+
355
+ real = ProtectedPaths.real(path)
356
+ return false if tmp_roots.any? { |tmp| ShellPaths.within?(real, tmp) }
357
+ return true if @root.empty?
358
+
359
+ !ShellPaths.within?(real, ProtectedPaths.real(@root))
360
+ end
361
+
362
+ def tmp_roots = @tmp_roots ||= PairCommand.tmp_roots
363
+ end
364
+ end
365
+ end