agents_control 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aadeb50868f7e15b7da9346e051f268ffad1c652c1e7297d54cb328380b030f6
4
- data.tar.gz: cf62c6519dfd54bb7b9ee169c5e1b58f5bcf92b54993e5a61e954547180b6ec2
3
+ metadata.gz: 7cbdfdaec647d99052be4572b93c02e8a1ba8f1169219a85c3abebb1ce1d794b
4
+ data.tar.gz: 5d486d2543a88f451eec31e8261ddf8fafb8170446f09f1f13596b07e639f19a
5
5
  SHA512:
6
- metadata.gz: dce0f05882260d26dddc1a65f392ef0cf8ad7351b1ccbd07e29d2378c2871077434669e4f3c7248b682fd10aa72aedbfbbfae5739703222040bd3741e9a2be4a
7
- data.tar.gz: f3e61a373a6252b920e6086b9e9b01591855fecc8708ffd059e0f87d412fce749d97cc445a6553aa42d69e404c60973ab1eb6551c2db60ae3362cce2756964f7
6
+ metadata.gz: e47eca628d62039dd339691b713ad2b123f5fe9205b0394f623fe51806cbb0711b3ee93f5708548855049d6ebf3631776276e8f21b3b977e776392f7bb842be1
7
+ data.tar.gz: cdec6b32aade79e9b165c72ac9fc121116b1a90ab1c7e16066737561653c9e5090ab87b593e55da04ca78dbe02099a4903bc626b008fe16170a448a65ab8ecf3
data/README.md CHANGED
@@ -89,6 +89,14 @@ agents_control # after that, just open the console
89
89
  The wizard catches your `chat_id` from your own message and adds it to
90
90
  the allowed list — no need to type in a long number by hand.
91
91
 
