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
|
@@ -10,10 +10,27 @@ module AgentsControl
|
|
|
10
10
|
class Router
|
|
11
11
|
MAX_MESSAGE = Chunker::MAX_MESSAGE
|
|
12
12
|
|
|
13
|
+
# Long enough to walk somewhere quieter and type carefully,
|
|
14
|
+
# short enough that a challenge left unanswered doesn't sit
|
|
15
|
+
# around waiting to be replied to by accident tomorrow.
|
|
16
|
+
CHALLENGE_TTL = 300
|
|
17
|
+
|
|
13
18
|
# Commands in these tabs go to a remote server, not the laptop —
|
|
14
19
|
# they get a separate confirmation.
|
|
15
20
|
REMOTE_COMMANDS = %w[ssh mosh].freeze
|
|
16
21
|
|
|
22
|
+
# Programs that read keystrokes as their own input, not as a
|
|
23
|
+
# line to submit to a shell. Sending a command into one of
|
|
24
|
+
# these wouldn't run it — it'd feed the letters to whatever's
|
|
25
|
+
# already there instead: pager navigation, an editor's insert
|
|
26
|
+
# mode, a REPL evaluating each character-by-character. Confirmed
|
|
27
|
+
# against a real incident: `git log`'s pager was still open,
|
|
28
|
+
# "git status" went in as keystrokes, and nothing about it ran
|
|
29
|
+
# as a command at all.
|
|
30
|
+
INTERACTIVE_COMMANDS = %w[less more most vim vi nvim emacs nano pico
|
|
31
|
+
man top htop irb pry python python3 node
|
|
32
|
+
mysql psql sqlite3].freeze
|
|
33
|
+
|
|
17
34
|
# Shared list for the Telegram menu and for /help: a menu that's
|
|
18
35
|
# drifted from reality is worse than no menu. The third element
|
|
19
36
|
# is the argument syntax, needed only in /help; setMyCommands
|
|
@@ -35,7 +52,8 @@ module AgentsControl
|
|
|
35
52
|
HELP = (["Commands:", ""] +
|
|
36
53
|
COMMANDS.map { |name, text, hint| "/#{name}#{hint ? " #{hint}" : ''} — #{text}" }).join("\n")
|
|
37
54
|
|
|
38
|
-
def initialize(api:, registry:, store:, config:, keyboards: nil, pending: nil, logger: nil
|
|
55
|
+
def initialize(api:, registry:, store:, config:, keyboards: nil, pending: nil, logger: nil,
|
|
56
|
+
passphrase: nil, inbox: nil)
|
|
39
57
|
@api = api
|
|
40
58
|
@registry = registry
|
|
41
59
|
@store = store
|
|
@@ -43,6 +61,18 @@ module AgentsControl
|
|
|
43
61
|
@keyboards = keyboards || Keyboards.new(store: store)
|
|
44
62
|
@pending = pending
|
|
45
63
|
@logger = logger
|
|
64
|
+
@passphrase = passphrase || Passphrase.new
|
|
65
|
+
@inbox = inbox
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Called once by the daemon on start; everything else here is
|
|
69
|
+
# driven by an incoming update.
|
|
70
|
+
def announce(chat_ids)
|
|
71
|
+
chat_ids.each { |chat_id| announce_passphrase(chat_id) }
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
# A notice that couldn't be sent is not a reason for the
|
|
74
|
+
# daemon to fail to come up.
|
|
75
|
+
log("couldn't send the passphrase notice: #{e.class}")
|
|
46
76
|
end
|
|
47
77
|
|
|
48
78
|
def handle(update)
|
|
@@ -70,10 +100,29 @@ module AgentsControl
|
|
|
70
100
|
chat_id = message.dig("chat", "id")
|
|
71
101
|
return unless allowed?(chat_id)
|
|
72
102
|
|
|
103
|
+
attachment = Inbox.attachment(message)
|
|
104
|
+
return receive(chat_id, message, attachment) if attachment
|
|
105
|
+
|
|
73
106
|
text = message["text"].to_s.strip
|
|
74
107
|
|
|
75
108
|
replied = message["reply_to_message"]
|
|
76
|
-
|
|
109
|
+
if replied && !text.empty?
|
|
110
|
+
# Checked before answer_by_reply, which would otherwise take
|
|
111
|
+
# the passphrase for something to type into a pane — and a
|
|
112
|
+
# passphrase typed into a terminal is a passphrase in the
|
|
113
|
+
# shell history.
|
|
114
|
+
challenge = @store.take(challenge_key(chat_id, replied["message_id"]))
|
|
115
|
+
return unlock(chat_id, challenge, text, message["message_id"]) if challenge
|
|
116
|
+
|
|
117
|
+
# Scoped to a reply on the wizard's own message rather than
|
|
118
|
+
# "whatever comes next": otherwise a half-finished wizard
|
|
119
|
+
# swallows the next thing typed, which could be a /run or an
|
|
120
|
+
# answer an agent is waiting on.
|
|
121
|
+
step = @store.take(wizard_key(chat_id, replied["message_id"]))
|
|
122
|
+
return wizard_entry(chat_id, step, text, message["message_id"]) if step
|
|
123
|
+
|
|
124
|
+
return answer_by_reply(chat_id, replied, text)
|
|
125
|
+
end
|
|
77
126
|
|
|
78
127
|
return compose_answer(chat_id, text) if composing?(chat_id) && !text.start_with?("/")
|
|
79
128
|
|
|
@@ -119,6 +168,60 @@ module AgentsControl
|
|
|
119
168
|
end
|
|
120
169
|
end
|
|
121
170
|
|
|
171
|
+
# A file from the phone becomes a file on disk, and the agent is
|
|
172
|
+
# handed its path.
|
|
173
|
+
#
|
|
174
|
+
# A path and not a link: Telegram serves file bytes from a URL
|
|
175
|
+
# with the bot token in it, so pasting that into a pane would
|
|
176
|
+
# put the token in the terminal, in the agent's context, and in
|
|
177
|
+
# the shell history — and a leaked token is remote code
|
|
178
|
+
# execution on this machine.
|
|
179
|
+
def receive(chat_id, message, attachment)
|
|
180
|
+
path = inbox.fetch(attachment)
|
|
181
|
+
log("received #{attachment.kind}: #{File.basename(path)}")
|
|
182
|
+
|
|
183
|
+
deliver_file(chat_id, message, path)
|
|
184
|
+
rescue Inbox::TooLarge => e
|
|
185
|
+
say(chat_id, "That file is #{e.message}.")
|
|
186
|
+
rescue Api::Error, SystemCallError => e
|
|
187
|
+
say(chat_id, "Couldn't save that file: #{@api.redact(e.message)}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Three ways to say where it goes, in order of how explicit they
|
|
191
|
+
# are: a caption naming a tab, a reply to something already tied
|
|
192
|
+
# to a session, or neither — in which case the path is just
|
|
193
|
+
# handed back, since guessing at a target for a file is how it
|
|
194
|
+
# ends up pasted into the wrong project.
|
|
195
|
+
def deliver_file(chat_id, message, path)
|
|
196
|
+
caption = message["caption"].to_s.strip
|
|
197
|
+
|
|
198
|
+
if (target = caption[%r{\A/run\s+(\d+)}i, 1])
|
|
199
|
+
return with_session(chat_id, target) do |session|
|
|
200
|
+
execute(chat_id, session, with_path(caption.sub(%r{\A/run\s+\d+\s*}i, ""), path),
|
|
201
|
+
show_result: !session.agent?)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
replied = message["reply_to_message"]
|
|
206
|
+
remembered = replied && @store.get("reply:#{chat_id}:#{replied['message_id']}")
|
|
207
|
+
return type_into_session(chat_id, remembered, with_path(caption, path)) if remembered
|
|
208
|
+
|
|
209
|
+
say(chat_id, "Saved:\n\n`#{path}`\n\nReply to a session's message with a file to hand it " \
|
|
210
|
+
"straight over, or caption it `/run N`.")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Escaped, because a name like "quarterly report.csv" is
|
|
214
|
+
# entirely ordinary and an unescaped one arrives at the shell as
|
|
215
|
+
# two arguments. Shellwords leaves a path that needs nothing
|
|
216
|
+
# alone, so the common case stays readable.
|
|
217
|
+
def with_path(text, path)
|
|
218
|
+
[text, Shellwords.escape(path)].reject { |part| part.to_s.empty? }.join(" ")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def inbox
|
|
222
|
+
@inbox ||= Inbox.new(api: @api, keep_days: @config.get("telegram.inbox_keep_days", 14))
|
|
223
|
+
end
|
|
224
|
+
|
|
122
225
|
def composing_key(chat_id) = "composing:#{chat_id}"
|
|
123
226
|
|
|
124
227
|
def composing?(chat_id) = !@store.get(composing_key(chat_id)).nil?
|
|
@@ -196,19 +299,30 @@ module AgentsControl
|
|
|
196
299
|
|
|
197
300
|
with_session(chat_id, number) do |session|
|
|
198
301
|
next say(chat_id, "This session has no terminal — nothing to run there.") if session.terminalless?
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
302
|
+
next confirm_remote(chat_id, session, command) if remote?(session)
|
|
303
|
+
|
|
304
|
+
# An agent isn't a shell command that finishes in a couple
|
|
305
|
+
# of seconds — it's a real task, and hooks already own
|
|
306
|
+
# telling Telegram when it's actually done or needs
|
|
307
|
+
# something. Capturing a "result" a moment after typing
|
|
308
|
+
# would just catch it mid-thought and fall back to dumping
|
|
309
|
+
# its whole transcript, which isn't a result at all — same
|
|
310
|
+
# reasoning as replying to an agent's own question
|
|
311
|
+
# (type_into_session).
|
|
312
|
+
next execute(chat_id, session, command, show_result: false) if session.agent?
|
|
313
|
+
|
|
314
|
+
next confirm_interactive(chat_id, session, command) if interactive?(session)
|
|
315
|
+
|
|
316
|
+
# One capture serves two purposes: it's checked for a
|
|
317
|
+
# pager's bare `:` prompt right here, and reused as
|
|
318
|
+
# execute()'s own "before" snapshot if it turns out clean —
|
|
319
|
+
# capturing twice would desync anything that reads the
|
|
320
|
+
# screen expecting to see it change between calls.
|
|
321
|
+
before = capture_screen(session, lines: run_result_lines)
|
|
322
|
+
if paused_for_input?(before)
|
|
323
|
+
confirm_interactive(chat_id, session, command)
|
|
202
324
|
else
|
|
203
|
-
|
|
204
|
-
# couple of seconds — it's a real task, and hooks already
|
|
205
|
-
# own telling Telegram when it's actually done or needs
|
|
206
|
-
# something. Capturing a "result" a moment after typing
|
|
207
|
-
# would just catch it mid-thought and fall back to
|
|
208
|
-
# dumping its whole transcript, which isn't a result at
|
|
209
|
-
# all — same reasoning as replying to an agent's own
|
|
210
|
-
# question (type_into_session).
|
|
211
|
-
execute(chat_id, session, command, show_result: !session.agent?)
|
|
325
|
+
execute(chat_id, session, command, show_result: true, before: before)
|
|
212
326
|
end
|
|
213
327
|
end
|
|
214
328
|
end
|
|
@@ -219,6 +333,21 @@ module AgentsControl
|
|
|
219
333
|
REMOTE_COMMANDS.include?(session.foreground_command.to_s)
|
|
220
334
|
end
|
|
221
335
|
|
|
336
|
+
def interactive?(session)
|
|
337
|
+
INTERACTIVE_COMMANDS.include?(session.foreground_command.to_s)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Backstop for interactive? missing a pager by name — a custom
|
|
341
|
+
# $PAGER, or anything else not on that list. A bare `:` as the
|
|
342
|
+
# entire last line is the one thing practically every pager in
|
|
343
|
+
# the less/more lineage agrees on for "waiting on you"; a real
|
|
344
|
+
# shell prompt always has more on that line than a single
|
|
345
|
+
# colon. Takes the screen rather than the session so the
|
|
346
|
+
# caller's own capture can be reused instead of taking another.
|
|
347
|
+
def paused_for_input?(screen)
|
|
348
|
+
screen.to_s.rstrip.lines.last.to_s.strip == ":"
|
|
349
|
+
end
|
|
350
|
+
|
|
222
351
|
def confirm_remote(chat_id, session, command)
|
|
223
352
|
key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command },
|
|
224
353
|
ttl: 300)
|
|
@@ -233,6 +362,171 @@ module AgentsControl
|
|
|
233
362
|
markup: markup)
|
|
234
363
|
end
|
|
235
364
|
|
|
365
|
+
# A tap answers "did you mean this?", which covers the thumb on
|
|
366
|
+
# the wrong line. A passphrase answers "are you you?", which is
|
|
367
|
+
# the other half — an unlocked phone in someone else's hand
|
|
368
|
+
# taps just as well as its owner does. When one is set it
|
|
369
|
+
# replaces the button for these commands rather than joining
|
|
370
|
+
# it: typing a passphrase is already a deliberate act, and two
|
|
371
|
+
# steps for one decision is how people learn to hurry through
|
|
372
|
+
# both.
|
|
373
|
+
#
|
|
374
|
+
# No passphrase set and it's the button, exactly as before.
|
|
375
|
+
def confirm_policy(chat_id, session, command, reason)
|
|
376
|
+
return challenge(chat_id, session, command, reason) if passphrase.configured?
|
|
377
|
+
return confirm_tap(chat_id, session, command, reason) unless offer_passphrase?
|
|
378
|
+
|
|
379
|
+
offer(chat_id, session, command, reason)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def offer_passphrase? = @config.get("telegram.offer_passphrase", true)
|
|
383
|
+
|
|
384
|
+
# Shown instead of a bare button the first times a destructive
|
|
385
|
+
# command turns up with no passphrase set. It still runs on one
|
|
386
|
+
# tap — refusing to would break the tool — but the offer sits
|
|
387
|
+
# next to it, at the one moment the reason for it is on screen.
|
|
388
|
+
#
|
|
389
|
+
# "Don't ask again" makes the refusal stick. Without that this
|
|
390
|
+
# is a wizard that greets someone at every `rm -rf ~` forever,
|
|
391
|
+
# which is precisely the nagging it's supposed to avoid.
|
|
392
|
+
def offer(chat_id, session, command, reason)
|
|
393
|
+
run_key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command,
|
|
394
|
+
"vetted" => true }, ttl: 300)
|
|
395
|
+
set_key = @store.put({ "action" => "passphrase_wizard" }, ttl: 300)
|
|
396
|
+
hush_key = @store.put({ "action" => "passphrase_never" }, ttl: 300)
|
|
397
|
+
|
|
398
|
+
markup = { inline_keyboard: [
|
|
399
|
+
[{ text: "⚠️ Run anyway", callback_data: run_key }],
|
|
400
|
+
[{ text: "🔐 Set a passphrase", callback_data: set_key }],
|
|
401
|
+
[{ text: "Don't ask again", callback_data: hush_key }]
|
|
402
|
+
] }
|
|
403
|
+
|
|
404
|
+
say(chat_id, "#{reason}\n\n`#{command}`\n\nNothing was sent. Commands like this ask for a " \
|
|
405
|
+
"passphrase when one is set — right now there isn't one, so they ask for a tap " \
|
|
406
|
+
"instead, and a tap is something anyone holding this phone can do.",
|
|
407
|
+
markup: markup)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def confirm_tap(chat_id, session, command, reason)
|
|
411
|
+
key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command,
|
|
412
|
+
"vetted" => true }, ttl: 300)
|
|
413
|
+
|
|
414
|
+
markup = { inline_keyboard: [[
|
|
415
|
+
{ text: "⚠️ Run anyway", callback_data: key },
|
|
416
|
+
{ text: "cancel", callback_data: @store.put({ "action" => "cancel" }, ttl: 300) }
|
|
417
|
+
]] }
|
|
418
|
+
|
|
419
|
+
say(chat_id, "#{reason} — it won't go to #{session.label} without a confirmation:\n\n`#{command}`",
|
|
420
|
+
markup: markup)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def challenge(chat_id, session, command, reason)
|
|
424
|
+
sent = say(chat_id, "#{reason}\n\n`#{command}`\n\nReply to this message with the passphrase to " \
|
|
425
|
+
"send it to #{session.label}. Anything else cancels.")
|
|
426
|
+
return unless sent
|
|
427
|
+
|
|
428
|
+
@store.put({ "session_id" => session.id, "text" => command },
|
|
429
|
+
ttl: CHALLENGE_TTL, key: challenge_key(chat_id, sent["message_id"]))
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def challenge_key(chat_id, message_id) = "unlock:#{chat_id}:#{message_id}"
|
|
433
|
+
|
|
434
|
+
def wizard_key(chat_id, message_id) = "setpass:#{chat_id}:#{message_id}"
|
|
435
|
+
|
|
436
|
+
def start_wizard(chat_id)
|
|
437
|
+
return say(chat_id, "A passphrase is already set.") if passphrase.configured?
|
|
438
|
+
|
|
439
|
+
ask_entry(chat_id, "Reply to this message with a passphrase — at least " \
|
|
440
|
+
"#{Passphrase::MINIMUM} characters. It's deleted from the chat the moment " \
|
|
441
|
+
"it's read, and you'll be asked to type it once more.")
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def ask_entry(chat_id, prompt, sealed: nil)
|
|
445
|
+
sent = say(chat_id, prompt)
|
|
446
|
+
return unless sent
|
|
447
|
+
|
|
448
|
+
@store.put({ "sealed" => sealed }, ttl: CHALLENGE_TTL,
|
|
449
|
+
key: wizard_key(chat_id, sent["message_id"]))
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Both entries are deleted, and the first never waits as plain
|
|
453
|
+
# text: what's held between the two prompts is a derivation of
|
|
454
|
+
# it, which is enough to tell whether the second matches and no
|
|
455
|
+
# use for anything else.
|
|
456
|
+
def wizard_entry(chat_id, step, entry, message_id)
|
|
457
|
+
@api.delete_message(chat_id: chat_id, message_id: message_id)
|
|
458
|
+
|
|
459
|
+
return wizard_confirm(chat_id, step["sealed"], entry) if step["sealed"]
|
|
460
|
+
return ask_entry(chat_id, "That's under #{Passphrase::MINIMUM} characters. Reply with a longer one.") \
|
|
461
|
+
if entry.length < Passphrase::MINIMUM
|
|
462
|
+
|
|
463
|
+
ask_entry(chat_id, "Once more, to be sure.", sealed: Passphrase.sealed(entry))
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def wizard_confirm(chat_id, sealed, entry)
|
|
467
|
+
unless Passphrase.sealed_matches?(sealed, entry)
|
|
468
|
+
return ask_entry(chat_id, "Those two didn't match. Reply with a passphrase to start over.")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
save_passphrase(chat_id, entry)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Writing to the Keychain from a background daemon isn't the
|
|
475
|
+
# same as writing from a terminal: a locked keychain can put the
|
|
476
|
+
# `security` call in front of an authorization dialog nobody is
|
|
477
|
+
# looking at, and it fails on Executor's timeout rather than
|
|
478
|
+
# succeeding. Saying so beats a silent nothing.
|
|
479
|
+
def save_passphrase(chat_id, entry)
|
|
480
|
+
passphrase.set(entry)
|
|
481
|
+
say(chat_id, "Passphrase set. Destructive commands will ask for it from now on — " \
|
|
482
|
+
"send that command again if you still want it.\n\nSet from this chat, so it " \
|
|
483
|
+
"guards against whoever gets to it later, not whoever has it now. " \
|
|
484
|
+
"`agents_control passphrase set` at the machine if that matters.")
|
|
485
|
+
rescue Passphrase::TooShort => e
|
|
486
|
+
ask_entry(chat_id, "#{e.message.capitalize}. Reply with a longer one.")
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
log("couldn't save passphrase: #{e.class}")
|
|
489
|
+
say(chat_id, "Couldn't save it: #{@api.redact(e.message)}\n\n" \
|
|
490
|
+
"Set it at the machine instead: `agents_control passphrase set`")
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# The passphrase is deleted from the chat either way, and before
|
|
494
|
+
# anything else happens: whether it was right has no bearing on
|
|
495
|
+
# it not belonging in a message history that syncs to every
|
|
496
|
+
# device the account is signed in on.
|
|
497
|
+
def unlock(chat_id, challenge, attempt, message_id)
|
|
498
|
+
@api.delete_message(chat_id: chat_id, message_id: message_id)
|
|
499
|
+
|
|
500
|
+
unless passphrase.matches?(attempt)
|
|
501
|
+
log("unlock refused: session=#{challenge['session_id']}")
|
|
502
|
+
return say(chat_id, "That isn't the passphrase — nothing was sent. Send the command " \
|
|
503
|
+
"again to retry, or run `agents_control passphrase clear` at the " \
|
|
504
|
+
"machine if it's lost.")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
session = @registry.refresh.find(challenge["session_id"])
|
|
508
|
+
return say(chat_id, "That session is already closed.") unless session
|
|
509
|
+
|
|
510
|
+
execute(chat_id, session, challenge["text"], show_result: !session.agent?, vetted: true)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def passphrase = @passphrase
|
|
514
|
+
|
|
515
|
+
def confirm_interactive(chat_id, session, command)
|
|
516
|
+
key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command },
|
|
517
|
+
ttl: 300)
|
|
518
|
+
|
|
519
|
+
markup = { inline_keyboard: [[
|
|
520
|
+
{ text: "⚠️ Send anyway", callback_data: key },
|
|
521
|
+
{ text: "cancel", callback_data: @store.put({ "action" => "cancel" }, ttl: 300) }
|
|
522
|
+
]] }
|
|
523
|
+
|
|
524
|
+
say(chat_id, "#{session.label} is inside #{session.foreground_command} right now, " \
|
|
525
|
+
"not at a shell prompt — the text would go to that, not run as a command:\n\n" \
|
|
526
|
+
"`#{command}`",
|
|
527
|
+
markup: markup)
|
|
528
|
+
end
|
|
529
|
+
|
|
236
530
|
# Enter is sent as a separate call, not tacked onto the same
|
|
237
531
|
# input: a merged call can fail to send multi-line text at all.
|
|
238
532
|
TYPING_PAUSE = 0.4
|
|
@@ -249,9 +543,31 @@ module AgentsControl
|
|
|
249
543
|
|
|
250
544
|
def run_result_lines = @config.get("terminal.run_result_lines", 200)
|
|
251
545
|
|
|
252
|
-
|
|
546
|
+
# Every way text reaches a pane comes through here — /run, a
|
|
547
|
+
# reply, a file's caption, a confirmation that was taken — so
|
|
548
|
+
# this is where the guard belongs. It used to sit in `run`
|
|
549
|
+
# alone, which made it worth exactly as much as the least
|
|
550
|
+
# guarded of the other five callers: a caption reading
|
|
551
|
+
# "/run 3 rm -rf ~ #" went straight through.
|
|
552
|
+
#
|
|
553
|
+
# `vetted` is set only by the confirmations that already asked
|
|
554
|
+
# about *this* command. A tab running ssh gets its own
|
|
555
|
+
# confirmation first, and that answered a different question —
|
|
556
|
+
# "this goes to another machine", not "this destroys one" — so
|
|
557
|
+
# it doesn't count, and the guard still applies after it.
|
|
558
|
+
#
|
|
559
|
+
# Against an agent the text is a prompt rather than a command
|
|
560
|
+
# line: "look at how .env is loaded" is an ordinary thing to
|
|
561
|
+
# ask, and what the agent then tries to run is the hook path's
|
|
562
|
+
# business (Dispatcher#forbidden?).
|
|
563
|
+
def execute(chat_id, session, command, show_result: false, before: nil, vetted: false)
|
|
564
|
+
unless vetted || session.agent?
|
|
565
|
+
verdict = Policy.check(command)
|
|
566
|
+
return confirm_policy(chat_id, session, command, verdict.reason) if verdict.confirm?
|
|
567
|
+
end
|
|
568
|
+
|
|
253
569
|
backend = @registry.backend_for(session)
|
|
254
|
-
before
|
|
570
|
+
before ||= capture_screen(session, lines: run_result_lines) if show_result
|
|
255
571
|
|
|
256
572
|
ok = backend.send_text(session.id, command, newline: false) &&
|
|
257
573
|
sleep(TYPING_PAUSE).then { backend.send_text(session.id, "", newline: true) }
|
|
@@ -383,7 +699,7 @@ module AgentsControl
|
|
|
383
699
|
end
|
|
384
700
|
|
|
385
701
|
def settings_menu
|
|
386
|
-
@settings_menu ||= SettingsMenu.new(store: @store, config: @config)
|
|
702
|
+
@settings_menu ||= SettingsMenu.new(store: @store, config: @config, passphrase: passphrase)
|
|
387
703
|
end
|
|
388
704
|
|
|
389
705
|
def show_settings(chat_id)
|
|
@@ -425,7 +741,30 @@ module AgentsControl
|
|
|
425
741
|
say(chat_id, "Tabs: #{sessions.size}\nAgents: #{sessions.count(&:agent?)}\n" \
|
|
426
742
|
"Backends: #{backends.empty? ? 'none' : backends}\n" \
|
|
427
743
|
"Mode: #{@config.get('answers.away', false) ? '🚶 away' : '🪑 present'}\n" \
|
|
428
|
-
"Waiting for a reply: #{@pending ? @pending.size : 0}"
|
|
744
|
+
"Waiting for a reply: #{@pending ? @pending.size : 0}\n" \
|
|
745
|
+
"Destructive commands: #{passphrase.configured? ? '🔐 passphrase' : 'a tap'}")
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# One message, once, and only where there's something to say.
|
|
749
|
+
# Sent from the daemon on start rather than pushed at a moment
|
|
750
|
+
# someone is deciding something else — this bot carries real
|
|
751
|
+
# interruptions (an agent stopped, a permission is needed), and
|
|
752
|
+
# spending that attention on a setting nobody has chosen yet is
|
|
753
|
+
# how people learn to dismiss the ones that matter.
|
|
754
|
+
def announce_passphrase(chat_id)
|
|
755
|
+
return if passphrase.configured? || !offer_passphrase?
|
|
756
|
+
return if @config.get("telegram.passphrase_notice_sent", false)
|
|
757
|
+
|
|
758
|
+
key = @store.put({ "action" => "passphrase_wizard" }, ttl: 86_400)
|
|
759
|
+
hush = @store.put({ "action" => "passphrase_never" }, ttl: 86_400)
|
|
760
|
+
|
|
761
|
+
say(chat_id, "🔐 No passphrase set\n\nDestructive commands from this chat — `rm -rf ~`, " \
|
|
762
|
+
"`mkfs`, `dd` to a device, a fork bomb — ask for a tap on a button. A tap is " \
|
|
763
|
+
"something anyone holding this phone can do.",
|
|
764
|
+
markup: { inline_keyboard: [[{ text: "🔐 Set a passphrase", callback_data: key },
|
|
765
|
+
{ text: "Not now", callback_data: hush }]] })
|
|
766
|
+
|
|
767
|
+
@config.set("telegram.passphrase_notice_sent", true).save
|
|
429
768
|
end
|
|
430
769
|
|
|
431
770
|
def handle_callback(callback)
|
|
@@ -456,6 +795,8 @@ module AgentsControl
|
|
|
456
795
|
when "transcript" then show_transcript(chat_id, payload)
|
|
457
796
|
when "setting" then change_setting(chat_id, payload)
|
|
458
797
|
when "menu_choice" then choose_menu_option(chat_id, payload)
|
|
798
|
+
when "passphrase_wizard" then start_wizard(chat_id)
|
|
799
|
+
when "passphrase_never" then stop_offering(chat_id)
|
|
459
800
|
when "ask_question_choice" then answer_ask_user_question(chat_id, payload)
|
|
460
801
|
else act_on_session(chat_id, payload)
|
|
461
802
|
end
|
|
@@ -472,6 +813,12 @@ module AgentsControl
|
|
|
472
813
|
deliver(chat_id, payload["question_id"], reply)
|
|
473
814
|
end
|
|
474
815
|
|
|
816
|
+
def stop_offering(chat_id)
|
|
817
|
+
@config.set("telegram.offer_passphrase", false).save
|
|
818
|
+
say(chat_id, "Won't bring it up again. Destructive commands will keep asking for a tap, and " \
|
|
819
|
+
"`/settings` can still turn a passphrase on later.")
|
|
820
|
+
end
|
|
821
|
+
|
|
475
822
|
def start_compose(chat_id, payload)
|
|
476
823
|
@store.put(payload["question_id"], ttl: 600, key: composing_key(chat_id))
|
|
477
824
|
say(chat_id, "Write your reply as the next message.")
|
|
@@ -522,7 +869,8 @@ module AgentsControl
|
|
|
522
869
|
case payload["action"]
|
|
523
870
|
when "focus" then focus_session(chat_id, session)
|
|
524
871
|
when "screen" then show_screen(chat_id, session)
|
|
525
|
-
when "run" then execute(chat_id, session, payload["text"], show_result: !session.agent
|
|
872
|
+
when "run" then execute(chat_id, session, payload["text"], show_result: !session.agent?,
|
|
873
|
+
vetted: payload["vetted"] ? true : false)
|
|
526
874
|
when "close_confirm" then say(chat_id, "Close #{session.label}?",
|
|
527
875
|
markup: @keyboards.confirm("close", session))
|
|
528
876
|
when "close" then close_session(chat_id, session)
|
|
@@ -35,15 +35,17 @@ module AgentsControl
|
|
|
35
35
|
values: [100, 200, 500, 1000], unit: "", default: 200 }
|
|
36
36
|
].freeze
|
|
37
37
|
|
|
38
|
-
def initialize(store:, config:)
|
|
38
|
+
def initialize(store:, config:, passphrase: nil)
|
|
39
39
|
@store = store
|
|
40
40
|
@config = config
|
|
41
|
+
@passphrase = passphrase || Passphrase.new
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# List rows without a header — the console shows the same rows
|
|
44
45
|
# in a menu navigated with arrow keys.
|
|
45
46
|
def rows
|
|
46
|
-
(TOGGLES + CHOICES).map { |item| "#{item[:label]}: #{value_label(item)}" }
|
|
47
|
+
(TOGGLES + CHOICES).map { |item| "#{item[:label]}: #{value_label(item)}" } +
|
|
48
|
+
["Destructive commands: #{@passphrase.configured? ? '🔐 passphrase' : 'a tap'}"]
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def text
|
|
@@ -53,10 +55,18 @@ module AgentsControl
|
|
|
53
55
|
def markup
|
|
54
56
|
rows = TOGGLES.map { |item| [toggle_button(item)] }
|
|
55
57
|
rows += CHOICES.map { |item| [choice_button(item)] }
|
|
58
|
+
rows << [passphrase_button] unless @passphrase.configured?
|
|
56
59
|
|
|
57
60
|
{ inline_keyboard: rows }
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
# The one place the offer lives permanently, so a refusal
|
|
64
|
+
# elsewhere doesn't make it unreachable.
|
|
65
|
+
def passphrase_button
|
|
66
|
+
{ text: "🔐 Set a passphrase",
|
|
67
|
+
callback_data: @store.put({ "action" => "passphrase_wizard" }, ttl: 3600) }
|
|
68
|
+
end
|
|
69
|
+
|
|
60
70
|
# Apply a press and return what changed, to show the human.
|
|
61
71
|
def apply(payload)
|
|
62
72
|
item = find(payload["key"])
|
data/lib/agents_control/cli.rb
CHANGED
|
@@ -52,6 +52,11 @@ module AgentsControl
|
|
|
52
52
|
The token is entered without echo and saved to the Keychain or
|
|
53
53
|
libsecret. It's deliberately never accepted as a command-line
|
|
54
54
|
argument: it would leak into `ps` and shell history.
|
|
55
|
+
|
|
56
|
+
Finally, offers to set a passphrase for destructive commands
|
|
57
|
+
(rm -rf ~, mkfs, dd to a device, a fork bomb) arriving from
|
|
58
|
+
Telegram. Optional — without one they ask for a tap on a button
|
|
59
|
+
instead.
|
|
55
60
|
TEXT
|
|
56
61
|
def setup
|
|
57
62
|
token = ask_token
|
|
@@ -65,7 +70,9 @@ module AgentsControl
|
|
|
65
70
|
say("Token saved: #{secrets.target.name}", :green)
|
|
66
71
|
publish_commands(api)
|
|
67
72
|
|
|
68
|
-
capture_chat_id(api, me)
|
|
73
|
+
return unless capture_chat_id(api, me)
|
|
74
|
+
|
|
75
|
+
offer_passphrase
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
desc "daemon", "Run the daemon: agent hooks plus Telegram"
|
|
@@ -144,11 +151,105 @@ module AgentsControl
|
|
|
144
151
|
end
|
|
145
152
|
end
|
|
146
153
|
|
|
154
|
+
desc "passphrase SUBCOMMAND", "Passphrase for destructive commands (set/clear/status)"
|
|
155
|
+
long_desc <<~TEXT
|
|
156
|
+
A handful of commands are held back when they arrive from
|
|
157
|
+
Telegram — a recursive force delete aimed at / or $HOME, a fork
|
|
158
|
+
bomb, mkfs, dd to a raw device. Without a passphrase they come
|
|
159
|
+
back as a confirmation button. With one, they come back asking
|
|
160
|
+
for it, so an unlocked phone in someone else's hand can't tap
|
|
161
|
+
its way past them.
|
|
162
|
+
|
|
163
|
+
Entered without echo and stored derived (PBKDF2) in the Keychain
|
|
164
|
+
or libsecret. Deliberately never a command-line argument: it
|
|
165
|
+
would leak into `ps` and shell history.
|
|
166
|
+
TEXT
|
|
167
|
+
def passphrase(subcommand = "status")
|
|
168
|
+
store = Passphrase.new(secrets: secrets)
|
|
169
|
+
|
|
170
|
+
case subcommand
|
|
171
|
+
when "set" then set_passphrase(store)
|
|
172
|
+
when "clear" then clear_passphrase(store)
|
|
173
|
+
when "status" then say(store.configured? ? "Passphrase is set." : "No passphrase — a button is used instead.")
|
|
174
|
+
else say("Usage: agents_control passphrase set|clear|status", :yellow)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
147
178
|
desc "version", "Version"
|
|
148
179
|
def version = say(AgentsControl::VERSION)
|
|
149
180
|
|
|
150
181
|
private
|
|
151
182
|
|
|
183
|
+
# Offered at the end of setup rather than left to be found later:
|
|
184
|
+
# finishing setup is the moment the bot can start typing into this
|
|
185
|
+
# machine, and the only moment its owner is guaranteed to be
|
|
186
|
+
# reading. Skippable — a setup that can't be finished without
|
|
187
|
+
# inventing a passphrase on the spot is a setup people abandon.
|
|
188
|
+
def offer_passphrase
|
|
189
|
+
return unless $stdin.tty?
|
|
190
|
+
|
|
191
|
+
store = Passphrase.new(secrets: secrets)
|
|
192
|
+
return say("A passphrase is already set. Change it with: agents_control passphrase set") if store.configured?
|
|
193
|
+
|
|
194
|
+
say("")
|
|
195
|
+
say("Optional: a passphrase for destructive commands", :yellow)
|
|
196
|
+
say("")
|
|
197
|
+
say("A few commands are held back when they arrive from Telegram —")
|
|
198
|
+
say("`rm -rf ~`, `mkfs`, `dd` writing to a device, a fork bomb. Set a")
|
|
199
|
+
say("passphrase and you'll be asked to type it before one of those is")
|
|
200
|
+
say("sent; leave this blank and they ask for a tap on a button instead.")
|
|
201
|
+
say("")
|
|
202
|
+
say("The difference shows up when it isn't you holding the phone: a")
|
|
203
|
+
say("button taps just as easily for whoever picked it up. Typing a")
|
|
204
|
+
say("passphrase doesn't.")
|
|
205
|
+
say("")
|
|
206
|
+
say("Blank to skip — you can set one any time with:", :white)
|
|
207
|
+
say(" agents_control passphrase set", :white)
|
|
208
|
+
say("")
|
|
209
|
+
|
|
210
|
+
set_passphrase(store, skippable: true)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def set_passphrase(store, skippable: false)
|
|
214
|
+
value = ask_passphrase("Passphrase: ")
|
|
215
|
+
return skipped(skippable) if value.empty?
|
|
216
|
+
|
|
217
|
+
return say("They don't match — left unchanged.", :yellow) unless value == ask_passphrase("Again: ")
|
|
218
|
+
|
|
219
|
+
store.set(value)
|
|
220
|
+
say("Passphrase saved: #{secrets.target.name}", :green)
|
|
221
|
+
rescue Passphrase::TooShort => e
|
|
222
|
+
say("#{e.message.capitalize} — left unchanged.", :yellow)
|
|
223
|
+
rescue Passphrase::NotSaved, AgentsControl::Error => e
|
|
224
|
+
say("Couldn't save it: #{e.message}", :red)
|
|
225
|
+
say("Check `agents_control doctor` — the secret store may be locked or missing.", :white)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def skipped(skippable)
|
|
229
|
+
return say("Nothing entered — left unchanged.", :yellow) unless skippable
|
|
230
|
+
|
|
231
|
+
say("Skipped — destructive commands will ask for a tap.", :white)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def clear_passphrase(store)
|
|
235
|
+
return say("There was no passphrase set.", :yellow) unless store.configured?
|
|
236
|
+
|
|
237
|
+
store.clear
|
|
238
|
+
say("Passphrase removed — destructive commands go back to a confirmation button.", :green)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def ask_passphrase(label)
|
|
242
|
+
return $stdin.gets.to_s.chomp unless $stdin.tty?
|
|
243
|
+
|
|
244
|
+
$stderr.print(label)
|
|
245
|
+
# Same class of failures as the token prompt: ENOTTY, ENODEV, ENXIO.
|
|
246
|
+
$stdin.noecho(&:gets).to_s.chomp
|
|
247
|
+
rescue SystemCallError, IOError
|
|
248
|
+
""
|
|
249
|
+
ensure
|
|
250
|
+
$stderr.puts if $stdin.tty?
|
|
251
|
+
end
|
|
252
|
+
|
|
152
253
|
Api = Channels::Telegram::Api
|
|
153
254
|
Router = Channels::Telegram::Router
|
|
154
255
|
Bot = Channels::Telegram::Bot
|
|
@@ -268,6 +369,7 @@ module AgentsControl
|
|
|
268
369
|
return say("Timed out. Run setup again.", :red) unless chat
|
|
269
370
|
|
|
270
371
|
allow(chat)
|
|
372
|
+
true
|
|
271
373
|
end
|
|
272
374
|
|
|
273
375
|
def wait_for_message(api, seconds: 120)
|