agents_control 0.2.1 → 0.3.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/README.md +131 -2
- data/lib/agents_control/agents/claude_code.rb +15 -0
- data/lib/agents_control/channels/telegram/api.rb +74 -0
- data/lib/agents_control/channels/telegram/router.rb +367 -19
- data/lib/agents_control/channels/telegram/settings_menu.rb +12 -2
- data/lib/agents_control/cli.rb +103 -1
- data/lib/agents_control/config.rb +16 -1
- data/lib/agents_control/daemon.rb +22 -3
- data/lib/agents_control/doctor.rb +19 -1
- data/lib/agents_control/inbox.rb +161 -0
- data/lib/agents_control/passphrase.rb +154 -0
- data/lib/agents_control/policy.rb +94 -0
- data/lib/agents_control/screen_watcher.rb +14 -3
- data/lib/agents_control/secrets.rb +5 -1
- data/lib/agents_control/terminals/base.rb +12 -1
- data/lib/agents_control/version.rb +1 -1
- data/lib/agents_control.rb +4 -0
- metadata +4 -1
|
@@ -16,7 +16,22 @@ module AgentsControl
|
|
|
16
16
|
# the setup wizard. Without this filter, anyone who found the bot
|
|
17
17
|
# could approve command execution on this machine.
|
|
18
18
|
"allowed_chat_ids" => [],
|
|
19
|
-
"poll_timeout" => 30
|
|
19
|
+
"poll_timeout" => 30,
|
|
20
|
+
# How long a file sent from the phone stays on disk. An agent
|
|
21
|
+
# reads it within minutes of it arriving; the directory is full
|
|
22
|
+
# of screenshots and logs, and without a sweep it only grows.
|
|
23
|
+
"inbox_keep_days" => 14,
|
|
24
|
+
# Whether a destructive command with no passphrase set offers to
|
|
25
|
+
# set one. Turned off by "Don't ask again": someone who has
|
|
26
|
+
# decided they don't want a passphrase shouldn't meet a wizard
|
|
27
|
+
# at every rm -rf, which is the nagging this is meant to avoid.
|
|
28
|
+
"offer_passphrase" => true,
|
|
29
|
+
# Whether the one-time notice has gone out. Sent once, on the
|
|
30
|
+
# first daemon start where no passphrase is set — otherwise
|
|
31
|
+
# someone who never types a destructive command themselves never
|
|
32
|
+
# learns the option exists, and the first one that does turn up
|
|
33
|
+
# may not be theirs.
|
|
34
|
+
"passphrase_notice_sent" => false
|
|
20
35
|
},
|
|
21
36
|
"answers" => {
|
|
22
37
|
# Answering "continue" on the user's behalf is safe.
|
|
@@ -187,27 +187,44 @@ module AgentsControl
|
|
|
187
187
|
# instance, if another agents_control is already running nearby with
|
|
188
188
|
# a different secret.
|
|
189
189
|
def verify_hooks
|
|
190
|
-
|
|
191
|
-
hook = settings.dig("hooks", "Stop", 0, "hooks", 0)
|
|
190
|
+
hook = Agents::ClaudeCode.own_hook
|
|
192
191
|
return log("couldn't find our own hooks in settings — events won't arrive") unless hook
|
|
193
192
|
|
|
194
193
|
code = probe_hook(hook)
|
|
195
194
|
return if code == 200
|
|
196
195
|
|
|
196
|
+
if code == :not_loopback
|
|
197
|
+
return log("WARNING: the hook in settings.json doesn't point at this machine — " \
|
|
198
|
+
"run `agents_control hooks install` to put it back.")
|
|
199
|
+
end
|
|
200
|
+
|
|
197
201
|
log("WARNING: the hook returned #{code}, agent events won't reach me.")
|
|
198
202
|
log("This usually means another agents_control is running nearby — stop it.")
|
|
199
203
|
rescue StandardError => e
|
|
200
204
|
log("couldn't verify hooks: #{e.message}")
|
|
201
205
|
end
|
|
202
206
|
|
|
207
|
+
# Our own hook is always loopback, and this replays the entry's
|
|
208
|
+
# headers — which carry its bearer secret. Anything but 127.0.0.1
|
|
209
|
+
# means the file was edited by something other than us, and the
|
|
210
|
+
# answer to that is to reinstall the hooks, not to send our
|
|
211
|
+
# credentials to whatever address is now written there.
|
|
212
|
+
LOOPBACK = %w[127.0.0.1 localhost ::1].freeze
|
|
213
|
+
|
|
203
214
|
def probe_hook(hook)
|
|
204
215
|
uri = URI(hook["url"])
|
|
216
|
+
return :not_loopback unless LOOPBACK.include?(uri.host)
|
|
217
|
+
|
|
205
218
|
request = Net::HTTP::Post.new(uri)
|
|
206
219
|
request["Content-Type"] = "application/json"
|
|
207
220
|
hook.fetch("headers", {}).each { |key, value| request[key] = value }
|
|
208
221
|
request.body = JSON.generate({ "hook_event_name" => "Ping" })
|
|
209
222
|
|
|
210
|
-
|
|
223
|
+
# use_ssl follows the scheme rather than defaulting to off: without
|
|
224
|
+
# it Net::HTTP writes the request, Authorization header and all, as
|
|
225
|
+
# cleartext to an https port.
|
|
226
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
227
|
+
read_timeout: 5) { |http| http.request(request) }.code.to_i
|
|
211
228
|
end
|
|
212
229
|
|
|
213
230
|
def publish_commands
|
|
@@ -237,6 +254,8 @@ module AgentsControl
|
|
|
237
254
|
config: @config, pending: pending, logger: @logger
|
|
238
255
|
)
|
|
239
256
|
|
|
257
|
+
router.announce(@config.get("telegram.allowed_chat_ids", []))
|
|
258
|
+
|
|
240
259
|
@bot = Channels::Telegram::Bot.new(api: api, router: router, store: @store,
|
|
241
260
|
config: @config, logger: @logger).start
|
|
242
261
|
end
|
|
@@ -27,7 +27,7 @@ module AgentsControl
|
|
|
27
27
|
|
|
28
28
|
CHECKS = %i[
|
|
29
29
|
ruby_check terminal_check automation_check agent_binary_check
|
|
30
|
-
token_check chats_check bot_check daemon_check hooks_check
|
|
30
|
+
token_check chats_check passphrase_check bot_check daemon_check hooks_check
|
|
31
31
|
anchors_check wake_check
|
|
32
32
|
].freeze
|
|
33
33
|
|
|
@@ -46,6 +46,24 @@ module AgentsControl
|
|
|
46
46
|
|
|
47
47
|
private
|
|
48
48
|
|
|
49
|
+
# An unusable record is the case worth naming out loud: everywhere
|
|
50
|
+
# else it's treated as "no passphrase" so the command stays
|
|
51
|
+
# reachable, and without this nobody would ever learn that what
|
|
52
|
+
# they set isn't being used.
|
|
53
|
+
def passphrase_check
|
|
54
|
+
store = Passphrase.new(secrets: @secrets)
|
|
55
|
+
|
|
56
|
+
if store.unusable?
|
|
57
|
+
warn("passphrase", "stored, but not in a format this version reads",
|
|
58
|
+
"agents_control passphrase set — writes a fresh one")
|
|
59
|
+
elsif store.configured?
|
|
60
|
+
ok("passphrase", "set — destructive commands ask for it")
|
|
61
|
+
else
|
|
62
|
+
warn("passphrase", "not set — destructive commands ask for a tap",
|
|
63
|
+
"agents_control passphrase set")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
49
67
|
def ok(name, detail) = Check.new(name: name, status: :ok, detail: detail)
|
|
50
68
|
def warn(name, detail, fix = nil) = Check.new(name: name, status: :warn, detail: detail, fix: fix)
|
|
51
69
|
def fail(name, detail, fix = nil) = Check.new(name: name, status: :fail, detail: detail, fix: fix)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module AgentsControl
|
|
6
|
+
# Files arriving from Telegram — a screenshot, a log, a CSV, a screen
|
|
7
|
+
# recording — landed on disk so an agent can read them.
|
|
8
|
+
#
|
|
9
|
+
# On disk, and not as a link, for one reason: Telegram serves file
|
|
10
|
+
# bytes from `/file/bot<TOKEN>/<path>`, so the download URL *is* the
|
|
11
|
+
# bot token. Handing that to an agent would put the token in its
|
|
12
|
+
# context, in the pane, and in the shell history — and this project
|
|
13
|
+
# treats a leaked bot token as equivalent to remote code execution.
|
|
14
|
+
# The URL is built inside Api#download and never leaves it. What the
|
|
15
|
+
# agent gets is a local path.
|
|
16
|
+
class Inbox
|
|
17
|
+
# Order matters: a photo comes as an array of sizes and the last is
|
|
18
|
+
# the largest, while a document carries the original filename. A
|
|
19
|
+
# message can hold exactly one of these.
|
|
20
|
+
KINDS = %w[document photo video audio voice video_note animation].freeze
|
|
21
|
+
|
|
22
|
+
# getFile refuses to serve anything larger, so a bigger file is
|
|
23
|
+
# rejected before the round trip rather than after it. The declared
|
|
24
|
+
# size is only the sender's word, though, so the same bound is
|
|
25
|
+
# enforced again on the bytes as they actually arrive.
|
|
26
|
+
MAX_BYTES = 20 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# Kept out of the project directory on purpose: files arrive from a
|
|
29
|
+
# phone, and dropping them into whatever repository happened to be
|
|
30
|
+
# open would put them in front of `git status` and eventually into
|
|
31
|
+
# a commit.
|
|
32
|
+
def self.path
|
|
33
|
+
base = ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state")
|
|
34
|
+
File.join(base, "agents_control", "inbox")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Attachment = Struct.new(:file_id, :file_name, :file_size, :kind, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
# The attachment in a message, or nil if it carries none.
|
|
40
|
+
def self.attachment(message)
|
|
41
|
+
KINDS.each do |kind|
|
|
42
|
+
value = message[kind]
|
|
43
|
+
next unless value
|
|
44
|
+
|
|
45
|
+
# photo is an array of increasing sizes; the last is the one
|
|
46
|
+
# worth having, since the smaller ones are Telegram's own
|
|
47
|
+
# thumbnails rather than what was sent.
|
|
48
|
+
descriptor = value.is_a?(Array) ? value.last : value
|
|
49
|
+
next unless descriptor.is_a?(Hash) && descriptor["file_id"]
|
|
50
|
+
|
|
51
|
+
return Attachment.new(file_id: descriptor["file_id"], file_name: descriptor["file_name"],
|
|
52
|
+
file_size: descriptor["file_size"], kind: kind)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class TooLarge < StandardError; end
|
|
59
|
+
|
|
60
|
+
def initialize(api:, dir: self.class.path, keep_days: 14)
|
|
61
|
+
@api = api
|
|
62
|
+
@dir = dir
|
|
63
|
+
@keep_days = keep_days
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The local path the file landed at.
|
|
67
|
+
def fetch(attachment)
|
|
68
|
+
size = attachment.file_size.to_i
|
|
69
|
+
raise TooLarge, "#{human(size)} is over the #{human(MAX_BYTES)} Telegram allows a bot to download" if size > MAX_BYTES
|
|
70
|
+
|
|
71
|
+
remote = @api.file_path_for(attachment.file_id)
|
|
72
|
+
prepare
|
|
73
|
+
destination = File.join(@dir, name_for(attachment, remote))
|
|
74
|
+
|
|
75
|
+
download(remote, destination)
|
|
76
|
+
prune
|
|
77
|
+
destination
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Downloaded beside the destination and renamed into place: a
|
|
83
|
+
# connection that drops half way through otherwise leaves a
|
|
84
|
+
# truncated file with a name that promises a whole one, and the
|
|
85
|
+
# agent reads it as though it were complete.
|
|
86
|
+
def download(remote, destination)
|
|
87
|
+
partial = "#{destination}.part"
|
|
88
|
+
@api.download(remote, to: partial, max_bytes: MAX_BYTES)
|
|
89
|
+
File.chmod(0o600, partial)
|
|
90
|
+
File.rename(partial, destination)
|
|
91
|
+
ensure
|
|
92
|
+
FileUtils.rm_f(partial) if partial && File.exist?(partial)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prepare
|
|
96
|
+
FileUtils.mkdir_p(@dir)
|
|
97
|
+
# Logs and screenshots are exactly the kind of thing that carries
|
|
98
|
+
# a token or a customer's name through in passing.
|
|
99
|
+
File.chmod(0o700, @dir)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A timestamp prefix rather than a check for collisions: two
|
|
103
|
+
# screenshots sent a minute apart are both called Screenshot.png,
|
|
104
|
+
# and the second one silently replacing the first is worse than
|
|
105
|
+
# having both.
|
|
106
|
+
def name_for(attachment, remote)
|
|
107
|
+
stamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
108
|
+
base = sanitize(attachment.file_name)
|
|
109
|
+
# The fallback extension comes from getFile's answer, which is
|
|
110
|
+
# Telegram's word and not ours — the API client escapes the same
|
|
111
|
+
# value per segment before it goes into a URL, and it gets the
|
|
112
|
+
# same treatment here rather than being spliced into a filename
|
|
113
|
+
# that will be typed into a shell.
|
|
114
|
+
base = "#{attachment.kind}#{extension(remote)}" if base.empty?
|
|
115
|
+
|
|
116
|
+
"#{stamp}-#{base}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Its own cleaning rather than sanitize's, which is written for a
|
|
120
|
+
# whole filename and strips the leading dot an extension needs.
|
|
121
|
+
# Everything but word characters and dots goes: this ends up in a
|
|
122
|
+
# path that gets typed into a shell, and it comes from getFile's
|
|
123
|
+
# answer — Telegram's word, not ours. The API client escapes the
|
|
124
|
+
# same value per segment before putting it in a URL.
|
|
125
|
+
def extension(remote)
|
|
126
|
+
File.extname(remote.to_s).gsub(/[^\w.]/, "")[0, 16]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The name comes from whoever sent the file. It decides a path here,
|
|
130
|
+
# so it doesn't get to contain one: no separators, no traversal, no
|
|
131
|
+
# leading dot to hide the result, and short enough for any
|
|
132
|
+
# filesystem to accept.
|
|
133
|
+
def sanitize(name)
|
|
134
|
+
# Stripped before File.basename, which raises on a null byte
|
|
135
|
+
# rather than returning anything — and the name is JSON from
|
|
136
|
+
# Telegram, so it can carry one.
|
|
137
|
+
safe = name.to_s.delete("\u0000").tr("\n\r\t", " ")
|
|
138
|
+
cleaned = File.basename(safe).gsub(/[^\w.\- ]/, "_").squeeze("_").strip
|
|
139
|
+
cleaned = cleaned.sub(/\A\.+/, "")
|
|
140
|
+
|
|
141
|
+
cleaned.length > 96 ? "#{cleaned[0, 60]}#{File.extname(cleaned)[0, 16]}" : cleaned
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Nothing here is meant to be kept: an agent reads the file within
|
|
145
|
+
# minutes of it arriving. Without this the directory only ever
|
|
146
|
+
# grows, and it's full of screenshots and logs.
|
|
147
|
+
def prune
|
|
148
|
+
cutoff = Time.now - (@keep_days * 24 * 60 * 60)
|
|
149
|
+
|
|
150
|
+
Dir.glob(File.join(@dir, "*")).each do |path|
|
|
151
|
+
FileUtils.rm_f(path) if File.file?(path) && File.mtime(path) < cutoff
|
|
152
|
+
end
|
|
153
|
+
rescue SystemCallError
|
|
154
|
+
# A directory that can't be tidied is not a reason to refuse the
|
|
155
|
+
# file that was just asked for.
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def human(bytes) = "#{(bytes / 1024.0 / 1024.0).round(1)}MB"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
# The passphrase that unlocks a catastrophic command from Telegram.
|
|
8
|
+
#
|
|
9
|
+
# Stored the same place as the bot token — Keychain or libsecret,
|
|
10
|
+
# never the config file, never argv — and stored derived rather than
|
|
11
|
+
# plain: a token grants access, so it has to be readable back, but a
|
|
12
|
+
# passphrase only ever has to be *checked*, and there's no reason to
|
|
13
|
+
# keep something readable when a comparison would do.
|
|
14
|
+
#
|
|
15
|
+
# PBKDF2-HMAC-SHA256 through OpenSSL, which is stdlib. bcrypt or
|
|
16
|
+
# argon2 would be better at resisting an offline attack, and both are
|
|
17
|
+
# native extensions — the gem promises a pure-Ruby install with
|
|
18
|
+
# nothing to compile, and that promise is worth more here than the
|
|
19
|
+
# margin: anyone who can read this hash out of the Keychain can
|
|
20
|
+
# already read the bot token sitting next to it, which is the whole
|
|
21
|
+
# machine anyway.
|
|
22
|
+
class Passphrase
|
|
23
|
+
KEY = "run_passphrase"
|
|
24
|
+
ITERATIONS = 210_000
|
|
25
|
+
LENGTH = 32
|
|
26
|
+
|
|
27
|
+
# The wizard's two prompts are minutes apart at most, but what
|
|
28
|
+
# bridges them is written to the store on disk (0600, with a TTL) —
|
|
29
|
+
# so it gets the same cost as the record it will become. Cheaper
|
|
30
|
+
# here would mean the throwaway derivation is the easier of the two
|
|
31
|
+
# to attack offline, which is a strange place to save 200ms.
|
|
32
|
+
SEAL_ITERATIONS = ITERATIONS
|
|
33
|
+
PREFIX = "pbkdf2-sha256"
|
|
34
|
+
|
|
35
|
+
# A passphrase short enough to guess isn't protecting anything from
|
|
36
|
+
# someone holding an unlocked phone, which is the case this exists
|
|
37
|
+
# for in the first place.
|
|
38
|
+
MINIMUM = 8
|
|
39
|
+
|
|
40
|
+
class TooShort < StandardError; end
|
|
41
|
+
|
|
42
|
+
# The write went nowhere. Secrets#set reports which provider it
|
|
43
|
+
# picked, not whether that provider succeeded, and a Keychain that
|
|
44
|
+
# refused (locked, no authorization from a background daemon) would
|
|
45
|
+
# otherwise be indistinguishable from one that saved — leaving
|
|
46
|
+
# someone told their passphrase is set while destructive commands
|
|
47
|
+
# go on asking for a tap.
|
|
48
|
+
class NotSaved < StandardError; end
|
|
49
|
+
|
|
50
|
+
def initialize(secrets: Secrets.new)
|
|
51
|
+
@secrets = secrets
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# "There's a record this version can actually check against", not
|
|
55
|
+
# "the entry isn't empty". The two diverge exactly when something
|
|
56
|
+
# unusable is stored — a plain string put in the environment
|
|
57
|
+
# variable, a truncated write, a format from a version this one was
|
|
58
|
+
# downgraded from — and every one of those would otherwise ask for
|
|
59
|
+
# a passphrase that can never match. Unusable is treated as absent,
|
|
60
|
+
# which falls back to the confirmation button rather than locking
|
|
61
|
+
# the command away. `doctor` reports the difference, so it isn't
|
|
62
|
+
# silent.
|
|
63
|
+
def configured? = !parse(stored).nil?
|
|
64
|
+
|
|
65
|
+
# Whether something is stored that isn't usable — for doctor, which
|
|
66
|
+
# is the one place that should say so out loud.
|
|
67
|
+
def unusable? = !stored.to_s.empty? && parse(stored).nil?
|
|
68
|
+
|
|
69
|
+
def set(plain)
|
|
70
|
+
value = plain.to_s
|
|
71
|
+
raise TooShort, "the passphrase needs at least #{MINIMUM} characters" if value.length < MINIMUM
|
|
72
|
+
|
|
73
|
+
salt = SecureRandom.hex(16)
|
|
74
|
+
@secrets.set(KEY, [PREFIX, ITERATIONS, salt, derive(value, salt, ITERATIONS)].join("$"))
|
|
75
|
+
|
|
76
|
+
raise NotSaved, "the secret store didn't keep it" unless matches?(value)
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def clear = @secrets.delete(KEY)
|
|
82
|
+
|
|
83
|
+
# False whenever anything is off — no passphrase set, a record this
|
|
84
|
+
# version doesn't understand, an empty candidate. A check that
|
|
85
|
+
# can't be made is a check that didn't pass.
|
|
86
|
+
def matches?(candidate)
|
|
87
|
+
record = parse(stored)
|
|
88
|
+
return false unless record
|
|
89
|
+
return false if candidate.to_s.empty?
|
|
90
|
+
|
|
91
|
+
secure_compare(derive(candidate.to_s, record[:salt], record[:iterations]), record[:hash])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Derives against a throwaway salt so two entries can be compared
|
|
95
|
+
# without either being kept. The wizard asks twice, and the first
|
|
96
|
+
# answer waits for the second in the store — as a derivation, never
|
|
97
|
+
# as the passphrase itself.
|
|
98
|
+
def self.sealed(value)
|
|
99
|
+
salt = SecureRandom.hex(16)
|
|
100
|
+
[salt, OpenSSL::KDF.pbkdf2_hmac(value.to_s, salt: salt, iterations: SEAL_ITERATIONS,
|
|
101
|
+
length: LENGTH, hash: "SHA256").unpack1("H*")]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.sealed_matches?(sealed, candidate)
|
|
105
|
+
salt, expected = sealed
|
|
106
|
+
return false if salt.to_s.empty? || candidate.to_s.empty?
|
|
107
|
+
|
|
108
|
+
OpenSSL.secure_compare(
|
|
109
|
+
OpenSSL::KDF.pbkdf2_hmac(candidate.to_s, salt: salt, iterations: SEAL_ITERATIONS,
|
|
110
|
+
length: LENGTH, hash: "SHA256").unpack1("H*"),
|
|
111
|
+
expected.to_s
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Read past the environment-variable provider on purpose. A
|
|
118
|
+
# passphrase is only ever checked, never read back, so there's no
|
|
119
|
+
# reason to accept one from the environment — and a plain one put
|
|
120
|
+
# in AGENTS_CONTROL_RUN_PASSPHRASE would take precedence over the
|
|
121
|
+
# Keychain, match nothing, and lock the command away for good:
|
|
122
|
+
# `passphrase clear` only writes to writable providers, and the
|
|
123
|
+
# environment isn't one.
|
|
124
|
+
def stored
|
|
125
|
+
@secrets.get(KEY, skip: Secrets::Providers::Env)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A record is four fields with our prefix, a salt, a positive
|
|
129
|
+
# iteration count, and a hash. Anything else is not a passphrase
|
|
130
|
+
# this version can check.
|
|
131
|
+
def parse(record)
|
|
132
|
+
prefix, iterations, salt, hash = record.to_s.split("$", 4)
|
|
133
|
+
return unless prefix == PREFIX
|
|
134
|
+
return unless salt.to_s.length.positive? && hash.to_s.length.positive?
|
|
135
|
+
return unless iterations.to_s.match?(/\A[1-9][0-9]*\z/)
|
|
136
|
+
|
|
137
|
+
{ iterations: iterations.to_i, salt: salt, hash: hash }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def derive(value, salt, iterations)
|
|
141
|
+
OpenSSL::KDF.pbkdf2_hmac(value, salt: salt.to_s, iterations: iterations,
|
|
142
|
+
length: LENGTH, hash: "SHA256").unpack1("H*")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Fixed-time comparison. The attacker here is someone typing into a
|
|
146
|
+
# Telegram chat rather than measuring microseconds, so this is
|
|
147
|
+
# closer to hygiene than to a threat being answered — but a
|
|
148
|
+
# comparison that leaks its answer through timing is never the
|
|
149
|
+
# right one to write.
|
|
150
|
+
def secure_compare(one, two)
|
|
151
|
+
OpenSSL.secure_compare(one.to_s, two.to_s)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
# Recognises a command from Telegram that must not go into a pane
|
|
5
|
+
# blind — the handful that destroy a disk or the machine outright.
|
|
6
|
+
#
|
|
7
|
+
# This is a seatbelt, not a security system. It answers one question:
|
|
8
|
+
# did the phone in someone's pocket just send something there is no
|
|
9
|
+
# coming back from? A wrong tab number in a list, a command pasted
|
|
10
|
+
# into the wrong chat, a thumb on the wrong line — those are the
|
|
11
|
+
# cases, and they're the reason the list stays small enough to hold
|
|
12
|
+
# in your head.
|
|
13
|
+
#
|
|
14
|
+
# The outcome is never a refusal. The person on the other end owns
|
|
15
|
+
# the machine; a check they can't get past doesn't prevent anything,
|
|
16
|
+
# it just makes them walk to the laptop and type it there.
|
|
17
|
+
class Policy
|
|
18
|
+
Verdict = Struct.new(:action, :reason, keyword_init: true) do
|
|
19
|
+
def allow? = action == :allow
|
|
20
|
+
def confirm? = action == :confirm
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ALLOWED = Verdict.new(action: :allow).freeze
|
|
24
|
+
|
|
25
|
+
# Targets that make a recursive force delete unrecoverable rather
|
|
26
|
+
# than merely annoying. `rm -rf /tmp/build` is someone's ordinary
|
|
27
|
+
# Tuesday; `rm -rf ~` is the end of the machine.
|
|
28
|
+
FATAL_TARGETS = ["/", "/*", "~", "~/", "~/*", "$HOME", "$HOME/*", "${HOME}", "${HOME}/*",
|
|
29
|
+
"/usr", "/etc", "/var", "/System", "/Library", "/Applications",
|
|
30
|
+
"/Users", "/home", "/opt", "/bin", "/sbin"].freeze
|
|
31
|
+
|
|
32
|
+
# The bar for an entry: catastrophic, irreversible, and nobody
|
|
33
|
+
# types it from a phone on purpose. Not "sensitive", not "usually a
|
|
34
|
+
# mistake" — `cat .env` and `git push --force` are somebody's
|
|
35
|
+
# ordinary afternoon, and a list that stops those trains people to
|
|
36
|
+
# tap past it without reading, which costs more than it saves.
|
|
37
|
+
#
|
|
38
|
+
# Each carries its own plain-language reason. "Matches a pattern"
|
|
39
|
+
# tells someone glancing at a phone nothing they can act on; "this
|
|
40
|
+
# erases your home directory" does.
|
|
41
|
+
CATASTROPHIC = [
|
|
42
|
+
[/:\(\)\s*\{[^}]*\|[^}]*&[^}]*\}\s*;\s*:/,
|
|
43
|
+
"that's a fork bomb — it locks the machine up until it's power-cycled"],
|
|
44
|
+
[/\bmkfs(\.\w+)?\b/,
|
|
45
|
+
"mkfs formats a filesystem — everything on that device is gone"],
|
|
46
|
+
[/\bdd\b[^|;]*\bof=\/dev\//,
|
|
47
|
+
"dd writing to a raw device destroys whatever filesystem is on it"]
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# An `rm` carrying both a recursive and a force flag — bundled
|
|
51
|
+
# (-rf, -fr, -Rf) or apart (-r -f, --recursive --force) — aimed at
|
|
52
|
+
# something there's no coming back from. Matched on the parsed
|
|
53
|
+
# operands rather than as a substring: "rm -rf /" and
|
|
54
|
+
# "rm -rf /tmp/build" differ by one character as text, and by
|
|
55
|
+
# everything else in what they do.
|
|
56
|
+
def self.reckless_delete(command)
|
|
57
|
+
words = command.to_s.split
|
|
58
|
+
# Matched on the command's basename, and case-insensitively:
|
|
59
|
+
# `/bin/rm` is the same program, and macOS's case-insensitive
|
|
60
|
+
# filesystem happily runs `RM` as well.
|
|
61
|
+
at = words.index { |word| File.basename(word).casecmp?("rm") }
|
|
62
|
+
return unless at
|
|
63
|
+
|
|
64
|
+
flags, operands = words.drop(at + 1).partition { |word| word.start_with?("-") }
|
|
65
|
+
joined = flags.join(" ")
|
|
66
|
+
return unless joined.match?(/--recursive|-[a-zA-Z]*[rR]/) && joined.match?(/--force|-[a-zA-Z]*f/)
|
|
67
|
+
|
|
68
|
+
target = operands.find { |operand| fatal_target?(operand) }
|
|
69
|
+
"this recursively deletes #{target} — there is no undo" if target
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Quotes come off before comparing: `rm -rf "$HOME"` is an entirely
|
|
73
|
+
# ordinary way to type the thing this exists to catch, and it isn't
|
|
74
|
+
# the same string as `rm -rf $HOME`.
|
|
75
|
+
def self.fatal_target?(operand)
|
|
76
|
+
bare = operand.to_s.gsub(/\A['"]|['"]\z/, "")
|
|
77
|
+
|
|
78
|
+
FATAL_TARGETS.include?(bare) || FATAL_TARGETS.include?(bare.chomp("/"))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.catastrophic(command)
|
|
82
|
+
reckless = reckless_delete(command)
|
|
83
|
+
return reckless if reckless
|
|
84
|
+
|
|
85
|
+
CATASTROPHIC.find { |pattern, _| command.to_s.match?(pattern) }&.last
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.check(command)
|
|
89
|
+
fatal = catastrophic(command)
|
|
90
|
+
|
|
91
|
+
fatal ? Verdict.new(action: :confirm, reason: fatal) : ALLOWED
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -95,17 +95,28 @@ module AgentsControl
|
|
|
95
95
|
# Looks for the last textual occurrence of "❯ 1. …" — the one
|
|
96
96
|
# closest to the screen's current state, not a random numbered list
|
|
97
97
|
# left over somewhere higher in the scrollback.
|
|
98
|
+
#
|
|
99
|
+
# Options don't have to be adjacent lines: a richer menu (a skill's
|
|
100
|
+
# own wizard, say) draws a couple of lines of description under
|
|
101
|
+
# each one, or a divider before a trailing meta-option ("Chat about
|
|
102
|
+
# this"). Anything that isn't itself the next numbered option is
|
|
103
|
+
# just skipped over rather than ending the scan — the option only
|
|
104
|
+
# actually ends once a numbered line shows up out of sequence
|
|
105
|
+
# (a stray list elsewhere on screen), which is what keeps this from
|
|
106
|
+
# picking up something unrelated further down.
|
|
98
107
|
def parse_menu(text)
|
|
99
108
|
lines = text.to_s.each_line.map(&:chomp)
|
|
100
109
|
start = lines.rindex { |line| line.match?(CURSOR_OPTION) }
|
|
101
110
|
return [] unless start
|
|
102
111
|
|
|
103
112
|
options = [lines[start][CURSOR_OPTION, 1]]
|
|
104
|
-
index = start + 1
|
|
105
113
|
|
|
106
|
-
|
|
114
|
+
lines[(start + 1)..].to_a.each do |line|
|
|
115
|
+
m = line.match(OPTION)
|
|
116
|
+
next unless m
|
|
117
|
+
break unless m[1].to_i == options.size + 1
|
|
118
|
+
|
|
107
119
|
options << m[2]
|
|
108
|
-
index += 1
|
|
109
120
|
end
|
|
110
121
|
|
|
111
122
|
options.size >= 2 ? options : []
|
|
@@ -25,8 +25,12 @@ module AgentsControl
|
|
|
25
25
|
@providers = providers || default_providers
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# skip names a provider class to read past — for a secret that has
|
|
29
|
+
# no business coming from one of them (see Passphrase).
|
|
30
|
+
def get(key, skip: nil)
|
|
29
31
|
readable.each do |provider|
|
|
32
|
+
next if skip && provider.is_a?(skip)
|
|
33
|
+
|
|
30
34
|
value = provider.get(key.to_s)
|
|
31
35
|
return value if value && !value.empty?
|
|
32
36
|
end
|
|
@@ -61,8 +61,19 @@ module AgentsControl
|
|
|
61
61
|
FIELD = "\x1F"
|
|
62
62
|
RECORD = "\x1E"
|
|
63
63
|
|
|
64
|
+
# tmux 3.5 prints a non-printable byte in -F output as its octal
|
|
65
|
+
# escape — the four characters "\037" — where 3.7 emits the byte
|
|
66
|
+
# itself. Splitting on the byte then finds nothing, and the whole
|
|
67
|
+
# backend reports no panes at all: on Debian 13 and Ubuntu 24.10,
|
|
68
|
+
# whose tmux is 3.5a, the tool showed an empty tab list and gave
|
|
69
|
+
# no hint why. Both forms are accepted rather than betting on a
|
|
70
|
+
# version, and only these two sequences are converted — a window
|
|
71
|
+
# name containing the literal text is not something to go looking
|
|
72
|
+
# for.
|
|
73
|
+
ESCAPED = { "\\037" => FIELD, "\\036" => RECORD }.freeze
|
|
74
|
+
|
|
64
75
|
def parse_records(output, fields)
|
|
65
|
-
output.split(RECORD).filter_map do |record|
|
|
76
|
+
output.to_s.gsub(/\\03[67]/, ESCAPED).split(RECORD).filter_map do |record|
|
|
66
77
|
next if record.strip.empty?
|
|
67
78
|
|
|
68
79
|
values = record.split(FIELD, -1)
|
data/lib/agents_control.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "io/console"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
# The base error is declared before the other files: error classes
|
|
6
7
|
# inherit from it right in a class body (the Telegram client, for
|
|
@@ -29,6 +30,9 @@ require_relative "agents_control/transcript"
|
|
|
29
30
|
require_relative "agents_control/agents/base"
|
|
30
31
|
require_relative "agents_control/agents/claude_code"
|
|
31
32
|
require_relative "agents_control/hooks/server"
|
|
33
|
+
require_relative "agents_control/inbox"
|
|
34
|
+
require_relative "agents_control/passphrase"
|
|
35
|
+
require_relative "agents_control/policy"
|
|
32
36
|
require_relative "agents_control/dispatcher"
|
|
33
37
|
require_relative "agents_control/channels/base"
|
|
34
38
|
require_relative "agents_control/channels/telegram/markdown"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agents_control
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sapar Kurmanov
|
|
@@ -58,9 +58,12 @@ files:
|
|
|
58
58
|
- lib/agents_control/event.rb
|
|
59
59
|
- lib/agents_control/executor.rb
|
|
60
60
|
- lib/agents_control/hooks/server.rb
|
|
61
|
+
- lib/agents_control/inbox.rb
|
|
61
62
|
- lib/agents_control/keyboard.rb
|
|
62
63
|
- lib/agents_control/menu.rb
|
|
64
|
+
- lib/agents_control/passphrase.rb
|
|
63
65
|
- lib/agents_control/pending.rb
|
|
66
|
+
- lib/agents_control/policy.rb
|
|
64
67
|
- lib/agents_control/process_probe.rb
|
|
65
68
|
- lib/agents_control/prompt.rb
|
|
66
69
|
- lib/agents_control/rate_limit_watcher.rb
|