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/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Webhook alerts (AL-042).
|
|
2
|
+
|
|
3
|
+
Posts a minimal JSON payload to a configured URL when a `critical`
|
|
4
|
+
severity alert fires. Deliberately content-light: only rule_id,
|
|
5
|
+
severity, message, and timestamp — never argv, file paths, domains, or
|
|
6
|
+
any other captured content (NFR-1: nothing leaves the machine except to
|
|
7
|
+
a user-configured webhook, and even then, as little as possible).
|
|
8
|
+
|
|
9
|
+
HTTPS is required for remote hosts; plain HTTP is only allowed to
|
|
10
|
+
localhost (useful for local test receivers / local automation), so a
|
|
11
|
+
misconfigured webhook URL can't silently leak alert text in the clear
|
|
12
|
+
over the network.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from handcuff.rules.engine import Alert
|
|
22
|
+
|
|
23
|
+
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_allowed_url(url: str) -> bool:
|
|
27
|
+
parsed = urlparse(url)
|
|
28
|
+
if parsed.scheme == "https":
|
|
29
|
+
return True
|
|
30
|
+
if parsed.scheme == "http" and parsed.hostname in _LOCALHOST_HOSTS:
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def alert_payload(alert: Alert) -> dict:
|
|
36
|
+
"""Content-light payload: no argv/path/domain/file content, ever."""
|
|
37
|
+
return {
|
|
38
|
+
"rule_id": alert.rule_id,
|
|
39
|
+
"severity": alert.severity,
|
|
40
|
+
"message": alert.message,
|
|
41
|
+
"ts": alert.ts,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def post_alert(url: str, alert: Alert, timeout: float = 5.0) -> bool:
|
|
46
|
+
"""Post a critical alert to the configured webhook. Returns False
|
|
47
|
+
(never raises) on any failure — a broken webhook must not crash or
|
|
48
|
+
stall the recording session."""
|
|
49
|
+
if not is_allowed_url(url):
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
53
|
+
resp = await client.post(url, json=alert_payload(alert))
|
|
54
|
+
return resp.status_code < 400
|
|
55
|
+
except httpx.HTTPError:
|
|
56
|
+
return False
|
handcuff/banner.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Startup banner (Handcuff's "logo"), printed like Claude Code/Gemini
|
|
2
|
+
CLI show a welcome screen on launch.
|
|
3
|
+
|
|
4
|
+
Two-column layout matching Claude Code's actual screen: a bordered
|
|
5
|
+
panel (icon + welcome + identity) on the left, "Quick start"/"What's
|
|
6
|
+
new" on the right — not a single lonely box in empty space.
|
|
7
|
+
|
|
8
|
+
Uses Rich's own box-drawing (rounded corners, the magnifying-glass
|
|
9
|
+
icon) which Rich encodes correctly for the detected terminal — verified
|
|
10
|
+
directly against Windows Terminal. Only hand-typed *content* avoids
|
|
11
|
+
non-ASCII (verified while building this that this environment's
|
|
12
|
+
console can mangle hand-typed non-ASCII depending on code page); Rich's
|
|
13
|
+
box-drawing glyphs are a separate, correctly-handled concern.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import getpass
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from rich import box
|
|
26
|
+
from rich.align import Align
|
|
27
|
+
from rich.console import Console, Group
|
|
28
|
+
from rich.panel import Panel
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
from rich_pixels import Pixels
|
|
32
|
+
|
|
33
|
+
from handcuff.tui.widgets.hero import render_hero_pixels
|
|
34
|
+
|
|
35
|
+
# Explicit hex, not a named Rich color ("bright_cyan" etc.): verified
|
|
36
|
+
# directly from a user screenshot that named ANSI colors get remapped
|
|
37
|
+
# by the terminal's own color theme (rendered as purple instead of
|
|
38
|
+
# cyan there), while hex triggers Rich's 24-bit true-color escape codes
|
|
39
|
+
# and bypasses that remapping — same reason the Hero widget's lens ring
|
|
40
|
+
# (already hex-based) rendered correctly cyan in that same screenshot.
|
|
41
|
+
ACCENT = "#00D9FF" # matches the TUI theme's primary color
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version() -> str:
|
|
45
|
+
try:
|
|
46
|
+
return version("handcuff")
|
|
47
|
+
except PackageNotFoundError:
|
|
48
|
+
return "0.1.0"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _short_cwd(max_len: int = 42) -> str:
|
|
52
|
+
cwd = str(Path.cwd())
|
|
53
|
+
home = str(Path.home())
|
|
54
|
+
if cwd.startswith(home):
|
|
55
|
+
cwd = "~" + cwd[len(home) :]
|
|
56
|
+
if len(cwd) <= max_len:
|
|
57
|
+
return cwd
|
|
58
|
+
head, tail = cwd[: max_len // 2 - 2], cwd[-(max_len // 2 - 1) :]
|
|
59
|
+
return f"{head}...{tail}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _icon() -> Pixels:
|
|
63
|
+
"""Reuses the exact same robot-inside-a-lens image the TUI's Hero
|
|
64
|
+
widget draws (frozen, non-animated) — the startup banner and the
|
|
65
|
+
live dashboard used to show two unrelated icons; this keeps the
|
|
66
|
+
mascot consistent everywhere Handcuff shows its face."""
|
|
67
|
+
return render_hero_pixels(lens_color=ACCENT)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _left_panel() -> Panel:
|
|
71
|
+
welcome = Text(f"Welcome back, {getpass.getuser()}!", style="bold white", justify="center")
|
|
72
|
+
info = Text(
|
|
73
|
+
f"v{_version()} - {getpass.getuser()} - {platform.system()} {platform.release()}",
|
|
74
|
+
style="dim",
|
|
75
|
+
justify="center",
|
|
76
|
+
)
|
|
77
|
+
cwd = Text(_short_cwd(), style="dim", justify="center")
|
|
78
|
+
|
|
79
|
+
body = Group(
|
|
80
|
+
Text(""),
|
|
81
|
+
Align.center(_icon()),
|
|
82
|
+
Text(""),
|
|
83
|
+
Align.center(welcome),
|
|
84
|
+
Align.center(info),
|
|
85
|
+
Align.center(cwd),
|
|
86
|
+
Text(""),
|
|
87
|
+
)
|
|
88
|
+
return Panel(body, box=box.ROUNDED, border_style=ACCENT, width=50)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _right_column() -> Text:
|
|
92
|
+
right = Text()
|
|
93
|
+
right.append("Quick start\n\n", style=f"bold {ACCENT}")
|
|
94
|
+
right.append("handcuff watch -- <cmd>\n", style="bold white")
|
|
95
|
+
right.append(" record a process as it runs\n\n", style="dim")
|
|
96
|
+
right.append("handcuff tui\n", style="bold white")
|
|
97
|
+
right.append(" open this live dashboard\n\n", style="dim")
|
|
98
|
+
right.append("handcuff doctor\n", style="bold white")
|
|
99
|
+
right.append(" check capture capabilities\n\n", style="dim")
|
|
100
|
+
right.append("What's new\n", style=f"bold {ACCENT}")
|
|
101
|
+
right.append("Real OS-level file & network blocking (--enforce)\n", style="dim")
|
|
102
|
+
right.append("Session replay, export, and trust lists\n", style="dim")
|
|
103
|
+
return right
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def banner_grid() -> Table:
|
|
107
|
+
grid = Table.grid(expand=False, padding=(0, 3))
|
|
108
|
+
grid.add_column()
|
|
109
|
+
grid.add_column()
|
|
110
|
+
grid.add_row(_left_panel(), _right_column())
|
|
111
|
+
return grid
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def should_show_banner() -> bool:
|
|
115
|
+
"""Skip the banner when output isn't an interactive terminal (piped/
|
|
116
|
+
redirected output, CI logs) or when explicitly suppressed — mirrors
|
|
117
|
+
how `--quiet` already suppresses noise elsewhere in this CLI."""
|
|
118
|
+
if os.environ.get("HANDCUFF_NO_BANNER"):
|
|
119
|
+
return False
|
|
120
|
+
return sys.stdout.isatty()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_banner(console: Console | None = None, force: bool = False) -> None:
|
|
124
|
+
if not force and not should_show_banner():
|
|
125
|
+
return
|
|
126
|
+
console = console or Console()
|
|
127
|
+
console.print(banner_grid())
|
|
128
|
+
console.print()
|
|
File without changes
|
handcuff/capture/base.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""CaptureBackend protocol (TRD §4).
|
|
2
|
+
|
|
3
|
+
All platform-specific capture implementations conform to this Protocol
|
|
4
|
+
so the rest of the system (bus, writer, `doctor`) is backend-agnostic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from handcuff.core.events import RawEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class CaptureBackend(Protocol):
|
|
18
|
+
name: str
|
|
19
|
+
capabilities: set[str] # subset of {"process", "file", "network", "argv", "content"}
|
|
20
|
+
|
|
21
|
+
async def start(self, root_pid: int, watch_paths: list[Path]) -> None: ...
|
|
22
|
+
|
|
23
|
+
def events(self) -> AsyncIterator[RawEvent]: ...
|
|
24
|
+
|
|
25
|
+
async def stop(self) -> None: ...
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def available() -> bool: ...
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""File event capture (AL-030) via watchdog.
|
|
2
|
+
|
|
3
|
+
Emits FILE_WRITE / FILE_DELETE / FILE_RENAME RawEvents for changes under
|
|
4
|
+
`watch_paths`. watchdog delivers callbacks on its own observer threads, so
|
|
5
|
+
we hand events across to the asyncio side through a thread-safe queue that
|
|
6
|
+
`events()` drains on the event loop.
|
|
7
|
+
|
|
8
|
+
Windows fidelity caveat (documented honestly): watchdog's Windows backend
|
|
9
|
+
(ReadDirectoryChangesW) reports *what* changed but not *which PID* caused
|
|
10
|
+
it. So file events here are attributed to the session, not to a specific
|
|
11
|
+
process in the tree. True per-PID file attribution on Windows needs a
|
|
12
|
+
kernel minifilter driver, which is out of scope for the no-privilege
|
|
13
|
+
default backend.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import queue
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from watchdog.events import (
|
|
27
|
+
DirDeletedEvent,
|
|
28
|
+
FileCreatedEvent,
|
|
29
|
+
FileDeletedEvent,
|
|
30
|
+
FileModifiedEvent,
|
|
31
|
+
FileMovedEvent,
|
|
32
|
+
FileSystemEvent,
|
|
33
|
+
FileSystemEventHandler,
|
|
34
|
+
)
|
|
35
|
+
from watchdog.observers import Observer
|
|
36
|
+
|
|
37
|
+
from handcuff.core.events import EventKind, RawEvent
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now_ms() -> int:
|
|
41
|
+
return int(time.time() * 1000)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _size(path: str) -> int:
|
|
45
|
+
try:
|
|
46
|
+
return Path(path).stat().st_size
|
|
47
|
+
except OSError:
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Content hashing is opt-in (AL-033) and size-capped: hashing huge files on
|
|
52
|
+
# every modify callback would stall the observer thread and thrash disk.
|
|
53
|
+
CONTENT_HASH_MAX_BYTES = 1 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _content_sha256(path: str, max_bytes: int = CONTENT_HASH_MAX_BYTES) -> str | None:
|
|
57
|
+
"""SHA-256 of the file's bytes, or None if unreadable or over the cap.
|
|
58
|
+
Only the hash is recorded — never the content itself."""
|
|
59
|
+
try:
|
|
60
|
+
p = Path(path)
|
|
61
|
+
if p.stat().st_size > max_bytes:
|
|
62
|
+
return None
|
|
63
|
+
return hashlib.sha256(p.read_bytes()).hexdigest()
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Handler(FileSystemEventHandler):
|
|
69
|
+
def __init__(self, sink: queue.Queue[RawEvent], hash_content: bool = False) -> None:
|
|
70
|
+
self._sink = sink
|
|
71
|
+
self._hash_content = hash_content
|
|
72
|
+
|
|
73
|
+
def _write_payload(self, path: str, op: str) -> dict:
|
|
74
|
+
payload = {"path": path, "bytes_delta": _size(path), "op": op}
|
|
75
|
+
if self._hash_content:
|
|
76
|
+
digest = _content_sha256(path)
|
|
77
|
+
if digest is not None:
|
|
78
|
+
payload["content_sha256"] = digest
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
82
|
+
if isinstance(event, FileCreatedEvent):
|
|
83
|
+
path = os.fsdecode(event.src_path)
|
|
84
|
+
self._emit(EventKind.FILE_WRITE, self._write_payload(path, "create"))
|
|
85
|
+
|
|
86
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
87
|
+
if isinstance(event, FileModifiedEvent):
|
|
88
|
+
path = os.fsdecode(event.src_path)
|
|
89
|
+
self._emit(EventKind.FILE_WRITE, self._write_payload(path, "modify"))
|
|
90
|
+
|
|
91
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
92
|
+
if isinstance(event, (FileDeletedEvent, DirDeletedEvent)):
|
|
93
|
+
self._emit(EventKind.FILE_DELETE, {"path": os.fsdecode(event.src_path)})
|
|
94
|
+
|
|
95
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
96
|
+
if isinstance(event, FileMovedEvent):
|
|
97
|
+
self._emit(
|
|
98
|
+
EventKind.FILE_RENAME,
|
|
99
|
+
{"path": os.fsdecode(event.src_path), "to": os.fsdecode(event.dest_path)},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _emit(self, kind: EventKind, payload: dict) -> None:
|
|
103
|
+
self._sink.put(
|
|
104
|
+
RawEvent(kind=kind, ts=_now_ms(), pid=None, ppid=None, payload=payload)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FileCaptureBackend:
|
|
109
|
+
name = "watchdog"
|
|
110
|
+
capabilities = {"file"}
|
|
111
|
+
|
|
112
|
+
def __init__(self, hash_content: bool = False) -> None:
|
|
113
|
+
self._observer: Any | None = None
|
|
114
|
+
self._sink: queue.Queue[RawEvent] = queue.Queue()
|
|
115
|
+
self._stopped = False
|
|
116
|
+
self._hash_content = hash_content
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def available() -> bool:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
async def start(self, root_pid: int, watch_paths: list[Path]) -> None:
|
|
123
|
+
self._stopped = False
|
|
124
|
+
self._observer = Observer()
|
|
125
|
+
handler = _Handler(self._sink, hash_content=self._hash_content)
|
|
126
|
+
for p in watch_paths:
|
|
127
|
+
path = p.expanduser().resolve()
|
|
128
|
+
if path.exists() and path.is_dir():
|
|
129
|
+
self._observer.schedule(handler, str(path), recursive=True)
|
|
130
|
+
self._observer.start()
|
|
131
|
+
|
|
132
|
+
async def events(self):
|
|
133
|
+
while not self._stopped or not self._sink.empty():
|
|
134
|
+
try:
|
|
135
|
+
yield self._sink.get_nowait()
|
|
136
|
+
except queue.Empty:
|
|
137
|
+
await asyncio.sleep(0.05)
|
|
138
|
+
|
|
139
|
+
async def stop(self) -> None:
|
|
140
|
+
self._stopped = True
|
|
141
|
+
if self._observer is not None:
|
|
142
|
+
self._observer.stop()
|
|
143
|
+
self._observer.join(timeout=3)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Network event capture (AL-031) via psutil polling.
|
|
2
|
+
|
|
3
|
+
Polls `psutil.net_connections` and emits a NET_CONNECT RawEvent the first
|
|
4
|
+
time a given (pid, remote_ip, remote_port) tuple is seen for a PID in the
|
|
5
|
+
observed process tree. Reverse-DNS is attempted (best-effort, cached) to
|
|
6
|
+
attach a domain.
|
|
7
|
+
|
|
8
|
+
Windows fidelity caveats (documented honestly):
|
|
9
|
+
* Enumerating connections for other processes' PIDs can require admin on
|
|
10
|
+
some Windows configs; when access is denied we simply see fewer
|
|
11
|
+
connections rather than crashing.
|
|
12
|
+
* Polling means very short-lived connections between polls can be missed
|
|
13
|
+
(same tradeoff as procfs process capture).
|
|
14
|
+
* Byte counts are not available from net_connections, so `bytes` is 0.
|
|
15
|
+
* Loopback (127.0.0.1/::1) connections are unreliable to attribute on
|
|
16
|
+
Windows specifically: verified while building this that the PID for
|
|
17
|
+
the client side of a loopback TCP connection is frequently reported
|
|
18
|
+
as 0 by the time our poll observes it (Windows appears to zero the
|
|
19
|
+
owning-PID field once the socket leaves ESTABLISHED, which for fast
|
|
20
|
+
loopback round-trips can happen within a few ms) — distinct from the
|
|
21
|
+
general short-connection-miss tradeoff above, this affects loopback
|
|
22
|
+
specifically even when the connection lives longer than a poll
|
|
23
|
+
interval. Non-loopback (real network) connections were verified to
|
|
24
|
+
attribute PIDs correctly and reliably. Practical effect: don't rely
|
|
25
|
+
on this capture path to see an agent's own calls to a service on
|
|
26
|
+
localhost; it's built for outbound calls to real hosts/domains.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import socket
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
import psutil
|
|
37
|
+
|
|
38
|
+
from handcuff.core.events import EventKind, RawEvent
|
|
39
|
+
|
|
40
|
+
DEFAULT_POLL_INTERVAL_MS = 200
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_ms() -> int:
|
|
44
|
+
return int(time.time() * 1000)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NetworkCaptureBackend:
|
|
48
|
+
name = "psutil-net"
|
|
49
|
+
capabilities = {"network"}
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
pid_filter: Callable[[int], bool] | None = None,
|
|
54
|
+
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
|
|
55
|
+
resolve_dns: bool = True,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._pid_filter = pid_filter or (lambda _pid: True)
|
|
58
|
+
self._poll_interval_s = poll_interval_ms / 1000
|
|
59
|
+
self._resolve_dns = resolve_dns
|
|
60
|
+
self._seen: set[tuple[int | None, str, int]] = set()
|
|
61
|
+
self._dns_cache: dict[str, str | None] = {}
|
|
62
|
+
self._queue: asyncio.Queue[RawEvent] = asyncio.Queue()
|
|
63
|
+
self._stopped = False
|
|
64
|
+
self._task: asyncio.Task | None = None
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def available() -> bool:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
async def start(self, root_pid: int, watch_paths) -> None:
|
|
71
|
+
self._stopped = False
|
|
72
|
+
self._task = asyncio.ensure_future(self._poll_loop())
|
|
73
|
+
|
|
74
|
+
def _resolve(self, ip: str) -> str | None:
|
|
75
|
+
if not self._resolve_dns:
|
|
76
|
+
return None
|
|
77
|
+
if ip in self._dns_cache:
|
|
78
|
+
return self._dns_cache[ip]
|
|
79
|
+
try:
|
|
80
|
+
name = socket.gethostbyaddr(ip)[0]
|
|
81
|
+
except (OSError, socket.herror):
|
|
82
|
+
name = None
|
|
83
|
+
self._dns_cache[ip] = name
|
|
84
|
+
return name
|
|
85
|
+
|
|
86
|
+
async def _poll_loop(self) -> None:
|
|
87
|
+
while not self._stopped:
|
|
88
|
+
try:
|
|
89
|
+
conns = psutil.net_connections(kind="inet")
|
|
90
|
+
except (psutil.AccessDenied, PermissionError):
|
|
91
|
+
conns = []
|
|
92
|
+
|
|
93
|
+
for c in conns:
|
|
94
|
+
if not c.raddr:
|
|
95
|
+
continue
|
|
96
|
+
if c.pid is not None and not self._pid_filter(c.pid):
|
|
97
|
+
continue
|
|
98
|
+
remote_ip = c.raddr.ip
|
|
99
|
+
remote_port = c.raddr.port
|
|
100
|
+
key = (c.pid, remote_ip, remote_port)
|
|
101
|
+
if key in self._seen:
|
|
102
|
+
continue
|
|
103
|
+
self._seen.add(key)
|
|
104
|
+
proto = "tcp" if c.type == socket.SOCK_STREAM else "udp"
|
|
105
|
+
domain = await asyncio.get_event_loop().run_in_executor(
|
|
106
|
+
None, self._resolve, remote_ip
|
|
107
|
+
)
|
|
108
|
+
await self._queue.put(
|
|
109
|
+
RawEvent(
|
|
110
|
+
kind=EventKind.NET_CONNECT,
|
|
111
|
+
ts=_now_ms(),
|
|
112
|
+
pid=c.pid,
|
|
113
|
+
ppid=None,
|
|
114
|
+
payload={
|
|
115
|
+
"dst_ip": remote_ip,
|
|
116
|
+
"domain": domain,
|
|
117
|
+
"port": remote_port,
|
|
118
|
+
"proto": proto,
|
|
119
|
+
"bytes": 0,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
await asyncio.sleep(self._poll_interval_s)
|
|
124
|
+
|
|
125
|
+
async def events(self):
|
|
126
|
+
while not self._stopped or not self._queue.empty():
|
|
127
|
+
try:
|
|
128
|
+
yield await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
|
129
|
+
except TimeoutError:
|
|
130
|
+
if self._stopped:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
async def stop(self) -> None:
|
|
134
|
+
self._stopped = True
|
|
135
|
+
if self._task is not None:
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(self._task, timeout=2)
|
|
138
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
139
|
+
self._task.cancel()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Cross-platform process capture backend (AL-023).
|
|
2
|
+
|
|
3
|
+
Implements `CaptureBackend` using `psutil` polling of the process tree.
|
|
4
|
+
On Linux, `psutil` reads `/proc` under the hood, so this *is* the
|
|
5
|
+
procfs-based default path described in TRD §2/§4. On macOS and Windows
|
|
6
|
+
it uses the platform's native process APIs via the same `psutil` calls
|
|
7
|
+
— reduced fidelity is possible on those platforms (short-lived
|
|
8
|
+
processes between polls can be missed) and is reported honestly via
|
|
9
|
+
`capabilities` / `doctor`, never silently upgraded.
|
|
10
|
+
|
|
11
|
+
This backend requires no elevated privileges.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import getpass
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import psutil
|
|
23
|
+
|
|
24
|
+
from handcuff.capture.tree import ProcessTreeTracker
|
|
25
|
+
from handcuff.core.events import EventKind, RawEvent
|
|
26
|
+
|
|
27
|
+
DEFAULT_POLL_INTERVAL_MS = 50
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PsutilCaptureBackend:
|
|
31
|
+
name = "psutil"
|
|
32
|
+
capabilities = {"process", "argv"}
|
|
33
|
+
|
|
34
|
+
def __init__(self, poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS) -> None:
|
|
35
|
+
self._poll_interval_s = poll_interval_ms / 1000
|
|
36
|
+
self._tracker: ProcessTreeTracker | None = None
|
|
37
|
+
self._queue: asyncio.Queue[RawEvent] = asyncio.Queue()
|
|
38
|
+
self._stopped = False
|
|
39
|
+
self._task: asyncio.Task | None = None
|
|
40
|
+
self._start_times: dict[int, float] = {}
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def available() -> bool:
|
|
44
|
+
return True # psutil works everywhere we support
|
|
45
|
+
|
|
46
|
+
async def start(self, root_pid: int, watch_paths: list[Path]) -> None:
|
|
47
|
+
self._tracker = ProcessTreeTracker(root_pid)
|
|
48
|
+
self._stopped = False
|
|
49
|
+
self._task = asyncio.ensure_future(self._poll_loop())
|
|
50
|
+
|
|
51
|
+
async def _poll_loop(self) -> None:
|
|
52
|
+
assert self._tracker is not None
|
|
53
|
+
while not self._stopped:
|
|
54
|
+
added, removed = self._tracker.refresh()
|
|
55
|
+
now = time.time()
|
|
56
|
+
now_ms = int(now * 1000)
|
|
57
|
+
|
|
58
|
+
for pid in added:
|
|
59
|
+
self._start_times[pid] = now
|
|
60
|
+
payload: dict = {}
|
|
61
|
+
try:
|
|
62
|
+
proc = psutil.Process(pid)
|
|
63
|
+
payload = {
|
|
64
|
+
"argv": proc.cmdline(),
|
|
65
|
+
"cwd": _safe(proc.cwd),
|
|
66
|
+
"exe": _safe(proc.exe),
|
|
67
|
+
"user": _safe(proc.username, default=getpass.getuser()),
|
|
68
|
+
}
|
|
69
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
70
|
+
pass
|
|
71
|
+
await self._queue.put(
|
|
72
|
+
RawEvent(
|
|
73
|
+
kind=EventKind.PROCESS_SPAWN,
|
|
74
|
+
ts=now_ms,
|
|
75
|
+
pid=pid,
|
|
76
|
+
ppid=self._tracker.root_pid,
|
|
77
|
+
payload=payload,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
for pid in removed:
|
|
82
|
+
started = self._start_times.pop(pid, now)
|
|
83
|
+
duration_ms = int((now - started) * 1000)
|
|
84
|
+
await self._queue.put(
|
|
85
|
+
RawEvent(
|
|
86
|
+
kind=EventKind.PROCESS_EXIT,
|
|
87
|
+
ts=now_ms,
|
|
88
|
+
pid=pid,
|
|
89
|
+
ppid=self._tracker.root_pid,
|
|
90
|
+
payload={"exit_code": None, "duration_ms": duration_ms},
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
await asyncio.sleep(self._poll_interval_s)
|
|
95
|
+
|
|
96
|
+
async def events(self) -> AsyncIterator[RawEvent]:
|
|
97
|
+
while not self._stopped or not self._queue.empty():
|
|
98
|
+
try:
|
|
99
|
+
yield await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
|
100
|
+
except TimeoutError:
|
|
101
|
+
if self._stopped:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
async def stop(self) -> None:
|
|
105
|
+
self._stopped = True
|
|
106
|
+
if self._task is not None:
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.wait_for(self._task, timeout=2)
|
|
109
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
110
|
+
self._task.cancel()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _safe(fn, default: str | None = None):
|
|
114
|
+
try:
|
|
115
|
+
return fn()
|
|
116
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, Exception):
|
|
117
|
+
return default
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Redaction engine (AL-032), on by default.
|
|
2
|
+
|
|
3
|
+
Scrubs secret-looking substrings from argv and file paths *before* they
|
|
4
|
+
are persisted. The redaction map is never stored — once redacted, the
|
|
5
|
+
original secret is gone from Handcuff's records.
|
|
6
|
+
|
|
7
|
+
Patterns: OpenAI-style `sk-...` keys, AWS access key ids, JWTs, GitHub
|
|
8
|
+
tokens, and generic high-entropy 32+ char tokens. Benign strings are
|
|
9
|
+
left untouched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections import Counter
|
|
17
|
+
|
|
18
|
+
from handcuff.core.events import EventKind, RawEvent
|
|
19
|
+
|
|
20
|
+
# High-confidence, specific patterns first.
|
|
21
|
+
_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
22
|
+
("openai", re.compile(r"sk-[A-Za-z0-9_\-]{20,}")),
|
|
23
|
+
("aws_akid", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
24
|
+
("github", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
|
25
|
+
("jwt", re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+")),
|
|
26
|
+
("slack", re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}\b")),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Generic high-entropy token: long alnum run that "looks random".
|
|
30
|
+
_TOKEN_RE = re.compile(r"\b[A-Za-z0-9+/_\-]{32,}\b")
|
|
31
|
+
_ENTROPY_THRESHOLD = 3.5 # bits/char; random base64 ~5-6, english words ~2-3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _shannon_entropy(s: str) -> float:
|
|
35
|
+
if not s:
|
|
36
|
+
return 0.0
|
|
37
|
+
counts = Counter(s)
|
|
38
|
+
n = len(s)
|
|
39
|
+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact_string(value: str) -> str:
|
|
43
|
+
redacted = value
|
|
44
|
+
for label, pat in _PATTERNS:
|
|
45
|
+
redacted = pat.sub(f"‹redacted:{label}›", redacted)
|
|
46
|
+
|
|
47
|
+
def _maybe_token(m: re.Match[str]) -> str:
|
|
48
|
+
tok = m.group(0)
|
|
49
|
+
if tok.startswith("‹redacted:"):
|
|
50
|
+
return tok
|
|
51
|
+
if _shannon_entropy(tok) >= _ENTROPY_THRESHOLD:
|
|
52
|
+
return "‹redacted:token›"
|
|
53
|
+
return tok
|
|
54
|
+
|
|
55
|
+
redacted = _TOKEN_RE.sub(_maybe_token, redacted)
|
|
56
|
+
return redacted
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def redact_event(event: RawEvent) -> RawEvent:
|
|
60
|
+
"""Mutate `event.payload` in place, redacting argv and path-like fields."""
|
|
61
|
+
payload = event.payload
|
|
62
|
+
if event.kind == EventKind.PROCESS_SPAWN and isinstance(payload.get("argv"), list):
|
|
63
|
+
payload["argv"] = [redact_string(str(a)) for a in payload["argv"]]
|
|
64
|
+
for key in ("path", "to", "cwd", "exe"):
|
|
65
|
+
if isinstance(payload.get(key), str):
|
|
66
|
+
payload[key] = redact_string(payload[key])
|
|
67
|
+
return event
|