countersign-agent 0.10.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.
@@ -0,0 +1,3 @@
1
+ """countersign — What You Approve eXecutes."""
2
+
3
+ __version__ = "0.10.0"
countersign/alerts.py ADDED
@@ -0,0 +1,64 @@
1
+ """Named delivery for alerts — the line must reach something a human reads.
2
+
3
+ `anchor watch` prints one line and exits with honest codes, but a line
4
+ nobody reads is not an alert: `--notify-cmd` runs a command with the
5
+ report as its last argument, `--notify-file` appends the report to a
6
+ file. Drift notices (census and MCP) honor the same mechanism through
7
+ the environment: `COUNTERSIGN_ALERT_HOOK` (command) and `COUNTERSIGN_ALERT_FILE`.
8
+
9
+ Honest mechanics:
10
+ - delivery runs with the privileges of the process that noticed the
11
+ problem — cron's for watch, the hook/proxy process for drift. Point it
12
+ only at things you trust to run there; countersign never escalates it.
13
+ - a failed delivery is REPORTED (stderr for watch, stderr for drift) and
14
+ never changes the original exit code: BROKEN stays 2 even when the
15
+ alerter is dead — that is two problems, not zero.
16
+ - no hook configured = nothing runs. The inbox and the terminal line
17
+ stay the record of truth.
18
+ """
19
+ from __future__ import annotations
20
+ import os
21
+ import shlex
22
+ import subprocess
23
+ import time
24
+ from pathlib import Path
25
+
26
+
27
+ def deliver(reason: str, *, cmd: str | None = None,
28
+ file: str | None = None) -> list[str]:
29
+ """Run the alert hooks. Returns delivery errors (empty = delivered).
30
+
31
+ Explicit `cmd`/`file` win; unset ones fall back to COUNTERSIGN_ALERT_HOOK /
32
+ COUNTERSIGN_ALERT_FILE so drift notices use the same wiring. The reason is
33
+ appended as the hook's last argument / the file's line payload.
34
+ """
35
+ errs: list[str] = []
36
+ if cmd is None:
37
+ cmd = os.environ.get("COUNTERSIGN_ALERT_HOOK") or None
38
+ if file is None:
39
+ file = os.environ.get("COUNTERSIGN_ALERT_FILE") or None
40
+ if cmd:
41
+ try:
42
+ argv = shlex.split(cmd)
43
+ if not argv:
44
+ errs.append("alert hook is empty")
45
+ else:
46
+ r = subprocess.run(argv + [reason], capture_output=True,
47
+ text=True, timeout=15)
48
+ if r.returncode != 0:
49
+ errs.append(f"alert hook exited {r.returncode}: "
50
+ f"{(r.stderr or r.stdout).strip()[:200]}")
51
+ except FileNotFoundError:
52
+ errs.append(f"alert hook not found: {shlex.split(cmd)[0]}")
53
+ except (OSError, ValueError, subprocess.SubprocessError) as e:
54
+ errs.append(f"alert hook failed: {e}")
55
+ if file:
56
+ try:
57
+ p = Path(file).expanduser()
58
+ p.parent.mkdir(parents=True, exist_ok=True)
59
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
60
+ with open(p, "a", encoding="utf-8") as f:
61
+ f.write(f"{stamp} {reason}\n")
62
+ except OSError as e:
63
+ errs.append(f"alert file write failed: {e}")
64
+ return errs
countersign/ask.py ADDED
@@ -0,0 +1,148 @@
1
+ """HITL channel owned by MITL.
2
+
3
+ The agent never sees this prompt. Backends, strongest first:
4
+ COUNTERSIGN_ASK=allow|deny — tests / unattended explicit
5
+ socket — countersign-askd if running
6
+ osascript / zenity — native dialog showing the render
7
+ fallback — DENY. There is no allow-when-nobody-is-there.
8
+
9
+ Never prompt on the agent's tty: that is how LITL padding works.
10
+ """
11
+ from __future__ import annotations
12
+ import json
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ from shutil import which
18
+
19
+ from .config import home
20
+
21
+ ALLOW, DENY = "allow", "deny"
22
+
23
+
24
+ def timeout() -> float:
25
+ try:
26
+ return float(os.environ.get("COUNTERSIGN_TIMEOUT", "60"))
27
+ except ValueError:
28
+ return 60.0
29
+
30
+
31
+ def sock_path():
32
+ return home() / "ask.sock"
33
+
34
+
35
+ def decide(text: str, digest: str, tags: list[str] | None = None
36
+ ) -> tuple[str, str]:
37
+ """Return (allow|deny, backend). Unreachable human → deny."""
38
+ forced = os.environ.get("COUNTERSIGN_ASK", "").strip().lower()
39
+ if forced in (ALLOW, DENY):
40
+ return forced, f"env:{forced}"
41
+ for name in _order():
42
+ fn = _BACKENDS.get(name)
43
+ if not fn:
44
+ continue
45
+ got = fn(text, digest, timeout(), tags or [])
46
+ if got in (ALLOW, DENY):
47
+ return got, name
48
+ return DENY, "unattended-deny"
49
+
50
+
51
+ def _order() -> list[str]:
52
+ raw = os.environ.get("COUNTERSIGN_ASK_BACKEND", "").strip()
53
+ if raw:
54
+ return [x.strip() for x in raw.split(",") if x.strip()]
55
+ out = []
56
+ if sock_path().exists():
57
+ out.append("socket")
58
+ if sys.platform == "darwin" and which("osascript"):
59
+ out.append("osascript")
60
+ elif which("zenity") and (os.environ.get("DISPLAY") or
61
+ os.environ.get("WAYLAND_DISPLAY")):
62
+ out.append("zenity")
63
+ return out
64
+
65
+
66
+ def _via_socket(text: str, digest: str, to: float, tags: list[str] | None = None
67
+ ) -> str | None:
68
+ p = sock_path()
69
+ if not p.exists() or not hasattr(socket, "AF_UNIX"):
70
+ return None
71
+ s = None
72
+ try:
73
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
74
+ s.settimeout(to)
75
+ s.connect(str(p))
76
+ s.sendall((json.dumps({"text": text, "digest": digest,
77
+ "tags": tags or []}) + "\n").encode())
78
+ buf = b""
79
+ while b"\n" not in buf:
80
+ chunk = s.recv(4096)
81
+ if not chunk:
82
+ break
83
+ buf += chunk
84
+ d = json.loads(buf.decode().strip() or "{}").get("decision")
85
+ return d if d in (ALLOW, DENY) else None
86
+ except Exception:
87
+ return None
88
+ finally:
89
+ if s is not None:
90
+ try:
91
+ s.close()
92
+ except OSError:
93
+ pass
94
+
95
+
96
+ def _via_osascript(text: str, digest: str, to: float,
97
+ tags: list[str] | None = None) -> str | None:
98
+ if sys.platform != "darwin" or not which("osascript"):
99
+ return None
100
+ body = (text[:1500] + ("…" if len(text) > 1500 else ""))
101
+ body = body.replace("\\", "\\\\").replace('"', '\\"')
102
+ script = (
103
+ f'display dialog "{body}" with title "countersign {digest}" '
104
+ f'buttons {{"Deny", "Allow"}} default button "Deny" '
105
+ f'with icon caution giving up after {int(to)}'
106
+ )
107
+ try:
108
+ r = subprocess.run(["osascript", "-e", script], capture_output=True,
109
+ text=True, timeout=to + 5)
110
+ out = (r.stdout or "").strip()
111
+ compact = out.replace(" ", "")
112
+ if "gave up:true" in compact:
113
+ return None
114
+ if "button returned:Allow" in out:
115
+ return ALLOW
116
+ if "button returned:Deny" in out:
117
+ return DENY
118
+ return None
119
+ except Exception:
120
+ return None
121
+
122
+
123
+ def _via_zenity(text: str, digest: str, to: float,
124
+ tags: list[str] | None = None) -> str | None:
125
+ if sys.platform == "darwin" or not which("zenity"):
126
+ return None
127
+ tagline = (" [" + ",".join(tags) + "]") if tags else ""
128
+ try:
129
+ rc = subprocess.run(
130
+ ["zenity", "--question", f"--title=countersign {digest}{tagline}",
131
+ f"--text={text}", "--ok-label=Allow", "--cancel-label=Deny",
132
+ f"--timeout={int(to)}", "--width=520"],
133
+ timeout=to + 5,
134
+ ).returncode
135
+ if rc == 0:
136
+ return ALLOW
137
+ if rc == 1:
138
+ return DENY
139
+ return None
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ _BACKENDS = {
145
+ "socket": _via_socket,
146
+ "osascript": _via_osascript,
147
+ "zenity": _via_zenity,
148
+ }