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.
@@ -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
- return answer_by_reply(chat_id, replied, text) if replied && !text.empty?
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
- if remote?(session)
201
- confirm_remote(chat_id, session, command)
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
- # An agent isn't a shell command that finishes in a
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
- def execute(chat_id, session, command, show_result: false)
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 = show_result ? capture_screen(session, lines: run_result_lines) : nil
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"])
@@ -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)