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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +19 -7
- data/docs/cli.md +32 -1
- data/docs/configuration.md +55 -5
- data/docs/guardrails.md +119 -7
- data/docs/internals/engine.md +11 -2
- data/docs/internals/participants.md +76 -20
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +14 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +8 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
- data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/engine.rb +289 -12
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +46 -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/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +85 -0
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +42 -10
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +14 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_access/remote.rb +32 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
- data/lib/samagotchi/session_manager.rb +21 -7
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +13 -6
- data/lib/samagotchi/system_one/client.rb +16 -7
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +11 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +149 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +320 -0
- data/lib/samagotchi/toolkit_suggest.rb +377 -0
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/update_command.rb +7 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -13
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +8 -4
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +34 -10
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +12 -13
- 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/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +7 -5
- 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 +31 -2
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +7 -3
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_hub.rb +24 -3
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/web/update_check.rb +179 -0
- data/lib/samagotchi/worker.rb +60 -8
- data/lib/samagotchi/worker_link.rb +1 -0
- 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:
|
|
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
|