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.
@@ -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
- 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
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
- def execute(chat_id, session, command, show_result: false, before: nil)
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"])
@@ -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
- settings = JSON.parse(File.read(Agents::ClaudeCode.settings_path))
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
- Net::HTTP.start(uri.host, uri.port, read_timeout: 5) { |http| http.request(request) }.code.to_i
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