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.
Files changed (65) hide show
  1. handcuff/__init__.py +0 -0
  2. handcuff/alerts/__init__.py +0 -0
  3. handcuff/alerts/webhooks.py +56 -0
  4. handcuff/banner.py +128 -0
  5. handcuff/capture/__init__.py +0 -0
  6. handcuff/capture/base.py +28 -0
  7. handcuff/capture/file_watch.py +143 -0
  8. handcuff/capture/net_watch.py +139 -0
  9. handcuff/capture/psutil_backend.py +117 -0
  10. handcuff/capture/redact.py +67 -0
  11. handcuff/capture/tree.py +43 -0
  12. handcuff/cli.py +362 -0
  13. handcuff/config.py +65 -0
  14. handcuff/core/__init__.py +0 -0
  15. handcuff/core/bus.py +74 -0
  16. handcuff/core/events.py +159 -0
  17. handcuff/core/hashing.py +79 -0
  18. handcuff/core/session.py +95 -0
  19. handcuff/core/signing.py +78 -0
  20. handcuff/doctor.py +124 -0
  21. handcuff/enforce/__init__.py +0 -0
  22. handcuff/enforce/file_acl.py +145 -0
  23. handcuff/enforce/firewall.py +179 -0
  24. handcuff/enforce/scan.py +50 -0
  25. handcuff/export/__init__.py +0 -0
  26. handcuff/export/html_export.py +126 -0
  27. handcuff/export/json_export.py +94 -0
  28. handcuff/export/md_export.py +113 -0
  29. handcuff/gateway/__init__.py +0 -0
  30. handcuff/gateway/gateway.py +331 -0
  31. handcuff/gateway/log.py +66 -0
  32. handcuff/gateway/policy.py +93 -0
  33. handcuff/integrations/__init__.py +5 -0
  34. handcuff/integrations/langchain.py +459 -0
  35. handcuff/replay.py +119 -0
  36. handcuff/rules/__init__.py +0 -0
  37. handcuff/rules/defaults.yaml +25 -0
  38. handcuff/rules/dsl.py +209 -0
  39. handcuff/rules/engine.py +152 -0
  40. handcuff/rules/injection.py +150 -0
  41. handcuff/rules/trust.py +53 -0
  42. handcuff/runner.py +382 -0
  43. handcuff/storage/__init__.py +0 -0
  44. handcuff/storage/db.py +101 -0
  45. handcuff/storage/writer.py +140 -0
  46. handcuff/tui/__init__.py +0 -0
  47. handcuff/tui/app.py +235 -0
  48. handcuff/tui/risk.py +65 -0
  49. handcuff/tui/styles.tcss +106 -0
  50. handcuff/tui/theme.py +62 -0
  51. handcuff/tui/widgets/__init__.py +0 -0
  52. handcuff/tui/widgets/banner_header.py +102 -0
  53. handcuff/tui/widgets/flag_cards.py +63 -0
  54. handcuff/tui/widgets/footer.py +42 -0
  55. handcuff/tui/widgets/hero.py +336 -0
  56. handcuff/tui/widgets/logs.py +69 -0
  57. handcuff/tui/widgets/monitoring.py +53 -0
  58. handcuff/tui/widgets/recommendation.py +49 -0
  59. handcuff/tui/widgets/risk_overview.py +63 -0
  60. handcuff/tui/widgets/trust.py +92 -0
  61. handcuff/verify.py +76 -0
  62. handcuff-0.2.0.dist-info/METADATA +149 -0
  63. handcuff-0.2.0.dist-info/RECORD +65 -0
  64. handcuff-0.2.0.dist-info/WHEEL +4 -0
  65. 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
@@ -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