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.
Files changed (73) hide show
  1. {handcuff-0.2.0 → handcuff-0.2.2}/.gitignore +1 -0
  2. handcuff-0.2.2/00_README_START_HERE.md +33 -0
  3. handcuff-0.2.2/Dummy Agentic Project/README.md +36 -0
  4. handcuff-0.2.2/Dummy Agentic Project/agent_output.txt +1 -0
  5. handcuff-0.2.2/Dummy Agentic Project/main.py +71 -0
  6. handcuff-0.2.2/Dummy Agentic Project/requirements.txt +5 -0
  7. handcuff-0.2.2/PKG-INFO +75 -0
  8. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/net_watch.py +1 -1
  9. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/psutil_backend.py +1 -1
  10. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/cli.py +2 -2
  11. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/html_export.py +2 -2
  12. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/md_export.py +2 -2
  13. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/writer.py +1 -1
  14. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/app.py +16 -1
  15. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/banner_header.py +2 -2
  16. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/flag_cards.py +63 -63
  17. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/logs.py +2 -2
  18. {handcuff-0.2.0 → handcuff-0.2.2}/pyproject.toml +6 -5
  19. handcuff-0.2.0/PKG-INFO +0 -149
  20. {handcuff-0.2.0 → handcuff-0.2.2}/.github/workflows/publish.yml +0 -0
  21. {handcuff-0.2.0 → handcuff-0.2.2}/README.md +0 -0
  22. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/__init__.py +0 -0
  23. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/alerts/__init__.py +0 -0
  24. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/alerts/webhooks.py +0 -0
  25. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/banner.py +0 -0
  26. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/__init__.py +0 -0
  27. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/base.py +0 -0
  28. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/file_watch.py +0 -0
  29. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/redact.py +0 -0
  30. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/capture/tree.py +0 -0
  31. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/config.py +0 -0
  32. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/__init__.py +0 -0
  33. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/bus.py +0 -0
  34. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/events.py +0 -0
  35. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/hashing.py +0 -0
  36. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/session.py +0 -0
  37. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/core/signing.py +0 -0
  38. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/doctor.py +0 -0
  39. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/__init__.py +0 -0
  40. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/file_acl.py +0 -0
  41. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/firewall.py +0 -0
  42. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/enforce/scan.py +0 -0
  43. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/__init__.py +0 -0
  44. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/export/json_export.py +0 -0
  45. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/__init__.py +0 -0
  46. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/gateway.py +0 -0
  47. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/log.py +0 -0
  48. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/gateway/policy.py +0 -0
  49. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/integrations/__init__.py +0 -0
  50. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/integrations/langchain.py +0 -0
  51. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/replay.py +0 -0
  52. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/__init__.py +0 -0
  53. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/defaults.yaml +0 -0
  54. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/dsl.py +0 -0
  55. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/engine.py +0 -0
  56. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/injection.py +0 -0
  57. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/rules/trust.py +0 -0
  58. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/runner.py +0 -0
  59. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/__init__.py +0 -0
  60. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/storage/db.py +0 -0
  61. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/__init__.py +0 -0
  62. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/risk.py +0 -0
  63. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/styles.tcss +0 -0
  64. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/theme.py +0 -0
  65. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/__init__.py +0 -0
  66. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/footer.py +0 -0
  67. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/hero.py +0 -0
  68. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/monitoring.py +0 -0
  69. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/recommendation.py +0 -0
  70. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/risk_overview.py +0 -0
  71. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/tui/widgets/trust.py +0 -0
  72. {handcuff-0.2.0 → handcuff-0.2.2}/handcuff/verify.py +0 -0
  73. {handcuff-0.2.0 → handcuff-0.2.2}/scripts/build_windows.ps1 +0 -0
@@ -7,6 +7,7 @@ tests/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
9
  .venv/
10
+ .venv310/
10
11
  handcuff.spec
11
12
  nonexistent.db
12
13
  *.db
@@ -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.")
@@ -0,0 +1,5 @@
1
+ langchain
2
+ langgraph
3
+ langchain-community
4
+ httpx
5
+ handcuff
@@ -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 UTC, datetime
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=UTC).isoformat()
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 UTC, datetime
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=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
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 UTC, datetime
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=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
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:
@@ -98,7 +98,7 @@ class StorageWriter:
98
98
  self._append(raw)
99
99
  if len(self._buffer) >= self._batch_size:
100
100
  self.flush()
101
- except TimeoutError:
101
+ except (TimeoutError, getattr(__import__("asyncio"), "TimeoutError")):
102
102
  self.flush()
103
103
  self.flush()
104
104
 
@@ -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 or self.session_id is None:
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 UTC, datetime
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=UTC)
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 UTC, datetime
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=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))
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 UTC, datetime
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=UTC).strftime("%H:%M:%S")
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.0"
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 = "README.md"
6
- requires-python = ">=3.11"
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 = "py311"
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.11"
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