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.
- countersign/__init__.py +3 -0
- countersign/alerts.py +64 -0
- countersign/ask.py +148 -0
- countersign/askd.py +668 -0
- countersign/audit.py +643 -0
- countersign/census.py +428 -0
- countersign/cli.py +716 -0
- countersign/config.py +19 -0
- countersign/demo.py +139 -0
- countersign/digest.py +25 -0
- countersign/fold.py +83 -0
- countersign/highlight.py +61 -0
- countersign/hook.py +134 -0
- countersign/inbound.py +276 -0
- countersign/install.py +95 -0
- countersign/mcp_proxy.py +292 -0
- countersign/openclaw.py +78 -0
- countersign/prompts.py +134 -0
- countersign/protect.py +98 -0
- countersign/remember.py +36 -0
- countersign/render.py +72 -0
- countersign/who.py +2 -0
- countersign_agent-0.10.0.dist-info/METADATA +406 -0
- countersign_agent-0.10.0.dist-info/RECORD +28 -0
- countersign_agent-0.10.0.dist-info/WHEEL +5 -0
- countersign_agent-0.10.0.dist-info/entry_points.txt +6 -0
- countersign_agent-0.10.0.dist-info/licenses/LICENSE +202 -0
- countersign_agent-0.10.0.dist-info/top_level.txt +1 -0
countersign/__init__.py
ADDED
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
|
+
}
|