agents_control 0.2.3 → 0.3.1
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 +123 -2
- data/lib/agents_control/agents/claude_code.rb +15 -0
- data/lib/agents_control/channels/telegram/api.rb +83 -3
- data/lib/agents_control/channels/telegram/bot.rb +51 -0
- data/lib/agents_control/channels/telegram/router.rb +302 -6
- 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 +51 -2
- 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,6 +10,11 @@ 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
|
|
@@ -47,7 +52,8 @@ module AgentsControl
|
|
|
47
52
|
HELP = (["Commands:", ""] +
|
|
48
53
|
COMMANDS.map { |name, text, hint| "/#{name}#{hint ? " #{hint}" : ''} — #{text}" }).join("\n")
|
|
49
54
|
|
|
50
|
-
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)
|
|
51
57
|
@api = api
|
|
52
58
|
@registry = registry
|
|
53
59
|
@store = store
|
|
@@ -55,6 +61,18 @@ module AgentsControl
|
|
|
55
61
|
@keyboards = keyboards || Keyboards.new(store: store)
|
|
56
62
|
@pending = pending
|
|
57
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}")
|
|
58
76
|
end
|
|
59
77
|
|
|
60
78
|
def handle(update)
|
|
@@ -82,10 +100,29 @@ module AgentsControl
|
|
|
82
100
|
chat_id = message.dig("chat", "id")
|
|
83
101
|
return unless allowed?(chat_id)
|
|
84
102
|
|
|
103
|
+
attachment = Inbox.attachment(message)
|
|
104
|
+
return receive(chat_id, message, attachment) if attachment
|
|
105
|
+
|
|
85
106
|
text = message["text"].to_s.strip
|
|
86
107
|
|
|
87
108
|
replied = message["reply_to_message"]
|
|
88
|
-
|
|
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
|
|
89
126
|
|
|
90
127
|
return compose_answer(chat_id, text) if composing?(chat_id) && !text.start_with?("/")
|
|
91
128
|
|
|
@@ -131,6 +168,60 @@ module AgentsControl
|
|
|
131
168
|
end
|
|
132
169
|
end
|
|
133
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
|
+
|
|
134
225
|
def composing_key(chat_id) = "composing:#{chat_id}"
|
|
135
226
|
|
|
136
227
|
def composing?(chat_id) = !@store.get(composing_key(chat_id)).nil?
|
|
@@ -219,6 +310,7 @@ module AgentsControl
|
|
|
219
310
|
# reasoning as replying to an agent's own question
|
|
220
311
|
# (type_into_session).
|
|
221
312
|
next execute(chat_id, session, command, show_result: false) if session.agent?
|
|
313
|
+
|
|
222
314
|
next confirm_interactive(chat_id, session, command) if interactive?(session)
|
|
223
315
|
|
|
224
316
|
# One capture serves two purposes: it's checked for a
|
|
@@ -270,6 +362,156 @@ module AgentsControl
|
|
|
270
362
|
markup: markup)
|
|
271
363
|
end
|
|
272
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
|
+
|
|
273
515
|
def confirm_interactive(chat_id, session, command)
|
|
274
516
|
key = @store.put({ "action" => "run", "session_id" => session.id, "text" => command },
|
|
275
517
|
ttl: 300)
|
|
@@ -301,7 +543,29 @@ module AgentsControl
|
|
|
301
543
|
|
|
302
544
|
def run_result_lines = @config.get("terminal.run_result_lines", 200)
|
|
303
545
|
|
|
304
|
-
|
|
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
|
+
|
|
305
569
|
backend = @registry.backend_for(session)
|
|
306
570
|
before ||= capture_screen(session, lines: run_result_lines) if show_result
|
|
307
571
|
|
|
@@ -435,7 +699,7 @@ module AgentsControl
|
|
|
435
699
|
end
|
|
436
700
|
|
|
437
701
|
def settings_menu
|
|
438
|
-
@settings_menu ||= SettingsMenu.new(store: @store, config: @config)
|
|
702
|
+
@settings_menu ||= SettingsMenu.new(store: @store, config: @config, passphrase: passphrase)
|
|
439
703
|
end
|
|
440
704
|
|
|
441
705
|
def show_settings(chat_id)
|
|
@@ -477,7 +741,30 @@ module AgentsControl
|
|
|
477
741
|
say(chat_id, "Tabs: #{sessions.size}\nAgents: #{sessions.count(&:agent?)}\n" \
|
|
478
742
|
"Backends: #{backends.empty? ? 'none' : backends}\n" \
|
|
479
743
|
"Mode: #{@config.get('answers.away', false) ? '🚶 away' : '🪑 present'}\n" \
|
|
480
|
-
"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
|
|
481
768
|
end
|
|
482
769
|
|
|
483
770
|
def handle_callback(callback)
|
|
@@ -508,6 +795,8 @@ module AgentsControl
|
|
|
508
795
|
when "transcript" then show_transcript(chat_id, payload)
|
|
509
796
|
when "setting" then change_setting(chat_id, payload)
|
|
510
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)
|
|
511
800
|
when "ask_question_choice" then answer_ask_user_question(chat_id, payload)
|
|
512
801
|
else act_on_session(chat_id, payload)
|
|
513
802
|
end
|
|
@@ -524,6 +813,12 @@ module AgentsControl
|
|
|
524
813
|
deliver(chat_id, payload["question_id"], reply)
|
|
525
814
|
end
|
|
526
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
|
+
|
|
527
822
|
def start_compose(chat_id, payload)
|
|
528
823
|
@store.put(payload["question_id"], ttl: 600, key: composing_key(chat_id))
|
|
529
824
|
say(chat_id, "Write your reply as the next message.")
|
|
@@ -574,7 +869,8 @@ module AgentsControl
|
|
|
574
869
|
case payload["action"]
|
|
575
870
|
when "focus" then focus_session(chat_id, session)
|
|
576
871
|
when "screen" then show_screen(chat_id, session)
|
|
577
|
-
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)
|
|
578
874
|
when "close_confirm" then say(chat_id, "Close #{session.label}?",
|
|
579
875
|
markup: @keyboards.confirm("close", session))
|
|
580
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)
|
|
@@ -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
|