92
+ At the end it offers to set a **passphrase for destructive commands**.
93
+ A few commands are held back when they arrive from Telegram — `rm -rf ~`,
94
+ `mkfs`, `dd` writing to a raw device, a fork bomb. With a passphrase set
95
+ you're asked to type it before one of those is sent; without one they
96
+ ask for a tap on a button instead. It's optional and skippable, and
97
+ `agents_control passphrase set` adds one later. See
98
+ [Destructive commands ask first](#destructive-commands-ask-first).
99
+
92
100
  Bot commands:
93
101
 
94
102
  | Command | What it does |
@@ -151,6 +159,50 @@ that, it's Telegram's client caching the old list, not a missing step
151
159
  on your end: close and reopen the chat, or restart the Telegram app,
152
160
  to force it to refresh.
153
161
 
162
+ ### Sending files in
163
+
164
+ Photos, videos, voice notes and documents — logs, a CSV, a spreadsheet,
165
+ a screenshot — can be sent to the bot and handed to a session. The file
166
+ is downloaded to `~/.local/state/agents_control/inbox/` and what reaches
167
+ the pane is its **local path**, so the agent opens it the way it opens
168
+ anything else:
169
+
170
+ ```
171
+ you › [screenshot.png] caption: /run 3 why does this render wrong?
172
+
173
+ pane › why does this render wrong? /Users/you/.local/state/agents_control/inbox/20260907-171530-screenshot.png
174
+ ```
175
+
176
+ Three ways to say where it goes:
177
+
178
+ | | |
179
+ |---|---|
180
+ | caption `/run N …` | that tab, with the rest of the caption as the message |
181
+ | reply with a file to a session's own message | that session |
182
+ | neither | saved, and the path comes back so you can use it |
183
+
184
+ Guessing a target for a file is how it ends up pasted into the wrong
185
+ project, so with nothing to go on it isn't guessed. However it gets
186
+ there, a file's path is checked and escaped like anything else typed
187
+ into a pane — a caption is a way of saying `/run`, so it's guarded like
188
+ one.
189
+
190
+ **A path and never a link, deliberately.** Telegram serves file bytes
191
+ from `https://api.telegram.org/file/bot<TOKEN>/…` — the download URL
192
+ *is* the bot token. Pasting that into a pane would put the token in the
193
+ terminal, in the agent's context, and in the shell history, and a
194
+ leaked token is remote code execution on this machine. The URL is built
195
+ inside the API client and never leaves it.
196
+
197
+ The name comes from whoever sent the file, so it doesn't get to be a
198
+ path: no separators, no `..`, no leading dot, and cut to a length any
199
+ filesystem takes. Files are written `0600` inside a `0700` directory —
200
+ logs and screenshots are exactly what carries a token or a customer's
201
+ name through in passing — and swept after `telegram.inbox_keep_days`
202
+ (14 by default), since an agent reads them within minutes and the
203
+ directory would otherwise only grow. Telegram caps what a bot can
204
+ download at 20MB; anything larger is refused before the round trip.
205
+
154
206
  ### Two modes
155
207
 
156
208
  This whole section is about Claude Code specifically — its hooks are
@@ -257,8 +309,10 @@ the full `Registry` — they see bare iTerm2 tabs and tmux panes alike.
257
309
 
258
310
  **CLI menus** (`terminal.watch_menus`, on by default, polled every 20
259
311
  seconds — `terminal.menu_poll_interval`). Notices the "❯ 1. … / 2. …"
260
- pattern Claude Code uses to draw any choice, and sends it to Telegram
261
- as buttons pressing one types the option's number straight into the pane.
312
+ pattern Claude Code (or a skill's own wizard) uses to draw any choice
313
+ options can carry a couple of lines of description each, or a divider
314
+ before a trailing one — and sends it to Telegram as buttons; pressing
315
+ one types the option's number straight into the pane.
262
316
 
263
317
  **Limit reset** (`answers.auto_resume_after_limit`, on by default,
264
318
  polled once a minute — `terminal.rate_limit_poll_interval`). Notices a
@@ -316,6 +370,73 @@ This tool lets you run commands on your machine from Telegram. That comes with s
316
370
  - Auto-replying "continue" is on by default; automatic tool approval is
317
371
  off. These are separate settings on purpose.
318
372
 
373
+ ### Destructive commands ask first
374
+
375
+ A few commands are held back when they arrive from Telegram: a
376
+ recursive force delete aimed at `/` or `$HOME`, a fork bomb, `mkfs`,
377
+ `dd` writing to a raw device. Each comes back saying what it actually
378
+ does — "this recursively deletes `~` — there is no undo" — rather than
379
+ which rule caught it. `rm -rf /tmp/build` goes straight through, as it
380
+ should: the list is a seatbelt for a thumb on the wrong line in a tab
381
+ listing, not a policy engine, and it stays short enough to hold in
382
+ your head.
383
+
384
+ By default that's a confirmation button, and the first times one of
385
+ these turns up the bot offers a passphrase alongside it — that's the
386
+ one moment the reason for it is on screen. **Don't ask again** makes
387
+ the refusal stick; `/settings` keeps the offer reachable either way.
388
+ The bot also says so once, on the first daemon start with no
389
+ passphrase set, because someone who never types a destructive command
390
+ themselves would otherwise never learn the option exists — and the
391
+ first one that does turn up might not be theirs.
392
+
393
+ A passphrase can be set from the chat itself (two entries, both
394
+ deleted) or with `agents_control passphrase set` at the machine. Set
395
+ from the chat, it guards against whoever gets to that chat *later*,
396
+ not whoever has it right now — the bot says so when it saves. That's
397
+ the honest trade: the alternative is that most people never set one,
398
+ since you only think about this while away from the keyboard.
399
+
400
+ Set a passphrase — during `agents_control setup`, from the bot, or any
401
+ time after — and the button becomes a question instead:
402
+
403
+ ```sh
404
+ agents_control passphrase set # entered without echo, stored in the Keychain
405
+ ```
406
+
407
+ ```
408
+ you › /run 3 rm -rf ~
409
+
410
+ bot › this recursively deletes ~ — there is no undo
411
+
412
+ rm -rf ~
413
+
414
+ Reply to this message with the passphrase to send it to
415
+ mobile-app. Anything else cancels.
416
+
417
+ you › ················ ← deleted from the chat once read
418
+ ```
419
+
420
+ A tap answers "did you mean this?" — which covers the wrong tab
421
+ number. A passphrase answers "are you you?", which is the other half:
422
+ an unlocked phone in someone else's hand taps just as well as its
423
+ owner does. It replaces the button rather than joining it — two steps
424
+ for one decision is how people learn to hurry through both — and the
425
+ message carrying it is deleted from the chat the moment it's read,
426
+ right or wrong, since a chat history syncs to every device the account
427
+ is signed in on.
428
+
429
+ The passphrase is stored derived (PBKDF2-HMAC-SHA256, salted) next to
430
+ the bot token, and is never accepted as a command-line argument.
431
+ `agents_control passphrase clear` goes back to the button.
432
+
433
+ ## Changes between versions
434
+
435
+ [CHANGELOG.md](CHANGELOG.md) — what changed and why, newest first. Read
436
+ the current entry before upgrading if you rely on `/run`: while the
437
+ major is 0, a minor bump may change behaviour, and the entry says so
438
+ where it does.
439
+
319
440
  ## Development
320
441
 
321
442
  ```sh
@@ -31,6 +31,21 @@ module AgentsControl
31
31
 
32
32
  def binaries = %w[claude]
33
33
 
34
+ # Our own Stop entry, found by the marker rather than by
35
+ # position. merge_hook appends ours last, so index 0 is someone
36
+ # else's the moment the user has a Stop hook of their own —
37
+ # which is common, and which made the startup self-check probe a
38
+ # stranger's endpoint with a stranger's credentials.
39
+ def own_hook(path = settings_path)
40
+ return unless File.exist?(path)
41
+
42
+ groups = JSON.parse(File.read(path)).dig("hooks", "Stop")
43
+ Array(groups).flat_map { |group| Array(group["hooks"]) }
44
+ .find { |hook| hook["_agents_control"] }
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+
34
49
  def handles?(payload)
35
50
  payload.is_a?(Hash) && payload.key?("hook_event_name")
36
51
  end
@@ -26,6 +26,10 @@ module AgentsControl
26
26
  # noticeably shorter than that.
27
27
  CALL_TIMEOUT = 8
28
28
 
29
+ # A phone on a train uploading a screen recording is a slow
30
+ # request that's still working, not a stuck one.
31
+ DOWNLOAD_TIMEOUT = 120
32
+
29
33
  Error = Class.new(AgentsControl::Error)
30
34
 
31
35
  # Another process is already reading updates with this same
@@ -66,6 +70,26 @@ module AgentsControl
66
70
  )
67
71
  end
68
72
 
73
+ # Two steps by design on Telegram's side: getFile resolves a
74
+ # file_id to a path that's valid for about an hour, and the
75
+ # bytes come from a different host prefix.
76
+ def file_path_for(file_id)
77
+ call("getFile", { file_id: file_id })["file_path"]
78
+ end
79
+
80
+ # Streams to disk rather than through memory: a 20MB video read
81
+ # into a String would sit in the daemon's heap for as long as
82
+ # Ruby felt like keeping it.
83
+ #
84
+ # The download URL carries the token in its path, which is why
85
+ # this method builds it and nothing else ever sees it — no
86
+ # caller, no log line, no message back to the chat.
87
+ def download(file_path, to:, max_bytes: nil)
88
+ @http.get_to_file(file_url_for(file_path), to, read_timeout: DOWNLOAD_TIMEOUT, max_bytes: max_bytes)
89
+ rescue Http::NetworkError => e
90
+ raise Unavailable, redact(e.message)
91
+ end
92
+
69
93
  def send_message(chat_id:, text:, reply_markup: nil, parse_mode: nil)
70
94
  call("sendMessage", {
71
95
  chat_id: chat_id, text: text, parse_mode: parse_mode,
@@ -82,6 +106,16 @@ module AgentsControl
82
106
 
83
107
  # The command menu in the Telegram UI: a button next to the
84
108
  # input field instead of hunting through the chat for a list.
109
+ # Best-effort on purpose: a passphrase typed into a chat is
110
+ # deleted the moment it's read, and a bot that can't delete it
111
+ # (too old, permissions) must still not leave the command it
112
+ # was unlocking half-done.
113
+ def delete_message(chat_id:, message_id:)
114
+ call("deleteMessage", chat_id: chat_id, message_id: message_id)
115
+ rescue Error, Unavailable
116
+ false
117
+ end
118
+
85
119
  def set_my_commands(commands)
86
120
  payload = commands.map { |name, description| { command: name, description: description } }
87
121
 
@@ -128,6 +162,17 @@ module AgentsControl
128
162
 
129
163
  def url_for(method) = URI("https://#{HOST}/bot#{@token}/#{method}")
130
164
 
165
+ # Telegram serves file bytes from /file/bot<token>/<path>.
166
+ # Escaped per path segment: the path comes back from the API
167
+ # and gets to decide part of a URL, so it doesn't get to smuggle
168
+ # a "?" or a "../" into one.
169
+ def file_url_for(file_path)
170
+ segments = file_path.to_s.split("/").reject(&:empty?).map { |part| URI::DEFAULT_PARSER.escape(part) }
171
+ raise Error, "getFile: refusing a path that escapes its directory" if segments.any? { |s| s == ".." }
172
+
173
+ URI("https://#{HOST}/file/bot#{@token}/#{segments.join('/')}")
174
+ end
175
+
131
176
  # Drop nil fields: Telegram treats them as values that were actually passed.
132
177
  def compact(params) = params.compact
133
178
 
@@ -156,6 +201,35 @@ module AgentsControl
156
201
  class Http
157
202
  NetworkError = Class.new(StandardError)
158
203
 
204
+ # Written straight to the open file as chunks arrive. The caller
205
+ # owns the path; a failure part-way leaves a partial file, which
206
+ # is why callers download to a temporary name and rename.
207
+ # max_bytes is counted as the bytes arrive, not taken from a
208
+ # header or from what the sender said the file weighs: both are
209
+ # claims, and the disk fills either way if they're wrong.
210
+ def get_to_file(uri, path, read_timeout:, max_bytes: nil)
211
+ Net::HTTP.start(uri.host, uri.port,
212
+ use_ssl: true, open_timeout: 10, read_timeout: read_timeout) do |http|
213
+ http.request(Net::HTTP::Get.new(uri)) do |response|
214
+ raise NetworkError, "download failed (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess)
215
+
216
+ written = 0
217
+ File.open(path, "wb") do |file|
218
+ response.read_body do |chunk|
219
+ written += chunk.bytesize
220
+ raise NetworkError, "the file is larger than expected" if max_bytes && written > max_bytes
221
+
222
+ file.write(chunk)
223
+ end
224
+ end
225
+ end
226
+ end
227
+
228
+ path
229
+ rescue SystemCallError, Timeout::Error, IOError, OpenSSL::SSL::SSLError, SocketError => e
230
+ raise NetworkError, e.message
231
+ end
232
+
159
233
  def post(uri, params, read_timeout:)
160
234
  request = Net::HTTP::Post.new(uri)
161
235
  request.set_form_data(params)