handcuff 0.2.0__tar.gz → 0.2.2__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 → handcuff-0.2.2}/.gitignore +1 -0
- handcuff-0.2.2/00_README_START_HERE.md +33 -0
- handcuff-0.2.2/Dummy Agentic Project/README.md +36 -0
- handcuff-0.2.2/Dummy Agentic Project/agent_output.txt +1 -0
- handcuff-0.2.2/Dummy Agentic Project/main.py +71 -0
- handcuff-0.2.2/Dummy Agentic Project/requirements.txt +5 -0
- handcuff-0.2.2/PKG-INFO +75 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/net_watch.py +1 -1
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/psutil_backend.py +1 -1
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/cli.py +2 -2
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/html_export.py +2 -2
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/md_export.py +2 -2
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/writer.py +1 -1
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/app.py +16 -1
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/banner_header.py +2 -2
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/flag_cards.py +63 -63
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/logs.py +2 -2
- {handcuff-0.2.0 → handcuff-0.2.2}/pyproject.toml +6 -5
- handcuff-0.2.0/PKG-INFO +0 -149
- {handcuff-0.2.0 → handcuff-0.2.2}/.github/workflows/publish.yml +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/README.md +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/alerts/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/alerts/webhooks.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/banner.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/base.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/file_watch.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/redact.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/tree.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/config.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/bus.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/events.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/hashing.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/session.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/signing.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/doctor.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/file_acl.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/firewall.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/scan.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/json_export.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/gateway.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/log.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/policy.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/integrations/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/integrations/langchain.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/replay.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/defaults.yaml +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/dsl.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/engine.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/injection.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/trust.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/runner.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/db.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/risk.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/styles.tcss +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/theme.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/__init__.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/footer.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/hero.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/monitoring.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/recommendation.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/risk_overview.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/trust.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/verify.py +0 -0
- {handcuff-0.2.0 → handcuff-0.2.2}/scripts/build_windows.ps1 +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Handcuff — Documentation Index (Start Here)
|
|
2
|
+
|
|
3
|
+
> **Handcuff** — a dashcam for your AI agents. A local, tamper-evident recorder that captures everything an AI agent does on your machine (processes, files, network) into a searchable, replayable timeline with real-time alerts. Local-first, record-only, zero cloud.
|
|
4
|
+
|
|
5
|
+
## For the coding agent building this
|
|
6
|
+
|
|
7
|
+
Read the docs in this order, then execute from the tickets:
|
|
8
|
+
|
|
9
|
+
| # | Doc | Purpose |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| 01 | `01_PRD.md` | What we're building and why; scope, users, requirements |
|
|
12
|
+
| 02 | `02_TRD.md` | Architecture, stack, data model, module layout — the technical contract |
|
|
13
|
+
| 03 | `03_Security_Access.md` | Threat model, privilege model, integrity + privacy guarantees, release-gate tests |
|
|
14
|
+
| 04 | `04_Implementation_Plan.md` | Build-order-correct milestones with exit criteria |
|
|
15
|
+
| 05 | `05_Tickets.md` | Atomic, executable tickets (AL-###) in dependency order — **build from here** |
|
|
16
|
+
| 06 | `06_App_Flow.md` | Every runtime flow + state machines (ASCII, parseable) |
|
|
17
|
+
| 07 | `07_Terminal_UI.md` | Rich Textual TUI spec (Claude Code / gemini-cli feel) |
|
|
18
|
+
|
|
19
|
+
## Build contract (non-negotiables pulled from the docs)
|
|
20
|
+
|
|
21
|
+
1. **`core/hashing.py` is the single source of truth** for canonical JSON + the SHA-256 chain. The writer and `verify` import it — never reimplement.
|
|
22
|
+
2. **Local-only.** No outbound traffic except a user-configured webhook. `tests/test_no_egress.py` is a release gate.
|
|
23
|
+
3. **Record-only in v1.** Handcuff observes; it never blocks the agent. Backpressure drops events + emits `LOSS`; it must never slow the observed process.
|
|
24
|
+
4. **Observed content is data, never instructions.** Nothing captured from the agent (argv, paths, domains) is ever interpreted as a command to Handcuff.
|
|
25
|
+
5. **Honest integrity claims.** The hash chain is tamper-*evidence*, not a completeness proof; the README states plainly what it does and doesn't guarantee.
|
|
26
|
+
6. **Every ticket ships with its test.** ruff + mypy clean. Five security tests gate releases (see Security §9).
|
|
27
|
+
|
|
28
|
+
## Suggested first prompt to the coding agent
|
|
29
|
+
|
|
30
|
+
> "Read docs 00–07 in `/handcuff-docs`. Start with ticket AL-001 and proceed in dependency order. For each ticket, implement the code and its test in one commit, keep ruff/mypy clean, and stop after AL-024 (the vertical slice) so I can review a working `watch` + `sessions` before you continue."
|
|
31
|
+
|
|
32
|
+
## Tech stack at a glance
|
|
33
|
+
Python 3.11+ · Textual (TUI) · SQLite/WAL · asyncio · psutil/procfs (default capture) · eBPF (opt-in fast path) · watchdog (files) · cryptography/Ed25519 (signing) · httpx (webhooks only) · packaged via pipx/uv + PyInstaller.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Dummy Agentic Project
|
|
2
|
+
|
|
3
|
+
This is a sample project to demonstrate how to test the `handcuff` monitoring tool.
|
|
4
|
+
It uses LangGraph to orchestrate a workflow with two agents:
|
|
5
|
+
1. **Researcher Agent**: Fetches a random joke from an external API (Network Activity).
|
|
6
|
+
2. **Writer Agent**: Saves the fetched joke to a local file (File Activity).
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
1. Create a virtual environment (optional but recommended):
|
|
10
|
+
```bash
|
|
11
|
+
python -m venv .venv
|
|
12
|
+
.venv\Scripts\activate
|
|
13
|
+
```
|
|
14
|
+
2. Install dependencies:
|
|
15
|
+
```bash
|
|
16
|
+
pip install -r requirements.txt
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Running the Agent normally
|
|
20
|
+
```bash
|
|
21
|
+
python main.py
|
|
22
|
+
```
|
|
23
|
+
This will fetch a joke and write it to `agent_output.txt`.
|
|
24
|
+
|
|
25
|
+
## Testing with Handcuff
|
|
26
|
+
To verify that Handcuff successfully intercepts the network and file I/O operations, run the script wrapped with the `handcuff` CLI:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
handcuff watch python main.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### What to look for in Handcuff:
|
|
33
|
+
- **Network Request**: Handcuff should flag the `GET` request to `official-joke-api.appspot.com`.
|
|
34
|
+
- **File Activity**: Handcuff should flag the creation and write operations for `agent_output.txt`.
|
|
35
|
+
|
|
36
|
+
You can view the generated reports in the terminal UI or export them to see the exact logs that Handcuff recorded.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Why do pumpkins sit on people’s porches? - They have no hands to knock on the door.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
from langgraph.graph import StateGraph, END
|
|
4
|
+
|
|
5
|
+
# Define our state
|
|
6
|
+
class AgentState(TypedDict):
|
|
7
|
+
task: str
|
|
8
|
+
fetched_data: str
|
|
9
|
+
output_file: str
|
|
10
|
+
status: str
|
|
11
|
+
|
|
12
|
+
# Node 1: The Researcher Agent
|
|
13
|
+
# This agent's job is to fetch data from an external API
|
|
14
|
+
def researcher_agent(state: AgentState):
|
|
15
|
+
print(f"[Researcher Agent] Fetching data for task: {state['task']}")
|
|
16
|
+
|
|
17
|
+
# Simulating a web search or API call (Network Activity)
|
|
18
|
+
# We fetch a random joke to have some dynamic content
|
|
19
|
+
response = httpx.get("https://official-joke-api.appspot.com/random_joke")
|
|
20
|
+
if response.status_code == 200:
|
|
21
|
+
data = response.json()
|
|
22
|
+
joke = f"{data['setup']} - {data['punchline']}"
|
|
23
|
+
else:
|
|
24
|
+
joke = "Failed to fetch a joke."
|
|
25
|
+
|
|
26
|
+
print(f"[Researcher Agent] Fetched data: {joke}")
|
|
27
|
+
|
|
28
|
+
return {"fetched_data": joke}
|
|
29
|
+
|
|
30
|
+
# Node 2: The Writer Agent
|
|
31
|
+
# This agent's job is to save the data to a local file
|
|
32
|
+
def writer_agent(state: AgentState):
|
|
33
|
+
print(f"[Writer Agent] Writing data to file: {state['output_file']}")
|
|
34
|
+
|
|
35
|
+
# Simulating file modifications (File Activity)
|
|
36
|
+
with open(state["output_file"], "w", encoding="utf-8") as f:
|
|
37
|
+
f.write(state["fetched_data"])
|
|
38
|
+
|
|
39
|
+
print("[Writer Agent] Data successfully written.")
|
|
40
|
+
return {"status": "completed"}
|
|
41
|
+
|
|
42
|
+
# Build the Graph
|
|
43
|
+
workflow = StateGraph(AgentState)
|
|
44
|
+
|
|
45
|
+
# Add nodes
|
|
46
|
+
workflow.add_node("researcher", researcher_agent)
|
|
47
|
+
workflow.add_node("writer", writer_agent)
|
|
48
|
+
|
|
49
|
+
# Add edges (Simple sequential flow)
|
|
50
|
+
workflow.set_entry_point("researcher")
|
|
51
|
+
workflow.add_edge("researcher", "writer")
|
|
52
|
+
workflow.add_edge("writer", END)
|
|
53
|
+
|
|
54
|
+
# Compile the graph
|
|
55
|
+
app = workflow.compile()
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
print("=== Starting LangGraph Agentic Workflow ===")
|
|
59
|
+
|
|
60
|
+
initial_state = {
|
|
61
|
+
"task": "Find a joke",
|
|
62
|
+
"fetched_data": "",
|
|
63
|
+
"output_file": "agent_output.txt",
|
|
64
|
+
"status": "pending"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Run the graph
|
|
68
|
+
app.invoke(initial_state)
|
|
69
|
+
|
|
70
|
+
print("=== Workflow Completed ===")
|
|
71
|
+
print("Check 'agent_output.txt' to see the result.")
|
handcuff-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handcuff
|
|
3
|
+
Version: 0.2.2
|
|
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.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: cryptography>=42.0
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: pillow>=10.0
|
|
25
|
+
Requires-Dist: psutil>=5.9
|
|
26
|
+
Requires-Dist: pyfiglet>=1.0
|
|
27
|
+
Requires-Dist: python-ulid>=2.2
|
|
28
|
+
Requires-Dist: rich-pixels>=3.0
|
|
29
|
+
Requires-Dist: ruamel-yaml>=0.18
|
|
30
|
+
Requires-Dist: textual>=0.58
|
|
31
|
+
Requires-Dist: typer>=0.12
|
|
32
|
+
Requires-Dist: watchdog>=4.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
35
|
+
Requires-Dist: pip-audit>=2.7; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
39
|
+
Provides-Extra: langchain
|
|
40
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# Handcuff — Documentation Index (Start Here)
|
|
44
|
+
|
|
45
|
+
> **Handcuff** — a dashcam for your AI agents. A local, tamper-evident recorder that captures everything an AI agent does on your machine (processes, files, network) into a searchable, replayable timeline with real-time alerts. Local-first, record-only, zero cloud.
|
|
46
|
+
|
|
47
|
+
## For the coding agent building this
|
|
48
|
+
|
|
49
|
+
Read the docs in this order, then execute from the tickets:
|
|
50
|
+
|
|
51
|
+
| # | Doc | Purpose |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| 01 | `01_PRD.md` | What we're building and why; scope, users, requirements |
|
|
54
|
+
| 02 | `02_TRD.md` | Architecture, stack, data model, module layout — the technical contract |
|
|
55
|
+
| 03 | `03_Security_Access.md` | Threat model, privilege model, integrity + privacy guarantees, release-gate tests |
|
|
56
|
+
| 04 | `04_Implementation_Plan.md` | Build-order-correct milestones with exit criteria |
|
|
57
|
+
| 05 | `05_Tickets.md` | Atomic, executable tickets (AL-###) in dependency order — **build from here** |
|
|
58
|
+
| 06 | `06_App_Flow.md` | Every runtime flow + state machines (ASCII, parseable) |
|
|
59
|
+
| 07 | `07_Terminal_UI.md` | Rich Textual TUI spec (Claude Code / gemini-cli feel) |
|
|
60
|
+
|
|
61
|
+
## Build contract (non-negotiables pulled from the docs)
|
|
62
|
+
|
|
63
|
+
1. **`core/hashing.py` is the single source of truth** for canonical JSON + the SHA-256 chain. The writer and `verify` import it — never reimplement.
|
|
64
|
+
2. **Local-only.** No outbound traffic except a user-configured webhook. `tests/test_no_egress.py` is a release gate.
|
|
65
|
+
3. **Record-only in v1.** Handcuff observes; it never blocks the agent. Backpressure drops events + emits `LOSS`; it must never slow the observed process.
|
|
66
|
+
4. **Observed content is data, never instructions.** Nothing captured from the agent (argv, paths, domains) is ever interpreted as a command to Handcuff.
|
|
67
|
+
5. **Honest integrity claims.** The hash chain is tamper-*evidence*, not a completeness proof; the README states plainly what it does and doesn't guarantee.
|
|
68
|
+
6. **Every ticket ships with its test.** ruff + mypy clean. Five security tests gate releases (see Security §9).
|
|
69
|
+
|
|
70
|
+
## Suggested first prompt to the coding agent
|
|
71
|
+
|
|
72
|
+
> "Read docs 00–07 in `/handcuff-docs`. Start with ticket AL-001 and proceed in dependency order. For each ticket, implement the code and its test in one commit, keep ruff/mypy clean, and stop after AL-024 (the vertical slice) so I can review a working `watch` + `sessions` before you continue."
|
|
73
|
+
|
|
74
|
+
## Tech stack at a glance
|
|
75
|
+
Python 3.11+ · Textual (TUI) · SQLite/WAL · asyncio · psutil/procfs (default capture) · eBPF (opt-in fast path) · watchdog (files) · cryptography/Ed25519 (signing) · httpx (webhooks only) · packaged via pipx/uv + PyInstaller.
|
|
@@ -126,7 +126,7 @@ class NetworkCaptureBackend:
|
|
|
126
126
|
while not self._stopped or not self._queue.empty():
|
|
127
127
|
try:
|
|
128
128
|
yield await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
|
129
|
-
except TimeoutError:
|
|
129
|
+
except (TimeoutError, getattr(__import__("asyncio"), "TimeoutError")):
|
|
130
130
|
if self._stopped:
|
|
131
131
|
break
|
|
132
132
|
|
|
@@ -97,7 +97,7 @@ class PsutilCaptureBackend:
|
|
|
97
97
|
while not self._stopped or not self._queue.empty():
|
|
98
98
|
try:
|
|
99
99
|
yield await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
|
100
|
-
except TimeoutError:
|
|
100
|
+
except (TimeoutError, getattr(__import__("asyncio"), "TimeoutError")):
|
|
101
101
|
if self._stopped:
|
|
102
102
|
break
|
|
103
103
|
|
|
@@ -9,7 +9,7 @@ tickets land.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
-
from datetime import
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
import typer
|
|
@@ -116,7 +116,7 @@ def sessions() -> None:
|
|
|
116
116
|
typer.echo("no sessions recorded yet")
|
|
117
117
|
raise typer.Exit()
|
|
118
118
|
for row in rows:
|
|
119
|
-
started = datetime.fromtimestamp(row["started_at"] / 1000, tz=
|
|
119
|
+
started = datetime.fromtimestamp(row["started_at"] / 1000, tz=timezone.utc).isoformat()
|
|
120
120
|
typer.echo(
|
|
121
121
|
f"{row['id']} {row['status']:<24} {started} {row['label'] or ''} {row['target_cmd']}"
|
|
122
122
|
)
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import html
|
|
11
11
|
import json
|
|
12
12
|
import sqlite3
|
|
13
|
-
from datetime import
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
@@ -38,7 +38,7 @@ th { background: #8882; }
|
|
|
38
38
|
def _ts(ms: int | None) -> str:
|
|
39
39
|
if not ms:
|
|
40
40
|
return "-"
|
|
41
|
-
return datetime.fromtimestamp(ms / 1000, tz=
|
|
41
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def _e(value: Any) -> str:
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import sqlite3
|
|
12
|
-
from datetime import
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
@@ -19,7 +19,7 @@ from handcuff.export.json_export import build_export_doc
|
|
|
19
19
|
def _ts(ms: int | None) -> str:
|
|
20
20
|
if not ms:
|
|
21
21
|
return "-"
|
|
22
|
-
return datetime.fromtimestamp(ms / 1000, tz=
|
|
22
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def _md_escape(text: str) -> str:
|
|
@@ -56,6 +56,7 @@ class HandcuffApp(App):
|
|
|
56
56
|
super().__init__()
|
|
57
57
|
self.db_path = db_path
|
|
58
58
|
self.session_id = session_id
|
|
59
|
+
self._auto_follow = (session_id is None)
|
|
59
60
|
self.paused = False
|
|
60
61
|
self._last_seq = -1
|
|
61
62
|
self._alert_count = 0
|
|
@@ -162,10 +163,24 @@ class HandcuffApp(App):
|
|
|
162
163
|
|
|
163
164
|
# --- real data polling ------------------------------------------------
|
|
164
165
|
def poll_updates(self) -> None:
|
|
165
|
-
if self.paused
|
|
166
|
+
if self.paused:
|
|
166
167
|
return
|
|
167
168
|
conn = connect(self.db_path)
|
|
168
169
|
|
|
170
|
+
if self._auto_follow:
|
|
171
|
+
row = conn.execute("SELECT id FROM sessions ORDER BY started_at DESC LIMIT 1").fetchone()
|
|
172
|
+
if row and row["id"] != self.session_id:
|
|
173
|
+
self.session_id = row["id"]
|
|
174
|
+
self._last_seq = -1
|
|
175
|
+
self._alert_count = 0
|
|
176
|
+
self._event_count = 0
|
|
177
|
+
self._severities = []
|
|
178
|
+
self.query_one("#logs", LiveActivity).clear()
|
|
179
|
+
|
|
180
|
+
if self.session_id is None:
|
|
181
|
+
conn.close()
|
|
182
|
+
return
|
|
183
|
+
|
|
169
184
|
new_events = conn.execute(
|
|
170
185
|
"SELECT seq, ts, kind, payload_json FROM events "
|
|
171
186
|
"WHERE session_id = ? AND seq > ? ORDER BY seq",
|
|
@@ -12,7 +12,7 @@ rather than invented to match a mockup that assumed fields we don't have.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import time
|
|
15
|
-
from datetime import
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
16
|
from importlib.metadata import PackageNotFoundError
|
|
17
17
|
from importlib.metadata import version as pkg_version
|
|
18
18
|
|
|
@@ -72,7 +72,7 @@ class SessionInfoBox(Static):
|
|
|
72
72
|
started_str = "-"
|
|
73
73
|
uptime_str = "00:00:00"
|
|
74
74
|
if self.started_at_ms is not None:
|
|
75
|
-
started_dt = datetime.fromtimestamp(self.started_at_ms / 1000, tz=
|
|
75
|
+
started_dt = datetime.fromtimestamp(self.started_at_ms / 1000, tz=timezone.utc)
|
|
76
76
|
started_str = started_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
77
77
|
elapsed = max(0, int(time.time() - self.started_at_ms / 1000))
|
|
78
78
|
h, rem = divmod(elapsed, 3600)
|
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
"""Flagged Events as bordered cards (matches the reference mockup),
|
|
2
|
-
replacing the plain DataTable version — one card per real alert, border
|
|
3
|
-
colored by severity, with a "> View Context" line that really does
|
|
4
|
-
raise a visible detail view (Textual's `notify`) rather than a fake
|
|
5
|
-
inert label.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from datetime import
|
|
11
|
-
|
|
12
|
-
from rich.text import Text
|
|
13
|
-
from textual.containers import Vertical
|
|
14
|
-
from textual.widgets import Static
|
|
15
|
-
|
|
16
|
-
_SEVERITY_STYLE = {
|
|
17
|
-
"critical": ("HIGH", "#FF4D4F"),
|
|
18
|
-
"warn": ("MEDIUM", "#F1C40F"),
|
|
19
|
-
"info": ("LOW", "#00D9FF"),
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class FlagCard(Static):
|
|
24
|
-
def __init__(self, severity: str, rule_id: str, message: str, ts_ms: int, **kwargs) -> None:
|
|
25
|
-
super().__init__(**kwargs)
|
|
26
|
-
self.severity = severity
|
|
27
|
-
self.rule_id = rule_id
|
|
28
|
-
self.message = message
|
|
29
|
-
self.ts_ms = ts_ms
|
|
30
|
-
label, color = _SEVERITY_STYLE.get(severity, ("LOW", "#8C96A0"))
|
|
31
|
-
self.styles.border = ("round", color)
|
|
32
|
-
|
|
33
|
-
def on_mount(self) -> None:
|
|
34
|
-
label, color = _SEVERITY_STYLE.get(self.severity, ("LOW", "#8C96A0"))
|
|
35
|
-
time_str = datetime.fromtimestamp(self.ts_ms / 1000, tz=
|
|
36
|
-
|
|
37
|
-
text = Text()
|
|
38
|
-
text.append(f"{self.message}", style=f"bold {color}")
|
|
39
|
-
text.append(f" {label}\n", style=f"bold {color}")
|
|
40
|
-
text.append(f"Rule: {self.rule_id}\n", style="#F4F7FA")
|
|
41
|
-
text.append(f"Time: {time_str}\n", style="#8C96A0")
|
|
42
|
-
text.append("> View Context", style="#00D9FF")
|
|
43
|
-
self.update(text)
|
|
44
|
-
|
|
45
|
-
def on_click(self) -> None:
|
|
46
|
-
self.app.notify(
|
|
47
|
-
f"rule={self.rule_id} severity={self.severity}\n{self.message}",
|
|
48
|
-
title="Flagged event context",
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class FlagCards(Vertical):
|
|
53
|
-
"""Container that rebuilds its child FlagCard widgets from a real
|
|
54
|
-
list of (severity, rule_id, message, ts_ms) tuples each time the
|
|
55
|
-
underlying alerts change."""
|
|
56
|
-
|
|
57
|
-
async def set_alerts(self, alerts: list[tuple[str, str, str, int]]) -> None:
|
|
58
|
-
await self.remove_children()
|
|
59
|
-
order = {"critical": 0, "warn": 1, "info": 2}
|
|
60
|
-
for severity, rule_id, message, ts_ms in sorted(
|
|
61
|
-
alerts, key=lambda a: order.get(a[0], 3)
|
|
62
|
-
):
|
|
63
|
-
await self.mount(FlagCard(severity, rule_id, message, ts_ms))
|
|
1
|
+
"""Flagged Events as bordered cards (matches the reference mockup),
|
|
2
|
+
replacing the plain DataTable version — one card per real alert, border
|
|
3
|
+
colored by severity, with a "> View Context" line that really does
|
|
4
|
+
raise a visible detail view (Textual's `notify`) rather than a fake
|
|
5
|
+
inert label.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
_SEVERITY_STYLE = {
|
|
17
|
+
"critical": ("HIGH", "#FF4D4F"),
|
|
18
|
+
"warn": ("MEDIUM", "#F1C40F"),
|
|
19
|
+
"info": ("LOW", "#00D9FF"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlagCard(Static):
|
|
24
|
+
def __init__(self, severity: str, rule_id: str, message: str, ts_ms: int, **kwargs) -> None:
|
|
25
|
+
super().__init__(**kwargs)
|
|
26
|
+
self.severity = severity
|
|
27
|
+
self.rule_id = rule_id
|
|
28
|
+
self.message = message
|
|
29
|
+
self.ts_ms = ts_ms
|
|
30
|
+
label, color = _SEVERITY_STYLE.get(severity, ("LOW", "#8C96A0"))
|
|
31
|
+
self.styles.border = ("round", color)
|
|
32
|
+
|
|
33
|
+
def on_mount(self) -> None:
|
|
34
|
+
label, color = _SEVERITY_STYLE.get(self.severity, ("LOW", "#8C96A0"))
|
|
35
|
+
time_str = datetime.fromtimestamp(self.ts_ms / 1000, tz=timezone.utc).strftime("%H:%M:%S")
|
|
36
|
+
|
|
37
|
+
text = Text()
|
|
38
|
+
text.append(f"{self.message}", style=f"bold {color}")
|
|
39
|
+
text.append(f" {label}\n", style=f"bold {color}")
|
|
40
|
+
text.append(f"Rule: {self.rule_id}\n", style="#F4F7FA")
|
|
41
|
+
text.append(f"Time: {time_str}\n", style="#8C96A0")
|
|
42
|
+
text.append("> View Context", style="#00D9FF")
|
|
43
|
+
self.update(text)
|
|
44
|
+
|
|
45
|
+
def on_click(self) -> None:
|
|
46
|
+
self.app.notify(
|
|
47
|
+
f"rule={self.rule_id} severity={self.severity}\n{self.message}",
|
|
48
|
+
title="Flagged event context",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FlagCards(Vertical):
|
|
53
|
+
"""Container that rebuilds its child FlagCard widgets from a real
|
|
54
|
+
list of (severity, rule_id, message, ts_ms) tuples each time the
|
|
55
|
+
underlying alerts change."""
|
|
56
|
+
|
|
57
|
+
async def set_alerts(self, alerts: list[tuple[str, str, str, int]]) -> None:
|
|
58
|
+
await self.remove_children()
|
|
59
|
+
order = {"critical": 0, "warn": 1, "info": 2}
|
|
60
|
+
for severity, rule_id, message, ts_ms in sorted(
|
|
61
|
+
alerts, key=lambda a: order.get(a[0], 3)
|
|
62
|
+
):
|
|
63
|
+
await self.mount(FlagCard(severity, rule_id, message, ts_ms))
|
|
@@ -6,7 +6,7 @@ table (TIME/LEVEL/ACTION/TARGET/STATUS), driven by real events from the
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
-
from datetime import
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
10
|
|
|
11
11
|
from textual.widgets import DataTable
|
|
12
12
|
|
|
@@ -56,7 +56,7 @@ class LiveActivity(DataTable):
|
|
|
56
56
|
def add_event(self, ts_ms: int, kind: str, payload_json: str) -> None:
|
|
57
57
|
payload = json.loads(payload_json) if payload_json else {}
|
|
58
58
|
level = _level_for(kind, payload)
|
|
59
|
-
time_str = datetime.fromtimestamp(ts_ms / 1000, tz=
|
|
59
|
+
time_str = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%H:%M:%S")
|
|
60
60
|
self.add_row(
|
|
61
61
|
time_str,
|
|
62
62
|
level,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "handcuff"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "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
|
-
readme = "
|
|
6
|
-
requires-python = ">=3.
|
|
5
|
+
readme = "00_README_START_HERE.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "MIT" }
|
|
8
8
|
authors = [{ name = "Varun Gupta" }]
|
|
9
9
|
keywords = ["agents", "langchain", "langgraph", "security", "observability", "prompt-injection"]
|
|
@@ -13,6 +13,7 @@ classifiers = [
|
|
|
13
13
|
"License :: OSI Approved :: MIT License",
|
|
14
14
|
"Operating System :: Microsoft :: Windows",
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
16
17
|
"Programming Language :: Python :: 3.11",
|
|
17
18
|
"Programming Language :: Python :: 3.12",
|
|
18
19
|
"Topic :: Security",
|
|
@@ -61,7 +62,7 @@ langchain = [
|
|
|
61
62
|
|
|
62
63
|
[tool.ruff]
|
|
63
64
|
line-length = 100
|
|
64
|
-
target-version = "
|
|
65
|
+
target-version = "py310"
|
|
65
66
|
|
|
66
67
|
[tool.ruff.lint]
|
|
67
68
|
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -70,7 +71,7 @@ select = ["E", "F", "I", "UP", "B"]
|
|
|
70
71
|
ignore = ["B008", "UP042"]
|
|
71
72
|
|
|
72
73
|
[tool.mypy]
|
|
73
|
-
python_version = "3.
|
|
74
|
+
python_version = "3.10"
|
|
74
75
|
ignore_missing_imports = true
|
|
75
76
|
disallow_untyped_defs = false
|
|
76
77
|
warn_unused_ignores = true
|
handcuff-0.2.0/PKG-INFO
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
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`).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|