handcuff 0.2.0__tar.gz

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 (67) hide show
  1. handcuff-0.2.0/.github/workflows/publish.yml +52 -0
  2. handcuff-0.2.0/.gitignore +28 -0
  3. handcuff-0.2.0/PKG-INFO +149 -0
  4. handcuff-0.2.0/README.md +108 -0
  5. handcuff-0.2.0/handcuff/__init__.py +0 -0
  6. handcuff-0.2.0/handcuff/alerts/__init__.py +0 -0
  7. handcuff-0.2.0/handcuff/alerts/webhooks.py +56 -0
  8. handcuff-0.2.0/handcuff/banner.py +128 -0
  9. handcuff-0.2.0/handcuff/capture/__init__.py +0 -0
  10. handcuff-0.2.0/handcuff/capture/base.py +28 -0
  11. handcuff-0.2.0/handcuff/capture/file_watch.py +143 -0
  12. handcuff-0.2.0/handcuff/capture/net_watch.py +139 -0
  13. handcuff-0.2.0/handcuff/capture/psutil_backend.py +117 -0
  14. handcuff-0.2.0/handcuff/capture/redact.py +67 -0
  15. handcuff-0.2.0/handcuff/capture/tree.py +43 -0
  16. handcuff-0.2.0/handcuff/cli.py +362 -0
  17. handcuff-0.2.0/handcuff/config.py +65 -0
  18. handcuff-0.2.0/handcuff/core/__init__.py +0 -0
  19. handcuff-0.2.0/handcuff/core/bus.py +74 -0
  20. handcuff-0.2.0/handcuff/core/events.py +159 -0
  21. handcuff-0.2.0/handcuff/core/hashing.py +79 -0
  22. handcuff-0.2.0/handcuff/core/session.py +95 -0
  23. handcuff-0.2.0/handcuff/core/signing.py +78 -0
  24. handcuff-0.2.0/handcuff/doctor.py +124 -0
  25. handcuff-0.2.0/handcuff/enforce/__init__.py +0 -0
  26. handcuff-0.2.0/handcuff/enforce/file_acl.py +145 -0
  27. handcuff-0.2.0/handcuff/enforce/firewall.py +179 -0
  28. handcuff-0.2.0/handcuff/enforce/scan.py +50 -0
  29. handcuff-0.2.0/handcuff/export/__init__.py +0 -0
  30. handcuff-0.2.0/handcuff/export/html_export.py +126 -0
  31. handcuff-0.2.0/handcuff/export/json_export.py +94 -0
  32. handcuff-0.2.0/handcuff/export/md_export.py +113 -0
  33. handcuff-0.2.0/handcuff/gateway/__init__.py +0 -0
  34. handcuff-0.2.0/handcuff/gateway/gateway.py +331 -0
  35. handcuff-0.2.0/handcuff/gateway/log.py +66 -0
  36. handcuff-0.2.0/handcuff/gateway/policy.py +93 -0
  37. handcuff-0.2.0/handcuff/integrations/__init__.py +5 -0
  38. handcuff-0.2.0/handcuff/integrations/langchain.py +459 -0
  39. handcuff-0.2.0/handcuff/replay.py +119 -0
  40. handcuff-0.2.0/handcuff/rules/__init__.py +0 -0
  41. handcuff-0.2.0/handcuff/rules/defaults.yaml +25 -0
  42. handcuff-0.2.0/handcuff/rules/dsl.py +209 -0
  43. handcuff-0.2.0/handcuff/rules/engine.py +152 -0
  44. handcuff-0.2.0/handcuff/rules/injection.py +150 -0
  45. handcuff-0.2.0/handcuff/rules/trust.py +53 -0
  46. handcuff-0.2.0/handcuff/runner.py +382 -0
  47. handcuff-0.2.0/handcuff/storage/__init__.py +0 -0
  48. handcuff-0.2.0/handcuff/storage/db.py +101 -0
  49. handcuff-0.2.0/handcuff/storage/writer.py +140 -0
  50. handcuff-0.2.0/handcuff/tui/__init__.py +0 -0
  51. handcuff-0.2.0/handcuff/tui/app.py +235 -0
  52. handcuff-0.2.0/handcuff/tui/risk.py +65 -0
  53. handcuff-0.2.0/handcuff/tui/styles.tcss +106 -0
  54. handcuff-0.2.0/handcuff/tui/theme.py +62 -0
  55. handcuff-0.2.0/handcuff/tui/widgets/__init__.py +0 -0
  56. handcuff-0.2.0/handcuff/tui/widgets/banner_header.py +102 -0
  57. handcuff-0.2.0/handcuff/tui/widgets/flag_cards.py +63 -0
  58. handcuff-0.2.0/handcuff/tui/widgets/footer.py +42 -0
  59. handcuff-0.2.0/handcuff/tui/widgets/hero.py +336 -0
  60. handcuff-0.2.0/handcuff/tui/widgets/logs.py +69 -0
  61. handcuff-0.2.0/handcuff/tui/widgets/monitoring.py +53 -0
  62. handcuff-0.2.0/handcuff/tui/widgets/recommendation.py +49 -0
  63. handcuff-0.2.0/handcuff/tui/widgets/risk_overview.py +63 -0
  64. handcuff-0.2.0/handcuff/tui/widgets/trust.py +92 -0
  65. handcuff-0.2.0/handcuff/verify.py +76 -0
  66. handcuff-0.2.0/pyproject.toml +80 -0
  67. handcuff-0.2.0/scripts/build_windows.ps1 +41 -0
