handcuff 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.
- handcuff/__init__.py +0 -0
- handcuff/alerts/__init__.py +0 -0
- handcuff/alerts/webhooks.py +56 -0
- handcuff/banner.py +128 -0
- handcuff/capture/__init__.py +0 -0
- handcuff/capture/base.py +28 -0
- handcuff/capture/file_watch.py +143 -0
- handcuff/capture/net_watch.py +139 -0
- handcuff/capture/psutil_backend.py +117 -0
- handcuff/capture/redact.py +67 -0
- handcuff/capture/tree.py +43 -0
- handcuff/cli.py +362 -0
- handcuff/config.py +65 -0
- handcuff/core/__init__.py +0 -0
- handcuff/core/bus.py +74 -0
- handcuff/core/events.py +159 -0
- handcuff/core/hashing.py +79 -0
- handcuff/core/session.py +95 -0
- handcuff/core/signing.py +78 -0
- handcuff/doctor.py +124 -0
- handcuff/enforce/__init__.py +0 -0
- handcuff/enforce/file_acl.py +145 -0
- handcuff/enforce/firewall.py +179 -0
- handcuff/enforce/scan.py +50 -0
- handcuff/export/__init__.py +0 -0
- handcuff/export/html_export.py +126 -0
- handcuff/export/json_export.py +94 -0
- handcuff/export/md_export.py +113 -0
- handcuff/gateway/__init__.py +0 -0
- handcuff/gateway/gateway.py +331 -0
- handcuff/gateway/log.py +66 -0
- handcuff/gateway/policy.py +93 -0
- handcuff/integrations/__init__.py +5 -0
- handcuff/integrations/langchain.py +459 -0
- handcuff/replay.py +119 -0
- handcuff/rules/__init__.py +0 -0
- handcuff/rules/defaults.yaml +25 -0
- handcuff/rules/dsl.py +209 -0
- handcuff/rules/engine.py +152 -0
- handcuff/rules/injection.py +150 -0
- handcuff/rules/trust.py +53 -0
- handcuff/runner.py +382 -0
- handcuff/storage/__init__.py +0 -0
- handcuff/storage/db.py +101 -0
- handcuff/storage/writer.py +140 -0
- handcuff/tui/__init__.py +0 -0
- handcuff/tui/app.py +235 -0
- handcuff/tui/risk.py +65 -0
- handcuff/tui/styles.tcss +106 -0
- handcuff/tui/theme.py +62 -0
- handcuff/tui/widgets/__init__.py +0 -0
- handcuff/tui/widgets/banner_header.py +102 -0
- handcuff/tui/widgets/flag_cards.py +63 -0
- handcuff/tui/widgets/footer.py +42 -0
- handcuff/tui/widgets/hero.py +336 -0
- handcuff/tui/widgets/logs.py +69 -0
- handcuff/tui/widgets/monitoring.py +53 -0
- handcuff/tui/widgets/recommendation.py +49 -0
- handcuff/tui/widgets/risk_overview.py +63 -0
- handcuff/tui/widgets/trust.py +92 -0
- handcuff/verify.py +76 -0
- handcuff-0.2.0.dist-info/METADATA +149 -0
- handcuff-0.2.0.dist-info/RECORD +65 -0
- handcuff-0.2.0.dist-info/WHEEL +4 -0
- handcuff-0.2.0.dist-info/entry_points.txt +2 -0
handcuff/capture/tree.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Process-tree tracker (AL-022).
|
|
2
|
+
|
|
3
|
+
Maintains the live set of PIDs descended from a root PID by polling
|
|
4
|
+
`psutil`. Short-lived processes that spawn and exit between polls are
|
|
5
|
+
inherently missable with this approach (documented limitation) — the
|
|
6
|
+
capture backend built on top of this is expected to emit `LOSS` for
|
|
7
|
+
detected gaps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import psutil
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProcessTreeTracker:
|
|
16
|
+
def __init__(self, root_pid: int) -> None:
|
|
17
|
+
self.root_pid = root_pid
|
|
18
|
+
self._pids: set[int] = set()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def pids(self) -> set[int]:
|
|
22
|
+
return set(self._pids)
|
|
23
|
+
|
|
24
|
+
def refresh(self) -> tuple[set[int], set[int]]:
|
|
25
|
+
"""Poll the system for descendants of root_pid. Returns
|
|
26
|
+
(added, removed) PID sets relative to the previous snapshot."""
|
|
27
|
+
current: set[int] = set()
|
|
28
|
+
if psutil.pid_exists(self.root_pid):
|
|
29
|
+
current.add(self.root_pid)
|
|
30
|
+
try:
|
|
31
|
+
root = psutil.Process(self.root_pid)
|
|
32
|
+
for child in root.children(recursive=True):
|
|
33
|
+
current.add(child.pid)
|
|
34
|
+
except psutil.NoSuchProcess:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
added = current - self._pids
|
|
38
|
+
removed = self._pids - current
|
|
39
|
+
self._pids = current
|
|
40
|
+
return added, removed
|
|
41
|
+
|
|
42
|
+
def contains(self, pid: int) -> bool:
|
|
43
|
+
return pid in self._pids
|
handcuff/cli.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Handcuff CLI (Typer app). AL-001/AL-024.
|
|
2
|
+
|
|
3
|
+
Commands implemented for the vertical slice: `watch`, `sessions`.
|
|
4
|
+
Remaining commands are placeholders so `--help` lists the full surface
|
|
5
|
+
described in the PRD/TRD; they raise NotImplementedError until their
|
|
6
|
+
tickets land.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from handcuff.banner import print_banner, should_show_banner
|
|
18
|
+
from handcuff.config import DEFAULT_DB_PATH
|
|
19
|
+
from handcuff.export.json_export import export_session_json
|
|
20
|
+
from handcuff.runner import list_sessions, watch_command
|
|
21
|
+
from handcuff.storage.db import connect
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="A dashcam for your AI agents.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback(invoke_without_command=True)
|
|
27
|
+
def main(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
no_banner: bool = typer.Option(
|
|
30
|
+
False, "--no-banner", help="Skip the startup banner for this invocation."
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""A dashcam for your AI agents."""
|
|
34
|
+
# The full welcome panel is a startup/session-entry thing (like
|
|
35
|
+
# Claude Code's splash), not something that should reprint on every
|
|
36
|
+
# one-shot command — showing it for `handcuff sessions`, `export`,
|
|
37
|
+
# etc. every single time floods the terminal with a large repeated
|
|
38
|
+
# panel (confirmed directly from a real screenshot: back-to-back
|
|
39
|
+
# invocations produced stacked, overlapping panel text). Only show
|
|
40
|
+
# it when entering the interactive dashboard (`tui`) or when
|
|
41
|
+
# `handcuff` is run bare with no subcommand.
|
|
42
|
+
show_for = {"tui", None}
|
|
43
|
+
if not no_banner and ctx.invoked_subcommand in show_for:
|
|
44
|
+
print_banner()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def watch(
|
|
49
|
+
command: list[str] = typer.Argument(..., help="Command to run and record, e.g. -- bash -c ..."),
|
|
50
|
+
label: str = typer.Option(None, help="Human-readable label for this session"),
|
|
51
|
+
quiet: bool = typer.Option(False, "--quiet", help="Headless: record with no UI"),
|
|
52
|
+
watch_path: list[str] = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--watch-path",
|
|
55
|
+
help="Directory to watch for file events (repeatable). Default: cwd + home.",
|
|
56
|
+
),
|
|
57
|
+
enforce: bool = typer.Option(
|
|
58
|
+
False,
|
|
59
|
+
"--enforce",
|
|
60
|
+
help=(
|
|
61
|
+
"Real OS-level blocking: deny-ACL sensitive files under watch paths "
|
|
62
|
+
"(no admin needed) and, if elevated, block outbound network via "
|
|
63
|
+
"Windows Firewall except --allow-domain. See `handcuff doctor` for "
|
|
64
|
+
"what's actually active."
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
protect_path: list[str] = typer.Option(
|
|
68
|
+
None, "--protect-path", help="Extra path to scan for sensitive files under --enforce."
|
|
69
|
+
),
|
|
70
|
+
allow_domain: list[str] = typer.Option(
|
|
71
|
+
None, "--allow-domain", help="Domain allowed through the firewall block (repeatable)."
|
|
72
|
+
),
|
|
73
|
+
webhook_url: str = typer.Option(
|
|
74
|
+
None,
|
|
75
|
+
"--webhook-url",
|
|
76
|
+
help="Post a content-light JSON payload here when a critical alert fires. "
|
|
77
|
+
"HTTPS required (plain HTTP only allowed to localhost).",
|
|
78
|
+
),
|
|
79
|
+
hash_content: bool = typer.Option(
|
|
80
|
+
False,
|
|
81
|
+
"--hash-content",
|
|
82
|
+
help="Record a SHA-256 of written file contents (files up to 1 MiB). "
|
|
83
|
+
"Only the hash is stored, never the content.",
|
|
84
|
+
),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Launch COMMAND under observation; record process activity to a session."""
|
|
87
|
+
paths = [Path(p) for p in watch_path] if watch_path else None
|
|
88
|
+
protected = [Path(p) for p in protect_path] if protect_path else None
|
|
89
|
+
session_id = asyncio.run(
|
|
90
|
+
watch_command(
|
|
91
|
+
list(command),
|
|
92
|
+
label=label,
|
|
93
|
+
watch_paths=paths,
|
|
94
|
+
enforce=enforce,
|
|
95
|
+
protected_paths=protected,
|
|
96
|
+
allowed_domains=list(allow_domain) if allow_domain else None,
|
|
97
|
+
webhook_url=webhook_url,
|
|
98
|
+
hash_content=hash_content,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
typer.echo(f"session {session_id} closed")
|
|
102
|
+
if enforce:
|
|
103
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
104
|
+
marks = conn.execute(
|
|
105
|
+
"SELECT payload_json FROM events WHERE session_id=? AND kind='MARK'", (session_id,)
|
|
106
|
+
).fetchall()
|
|
107
|
+
for m in marks:
|
|
108
|
+
typer.echo(f" [enforce] {m['payload_json']}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def sessions() -> None:
|
|
113
|
+
"""List recorded sessions."""
|
|
114
|
+
rows = list_sessions()
|
|
115
|
+
if not rows:
|
|
116
|
+
typer.echo("no sessions recorded yet")
|
|
117
|
+
raise typer.Exit()
|
|
118
|
+
for row in rows:
|
|
119
|
+
started = datetime.fromtimestamp(row["started_at"] / 1000, tz=UTC).isoformat()
|
|
120
|
+
typer.echo(
|
|
121
|
+
f"{row['id']} {row['status']:<24} {started} {row['label'] or ''} {row['target_cmd']}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def attach(
|
|
127
|
+
pid: int,
|
|
128
|
+
label: str = typer.Option(None, help="Human-readable label for this session"),
|
|
129
|
+
duration: float = typer.Option(
|
|
130
|
+
None, "--duration", help="Stop recording after this many seconds (default: until pid exits)"
|
|
131
|
+
),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Attach to an already-running process tree and record it until it exits."""
|
|
134
|
+
from handcuff.runner import attach_command
|
|
135
|
+
|
|
136
|
+
session_id = asyncio.run(attach_command(pid, label=label, duration_s=duration))
|
|
137
|
+
typer.echo(f"session {session_id} closed")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def tui(
|
|
142
|
+
session_id: str = typer.Argument(None, help="Session to watch (default: most recent)"),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Open the terminal dashboard: live event stream, alerts, process tree, stats."""
|
|
145
|
+
import time
|
|
146
|
+
|
|
147
|
+
from handcuff.tui.app import run_tui
|
|
148
|
+
|
|
149
|
+
if should_show_banner():
|
|
150
|
+
# Hold the banner briefly before Textual clears the screen and
|
|
151
|
+
# takes over — otherwise it flashes by unseen, since Textual's
|
|
152
|
+
# alt-screen switch happens almost immediately on run().
|
|
153
|
+
time.sleep(0.6)
|
|
154
|
+
|
|
155
|
+
run_tui(session_id=session_id)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def replay(
|
|
160
|
+
session_id: str,
|
|
161
|
+
to_alert: int = typer.Option(
|
|
162
|
+
None, "--to-alert", help="Jump straight to the Nth alert (1-indexed) and print it"
|
|
163
|
+
),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Scrub through a past session.
|
|
166
|
+
|
|
167
|
+
Interactive by default: n=next, p=previous, a=next alert,
|
|
168
|
+
A=previous alert, q=quit. Pass --to-alert N for a non-interactive
|
|
169
|
+
jump-and-print (useful for scripting/CI).
|
|
170
|
+
"""
|
|
171
|
+
from handcuff.replay import ReplaySession
|
|
172
|
+
|
|
173
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
174
|
+
rs = ReplaySession(conn, session_id)
|
|
175
|
+
if rs.total == 0:
|
|
176
|
+
typer.echo("session has no events")
|
|
177
|
+
raise typer.Exit()
|
|
178
|
+
|
|
179
|
+
def render(ev) -> str:
|
|
180
|
+
marker = f" [ALERT: {', '.join(ev.alert_rule_ids)}]" if ev.alert_rule_ids else ""
|
|
181
|
+
return f"[{rs.cursor + 1}/{rs.total}] seq={ev.seq} {ev.kind}{marker} {ev.payload}"
|
|
182
|
+
|
|
183
|
+
if to_alert is not None:
|
|
184
|
+
rs.cursor = 0
|
|
185
|
+
event = None
|
|
186
|
+
for _ in range(to_alert):
|
|
187
|
+
event = rs.next_alert()
|
|
188
|
+
if event is None:
|
|
189
|
+
typer.echo(f"only {rs.alert_count()} alert(s) in this session")
|
|
190
|
+
raise typer.Exit(code=1)
|
|
191
|
+
typer.echo(render(event))
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
typer.echo(render(rs.current()))
|
|
195
|
+
typer.echo("commands: n=next p=prev a=next-alert A=prev-alert q=quit")
|
|
196
|
+
while True:
|
|
197
|
+
try:
|
|
198
|
+
cmd = input("> ").strip()
|
|
199
|
+
except EOFError:
|
|
200
|
+
break
|
|
201
|
+
if cmd == "q":
|
|
202
|
+
break
|
|
203
|
+
elif cmd == "n":
|
|
204
|
+
ev = rs.step_forward()
|
|
205
|
+
elif cmd == "p":
|
|
206
|
+
ev = rs.step_backward()
|
|
207
|
+
elif cmd == "a":
|
|
208
|
+
ev = rs.next_alert() or rs.current()
|
|
209
|
+
elif cmd == "A":
|
|
210
|
+
ev = rs.prev_alert() or rs.current()
|
|
211
|
+
else:
|
|
212
|
+
typer.echo("unknown command")
|
|
213
|
+
continue
|
|
214
|
+
typer.echo(render(ev))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def export(
|
|
219
|
+
session_id: str,
|
|
220
|
+
format: str = typer.Option("json", help="Export format: json, md, or html"),
|
|
221
|
+
output: str = typer.Option(None, help="Output file path (default: <session_id>.<format>)"),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Export a session report."""
|
|
224
|
+
from handcuff.export.html_export import export_session_html
|
|
225
|
+
from handcuff.export.md_export import export_session_md
|
|
226
|
+
|
|
227
|
+
exporters = {"json": export_session_json, "md": export_session_md, "html": export_session_html}
|
|
228
|
+
if format not in exporters:
|
|
229
|
+
typer.echo(f"unknown format {format!r}; choose from: {', '.join(exporters)}")
|
|
230
|
+
raise typer.Exit(code=2)
|
|
231
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
232
|
+
out_path = Path(output) if output else Path(f"{session_id}.{format}")
|
|
233
|
+
exporters[format](conn, session_id, out_path)
|
|
234
|
+
typer.echo(f"exported to {out_path}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command()
|
|
238
|
+
def verify(
|
|
239
|
+
session_id: str = typer.Argument(None, help="Session id to verify from the live DB"),
|
|
240
|
+
file: str = typer.Option(None, "--file", help="Verify a JSON export file instead"),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Re-validate a session's hash chain and signature."""
|
|
243
|
+
from handcuff.verify import verify_export_file, verify_session_db
|
|
244
|
+
|
|
245
|
+
if file:
|
|
246
|
+
result = verify_export_file(Path(file))
|
|
247
|
+
elif session_id:
|
|
248
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
249
|
+
result = verify_session_db(conn, session_id)
|
|
250
|
+
else:
|
|
251
|
+
typer.echo("provide a session_id or --file")
|
|
252
|
+
raise typer.Exit(code=2)
|
|
253
|
+
|
|
254
|
+
typer.echo(f"chain: {'OK' if result.chain.ok else 'FAILED'}")
|
|
255
|
+
if not result.chain.ok:
|
|
256
|
+
typer.echo(f" failed at seq={result.chain.failed_seq} reason={result.chain.reason}")
|
|
257
|
+
if result.signature_ok is not None:
|
|
258
|
+
typer.echo(f"signature: {'OK' if result.signature_ok else 'FAILED'}")
|
|
259
|
+
|
|
260
|
+
if not result.chain.ok or result.signature_ok is False:
|
|
261
|
+
raise typer.Exit(code=1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
rules_app = typer.Typer(help="Manage alert rules / trust list.")
|
|
265
|
+
trust_app = typer.Typer(help="Manage the trust allowlist (domains/paths/cmds).")
|
|
266
|
+
rules_app.add_typer(trust_app, name="trust")
|
|
267
|
+
app.add_typer(rules_app, name="rules")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@trust_app.command("add")
|
|
271
|
+
def trust_add(
|
|
272
|
+
kind: str = typer.Argument(..., help="domain|path|cmd"),
|
|
273
|
+
value: str = typer.Argument(...),
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Add an entry to the trust list."""
|
|
276
|
+
from handcuff.rules.trust import add_trust
|
|
277
|
+
|
|
278
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
279
|
+
add_trust(conn, kind, value)
|
|
280
|
+
typer.echo(f"trusted {kind}: {value}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@trust_app.command("list")
|
|
284
|
+
def trust_list(kind: str = typer.Option(None, help="Filter by kind (domain|path|cmd)")) -> None:
|
|
285
|
+
"""List trust list entries."""
|
|
286
|
+
from handcuff.rules.trust import list_trust
|
|
287
|
+
|
|
288
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
289
|
+
entries = list_trust(conn, kind=kind)
|
|
290
|
+
if not entries:
|
|
291
|
+
typer.echo("no trust entries")
|
|
292
|
+
return
|
|
293
|
+
for e in entries:
|
|
294
|
+
typer.echo(f"{e.kind:<8} {e.value}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@trust_app.command("rm")
|
|
298
|
+
def trust_rm(
|
|
299
|
+
kind: str = typer.Argument(..., help="domain|path|cmd"),
|
|
300
|
+
value: str = typer.Argument(...),
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Remove an entry from the trust list."""
|
|
303
|
+
from handcuff.rules.trust import remove_trust
|
|
304
|
+
|
|
305
|
+
conn = connect(DEFAULT_DB_PATH)
|
|
306
|
+
removed = remove_trust(conn, kind, value)
|
|
307
|
+
typer.echo(f"removed: {removed}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
config_app = typer.Typer(help="Show/edit configuration (~/.config/handcuff/config.yaml).")
|
|
311
|
+
app.add_typer(config_app, name="config")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@config_app.command("list")
|
|
315
|
+
def config_list() -> None:
|
|
316
|
+
"""Show all config values (defaults merged with the config file)."""
|
|
317
|
+
from handcuff.config import DEFAULT_CONFIG_PATH, load_config
|
|
318
|
+
|
|
319
|
+
typer.echo(f"config file: {DEFAULT_CONFIG_PATH} (exists={DEFAULT_CONFIG_PATH.exists()})")
|
|
320
|
+
typer.echo(f"db path: {DEFAULT_DB_PATH}")
|
|
321
|
+
for k, v in load_config().items():
|
|
322
|
+
typer.echo(f"{k} = {v}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@config_app.command("get")
|
|
326
|
+
def config_get(key: str) -> None:
|
|
327
|
+
"""Print one config value."""
|
|
328
|
+
from handcuff.config import load_config
|
|
329
|
+
|
|
330
|
+
cfg = load_config()
|
|
331
|
+
if key not in cfg:
|
|
332
|
+
typer.echo(f"unknown key {key!r}; known: {', '.join(cfg)}")
|
|
333
|
+
raise typer.Exit(code=2)
|
|
334
|
+
typer.echo(f"{cfg[key]}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@config_app.command("set")
|
|
338
|
+
def config_set(key: str, value: str) -> None:
|
|
339
|
+
"""Set a config value, e.g. `handcuff config set retention_days 14`."""
|
|
340
|
+
from handcuff.config import CONFIG_KEYS, set_config_value
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
typed = set_config_value(key, value)
|
|
344
|
+
except KeyError:
|
|
345
|
+
typer.echo(f"unknown key {key!r}; known: {', '.join(CONFIG_KEYS)}")
|
|
346
|
+
raise typer.Exit(code=2) from None
|
|
347
|
+
except ValueError as exc:
|
|
348
|
+
typer.echo(f"invalid value: {exc}")
|
|
349
|
+
raise typer.Exit(code=2) from None
|
|
350
|
+
typer.echo(f"{key} = {typed}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.command()
|
|
354
|
+
def doctor() -> None:
|
|
355
|
+
"""Environment diagnostics: platform, capture capabilities, DB/key health."""
|
|
356
|
+
from handcuff.doctor import format_report, run_doctor
|
|
357
|
+
|
|
358
|
+
typer.echo(format_report(run_doctor()))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
app()
|
handcuff/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Default paths (TRD §10) + user config file.
|
|
2
|
+
|
|
3
|
+
The config file is a small flat YAML at ~/.config/handcuff/config.yaml,
|
|
4
|
+
read/written via `handcuff config get|set|list`. Only known keys are
|
|
5
|
+
accepted so a typo'd `config set` fails loudly instead of silently doing
|
|
6
|
+
nothing."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".config" / "handcuff"
|
|
16
|
+
DATA_DIR = Path.home() / ".local" / "share" / "handcuff"
|
|
17
|
+
DEFAULT_DB_PATH = DATA_DIR / "handcuff.db"
|
|
18
|
+
DEFAULT_KEY_PATH = CONFIG_DIR / "ed25519.key"
|
|
19
|
+
DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
|
20
|
+
|
|
21
|
+
# key -> (default, parser). Parsers turn the CLI string into the typed value.
|
|
22
|
+
def _parse_bool(s: str) -> bool:
|
|
23
|
+
if s.lower() in {"true", "1", "yes", "on"}:
|
|
24
|
+
return True
|
|
25
|
+
if s.lower() in {"false", "0", "no", "off"}:
|
|
26
|
+
return False
|
|
27
|
+
raise ValueError(f"not a boolean: {s!r}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CONFIG_KEYS: dict[str, tuple[Any, Any]] = {
|
|
31
|
+
"webhook_url": (None, str),
|
|
32
|
+
"retention_days": (30, int),
|
|
33
|
+
"redaction": (True, _parse_bool),
|
|
34
|
+
"hash_content": (False, _parse_bool),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict[str, Any]:
|
|
39
|
+
"""Defaults merged with whatever the config file overrides."""
|
|
40
|
+
cfg = {k: default for k, (default, _) in CONFIG_KEYS.items()}
|
|
41
|
+
if path.exists():
|
|
42
|
+
data = YAML(typ="safe").load(path.read_text(encoding="utf-8")) or {}
|
|
43
|
+
for k, v in data.items():
|
|
44
|
+
if k in CONFIG_KEYS:
|
|
45
|
+
cfg[k] = v
|
|
46
|
+
return cfg
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_config_value(key: str, raw_value: str, path: Path = DEFAULT_CONFIG_PATH) -> Any:
|
|
50
|
+
"""Parse and persist one key. Returns the typed value. Raises
|
|
51
|
+
KeyError/ValueError on unknown key or unparseable value."""
|
|
52
|
+
if key not in CONFIG_KEYS:
|
|
53
|
+
raise KeyError(key)
|
|
54
|
+
_, parser = CONFIG_KEYS[key]
|
|
55
|
+
value = parser(raw_value)
|
|
56
|
+
yaml = YAML(typ="safe")
|
|
57
|
+
yaml.default_flow_style = False
|
|
58
|
+
data: dict[str, Any] = {}
|
|
59
|
+
if path.exists():
|
|
60
|
+
data = yaml.load(path.read_text(encoding="utf-8")) or {}
|
|
61
|
+
data[key] = value
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
with path.open("w", encoding="utf-8") as f:
|
|
64
|
+
yaml.dump(data, f)
|
|
65
|
+
return value
|
|
File without changes
|
handcuff/core/bus.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Async event bus + fan-out with backpressure (AL-020).
|
|
2
|
+
|
|
3
|
+
A single bounded ingress queue (maxsize=10_000) fans out to N consumer
|
|
4
|
+
queues (StorageWriter, RulesEngine, TUI, ...). On ingress overflow we drop
|
|
5
|
+
the *oldest* queued item, increment a counter, and synthesize a `LOSS`
|
|
6
|
+
event summarizing the drop — the capture pipeline must never block the
|
|
7
|
+
observed agent (NFR-2).
|
|
8
|
+
|
|
9
|
+
Each consumer gets its own unbounded-ish fan-out queue so a slow consumer
|
|
10
|
+
(e.g. a laggy TUI) cannot block a fast one (e.g. the storage writer):
|
|
11
|
+
consumer queues are themselves bounded and drop-oldest independently.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
|
|
20
|
+
from handcuff.core.events import EventKind, RawEvent
|
|
21
|
+
|
|
22
|
+
INGRESS_MAXSIZE = 10_000
|
|
23
|
+
CONSUMER_MAXSIZE = 10_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EventBus:
|
|
27
|
+
def __init__(self, maxsize: int = INGRESS_MAXSIZE) -> None:
|
|
28
|
+
self._maxsize = maxsize
|
|
29
|
+
self._consumers: dict[str, asyncio.Queue[RawEvent]] = {}
|
|
30
|
+
self.dropped_count = 0
|
|
31
|
+
|
|
32
|
+
def subscribe(self, name: str, maxsize: int = CONSUMER_MAXSIZE) -> asyncio.Queue[RawEvent]:
|
|
33
|
+
q: asyncio.Queue[RawEvent] = asyncio.Queue(maxsize=maxsize)
|
|
34
|
+
self._consumers[name] = q
|
|
35
|
+
return q
|
|
36
|
+
|
|
37
|
+
def unsubscribe(self, name: str) -> None:
|
|
38
|
+
self._consumers.pop(name, None)
|
|
39
|
+
|
|
40
|
+
async def publish(self, event: RawEvent) -> None:
|
|
41
|
+
"""Fan out to every consumer. Each consumer queue drops its own
|
|
42
|
+
oldest items on overflow rather than blocking the publisher."""
|
|
43
|
+
for name, q in self._consumers.items():
|
|
44
|
+
overflowed = False
|
|
45
|
+
# Ensure room for both the LOSS marker and the real event
|
|
46
|
+
# (2 free slots), for queues with capacity to hold both;
|
|
47
|
+
# a maxsize==1 queue can only ever hold the latest event.
|
|
48
|
+
room_needed = 2 if q.maxsize > 1 else 1
|
|
49
|
+
while q.maxsize - q.qsize() < room_needed:
|
|
50
|
+
try:
|
|
51
|
+
q.get_nowait()
|
|
52
|
+
except asyncio.QueueEmpty:
|
|
53
|
+
break
|
|
54
|
+
self.dropped_count += 1
|
|
55
|
+
overflowed = True
|
|
56
|
+
if overflowed and room_needed == 2:
|
|
57
|
+
loss = RawEvent(
|
|
58
|
+
kind=EventKind.LOSS,
|
|
59
|
+
ts=int(time.time() * 1000),
|
|
60
|
+
pid=None,
|
|
61
|
+
ppid=None,
|
|
62
|
+
payload={
|
|
63
|
+
"reason": "buffer_overflow",
|
|
64
|
+
"dropped_estimate": self.dropped_count,
|
|
65
|
+
"consumer": name,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
q.put_nowait(loss)
|
|
69
|
+
q.put_nowait(event)
|
|
70
|
+
|
|
71
|
+
async def consume(self, name: str) -> AsyncIterator[RawEvent]:
|
|
72
|
+
q = self._consumers[name]
|
|
73
|
+
while True:
|
|
74
|
+
yield await q.get()
|
handcuff/core/events.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Event & session dataclasses (AL-010).
|
|
2
|
+
|
|
3
|
+
`RawEvent` is what capture backends emit (pre-sequencing). `Event` is the
|
|
4
|
+
persisted, chain-hashed form written by the storage layer. Payloads are
|
|
5
|
+
kind-specific TypedDicts documented in TRD §3.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, TypedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventKind(str, Enum):
|
|
16
|
+
PROCESS_SPAWN = "PROCESS_SPAWN"
|
|
17
|
+
PROCESS_EXIT = "PROCESS_EXIT"
|
|
18
|
+
FILE_READ = "FILE_READ"
|
|
19
|
+
FILE_WRITE = "FILE_WRITE"
|
|
20
|
+
FILE_DELETE = "FILE_DELETE"
|
|
21
|
+
FILE_RENAME = "FILE_RENAME"
|
|
22
|
+
NET_CONNECT = "NET_CONNECT"
|
|
23
|
+
LOSS = "LOSS"
|
|
24
|
+
ALERT = "ALERT"
|
|
25
|
+
MARK = "MARK"
|
|
26
|
+
GATEWAY_DENY = "GATEWAY_DENY"
|
|
27
|
+
INJECTION_DETECTED = "INJECTION_DETECTED"
|
|
28
|
+
AGENT_STEP = "AGENT_STEP"
|
|
29
|
+
TOOL_CALL = "TOOL_CALL"
|
|
30
|
+
TOOL_RESULT = "TOOL_RESULT"
|
|
31
|
+
RETRIEVAL = "RETRIEVAL"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProcessSpawnPayload(TypedDict, total=False):
|
|
35
|
+
argv: list[str]
|
|
36
|
+
cwd: str
|
|
37
|
+
exe: str
|
|
38
|
+
user: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProcessExitPayload(TypedDict, total=False):
|
|
42
|
+
exit_code: int
|
|
43
|
+
duration_ms: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FileReadPayload(TypedDict, total=False):
|
|
47
|
+
path: str
|
|
48
|
+
bytes: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GatewayDenyPayload(TypedDict, total=False):
|
|
52
|
+
op: str
|
|
53
|
+
path: str
|
|
54
|
+
reason: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FileWritePayload(TypedDict, total=False):
|
|
58
|
+
path: str
|
|
59
|
+
bytes_delta: int
|
|
60
|
+
op: str
|
|
61
|
+
content_hash: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileDeletePayload(TypedDict, total=False):
|
|
65
|
+
path: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FileRenamePayload(TypedDict, total=False):
|
|
69
|
+
path: str
|
|
70
|
+
to: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NetConnectPayload(TypedDict, total=False):
|
|
74
|
+
dst_ip: str
|
|
75
|
+
domain: str | None
|
|
76
|
+
port: int
|
|
77
|
+
proto: str
|
|
78
|
+
bytes: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LossPayload(TypedDict, total=False):
|
|
82
|
+
expected_seq: int
|
|
83
|
+
reason: str
|
|
84
|
+
dropped_estimate: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AlertPayload(TypedDict, total=False):
|
|
88
|
+
rule_id: str
|
|
89
|
+
severity: str
|
|
90
|
+
message: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MarkPayload(TypedDict, total=False):
|
|
94
|
+
note: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AgentStepPayload(TypedDict, total=False):
|
|
98
|
+
agent: str
|
|
99
|
+
run_id: str
|
|
100
|
+
parent_run_id: str | None
|
|
101
|
+
kind: str # "chain_start" | "chain_end" | "agent_action" | "agent_finish"
|
|
102
|
+
name: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ToolCallPayload(TypedDict, total=False):
|
|
106
|
+
agent: str
|
|
107
|
+
run_id: str
|
|
108
|
+
tool: str
|
|
109
|
+
input_excerpt: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ToolResultPayload(TypedDict, total=False):
|
|
113
|
+
agent: str
|
|
114
|
+
run_id: str
|
|
115
|
+
tool: str
|
|
116
|
+
output_excerpt: str
|
|
117
|
+
error: bool
|
|
118
|
+
blocked: bool
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RetrievalPayload(TypedDict, total=False):
|
|
122
|
+
agent: str
|
|
123
|
+
run_id: str
|
|
124
|
+
retriever: str
|
|
125
|
+
doc_count: int
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class InjectionDetectedPayload(TypedDict, total=False):
|
|
129
|
+
source: str # path or URL the tainting content came from
|
|
130
|
+
signature: str # which heuristic signature matched
|
|
131
|
+
reason: str
|
|
132
|
+
excerpt: str # short, already-redacted-adjacent excerpt for audit
|
|
133
|
+
taint_expires_at: int # epoch ms; sensitive ops before this are gated
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(slots=True)
|
|
137
|
+
class RawEvent:
|
|
138
|
+
"""Emitted by a capture backend, before sequencing/hashing."""
|
|
139
|
+
|
|
140
|
+
kind: EventKind
|
|
141
|
+
ts: int # epoch ms
|
|
142
|
+
pid: int | None
|
|
143
|
+
ppid: int | None
|
|
144
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True)
|
|
148
|
+
class Event:
|
|
149
|
+
"""Persisted, hash-chained event row (mirrors the `events` table)."""
|
|
150
|
+
|
|
151
|
+
session_id: str
|
|
152
|
+
seq: int
|
|
153
|
+
ts: int
|
|
154
|
+
kind: EventKind
|
|
155
|
+
pid: int | None
|
|
156
|
+
ppid: int | None
|
|
157
|
+
payload: dict[str, Any]
|
|
158
|
+
prev_hash: str
|
|
159
|
+
hash: str
|