kache-ssh 0.1.0__py3-none-any.whl

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.
kache/agent.py ADDED
@@ -0,0 +1,594 @@
1
+ """The gatekeeper: Kache's agent on every device.
2
+
3
+ It is an SSH server (asyncssh) that only listens to paired devices:
4
+ * an unknown device may only say hello and ask to pair; the request pops
5
+ up as a yes/no question in this device's Kache terminal;
6
+ * a paired device may send orders. Every order goes into this device's
7
+ feed with the exact command and who sent it, runs in the device's own
8
+ shell, streams its output back, and when it fails the gatekeeper
9
+ collects evidence and sends it back to the sender;
10
+ * Kache terminals subscribe to telemetry (health numbers) and to the feed.
11
+
12
+ Internal requests are SSH exec commands starting with "kache:"; anything
13
+ else is an order and is executed exactly as written.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import codecs
20
+ import json
21
+ import os
22
+ import re
23
+ import shlex
24
+ import shutil
25
+ import time
26
+ from collections import deque
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ import asyncssh
31
+ import psutil
32
+
33
+ from . import PROTOCOL, SCHEMA, __version__
34
+ from .config import (Paths, device_settings, now_stamp, os_description, save_json,
35
+ save_trusted, trusted_keys)
36
+ from .evidence import collect, first_token, new_id
37
+ from .keys import fingerprint, load_or_create_key, pairing_code, public_key_text
38
+ from .network import advertised_addresses, listen_hosts, tailscale_status
39
+ from .telemetry import Telemetry
40
+
41
+ TRAILER = "\x1eKACHE " # last stderr line of an order: {"exit", "duration", "evidence", "cwd"}
42
+ POSIX_BUILTINS = {
43
+ ".", ":", "[", "alias", "bg", "break", "builtin", "cd", "command", "continue", "declare", "echo", "eval",
44
+ "exec", "exit", "export", "false", "fg", "for", "function", "hash", "history", "if", "jobs", "kill",
45
+ "let", "local", "printf", "pwd", "read", "readonly", "return", "set", "shift", "source", "test", "time",
46
+ "times", "trap", "true", "type", "typeset", "ulimit", "umask", "unalias", "unset", "until", "wait", "while",
47
+ }
48
+
49
+
50
+ @dataclass
51
+ class Peer:
52
+ fingerprint: str
53
+ public_key: str
54
+ addr: str
55
+ trusted: bool
56
+ own: bool
57
+ name: str
58
+
59
+
60
+ def shell_for_os() -> str:
61
+ if os.name == "nt":
62
+ return shutil.which("pwsh") or shutil.which("powershell") or "powershell.exe"
63
+ return os.environ.get("SHELL") or "/bin/sh"
64
+
65
+
66
+ def shell_argv(shell: str, command: str, script_path: Path | None = None) -> list[str]:
67
+ if os.name == "nt":
68
+ # The order runs from a UTF-8 script file: no Windows quoting traps,
69
+ # and errors come out as text (-EncodedCommand turns them into CLIXML).
70
+ # UTF-8 output also switches the hidden console's code page, so native
71
+ # programs print å, ä, ö correctly. The tail turns a failing cmdlet or
72
+ # native program into a non-zero exit code.
73
+ script = ("$ProgressPreference='SilentlyContinue'\n"
74
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8\n"
75
+ "$OutputEncoding=[Text.Encoding]::UTF8\n"
76
+ f"{command}\n"
77
+ "$kacheOk=$?;if($LASTEXITCODE){exit $LASTEXITCODE}elseif(-not $kacheOk){exit 1}\n")
78
+ assert script_path is not None
79
+ script_path.parent.mkdir(parents=True, exist_ok=True)
80
+ script_path.write_text(script, encoding="utf-8-sig")
81
+ return [shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script_path)]
82
+ return [shell, "-c", command]
83
+
84
+
85
+ def order_environment() -> dict:
86
+ env = dict(os.environ)
87
+ env.setdefault("PYTHONIOENCODING", "utf-8") # Python writes UTF-8 to the pipe, not the ANSI code page
88
+ env.setdefault("PYTHONUNBUFFERED", "1") # and prints show up live, not in 8 KB blocks
89
+ return env
90
+
91
+
92
+ def kill_tree(pid: int) -> None:
93
+ try:
94
+ parent = psutil.Process(pid)
95
+ except psutil.Error:
96
+ return
97
+ for proc in parent.children(recursive=True) + [parent]:
98
+ try:
99
+ proc.kill()
100
+ except psutil.Error:
101
+ pass
102
+
103
+
104
+ class _Server(asyncssh.SSHServer):
105
+ """One per connection; remembers which key authenticated it."""
106
+
107
+ def __init__(self, gatekeeper: "Gatekeeper") -> None:
108
+ self.gk = gatekeeper
109
+ self.conn = None
110
+ self.peer: Peer | None = None
111
+
112
+ def connection_made(self, conn) -> None:
113
+ self.conn = conn
114
+ self.gk.connections.add(conn)
115
+
116
+ def connection_lost(self, exc) -> None:
117
+ self.gk.connections.discard(self.conn)
118
+
119
+ def begin_auth(self, username: str) -> bool:
120
+ return True
121
+
122
+ def public_key_auth_supported(self) -> bool:
123
+ return True
124
+
125
+ def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
126
+ # Every key may connect; what it may do is decided per request.
127
+ fp = key.get_fingerprint("sha256")
128
+ entry = self.gk.trusted.get(fp)
129
+ own = fp == self.gk.fingerprint
130
+ peername = self.conn.get_extra_info("peername") or ("?", 0)
131
+ self.peer = Peer(
132
+ fingerprint=fp, public_key=key.export_public_key().decode("ascii").strip(),
133
+ addr=str(peername[0]), trusted=own or entry is not None, own=own,
134
+ name="this device" if own else (entry or {}).get("name", "unknown"))
135
+ return True
136
+
137
+
138
+ class Gatekeeper:
139
+ def __init__(self, paths: Paths, port: int | None = None, host: str | None = None, log=None) -> None:
140
+ self.paths = paths
141
+ self.key = load_or_create_key(paths.key_file)
142
+ self.fingerprint = fingerprint(self.key)
143
+ settings = device_settings(paths)
144
+ self.name = settings["name"]
145
+ self.port = settings["port"] if port is None else port
146
+ self.fixed_host = host # tests and `kache agent --host` pin one interface
147
+ self.listen = settings.get("listen", "all")
148
+ self.hosts: list[str] = []
149
+ self.addresses: list[str] = []
150
+ self.tailscale: dict | None = None
151
+ self.connections: set = set()
152
+ self.trusted = trusted_keys(paths)
153
+ self.telemetry = Telemetry(str(Path.home().anchor or "/"))
154
+ self.shell = shell_for_os()
155
+ self.log = log or (lambda text: None)
156
+ self.subscribers: set[asyncio.Queue] = set()
157
+ self.history: deque = deque(maxlen=300)
158
+ self.pending: dict[str, dict] = {}
159
+ self.sessions: dict[tuple[str, str], str] = {}
160
+ self.server = None
161
+ self.stopped = asyncio.Event()
162
+ self._tasks: list[asyncio.Task] = []
163
+
164
+ # ---------------------------------------------------------------- life
165
+ async def start(self) -> "Gatekeeper":
166
+ await self._refresh_network()
167
+ await self._bind()
168
+ self.telemetry.latest = await asyncio.to_thread(self.telemetry.sample)
169
+ self._tasks.append(asyncio.create_task(self.telemetry.run()))
170
+ self._tasks.append(asyncio.create_task(self._watch_network()))
171
+ return self
172
+
173
+ async def _bind(self) -> None:
174
+ hosts = [self.fixed_host] if self.fixed_host else listen_hosts(self.listen, self.tailscale)
175
+ if self.server is not None:
176
+ self.server.close() # open connections stay up; only new ones use the new interfaces
177
+ self.server = await asyncssh.create_server(
178
+ lambda: _Server(self), hosts if len(hosts) > 1 else hosts[0], self.port,
179
+ server_host_keys=[self.key], process_factory=self._handle,
180
+ encoding="utf-8", allow_scp=False, agent_forwarding=False,
181
+ x11_forwarding=False, keepalive_interval=15, line_editor=False)
182
+ if self.port == 0:
183
+ self.port = self.server.get_port() or self.server.sockets[0].getsockname()[1]
184
+ self.hosts = hosts
185
+ self.log(f"gatekeeper '{self.name}' listening on {', '.join(hosts)} port {self.port} ({self.fingerprint})")
186
+
187
+ async def _refresh_network(self) -> None:
188
+ if self.fixed_host in ("127.0.0.1", "localhost", "::1"):
189
+ return # a local-only gatekeeper (tests) has nothing to advertise
190
+ self.tailscale = await asyncio.to_thread(tailscale_status)
191
+ self.addresses = await asyncio.to_thread(advertised_addresses, self.tailscale)
192
+
193
+ async def _watch_network(self) -> None:
194
+ """Addresses change (new Wi-Fi, Tailscale coming up after logon).
195
+ Keep the advertised list fresh, and in Tailscale-only mode bind to the
196
+ tailnet as soon as it appears."""
197
+ while True:
198
+ await asyncio.sleep(60)
199
+ try:
200
+ await self._refresh_network()
201
+ if not self.fixed_host and listen_hosts(self.listen, self.tailscale) != self.hosts:
202
+ await self._bind()
203
+ except Exception as error: # a network hiccup must not stop the gatekeeper
204
+ self.log(f"network refresh failed: {error!r}")
205
+
206
+ async def stop(self) -> None:
207
+ for task in self._tasks:
208
+ task.cancel()
209
+ if self.server:
210
+ self.server.close()
211
+ for conn in list(self.connections):
212
+ conn.close()
213
+ if self.server:
214
+ try:
215
+ await asyncio.wait_for(self.server.wait_closed(), 5)
216
+ except asyncio.TimeoutError:
217
+ pass
218
+
219
+ # ---------------------------------------------------------------- feed
220
+ def emit(self, event: dict) -> None:
221
+ event = {"v": SCHEMA, "time": time.time(), "device": self.name, "device_fp": self.fingerprint, **event}
222
+ self.history.append(event)
223
+ try:
224
+ if self.paths.feed_file.exists() and self.paths.feed_file.stat().st_size > 5_000_000:
225
+ self.paths.feed_file.replace(self.paths.feed_file.with_suffix(".old.jsonl"))
226
+ with self.paths.feed_file.open("a", encoding="utf-8") as handle:
227
+ handle.write(json.dumps(event) + "\n")
228
+ except OSError:
229
+ pass
230
+ for queue in list(self.subscribers):
231
+ if queue.full():
232
+ continue
233
+ queue.put_nowait(event)
234
+
235
+ # ---------------------------------------------------------------- requests
236
+ async def _handle(self, process) -> None:
237
+ conn = process.get_extra_info("connection")
238
+ owner = conn.get_owner() if conn is not None else None
239
+ peer = getattr(owner, "peer", None)
240
+ command = process.command
241
+ try:
242
+ if peer is None:
243
+ process.exit(1)
244
+ return
245
+ if command is None:
246
+ process.stderr.write("Kache sends one command at a time; interactive shells come later.\n")
247
+ process.exit(2)
248
+ return
249
+ if command.startswith("kache:"):
250
+ verb, _, argument = command[len("kache:"):].partition(" ")
251
+ await self._internal(verb, argument, peer, process)
252
+ elif not peer.trusted:
253
+ self._refuse(process)
254
+ else:
255
+ await self._order({"cmd": command}, peer, process)
256
+ except (BrokenPipeError, ConnectionError, asyncssh.Error):
257
+ pass
258
+ except Exception as error: # never let one request kill the gatekeeper
259
+ self.log(f"request failed: {error!r}")
260
+ try:
261
+ process.stderr.write(f"Kache gatekeeper error: {error}\n")
262
+ process.exit(1)
263
+ except Exception:
264
+ pass
265
+
266
+ def _refuse(self, process) -> None:
267
+ process.stderr.write("This device has not been paired with you. Send a pair request first.\n")
268
+ process.exit(77)
269
+
270
+ async def _internal(self, verb: str, argument: str, peer: Peer, process) -> None:
271
+ if verb == "hello":
272
+ self._reply(process, self.hello(peer))
273
+ return
274
+ if verb == "pair":
275
+ await self._pair(peer, process, argument)
276
+ return
277
+ if not peer.trusted:
278
+ self._refuse(process)
279
+ return
280
+ if verb == "order":
281
+ await self._order(json.loads(argument or "{}"), peer, process)
282
+ elif verb == "telemetry":
283
+ await self._stream(process, telemetry=True)
284
+ elif verb == "feed":
285
+ await self._stream(process, telemetry=False)
286
+ elif verb == "session":
287
+ data = json.loads(argument or "{}")
288
+ self._reply(process, {"cwd": self.sessions.get((peer.fingerprint, data.get("session", "default")), str(Path.home()))})
289
+ elif verb == "evidence":
290
+ path = self.paths.evidence_dir / f"{Path(argument.strip()).name}.json"
291
+ if path.exists():
292
+ process.stdout.write(path.read_text(encoding="utf-8"))
293
+ process.exit(0)
294
+ else:
295
+ process.stderr.write("No such evidence on this device.\n")
296
+ process.exit(1)
297
+ elif verb == "forget":
298
+ removed = self.trusted.pop(peer.fingerprint, None)
299
+ save_trusted(self.paths, self.trusted)
300
+ if removed:
301
+ self.emit({"kind": "unpaired", "sender": self._sender(peer), "text": f"{removed.get('name')} removed itself"})
302
+ self._reply(process, {"removed": bool(removed)})
303
+ elif peer.own:
304
+ await self._local(verb, argument, peer, process)
305
+ else:
306
+ process.stderr.write(f"Unknown or local-only request: kache:{verb}\n")
307
+ process.exit(1)
308
+
309
+ async def _local(self, verb: str, argument: str, peer: Peer, process) -> None:
310
+ """Requests only this device's own Kache terminal may make."""
311
+ if verb == "pending":
312
+ self._reply(process, [entry["info"] for entry in self.pending.values()])
313
+ elif verb == "decide":
314
+ data = json.loads(argument or "{}")
315
+ entry = self.pending.get(data.get("id", ""))
316
+ if entry and not entry["future"].done():
317
+ entry["future"].set_result(bool(data.get("accept")))
318
+ self._reply(process, {"ok": True})
319
+ else:
320
+ self._reply(process, {"ok": False, "reason": "no such pending request"})
321
+ elif verb == "trusted":
322
+ self._reply(process, self.trusted)
323
+ elif verb == "revoke":
324
+ target = argument.strip()
325
+ removed = [fp for fp, entry in self.trusted.items() if fp == target or entry.get("name") == target]
326
+ for fp in removed:
327
+ self.trusted.pop(fp, None)
328
+ save_trusted(self.paths, self.trusted)
329
+ for fp in removed:
330
+ self.emit({"kind": "unpaired", "sender": self._sender(peer), "text": f"revoked {fp}"})
331
+ self._reply(process, {"removed": removed})
332
+ elif verb == "reload":
333
+ settings = device_settings(self.paths)
334
+ self.name = settings["name"]
335
+ self.trusted = trusted_keys(self.paths)
336
+ listen = settings.get("listen", "all")
337
+ if listen != self.listen:
338
+ self.listen = listen
339
+ await self._refresh_network()
340
+ await self._bind()
341
+ self._reply(process, {"name": self.name, "listen": self.listen, "hosts": self.hosts})
342
+ elif verb == "shutdown":
343
+ self._reply(process, {"ok": True})
344
+ self.stopped.set()
345
+ else:
346
+ process.stderr.write(f"Unknown request: kache:{verb}\n")
347
+ process.exit(1)
348
+
349
+ def _reply(self, process, data) -> None:
350
+ process.stdout.write(json.dumps(data) + "\n")
351
+ process.exit(0)
352
+
353
+ def hello(self, peer: Peer | None = None) -> dict:
354
+ return {"name": self.name, "fingerprint": self.fingerprint, "public_key": public_key_text(self.key),
355
+ "os": os_description(), "kache": __version__, "proto": PROTOCOL, "schema": SCHEMA,
356
+ "port": self.port, "addresses": self.addresses, "listen": self.listen,
357
+ "paired": bool(peer and peer.trusted), "own": bool(peer and peer.own)}
358
+
359
+ def _sender(self, peer: Peer, label: str | None = None, via: str | None = None) -> dict:
360
+ label = str(label)[:40] if label else None
361
+ via = str(via)[:20] if via else None
362
+ return {"name": peer.name, "label": label, "via": via, "fingerprint": peer.fingerprint, "addr": peer.addr}
363
+
364
+ # ---------------------------------------------------------------- streams
365
+ async def _stream(self, process, telemetry: bool) -> None:
366
+ closed = asyncio.ensure_future(process.channel.wait_closed())
367
+ queue: asyncio.Queue | None = None
368
+ try:
369
+ if telemetry:
370
+ while not closed.done():
371
+ process.stdout.write(json.dumps(self.telemetry.latest) + "\n")
372
+ await asyncio.wait({closed}, timeout=2.0)
373
+ return
374
+ queue = asyncio.Queue(maxsize=2000)
375
+ self.subscribers.add(queue)
376
+ for event in list(self.history)[-60:]:
377
+ process.stdout.write(json.dumps({**event, "replay": True}) + "\n")
378
+ while not closed.done():
379
+ getter = asyncio.ensure_future(queue.get())
380
+ done, _ = await asyncio.wait({getter, closed}, return_when=asyncio.FIRST_COMPLETED)
381
+ if getter in done:
382
+ process.stdout.write(json.dumps(getter.result()) + "\n")
383
+ else:
384
+ getter.cancel()
385
+ finally:
386
+ if queue is not None:
387
+ self.subscribers.discard(queue)
388
+ closed.cancel()
389
+
390
+ # ---------------------------------------------------------------- pairing
391
+ async def _pair(self, peer: Peer, process, argument: str) -> None:
392
+ request = json.loads(argument or "{}")
393
+ answer = {"name": self.name, "fingerprint": self.fingerprint, "host_key": public_key_text(self.key),
394
+ "os": os_description(), "kache": __version__, "proto": PROTOCOL,
395
+ "addresses": self.addresses, "port": self.port,
396
+ "code": pairing_code(peer.fingerprint, self.fingerprint)}
397
+ if peer.own:
398
+ self._reply(process, {**answer, "accepted": False, "reason": "a device cannot pair with itself"})
399
+ return
400
+ if peer.trusted:
401
+ self._reply(process, {**answer, "accepted": True, "already": True})
402
+ return
403
+ if request.get("proto") != PROTOCOL:
404
+ self._reply(process, {**answer, "accepted": False,
405
+ "reason": f"Kache versions differ (this device speaks protocol {PROTOCOL}); update both with pip install -U kache-ssh"})
406
+ return
407
+ if len(self.pending) >= 5 or any(e["info"]["fingerprint"] == peer.fingerprint for e in self.pending.values()):
408
+ self._reply(process, {**answer, "accepted": False, "reason": "a request from you is already waiting"})
409
+ return
410
+ info = {"id": new_id("P"), "name": str(request.get("name") or "unknown")[:40], "os": request.get("os"),
411
+ "fingerprint": peer.fingerprint, "addr": peer.addr, "code": answer["code"], "created": time.time()}
412
+ future = asyncio.get_running_loop().create_future()
413
+ self.pending[info["id"]] = {"info": info, "future": future, "public_key": peer.public_key}
414
+ self.emit({"kind": "pair-request", **info})
415
+ try:
416
+ accepted = await asyncio.wait_for(asyncio.shield(future), timeout=180)
417
+ reason = None if accepted else "declined"
418
+ except asyncio.TimeoutError:
419
+ accepted, reason = False, "nobody answered on that device within 3 minutes"
420
+ finally:
421
+ self.pending.pop(info["id"], None)
422
+ if accepted:
423
+ self.trusted[peer.fingerprint] = {"name": info["name"], "public_key": peer.public_key, "os": info["os"],
424
+ "addr": peer.addr, "added": now_stamp()}
425
+ save_trusted(self.paths, self.trusted)
426
+ peer.trusted, peer.name = True, info["name"]
427
+ self.emit({"kind": "paired", "name": info["name"], "fingerprint": peer.fingerprint, "addr": peer.addr})
428
+ else:
429
+ self.emit({"kind": "pair-declined", "name": info["name"], "fingerprint": peer.fingerprint, "reason": reason})
430
+ self._reply(process, {**answer, "accepted": accepted, "reason": reason})
431
+
432
+ # ---------------------------------------------------------------- orders
433
+ async def _order(self, order: dict, peer: Peer, process) -> None:
434
+ command = str(order.get("cmd", "")).strip()
435
+ order_id = order.get("id") or new_id("O")
436
+ session = str(order.get("session") or "default")
437
+ sender = self._sender(peer, order.get("label"), order.get("via"))
438
+ key = (peer.fingerprint, session)
439
+ cwd = self.sessions.get(key) or str(Path.home())
440
+ order = {"id": order_id, "cmd": command, "sender": sender, "cwd": cwd, "session": session}
441
+ self.emit({"kind": "order", **order})
442
+ started = time.monotonic()
443
+
444
+ last_char = {"stdout": "\n", "stderr": "\n"}
445
+
446
+ def finish(code: int, evidence: dict | None = None, rejected: str | None = None) -> None:
447
+ duration = time.monotonic() - started
448
+ event = {"kind": "done", "id": order_id, "cmd": command, "sender": sender,
449
+ "exit": code, "duration": round(duration, 3), "session": session}
450
+ if evidence:
451
+ event["evidence"] = evidence["id"]
452
+ event["summary"] = evidence["summary"]
453
+ if rejected:
454
+ event["rejected"] = rejected
455
+ self.emit(event)
456
+ trailer = {"exit": code, "duration": round(duration, 3), "cwd": self.sessions.get(key, cwd),
457
+ "evidence": evidence["id"] if evidence else None, "summary": evidence["summary"] if evidence else None,
458
+ "rejected": rejected}
459
+ newline = "" if last_char["stderr"] == "\n" else "\n" # the trailer must start its own line
460
+ process.stderr.write(newline + TRAILER + json.dumps(trailer) + "\n")
461
+ process.exit(0 if code == 0 else (code if 0 < code < 256 else 255))
462
+
463
+ if "proto" in order and order.get("proto") != PROTOCOL:
464
+ reason = f"Kache versions differ (sender protocol {order.get('proto')}, this device {PROTOCOL}). Update both."
465
+ process.stderr.write(reason + "\n")
466
+ finish(126, rejected=reason)
467
+ return
468
+ if not command:
469
+ finish(0)
470
+ return
471
+ cd = self._try_cd(command, key, cwd)
472
+ if cd is not None:
473
+ ok, message = cd
474
+ if message:
475
+ process.stderr.write(message)
476
+ finish(0 if ok else 1)
477
+ return
478
+ refusal = self._precheck(command)
479
+ if refusal:
480
+ process.stderr.write(refusal + "\n")
481
+ result = {"exit": 127, "duration": 0.0, "stdout_tail": [], "stderr_tail": [refusal], "cancelled": False}
482
+ finish(127, self._evidence(order, result), rejected=refusal)
483
+ return
484
+
485
+ script = self.paths.home / "orders" / f"{re.sub(r'[^A-Za-z0-9_-]', '', str(order_id))[:40] or new_id('O')}.ps1"
486
+ try:
487
+ await self._run_child(command, cwd, script, process, order, order_id, sender, started, finish, last_char)
488
+ finally:
489
+ if os.name == "nt":
490
+ try:
491
+ script.unlink()
492
+ except OSError:
493
+ pass
494
+
495
+ async def _run_child(self, command, cwd, script, process, order, order_id, sender, started, finish, last_char) -> None:
496
+ child = await asyncio.create_subprocess_exec(
497
+ *shell_argv(self.shell, command, script), cwd=cwd, stdin=asyncio.subprocess.DEVNULL,
498
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=order_environment(),
499
+ creationflags=0x08000000 if os.name == "nt" else 0) # CREATE_NO_WINDOW: a hidden console
500
+ tails = {"stdout": deque(maxlen=60), "stderr": deque(maxlen=60)}
501
+
502
+ async def pump(stream, out, name: str) -> None:
503
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
504
+ partial = ""
505
+ while True:
506
+ chunk = await stream.read(4096)
507
+ if not chunk:
508
+ break
509
+ text = decoder.decode(chunk)
510
+ if not text:
511
+ continue
512
+ last_char[name] = text[-1]
513
+ try:
514
+ out.write(text)
515
+ except (BrokenPipeError, ConnectionError, asyncssh.Error):
516
+ pass
517
+ partial += text
518
+ *lines, partial = partial.split("\n")
519
+ tails[name].extend(line.rstrip("\r") for line in lines)
520
+ if partial:
521
+ tails[name].append(partial.rstrip("\r"))
522
+
523
+ pumps = asyncio.ensure_future(asyncio.gather(pump(child.stdout, process.stdout, "stdout"),
524
+ pump(child.stderr, process.stderr, "stderr")))
525
+ closed = asyncio.ensure_future(process.channel.wait_closed())
526
+ cancelled = False
527
+ done, _ = await asyncio.wait({pumps, closed}, return_when=asyncio.FIRST_COMPLETED)
528
+ if pumps not in done:
529
+ cancelled = True # the sender hung up (Ctrl+C on their side)
530
+ kill_tree(child.pid)
531
+ await pumps
532
+ closed.cancel()
533
+ code = await child.wait()
534
+ result = {"exit": code, "duration": time.monotonic() - started, "stdout_tail": list(tails["stdout"]),
535
+ "stderr_tail": list(tails["stderr"]), "cancelled": cancelled}
536
+ evidence = self._evidence(order, result) if (code != 0 or cancelled) else None
537
+ if cancelled:
538
+ self.emit({"kind": "done", "id": order_id, "cmd": command, "sender": sender, "exit": code,
539
+ "duration": round(result["duration"], 3), "cancelled": True, "session": order["session"],
540
+ "evidence": evidence["id"] if evidence else None,
541
+ "summary": evidence["summary"] if evidence else None})
542
+ return
543
+ finish(code, evidence)
544
+
545
+ def _evidence(self, order: dict, result: dict) -> dict:
546
+ bundle = collect(order, result, self.telemetry.latest, self.name, self.shell)
547
+ save_json(self.paths.evidence_dir / f"{bundle['id']}.json", bundle)
548
+ self.emit({"kind": "evidence", "id": bundle["id"], "order": order["id"], "cmd": order["cmd"],
549
+ "sender": order["sender"], "summary": bundle["summary"], "exit": bundle["exit"]})
550
+ return bundle
551
+
552
+ def _try_cd(self, command: str, key, cwd: str):
553
+ """`cd` moves the sender's session, so the next order starts there,
554
+ just like typing in a real terminal on this device."""
555
+ match = re.fullmatch(r"(cd|chdir|Set-Location|sl)(?:\s+(.+))?", command.strip(), re.I)
556
+ if not match:
557
+ return None
558
+ argument = (match.group(2) or "").strip().strip("\"'")
559
+ if not argument or argument == "~":
560
+ target = Path.home()
561
+ else:
562
+ target = Path(os.path.expandvars(os.path.expanduser(argument)))
563
+ if not target.is_absolute():
564
+ target = Path(cwd) / target
565
+ try:
566
+ target = target.resolve()
567
+ except OSError:
568
+ pass
569
+ if target.is_dir():
570
+ self.sessions[key] = str(target)
571
+ return True, ""
572
+ return False, f"cd: no such folder on {self.name}: {argument}\n"
573
+
574
+ def _precheck(self, command: str) -> str | None:
575
+ """Refuse orders that cannot work here, before running anything.
576
+ PowerShell resolves cmdlets and aliases itself, so on Windows the
577
+ gatekeeper lets the shell decide and explains the failure afterwards."""
578
+ if os.name == "nt":
579
+ return None
580
+ token = first_token(command)
581
+ if not token or token in POSIX_BUILTINS or "/" in token or "=" in token or token.startswith(("(", "{", "$")):
582
+ return None
583
+ if shutil.which(token):
584
+ return None
585
+ return f"Refused by the gatekeeper on {self.name}: '{token}' is not installed here ({os_description()})."
586
+
587
+
588
+ async def run_agent(paths: Paths, port: int | None = None, host: str | None = None) -> None:
589
+ gatekeeper = await Gatekeeper(paths, port=port, host=host, log=lambda t: print(t, flush=True)).start()
590
+ print(f"Kache gatekeeper for '{gatekeeper.name}' is running on port {gatekeeper.port}. Ctrl+C stops it.", flush=True)
591
+ try:
592
+ await gatekeeper.stopped.wait()
593
+ finally:
594
+ await gatekeeper.stop()