@@ -0,0 +1,52 @@
1
+ name: Publish to PyPI
2
+
3
+ # Trusted Publishing (OIDC) — no API token stored in GitHub. This
4
+ # workflow's filename ("publish.yml") and the job's environment
5
+ # ("pypi") must match exactly what you register in PyPI's
6
+ # "Add a new pending publisher" form.
7
+ on:
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch: {} # allows a manual run from the Actions tab too
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install build tooling
26
+ run: python -m pip install --upgrade build
27
+
28
+ - name: Build sdist and wheel
29
+ run: python -m build
30
+
31
+ - name: Upload build artifacts
32
+ uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+
37
+ publish:
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ environment:
41
+ name: pypi
42
+ url: https://pypi.org/p/handcuff
43
+ permissions:
44
+ id-token: write # required for OIDC trusted publishing
45
+ steps:
46
+ - uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ - name: Publish to PyPI
52
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,28 @@
1
+ build/
2
+ dist/
3
+ .env
4
+ tests/
5
+ .claude/
6
+ .mypy_cache/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .venv/
10
+ handcuff.spec
11
+ nonexistent.db
12
+ *.db
13
+ *.db-wal
14
+ *.db-shm
15
+ *.egg-info/
16
+ 00_README_START_HERE.md
17
+ 01_PRD.md
18
+ 02_TRD.md
19
+ 03_Security_Access.md
20
+ 04_Implementation_Plan.md
21
+ 05_Tickets.md
22
+ 06_App_Flow.md
23
+ 07_Terminal_UI.md
24
+ AgentLens_SPEC_Part1.md
25
+ AgentLens_SPEC_Part2.md
26
+ AgentLens_SPEC_Part3.md
27
+ AgentLens_SPEC_Part4.md
28
+ __pycache__/
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: handcuff
3
+ Version: 0.2.0
4
+ Summary: A dashcam for your AI agents: local, tamper-evident recorder for process/file/network activity, with a LangChain/LangGraph drop-in prompt-injection circuit breaker.
5
+ Project-URL: Homepage, https://github.com/Mister2005/Handcuff
6
+ Project-URL: Repository, https://github.com/Mister2005/Handcuff
7
+ Project-URL: Issues, https://github.com/Mister2005/Handcuff/issues
8
+ Author: Varun Gupta
9
+ License: MIT
10
+ Keywords: agents,langchain,langgraph,observability,prompt-injection,security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: cryptography>=42.0
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pillow>=10.0
24
+ Requires-Dist: psutil>=5.9
25
+ Requires-Dist: pyfiglet>=1.0
26
+ Requires-Dist: python-ulid>=2.2
27
+ Requires-Dist: rich-pixels>=3.0
28
+ Requires-Dist: ruamel-yaml>=0.18
29
+ Requires-Dist: textual>=0.58
30
+ Requires-Dist: typer>=0.12
31
+ Requires-Dist: watchdog>=4.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: mypy>=1.10; extra == 'dev'
34
+ Requires-Dist: pip-audit>=2.7; extra == 'dev'
35
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
36
+ Requires-Dist: pytest>=8.2; extra == 'dev'
37
+ Requires-Dist: ruff>=0.5; extra == 'dev'
38
+ Provides-Extra: langchain
39
+ Requires-Dist: langchain-core>=0.2; extra == 'langchain'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # Handcuff
43
+
44
+ **A dashcam for your AI agents.** Handcuff records the OS-level side effects of an AI agent — processes spawned, files written, network connections opened — into a local, tamper-evident, hash-chained SQLite timeline you can replay, search, export, and verify. Real-time policy alerts flag risky behavior (sudo, mass deletes, writes outside the workspace, unknown domains, force pushes) while the agent runs.
45
+
46
+ **Local-only by design:** nothing ever leaves your machine except an optional, user-configured, content-light webhook (HTTPS required). This is enforced by a `test_no_egress` release-gate test.
47
+
48
+ > **Platform note:** the current implementation targets **Windows** (psutil + watchdog capture, Windows ACL/Firewall enforcement, PyInstaller binary). The original planning docs describe a Linux-first design; that pivot is deliberate. Most capture code is psutil-generic, so Linux/macOS support is feasible but currently untested.
49
+
50
+ ## Install
51
+
52
+ ```powershell
53
+ # From PyPI (Python 3.11+) — note the distribution name; the import
54
+ # name and CLI command are still `handcuff` either way.
55
+ pip install handcuff
56
+ # with the LangChain/LangGraph integration:
57
+ pip install "handcuff[langchain]"
58
+
59
+ # From source, for development
60
+ pip install .
61
+
62
+ # Or use the prebuilt binary
63
+ dist\handcuff.exe --help
64
+ ```
65
+
66
+ ## Quickstart
67
+
68
+ ```powershell
69
+ # Record an agent (or any command) and everything it does
70
+ handcuff watch -- python my_agent.py
71
+
72
+ # List recorded sessions, then open the live dashboard
73
+ handcuff sessions
74
+ handcuff tui
75
+
76
+ # Replay a past session, jumping between alerts
77
+ handcuff replay <SESSION_ID>
78
+
79
+ # Export a report (json | md | html) and verify its hash chain
80
+ handcuff export <SESSION_ID> --format html
81
+ handcuff verify <SESSION_ID>
82
+
83
+ # Attach to something already running
84
+ handcuff attach <PID>
85
+
86
+ # Environment diagnostics
87
+ handcuff doctor
88
+ ```
89
+
90
+ ## Key features
91
+
92
+ - **Tamper-evident timeline** — every event is SHA-256 hash-chained; session heads are Ed25519-signed. `handcuff verify` re-validates the chain from the DB or from a JSON export.
93
+ - **Capture** — process spawn/exit (psutil), file write/delete/rename (watchdog), network connects (psutil). Optional `--hash-content` records a SHA-256 of written files (≤1 MiB; hash only, never content).
94
+ - **Rules & alerts** — safe expression DSL (AST-evaluated, no `eval`), windowed rules, default ruleset, trust allowlist (`handcuff rules trust add domain example.com`), optional webhook on critical alerts.
95
+ - **Redaction** — secrets (API keys, tokens) are scrubbed before events are persisted.
96
+ - **Enforcement (opt-in, Windows)** — `watch --enforce` applies real OS-level blocking: Deny-ACLs on sensitive files (no admin needed) and, if elevated, a Windows Firewall outbound block with `--allow-domain` exceptions. Always reverted on exit.
97
+ - **Cooperative gateway + prompt-injection circuit breaker** — `handcuff.gateway.Gateway` is a validating proxy for agent frameworks that route their file/network tool calls through it (same trust model as an OpenAI/LangChain/MCP tool sandbox — see its docstring for exact scope, it cannot stop raw shell/socket access). Every fetched URL (and any file read marked `untrusted=True`) is scanned with a heuristic prompt-injection signature scanner (`handcuff.rules.injection`) covering instruction-override phrasing, fake system/assistant turn markers, jailbreak personas, data-exfiltration instructions, and obfuscation tells (hidden Unicode, suspicious base64 blobs). A match "taints" the session for a configurable window and the gateway then **blocks further network egress** — the deterministic mitigation for Simon Willison's "lethal trifecta" (private data access + untrusted content + external communication), rather than trying to out-guess the injection semantically. This is a regex/heuristic tripwire, not a semantic guarantee — a novel or paraphrased injection can still slip past it; treat a miss as "not a known pattern" rather than "safe."
98
+
99
+ ```python
100
+ from pathlib import Path
101
+ from handcuff.gateway.gateway import Gateway, GatewayDenied
102
+
103
+ with Gateway(workspace_root=Path("./agent_workspace")) as gw:
104
+ page = gw.http_request("https://example.com/some-doc") # scanned automatically
105
+ if gw.is_tainted:
106
+ print("suspected prompt injection:", gw.taint_info.reason)
107
+ try:
108
+ gw.http_request("https://wherever-the-doc-said-to-send-data.example")
109
+ except GatewayDenied as e:
110
+ print("blocked:", e) # circuit breaker fired
111
+ ```
112
+ - **TUI dashboard** — trust meter, flagged-event cards, live monitoring, recommendation panel (Textual).
113
+ - **Exports** — JSON (with embedded verification block), Markdown, and self-contained HTML reports.
114
+ - **Multi-agent framework integration (LangChain / LangGraph), one line of code** — `handcuff.integrations.langchain.HandcuffHandler` is a real `langchain-core` `BaseCallbackHandler`. Pass it as `callbacks=[handler]` to any chain, agent, or graph and it records every chain/tool/retriever call as a hash-chained event **and** runs the same injection scanner + taint circuit breaker as the Gateway, scoped per agent. It works for concurrent/parallel agents (multiple LangGraph nodes, `ThreadPoolExecutor`, `asyncio.gather`) because it keys state off LangChain's own `run_id`/`parent_run_id` run tree — the same mechanism LangChain uses internally to keep parallel runs from crossing wires — with all shared state protected by a lock for real cross-thread safety. `pip install handcuff[langchain]` to pull in `langchain-core`. Verified against the real package end-to-end (parallel `ThreadPoolExecutor` agents, real `@tool`s, real blocking) — see `tests/test_langchain_real.py`.
115
+
116
+ ```python
117
+ from handcuff.integrations.langchain import HandcuffHandler
118
+
119
+ handler = HandcuffHandler() # writes to the same DB `handcuff tui` watches
120
+ result = my_graph.invoke(inputs, config={"callbacks": [handler]})
121
+ handler.close()
122
+ ```
123
+
124
+ Run `handcuff tui` in another terminal while your agent(s) run — it polls the same DB and picks the session up live automatically, no extra wiring. Honest scope: this only sees what happens through LangChain's own abstractions (chains/tools/retrievers) — a tool implementation that shells out or calls `requests` directly with no LangChain wrapper around it never fires these callbacks, and taint is scoped to one run tree (one top-level `.invoke()`), not the whole process.
125
+
126
+ ## Configuration
127
+
128
+ ```powershell
129
+ handcuff config list
130
+ handcuff config set retention_days 14
131
+ handcuff config set webhook_url https://hooks.example.com/handcuff
132
+ ```
133
+
134
+ Config lives at `~/.config/handcuff/config.yaml`; the DB at `~/.local/share/handcuff/handcuff.db`; the signing key at `~/.config/handcuff/ed25519.key`.
135
+
136
+ ## Development
137
+
138
+ ```powershell
139
+ python -m venv .venv && .venv\Scripts\pip install -e .[dev,langchain]
140
+ .venv\Scripts\python -m pytest tests -q # ~180 tests
141
+ .venv\Scripts\ruff check handcuff tests
142
+ scripts\build_windows.ps1 # PyInstaller binary
143
+ ```
144
+
145
+ Security release gates (must always pass): no-egress, chain-tamper detection, redaction, DSL safety, watchdog reconcile.
146
+
147
+ ## Documentation
148
+
149
+ Planning and design docs live in the repo root: [PRD](01_PRD.md), [TRD](02_TRD.md), [Security](03_Security_Access.md), [Implementation Plan](04_Implementation_Plan.md), [Tickets](05_Tickets.md), and the TUI spec (`Handcuff_SPEC_Part1-4.md`).
@@ -0,0 +1,108 @@
1
+ # Handcuff
2
+
3
+ **A dashcam for your AI agents.** Handcuff records the OS-level side effects of an AI agent — processes spawned, files written, network connections opened — into a local, tamper-evident, hash-chained SQLite timeline you can replay, search, export, and verify. Real-time policy alerts flag risky behavior (sudo, mass deletes, writes outside the workspace, unknown domains, force pushes) while the agent runs.
4
+
5
+ **Local-only by design:** nothing ever leaves your machine except an optional, user-configured, content-light webhook (HTTPS required). This is enforced by a `test_no_egress` release-gate test.
6
+
7
+ > **Platform note:** the current implementation targets **Windows** (psutil + watchdog capture, Windows ACL/Firewall enforcement, PyInstaller binary). The original planning docs describe a Linux-first design; that pivot is deliberate. Most capture code is psutil-generic, so Linux/macOS support is feasible but currently untested.
8
+
9
+ ## Install
10
+
11
+ ```powershell
12
+ # From PyPI (Python 3.11+) — note the distribution name; the import
13
+ # name and CLI command are still `handcuff` either way.
14
+ pip install handcuff
15
+ # with the LangChain/LangGraph integration:
16
+ pip install "handcuff[langchain]"
17
+
18
+ # From source, for development
19
+ pip install .
20
+
21
+ # Or use the prebuilt binary
22
+ dist\handcuff.exe --help
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ ```powershell
28
+ # Record an agent (or any command) and everything it does
29
+ handcuff watch -- python my_agent.py
30
+
31
+ # List recorded sessions, then open the live dashboard
32
+ handcuff sessions
33
+ handcuff tui
34
+
35
+ # Replay a past session, jumping between alerts
36
+ handcuff replay <SESSION_ID>
37
+
38
+ # Export a report (json | md | html) and verify its hash chain
39
+ handcuff export <SESSION_ID> --format html
40
+ handcuff verify <SESSION_ID>
41
+
42
+ # Attach to something already running
43
+ handcuff attach <PID>
44
+
45
+ # Environment diagnostics
46
+ handcuff doctor
47
+ ```
48
+
49
+ ## Key features
50
+
51
+ - **Tamper-evident timeline** — every event is SHA-256 hash-chained; session heads are Ed25519-signed. `handcuff verify` re-validates the chain from the DB or from a JSON export.
52
+ - **Capture** — process spawn/exit (psutil), file write/delete/rename (watchdog), network connects (psutil). Optional `--hash-content` records a SHA-256 of written files (≤1 MiB; hash only, never content).
53
+ - **Rules & alerts** — safe expression DSL (AST-evaluated, no `eval`), windowed rules, default ruleset, trust allowlist (`handcuff rules trust add domain example.com`), optional webhook on critical alerts.
54
+ - **Redaction** — secrets (API keys, tokens) are scrubbed before events are persisted.
55
+ - **Enforcement (opt-in, Windows)** — `watch --enforce` applies real OS-level blocking: Deny-ACLs on sensitive files (no admin needed) and, if elevated, a Windows Firewall outbound block with `--allow-domain` exceptions. Always reverted on exit.
56
+ - **Cooperative gateway + prompt-injection circuit breaker** — `handcuff.gateway.Gateway` is a validating proxy for agent frameworks that route their file/network tool calls through it (same trust model as an OpenAI/LangChain/MCP tool sandbox — see its docstring for exact scope, it cannot stop raw shell/socket access). Every fetched URL (and any file read marked `untrusted=True`) is scanned with a heuristic prompt-injection signature scanner (`handcuff.rules.injection`) covering instruction-override phrasing, fake system/assistant turn markers, jailbreak personas, data-exfiltration instructions, and obfuscation tells (hidden Unicode, suspicious base64 blobs). A match "taints" the session for a configurable window and the gateway then **blocks further network egress** — the deterministic mitigation for Simon Willison's "lethal trifecta" (private data access + untrusted content + external communication), rather than trying to out-guess the injection semantically. This is a regex/heuristic tripwire, not a semantic guarantee — a novel or paraphrased injection can still slip past it; treat a miss as "not a known pattern" rather than "safe."
57
+
58
+ ```python
59
+ from pathlib import Path
60
+ from handcuff.gateway.gateway import Gateway, GatewayDenied
61
+
62
+ with Gateway(workspace_root=Path("./agent_workspace")) as gw:
63
+ page = gw.http_request("https://example.com/some-doc") # scanned automatically
64
+ if gw.is_tainted:
65
+ print("suspected prompt injection:", gw.taint_info.reason)
66
+ try:
67
+ gw.http_request("https://wherever-the-doc-said-to-send-data.example")
68
+ except GatewayDenied as e:
69
+ print("blocked:", e) # circuit breaker fired
70
+ ```
71
+ - **TUI dashboard** — trust meter, flagged-event cards, live monitoring, recommendation panel (Textual).
72
+ - **Exports** — JSON (with embedded verification block), Markdown, and self-contained HTML reports.
73
+ - **Multi-agent framework integration (LangChain / LangGraph), one line of code** — `handcuff.integrations.langchain.HandcuffHandler` is a real `langchain-core` `BaseCallbackHandler`. Pass it as `callbacks=[handler]` to any chain, agent, or graph and it records every chain/tool/retriever call as a hash-chained event **and** runs the same injection scanner + taint circuit breaker as the Gateway, scoped per agent. It works for concurrent/parallel agents (multiple LangGraph nodes, `ThreadPoolExecutor`, `asyncio.gather`) because it keys state off LangChain's own `run_id`/`parent_run_id` run tree — the same mechanism LangChain uses internally to keep parallel runs from crossing wires — with all shared state protected by a lock for real cross-thread safety. `pip install handcuff[langchain]` to pull in `langchain-core`. Verified against the real package end-to-end (parallel `ThreadPoolExecutor` agents, real `@tool`s, real blocking) — see `tests/test_langchain_real.py`.
74
+
75
+ ```python
76
+ from handcuff.integrations.langchain import HandcuffHandler
77
+
78
+ handler = HandcuffHandler() # writes to the same DB `handcuff tui` watches
79
+ result = my_graph.invoke(inputs, config={"callbacks": [handler]})
80
+ handler.close()
81
+ ```
82
+
83
+ Run `handcuff tui` in another terminal while your agent(s) run — it polls the same DB and picks the session up live automatically, no extra wiring. Honest scope: this only sees what happens through LangChain's own abstractions (chains/tools/retrievers) — a tool implementation that shells out or calls `requests` directly with no LangChain wrapper around it never fires these callbacks, and taint is scoped to one run tree (one top-level `.invoke()`), not the whole process.
84
+
85
+ ## Configuration
86
+
87
+ ```powershell
88
+ handcuff config list
89
+ handcuff config set retention_days 14
90
+ handcuff config set webhook_url https://hooks.example.com/handcuff
91
+ ```
92
+
93
+ Config lives at `~/.config/handcuff/config.yaml`; the DB at `~/.local/share/handcuff/handcuff.db`; the signing key at `~/.config/handcuff/ed25519.key`.
94
+
95
+ ## Development
96
+
97
+ ```powershell
98
+ python -m venv .venv && .venv\Scripts\pip install -e .[dev,langchain]
99
+ .venv\Scripts\python -m pytest tests -q # ~180 tests
100
+ .venv\Scripts\ruff check handcuff tests
101
+ scripts\build_windows.ps1 # PyInstaller binary
102
+ ```
103
+
104
+ Security release gates (must always pass): no-egress, chain-tamper detection, redaction, DSL safety, watchdog reconcile.
105
+
106
+ ## Documentation
107
+
108
+ Planning and design docs live in the repo root: [PRD](01_PRD.md), [TRD](02_TRD.md), [Security](03_Security_Access.md), [Implementation Plan](04_Implementation_Plan.md), [Tickets](05_Tickets.md), and the TUI spec (`Handcuff_SPEC_Part1-4.md`).
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
@@ -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: ...