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.
- handcuff-0.2.0/.github/workflows/publish.yml +52 -0
- handcuff-0.2.0/.gitignore +28 -0
- handcuff-0.2.0/PKG-INFO +149 -0
- handcuff-0.2.0/README.md +108 -0
- handcuff-0.2.0/handcuff/__init__.py +0 -0
- handcuff-0.2.0/handcuff/alerts/__init__.py +0 -0
- handcuff-0.2.0/handcuff/alerts/webhooks.py +56 -0
- handcuff-0.2.0/handcuff/banner.py +128 -0
- handcuff-0.2.0/handcuff/capture/__init__.py +0 -0
- handcuff-0.2.0/handcuff/capture/base.py +28 -0
- handcuff-0.2.0/handcuff/capture/file_watch.py +143 -0
- handcuff-0.2.0/handcuff/capture/net_watch.py +139 -0
- handcuff-0.2.0/handcuff/capture/psutil_backend.py +117 -0
- handcuff-0.2.0/handcuff/capture/redact.py +67 -0
- handcuff-0.2.0/handcuff/capture/tree.py +43 -0
- handcuff-0.2.0/handcuff/cli.py +362 -0
- handcuff-0.2.0/handcuff/config.py +65 -0
- handcuff-0.2.0/handcuff/core/__init__.py +0 -0
- handcuff-0.2.0/handcuff/core/bus.py +74 -0
- handcuff-0.2.0/handcuff/core/events.py +159 -0
- handcuff-0.2.0/handcuff/core/hashing.py +79 -0
- handcuff-0.2.0/handcuff/core/session.py +95 -0
- handcuff-0.2.0/handcuff/core/signing.py +78 -0
- handcuff-0.2.0/handcuff/doctor.py +124 -0
- handcuff-0.2.0/handcuff/enforce/__init__.py +0 -0
- handcuff-0.2.0/handcuff/enforce/file_acl.py +145 -0
- handcuff-0.2.0/handcuff/enforce/firewall.py +179 -0
- handcuff-0.2.0/handcuff/enforce/scan.py +50 -0
- handcuff-0.2.0/handcuff/export/__init__.py +0 -0
- handcuff-0.2.0/handcuff/export/html_export.py +126 -0
- handcuff-0.2.0/handcuff/export/json_export.py +94 -0
- handcuff-0.2.0/handcuff/export/md_export.py +113 -0
- handcuff-0.2.0/handcuff/gateway/__init__.py +0 -0
- handcuff-0.2.0/handcuff/gateway/gateway.py +331 -0
- handcuff-0.2.0/handcuff/gateway/log.py +66 -0
- handcuff-0.2.0/handcuff/gateway/policy.py +93 -0
- handcuff-0.2.0/handcuff/integrations/__init__.py +5 -0
- handcuff-0.2.0/handcuff/integrations/langchain.py +459 -0
- handcuff-0.2.0/handcuff/replay.py +119 -0
- handcuff-0.2.0/handcuff/rules/__init__.py +0 -0
- handcuff-0.2.0/handcuff/rules/defaults.yaml +25 -0
- handcuff-0.2.0/handcuff/rules/dsl.py +209 -0
- handcuff-0.2.0/handcuff/rules/engine.py +152 -0
- handcuff-0.2.0/handcuff/rules/injection.py +150 -0
- handcuff-0.2.0/handcuff/rules/trust.py +53 -0
- handcuff-0.2.0/handcuff/runner.py +382 -0
- handcuff-0.2.0/handcuff/storage/__init__.py +0 -0
- handcuff-0.2.0/handcuff/storage/db.py +101 -0
- handcuff-0.2.0/handcuff/storage/writer.py +140 -0
- handcuff-0.2.0/handcuff/tui/__init__.py +0 -0
- handcuff-0.2.0/handcuff/tui/app.py +235 -0
- handcuff-0.2.0/handcuff/tui/risk.py +65 -0
- handcuff-0.2.0/handcuff/tui/styles.tcss +106 -0
- handcuff-0.2.0/handcuff/tui/theme.py +62 -0
- handcuff-0.2.0/handcuff/tui/widgets/__init__.py +0 -0
- handcuff-0.2.0/handcuff/tui/widgets/banner_header.py +102 -0
- handcuff-0.2.0/handcuff/tui/widgets/flag_cards.py +63 -0
- handcuff-0.2.0/handcuff/tui/widgets/footer.py +42 -0
- handcuff-0.2.0/handcuff/tui/widgets/hero.py +336 -0
- handcuff-0.2.0/handcuff/tui/widgets/logs.py +69 -0
- handcuff-0.2.0/handcuff/tui/widgets/monitoring.py +53 -0
- handcuff-0.2.0/handcuff/tui/widgets/recommendation.py +49 -0
- handcuff-0.2.0/handcuff/tui/widgets/risk_overview.py +63 -0
- handcuff-0.2.0/handcuff/tui/widgets/trust.py +92 -0
- handcuff-0.2.0/handcuff/verify.py +76 -0
- handcuff-0.2.0/pyproject.toml +80 -0
- 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__/
|
handcuff-0.2.0/PKG-INFO
ADDED
|
@@ -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`).
|
handcuff-0.2.0/README.md
ADDED
|
@@ -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: ...
|