conning 0.2.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.
conning/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Conning: a control room for your agent's test machines."""
2
+
3
+ __version__ = "0.2.0"
conning/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from conning.cli import main
2
+
3
+ raise SystemExit(main())
conning/admin.py ADDED
@@ -0,0 +1,489 @@
1
+ """The human side: adding, approving, testing and removing targets, approving commands, leases.
2
+
3
+ Anything that grants trust (going live, pinning a host key, approving a command, a lease) needs an
4
+ interactive terminal. An agent calling `conn add` from its shell has no TTY, so its target is saved
5
+ as pending and does nothing until a human runs `conn approve <name>`.
6
+ """
7
+
8
+ import fnmatch
9
+ import getpass
10
+ import re
11
+ import sys
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ from conning import audit, paths, ssh
17
+ from conning.core import PENDING, Core
18
+ from conning.policy import SAFE, WRAPPERS, policy_argv, program, remembered_rule
19
+ from conning.secrets import SecretError, store_in_keychain
20
+ from conning.state import Approval, Job, grant_lease, set_problem
21
+ from conning.targets import AUTHS, MODES, NAME_RE, Target, TargetFileError, load_targets, save_targets, select
22
+
23
+ MAX_LEASE_MINUTES = 60
24
+ APPROVE_WAIT = 10 # seconds `conn approve a:<id>` waits for the command before handing back its job id
25
+
26
+
27
+ class IO(Protocol):
28
+ tty: bool
29
+
30
+ def ask(self, prompt: str) -> str: ...
31
+
32
+ def secret(self, prompt: str) -> str: ...
33
+
34
+ def say(self, text: str) -> None: ...
35
+
36
+
37
+ class ConsoleIO:
38
+ def __init__(self):
39
+ self.tty = sys.stdin.isatty() and sys.stdout.isatty()
40
+
41
+ def ask(self, prompt: str) -> str: # pragma: no cover - interactive
42
+ return input(prompt)
43
+
44
+ def secret(self, prompt: str) -> str: # pragma: no cover - interactive
45
+ return getpass.getpass(prompt)
46
+
47
+ def say(self, text: str) -> None:
48
+ print(text)
49
+
50
+
51
+ def _save(t: Target) -> None:
52
+ path = paths.targets_file()
53
+ targets = load_targets(path)
54
+ targets[t.name] = t
55
+ save_targets(path, targets)
56
+
57
+
58
+ def _confirm(io: IO, question: str) -> bool:
59
+ return io.ask(f"{question} [y/N] ").strip().lower() in ("y", "yes")
60
+
61
+
62
+ def _need_tty(io: IO, what: str) -> int | None:
63
+ if io.tty:
64
+ return None
65
+ io.say(f"error: {what} needs a human at an interactive terminal")
66
+ return 1
67
+
68
+
69
+ def add(
70
+ core: Core,
71
+ io: IO,
72
+ name: str,
73
+ *,
74
+ host: str | None = None,
75
+ user: str | None = None,
76
+ port: int | None = None,
77
+ auth: str | None = None,
78
+ secret: str | None = None,
79
+ key: str | None = None,
80
+ os_name: str | None = None,
81
+ mode: str = "ask",
82
+ tags: list[str] | None = None,
83
+ retry: bool = False,
84
+ like: str | None = None,
85
+ ) -> int:
86
+ try:
87
+ targets = load_targets(paths.targets_file())
88
+ except TargetFileError as e:
89
+ io.say(f"error: {e}")
90
+ return 1
91
+ if like is not None: # the same machine as another target, usually as another user
92
+ base = targets.get(like)
93
+ if base is None:
94
+ io.say(f"error: no target {like} to copy")
95
+ return 1
96
+ host, port, os_name = host or base.host, port or base.port, os_name or base.os
97
+ user = user or base.user
98
+ tags = tags or list(base.tags)
99
+ auth = auth or base.auth
100
+ if user == base.user: # same login: same credentials
101
+ secret, key = secret or base.secret, key or base.key
102
+ if not NAME_RE.match(name):
103
+ io.say("error: target names use letters, digits, '.', '_' and '-'")
104
+ return 1
105
+ if mode not in MODES:
106
+ io.say(f"error: mode must be one of {', '.join(MODES)}")
107
+ return 1
108
+ if name in targets and not retry:
109
+ io.say(f"error: {name} already exists (use --retry to probe it again, or conn rm {name})")
110
+ return 1
111
+ if retry:
112
+ if name not in targets:
113
+ io.say(f"error: no target {name} to retry")
114
+ return 1
115
+ return approve_target(core, io, name)
116
+ if not io.tty and not (host and user):
117
+ io.say("error: --host and --user are required when adding without a terminal")
118
+ return 1
119
+ host = host or io.ask("host: ").strip()
120
+ user = user or io.ask("user: ").strip()
121
+ auth = auth or ("password" if secret else "key" if key else None)
122
+ if auth is None:
123
+ auth = (io.ask("auth [ssh-agent/key/password] (ssh-agent): ").strip() or "ssh-agent") if io.tty else "ssh-agent"
124
+ if auth not in AUTHS:
125
+ io.say(f"error: auth must be one of {', '.join(AUTHS)}")
126
+ return 1
127
+ if auth == "password" and not secret and not io.tty:
128
+ io.say("error: a password is typed by a human at a terminal, or give --secret <reference>")
129
+ return 1
130
+ if auth == "key" and not key:
131
+ key = io.ask("private key file: ").strip() if io.tty else None
132
+ if not key:
133
+ io.say("error: auth=key needs --key")
134
+ return 1
135
+ t = Target(
136
+ name=name,
137
+ host=host,
138
+ user=user,
139
+ port=port or 22,
140
+ auth=auth,
141
+ secret=secret,
142
+ key=key,
143
+ os=os_name or "linux",
144
+ shell="pwsh" if os_name == "windows" else "bash",
145
+ mode=mode,
146
+ tags=tuple(tags or ()),
147
+ status="pending",
148
+ )
149
+ if not io.tty:
150
+ _save(t)
151
+ audit.record("target", target=name, change="proposed")
152
+ io.say(f"{name} saved as pending. It does nothing until a human runs: conn approve {name}")
153
+ return 0
154
+ if auth == "password" and not secret:
155
+ t = replace(t, secret=store_in_keychain(name, io.secret(f"password for {user}@{host}: ")))
156
+ _save(t)
157
+ return _enroll(core, io, t)
158
+
159
+
160
+ def _stays_pending(io: IO, t: Target, message: str, reason: str) -> int:
161
+ """Tell the human, and record a host-free reason that `targets` shows next to the pending target."""
162
+ io.say(message)
163
+ set_problem(t.name, re.sub(rf"(?<![\w.-]){re.escape(t.host)}(?![\w.-])", "<host>", reason))
164
+ return 1
165
+
166
+
167
+ def _enroll(core: Core, io: IO, t: Target) -> int:
168
+ """Pin the host key the human confirms, probe OS and shell, and make the target live."""
169
+ try:
170
+ keys = ssh.scan_host_keys(t.host, t.port)
171
+ except ssh.SSHError as e:
172
+ return _stays_pending(io, t, f"error: {e}", str(e))
173
+ if not keys:
174
+ if not ssh.port_open(t.host, t.port):
175
+ return _stays_pending(io, t, ssh.no_ssh_hint(t), "no SSH server answered")
176
+ return _stays_pending(io, t, f"{t.name}: no host keys offered", "no host keys offered")
177
+ io.say(f"{t.name}: {t.user}@{t.host}:{t.port} auth={t.auth}" + (f" secret={t.secret}" if t.secret else ""))
178
+ for key_type, fp, _ in keys:
179
+ io.say(f" host key {key_type:<20} {fp}")
180
+ if not _confirm(io, "pin these host keys and make the target live?"):
181
+ return _stays_pending(io, t, "not pinned; the target stays pending", "host key not confirmed")
182
+ ssh.pin_host_keys(t.name, keys)
183
+ t = replace(t, hostkey=keys[0][1], status="active")
184
+ os_name, shell, problem = probe(core, t)
185
+ if problem:
186
+ _save(replace(t, status="pending"))
187
+ return _stays_pending(io, t, f"error: {problem}", f"login check failed: {problem}")
188
+ t = replace(t, os=os_name, shell=shell)
189
+ _save(t)
190
+ set_problem(t.name, None)
191
+ audit.record("target", target=t.name, change="live")
192
+ io.say(f"{t.name} is live: {os_name} {shell} mode={t.mode}")
193
+ return 0
194
+
195
+
196
+ def probe(core: Core, t: Target) -> tuple[str, str, str | None]:
197
+ """Return (os, shell, problem). POSIX if `uname` works, else PowerShell on Windows."""
198
+ try:
199
+ rc, out = core.transport.exec(t, "uname -s", timeout=30)
200
+ except (ssh.SSHError, SecretError) as e:
201
+ return t.os, t.shell, str(e)
202
+ if rc == 255:
203
+ return t.os, t.shell, core.transport.explain(t, rc, out)
204
+ if rc == 0 and out.strip():
205
+ return out.decode(errors="replace").strip().splitlines()[0].lower(), "bash", None
206
+ for shell in ("pwsh", "powershell"):
207
+ rc, out = core.transport.exec(t, f"{shell} -NoProfile -Command $PSVersionTable.PSVersion.Major", timeout=30)
208
+ if rc == 0:
209
+ return "windows", shell, None
210
+ return t.os, t.shell, "could not detect the remote shell (neither uname nor PowerShell answered)"
211
+
212
+
213
+ def approve(core: Core, io: IO, what: str, *, always: bool = False) -> int:
214
+ if (code := _need_tty(io, "approving")) is not None:
215
+ return code
216
+ if what.startswith("a:"):
217
+ code = approve_command(io, what[2:], always=always)
218
+ approved = Approval.load(what[2:])
219
+ if code == 0 and approved is not None and approved.status == "approved":
220
+ io.say(core.read(what, wait=APPROVE_WAIT)) # start it and show the result here too
221
+ for job_id in (Approval.load(what[2:]) or approved).jobs.values():
222
+ job = Job.load(job_id)
223
+ if job is not None: # what the human saw still counts as unread for the agent
224
+ job.seen = 0
225
+ job.save()
226
+ return code
227
+ return approve_target(core, io, what)
228
+
229
+
230
+ def approve_target(core: Core, io: IO, name: str) -> int:
231
+ if (code := _need_tty(io, "approving a target")) is not None:
232
+ return code
233
+ targets = load_targets(paths.targets_file())
234
+ t = targets.get(name)
235
+ if t is None:
236
+ io.say(f"error: no target {name}")
237
+ return 1
238
+ return _enroll(core, io, t)
239
+
240
+
241
+ def run_here(core: Core, io: IO, result: str, *, wait: int, max_bytes: int, grep: str | None) -> int:
242
+ """`conn run` at a terminal: a command that needs approval is asked about right here, and its
243
+ result printed, instead of handing back an id to copy into `conn approve`."""
244
+ match = PENDING.search(result)
245
+ if match is None or not io.tty:
246
+ io.say(result)
247
+ return 1 if result.startswith("error:") else 0
248
+ others = PENDING.sub("", result).strip()
249
+ if others:
250
+ io.say(others)
251
+ approval_id = match.group(1)
252
+ approve_command(io, approval_id, inline=True)
253
+ a = Approval.load(approval_id)
254
+ if a is None or a.status != "approved":
255
+ return 1
256
+ out = core.read(f"a:{approval_id}", wait=wait, grep=grep, max_bytes=max_bytes)
257
+ first, _, rest = out.partition("\n")
258
+ io.say(rest if first.startswith(f"approved a:{approval_id}") and rest else out)
259
+ return 1 if out.startswith("error:") else 0
260
+
261
+
262
+ def approve_command(io: IO, approval_id: str, *, always: bool = False, inline: bool = False) -> int:
263
+ a = Approval.load(approval_id)
264
+ if a is None:
265
+ io.say(f"error: no approval a:{approval_id}")
266
+ return 1
267
+ if a.status != "pending":
268
+ io.say(f"a:{a.id} is already {a.status}")
269
+ return 0
270
+ where = ", ".join(a.targets) + (f" (cwd {a.cwd})" if a.cwd else "")
271
+ io.say(f"needs approval on {where}: {a.display}" if inline else f"a:{a.id} on {where}\n {a.display}")
272
+ rule = remembered_rule(policy_argv(a.command, a.argv))
273
+ choices = "[a]llow once, [e] always allow '" + " ".join(rule) + "', [d]eny" if rule else "[a]llow once, [d]eny"
274
+ answer = "e" if always and rule else io.ask(f"{choices}: ").strip().lower()[:1]
275
+ choice = {"a": "once", "e": "always"}.get(answer) if answer in (("a", "e") if rule else ("a",)) else None
276
+ said = decide(a, choice or "deny", by="terminal")
277
+ if inline: # the result follows right here, so the note about the agent's `read` is noise
278
+ said = "\n".join(line for line in said.splitlines() if not line.startswith("approved a:"))
279
+ if said:
280
+ io.say(said)
281
+ return 0
282
+
283
+
284
+ def decide(a: Approval, answer: str, *, by: str) -> str:
285
+ """Record the human's answer to a pending command: once, always or deny. Returns one line.
286
+
287
+ Shared by `conn approve`, `conn watch` and the prompt in the agent client, so all three
288
+ remember the same rule and leave the same audit entry.
289
+ """
290
+ if a.status != "pending":
291
+ return f"a:{a.id} is already {a.status}"
292
+ if answer == "deny":
293
+ a.status = "denied"
294
+ a.save()
295
+ audit.record("denied", id=a.id, by=by)
296
+ return f"denied a:{a.id}"
297
+ rule = remembered_rule(policy_argv(a.command, a.argv))
298
+ lines = []
299
+ if answer == "always" and rule:
300
+ _add_rule(a.targets, rule)
301
+ lines.append(f"always allowing '{' '.join(rule)}' on {', '.join(a.targets)}")
302
+ a.status = "approved"
303
+ a.save()
304
+ audit.record("approved", id=a.id, by=by, always=bool(answer == "always" and rule) or None)
305
+ lines.append(f"approved a:{a.id}; the agent sees the result with read a:{a.id}")
306
+ return "\n".join(lines)
307
+
308
+
309
+ def _add_rule(names: list[str], rule: tuple[str, ...]) -> list[str]:
310
+ """Add an allow rule to these targets; returns the names that did not have it yet."""
311
+ path = paths.targets_file()
312
+ targets = load_targets(path)
313
+ changed = []
314
+ for name in names:
315
+ if name in targets and rule not in targets[name].allow:
316
+ targets[name] = replace(targets[name], allow=(*targets[name].allow, rule))
317
+ changed.append(name)
318
+ audit.record("rule", target=name, change="allow", rule=" ".join(rule))
319
+ save_targets(path, targets)
320
+ return changed
321
+
322
+
323
+ def rule_words(words: list[str]) -> tuple[str, ...] | str:
324
+ """Validate a rule typed by a human: a program and up to a few plain words. Returns the rule or an error."""
325
+ if not words:
326
+ return "error: give the command words to allow, e.g. conn allow lab systemctl status"
327
+ if any(w.startswith("-") for w in words):
328
+ return "error: rules match words, not flags (flags are ignored when matching)"
329
+ if program(words[0]) in WRAPPERS:
330
+ return f"error: '{words[0]}' can run anything, so it always asks"
331
+ return (program(words[0]), *words[1:])
332
+
333
+
334
+ def allow(io: IO, selector: str, words: list[str]) -> int:
335
+ """Human only: list a target's rules, or add one so the command runs without asking."""
336
+ targets = load_targets(paths.targets_file())
337
+ chosen = select(targets, selector)
338
+ if not chosen:
339
+ io.say(f"error: no target matches {selector!r}")
340
+ return 1
341
+ if not words:
342
+ for t in chosen:
343
+ rules = ", ".join("'" + " ".join(r) + "'" for r in t.allow) or "(none)"
344
+ safe = "the built-in read-only list" if t.safe else "no built-in list (safe = false)"
345
+ io.say(f"{t.name}: {safe}; rules: {rules}")
346
+ return 0
347
+ if (code := _need_tty(io, "changing the allowlist")) is not None:
348
+ return code
349
+ rule = rule_words(words)
350
+ if isinstance(rule, str):
351
+ io.say(rule)
352
+ return 1
353
+ names = [t.name for t in chosen]
354
+ shown = " ".join(rule)
355
+ if not _confirm(io, f"let the agent run '{shown} ...' on {', '.join(names)} without asking?"):
356
+ io.say("no change")
357
+ return 1
358
+ changed = _add_rule(names, rule)
359
+ io.say(f"allowing '{shown}' on {', '.join(changed)}" if changed else f"'{shown}' was already allowed")
360
+ return 0
361
+
362
+
363
+ def deny(io: IO, selector: str, words: list[str]) -> int:
364
+ """Remove an allow rule. Taking trust away needs no terminal."""
365
+ rule = rule_words(words)
366
+ if isinstance(rule, str):
367
+ io.say(rule)
368
+ return 1
369
+ path = paths.targets_file()
370
+ targets = load_targets(path)
371
+ chosen = select(targets, selector)
372
+ if not chosen:
373
+ io.say(f"error: no target matches {selector!r}")
374
+ return 1
375
+ changed = []
376
+ for t in chosen:
377
+ if rule in t.allow:
378
+ targets[t.name] = replace(t, allow=tuple(r for r in t.allow if r != rule))
379
+ changed.append(t.name)
380
+ audit.record("rule", target=t.name, change="deny", rule=" ".join(rule))
381
+ save_targets(path, targets)
382
+ shown = " ".join(rule)
383
+ if changed:
384
+ io.say(f"'{shown}' now asks again on {', '.join(changed)}")
385
+ elif rule[0] in SAFE:
386
+ io.say(f"'{rule[0]}' is on the built-in read-only list; to make it ask, set safe = false for the target")
387
+ else:
388
+ io.say(f"no target had the rule '{shown}'")
389
+ return 0
390
+
391
+
392
+ def remove(io: IO, name: str) -> int:
393
+ path = paths.targets_file()
394
+ targets = load_targets(path)
395
+ if name not in targets:
396
+ io.say(f"error: no target {name}")
397
+ return 1
398
+ del targets[name]
399
+ save_targets(path, targets)
400
+ ssh.pin_host_keys(name, [])
401
+ audit.record("target", target=name, change="removed")
402
+ io.say(f"removed {name}")
403
+ return 0
404
+
405
+
406
+ def test(core: Core, io: IO, name: str) -> int:
407
+ t = load_targets(paths.targets_file()).get(name)
408
+ if t is None:
409
+ io.say(f"error: no target {name}")
410
+ return 1
411
+ if t.status == "pending":
412
+ io.say(f"{name} is pending; a human runs: conn approve {name}")
413
+ return 1
414
+ os_name, shell, problem = probe(core, t)
415
+ if problem:
416
+ io.say(f"{name}: {problem}")
417
+ return 1
418
+ io.say(f"{name}: ok, {os_name} {shell}, host key pinned")
419
+ return 0
420
+
421
+
422
+ def lease(io: IO, name: str, minutes: int) -> int:
423
+ if (code := _need_tty(io, "granting a lease")) is not None:
424
+ return code
425
+ if name not in load_targets(paths.targets_file()):
426
+ io.say(f"error: no target {name}")
427
+ return 1
428
+ if not 1 <= minutes <= MAX_LEASE_MINUTES:
429
+ io.say(f"error: a lease lasts 1 to {MAX_LEASE_MINUTES} minutes")
430
+ return 1
431
+ if not _confirm(io, f"give the agent a full shell on {name} for {minutes} minutes?"):
432
+ io.say("no lease granted")
433
+ return 1
434
+ grant_lease(name, minutes)
435
+ audit.record("lease", target=name, minutes=minutes)
436
+ io.say(f"lease on {name} for {minutes} minutes")
437
+ return 0
438
+
439
+
440
+ UNSAFE_SSH_OPTIONS = ("forwardagent", "localforward", "remoteforward", "dynamicforward", "proxycommand", "forwardx11")
441
+
442
+
443
+ def import_ssh_config(io: IO, match: str = "*", config: Path | None = None) -> int:
444
+ """Turn Host blocks of an ssh config into pending targets. Unsafe options are never copied."""
445
+ config = config or Path.home() / ".ssh" / "config"
446
+ if not config.exists():
447
+ io.say(f"error: {config} not found")
448
+ return 1
449
+ hosts: list[tuple[str, dict[str, str]]] = []
450
+ current: list[tuple[str, dict[str, str]]] = []
451
+ for raw in config.read_text(encoding="utf-8", errors="replace").splitlines():
452
+ line = raw.strip()
453
+ if not line or line.startswith("#"):
454
+ continue
455
+ key, _, value = line.replace("=", " ", 1).partition(" ")
456
+ key, value = key.lower(), value.strip().strip('"')
457
+ if key == "host":
458
+ current = [(alias, {}) for alias in value.split() if not any(c in alias for c in "*?!")]
459
+ hosts += current
460
+ elif key == "match":
461
+ current = []
462
+ else:
463
+ for _, opts in current:
464
+ opts.setdefault(key, value)
465
+ path = paths.targets_file()
466
+ targets = load_targets(path)
467
+ added = []
468
+ for alias, opts in hosts:
469
+ if not fnmatch.fnmatch(alias, match) or alias in targets or not NAME_RE.match(alias):
470
+ continue
471
+ for option in UNSAFE_SSH_OPTIONS:
472
+ if option in opts and opts[option].lower() not in ("no", "none"):
473
+ io.say(f" ! {alias}: {option} in your ssh config; Conning drops it")
474
+ key = opts.get("identityfile")
475
+ targets[alias] = Target(
476
+ name=alias,
477
+ host=opts.get("hostname", alias),
478
+ user=opts.get("user", getpass.getuser()),
479
+ port=int(opts.get("port", 22)),
480
+ auth="key" if key else "ssh-agent",
481
+ key=key,
482
+ status="pending",
483
+ )
484
+ added.append(alias)
485
+ save_targets(path, targets)
486
+ io.say(f"imported {len(added)} target(s) as pending: {', '.join(added) or '-'}")
487
+ if added:
488
+ io.say("approve each with: conn approve <name>")
489
+ return 0
conning/askpass.py ADDED
@@ -0,0 +1,9 @@
1
+ """SSH_ASKPASS helper: ssh runs this to get the password, which Conning passed in the ssh process env."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ value = os.environ.get("CONNING_ASKPASS_VALUE")
7
+ if value is None:
8
+ sys.exit(1)
9
+ sys.stdout.write(value + "\n")
conning/audit.py ADDED
@@ -0,0 +1,126 @@
1
+ """The audit log: one JSON line per thing that happened, in the state dir.
2
+
3
+ Every process appends to the same file, so `conn watch` in one terminal sees what the agent does
4
+ through the MCP server in another. Entries carry commands and decisions, never secret values:
5
+ the agent never sees a secret, so the commands it sends cannot contain one.
6
+
7
+ Each entry holds the sha256 of the line before it (`prev`), so an edited or deleted line breaks the
8
+ chain and `conn doctor` says where. It runs as your user, so this is tamper-evident, not tamper-proof.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import time
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+
17
+ from conning import paths
18
+
19
+ try:
20
+ import fcntl
21
+ except ImportError: # pragma: no cover - Windows
22
+ fcntl = None
23
+
24
+ GENESIS = "0" * 64
25
+
26
+
27
+ def log_file() -> Path:
28
+ return paths.state_dir() / "audit.jsonl"
29
+
30
+
31
+ def record(event: str, **fields) -> None:
32
+ """Append one entry. Best effort: a full disk must not stop a command that already ran."""
33
+ entry = {"ts": round(time.time(), 3), "event": event, **{k: v for k, v in fields.items() if v is not None}}
34
+ path = log_file()
35
+ try:
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ with path.open("ab+") as f, _locked(f):
38
+ entry["prev"] = _digest(_last_line(f))
39
+ f.write((json.dumps(entry, separators=(",", ":")) + "\n").encode())
40
+ except OSError:
41
+ pass
42
+
43
+
44
+ @contextmanager
45
+ def _locked(f):
46
+ """Serialize writers, so two processes never chain to the same previous line."""
47
+ if fcntl is not None:
48
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
49
+ try:
50
+ yield
51
+ finally:
52
+ if fcntl is not None:
53
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
54
+
55
+
56
+ def _last_line(f) -> bytes:
57
+ f.seek(0, 2)
58
+ size = f.tell()
59
+ f.seek(max(0, size - 65536))
60
+ lines = f.read().splitlines()
61
+ return lines[-1] if lines else b""
62
+
63
+
64
+ def _digest(line: bytes) -> str:
65
+ return hashlib.sha256(line).hexdigest() if line else GENESIS
66
+
67
+
68
+ def verify() -> int | None:
69
+ """The 1-based number of the first line whose `prev` does not match the line before it, or None."""
70
+ path = log_file()
71
+ if not path.exists():
72
+ return None
73
+ previous = b""
74
+ for number, line in enumerate(path.read_bytes().splitlines(), 1):
75
+ try:
76
+ prev = json.loads(line).get("prev")
77
+ except ValueError:
78
+ return number
79
+ if prev != _digest(previous):
80
+ return number
81
+ previous = line
82
+ return None
83
+
84
+
85
+ def read_from(offset: int) -> tuple[list[dict], int]:
86
+ """Entries appended after byte `offset`, and the offset to continue from. Partial lines wait."""
87
+ path = log_file()
88
+ if not path.exists():
89
+ return [], 0
90
+ with path.open("rb") as f:
91
+ f.seek(offset)
92
+ data = f.read()
93
+ complete = data[: data.rfind(b"\n") + 1]
94
+ entries = []
95
+ for line in complete.splitlines():
96
+ try:
97
+ entries.append(json.loads(line))
98
+ except ValueError:
99
+ continue
100
+ return entries, offset + len(complete)
101
+
102
+
103
+ def tail(count: int) -> tuple[list[dict], int]:
104
+ """The last `count` entries and the end offset."""
105
+ entries, end = read_from(0)
106
+ return entries[-count:] if count > 0 else [], end
107
+
108
+
109
+ def describe(e: dict) -> str:
110
+ """One human line for an entry, as `conn watch` prints it."""
111
+ when = time.strftime("%H:%M:%S", time.localtime(e.get("ts", 0)))
112
+ target = e.get("target", "")
113
+ what = {
114
+ "run": lambda: f"run {target} {e.get('command', '')} ({e.get('decision', '')})",
115
+ "start": lambda: f"start {target} j:{e.get('job')} {e.get('command', '')}",
116
+ "exit": lambda: f"exit={e.get('rc')} {target} j:{e.get('job')}",
117
+ "approval": lambda: f"ASK a:{e.get('id')} {','.join(e.get('targets', []))} {e.get('command', '')}",
118
+ "approved": lambda: f"approved a:{e.get('id')} by {e.get('by')}" + (" (always)" if e.get("always") else ""),
119
+ "denied": lambda: f"denied a:{e.get('id')} by {e.get('by')}",
120
+ "transfer": lambda: f"transfer {e.get('source')} -> {e.get('dest')} ({e.get('result', '')})",
121
+ "read-file": lambda: f"read {target}:{e.get('path')}",
122
+ "lease": lambda: f"lease {target} {e.get('minutes')} min",
123
+ "target": lambda: f"target {target} {e.get('change')}",
124
+ "rule": lambda: f"rule {target} {e.get('change')} '{e.get('rule')}'",
125
+ }.get(e.get("event", ""), lambda: f"{e.get('event')} {target}")
126
+ return f"{when} {what()}"