handcuff 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {handcuff-0.2.3 → handcuff-0.3.0}/.gitignore +2 -1
  2. {handcuff-0.2.3 → handcuff-0.3.0}/PKG-INFO +3 -1
  3. handcuff-0.3.0/handcuff/integrations/langchain_sandbox.py +36 -0
  4. handcuff-0.3.0/handcuff/sandbox/__init__.py +4 -0
  5. handcuff-0.3.0/handcuff/sandbox/manager.py +184 -0
  6. {handcuff-0.2.3 → handcuff-0.3.0}/pyproject.toml +4 -1
  7. handcuff-0.2.3/01KX3HRHQKZHGVE0TWJV1SH6MX.json +0 -118
  8. handcuff-0.2.3/Dummy Agentic Project/README.md +0 -36
  9. handcuff-0.2.3/Dummy Agentic Project/agent_output.txt +0 -1
  10. handcuff-0.2.3/Dummy Agentic Project/main.py +0 -71
  11. handcuff-0.2.3/Dummy Agentic Project/requirements.txt +0 -5
  12. {handcuff-0.2.3 → handcuff-0.3.0}/.github/workflows/publish.yml +0 -0
  13. {handcuff-0.2.3 → handcuff-0.3.0}/00_README_START_HERE.md +0 -0
  14. {handcuff-0.2.3 → handcuff-0.3.0}/README.md +0 -0
  15. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/__init__.py +0 -0
  16. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/alerts/__init__.py +0 -0
  17. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/alerts/webhooks.py +0 -0
  18. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/banner.py +0 -0
  19. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/__init__.py +0 -0
  20. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/base.py +0 -0
  21. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/file_watch.py +0 -0
  22. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/net_watch.py +0 -0
  23. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/psutil_backend.py +0 -0
  24. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/redact.py +0 -0
  25. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/capture/tree.py +0 -0
  26. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/cli.py +0 -0
  27. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/config.py +0 -0
  28. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/__init__.py +0 -0
  29. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/bus.py +0 -0
  30. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/events.py +0 -0
  31. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/hashing.py +0 -0
  32. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/session.py +0 -0
  33. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/core/signing.py +0 -0
  34. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/doctor.py +0 -0
  35. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/enforce/__init__.py +0 -0
  36. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/enforce/file_acl.py +0 -0
  37. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/enforce/firewall.py +0 -0
  38. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/enforce/scan.py +0 -0
  39. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/export/__init__.py +0 -0
  40. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/export/html_export.py +0 -0
  41. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/export/json_export.py +0 -0
  42. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/export/md_export.py +0 -0
  43. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/gateway/__init__.py +0 -0
  44. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/gateway/gateway.py +0 -0
  45. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/gateway/log.py +0 -0
  46. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/gateway/policy.py +0 -0
  47. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/integrations/__init__.py +0 -0
  48. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/integrations/langchain.py +0 -0
  49. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/replay.py +0 -0
  50. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/__init__.py +0 -0
  51. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/defaults.yaml +0 -0
  52. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/dsl.py +0 -0
  53. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/engine.py +0 -0
  54. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/injection.py +0 -0
  55. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/rules/trust.py +0 -0
  56. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/runner.py +0 -0
  57. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/storage/__init__.py +0 -0
  58. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/storage/db.py +0 -0
  59. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/storage/writer.py +0 -0
  60. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/__init__.py +0 -0
  61. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/app.py +0 -0
  62. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/risk.py +0 -0
  63. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/styles.tcss +0 -0
  64. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/theme.py +0 -0
  65. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/__init__.py +0 -0
  66. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/banner_header.py +0 -0
  67. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/flag_cards.py +0 -0
  68. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/footer.py +0 -0
  69. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/hero.py +0 -0
  70. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/logs.py +0 -0
  71. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/monitoring.py +0 -0
  72. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/recommendation.py +0 -0
  73. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/risk_overview.py +0 -0
  74. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/tui/widgets/trust.py +0 -0
  75. {handcuff-0.2.3 → handcuff-0.3.0}/handcuff/verify.py +0 -0
  76. {handcuff-0.2.3 → handcuff-0.3.0}/scripts/build_windows.ps1 +0 -0
@@ -26,4 +26,5 @@ AgentLens_SPEC_Part1.md
26
26
  AgentLens_SPEC_Part2.md
27
27
  AgentLens_SPEC_Part3.md
28
28
  AgentLens_SPEC_Part4.md
29
- __pycache__/
29
+ __pycache__/
30
+ Dummy Agentic Project/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: handcuff
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
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
5
  Project-URL: Homepage, https://github.com/Mister2005/Handcuff
6
6
  Project-URL: Repository, https://github.com/Mister2005/Handcuff
@@ -38,6 +38,8 @@ Requires-Dist: pytest>=8.2; extra == 'dev'
38
38
  Requires-Dist: ruff>=0.5; extra == 'dev'
39
39
  Provides-Extra: langchain
40
40
  Requires-Dist: langchain-core>=0.2; extra == 'langchain'
41
+ Provides-Extra: sandbox
42
+ Requires-Dist: docker>=7.0.0; extra == 'sandbox'
41
43
  Description-Content-Type: text/markdown
42
44
 
43
45
  # Handcuff — Documentation Index (Start Here)
@@ -0,0 +1,36 @@
1
+ """LangChain tools that run inside a Docker Sandbox"""
2
+
3
+ from pathlib import Path
4
+ from langchain_core.tools import tool
5
+
6
+ from handcuff.sandbox.manager import DockerSandbox, SandboxError, QuarantineViolation
7
+
8
+ @tool
9
+ def sandboxed_download(url: str, dest_filename: str) -> str:
10
+ """Download a file from a URL securely into an isolated Sandbox.
11
+ The file is scanned for prompt injection and malicious signatures
12
+ before being copied to the local workspace.
13
+
14
+ Args:
15
+ url: The URL to download.
16
+ dest_filename: The local filename to save the safe file to.
17
+ """
18
+ try:
19
+ with DockerSandbox() as sandbox:
20
+ container_path = "/tmp/downloaded_file"
21
+ # Execute python inside the container to download, safely passing url via env var
22
+ cmd = f"""python -c "import os, urllib.request; urllib.request.urlretrieve(os.environ['DOWNLOAD_URL'], '{container_path}')" """
23
+ sandbox.execute(cmd, environment={"DOWNLOAD_URL": url})
24
+
25
+ # Copy to host through the quarantine scanner
26
+ host_path = Path.cwd() / dest_filename
27
+ sandbox.copy_quarantined(container_path, host_path)
28
+
29
+ return f"Successfully downloaded and verified safe. Saved to {dest_filename}"
30
+
31
+ except SandboxError as e:
32
+ return f"Sandbox execution failed: {e}"
33
+ except QuarantineViolation as e:
34
+ return f"QUARANTINE BLOCKED: File was malicious and destroyed in the sandbox. {e}"
35
+ except Exception as e:
36
+ return f"Error: {e}"
@@ -0,0 +1,4 @@
1
+ """Handcuff Sandbox Module"""
2
+ from .manager import DockerSandbox, SandboxError, QuarantineViolation
3
+
4
+ __all__ = ["DockerSandbox", "SandboxError", "QuarantineViolation"]
@@ -0,0 +1,184 @@
1
+ """Docker Sandbox Manager for Handcuff Quarantine.
2
+
3
+ Provides an ephemeral Docker container for executing risky agent
4
+ tools (e.g. downloading datasets, cloning repos) without risking
5
+ the host machine.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import os
11
+ import tarfile
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import docker
16
+ _HAS_DOCKER = True
17
+ except ImportError:
18
+ _HAS_DOCKER = False
19
+
20
+ from handcuff.rules.injection import scan_for_injection
21
+
22
+
23
+ class SandboxError(Exception):
24
+ """Raised for general sandbox execution failures."""
25
+ pass
26
+
27
+
28
+ class QuarantineViolation(PermissionError):
29
+ """Raised when a file fails heuristic quarantine checks and is blocked."""
30
+ pass
31
+
32
+
33
+ class DockerSandbox:
34
+ """Ephemeral Docker container manager for secure execution and quarantine."""
35
+
36
+ def __init__(self, image: str = "python:3.11-slim"):
37
+ if not _HAS_DOCKER:
38
+ raise ImportError("The 'docker' package is required. Run: pip install handcuff[sandbox]")
39
+
40
+ self.image = image
41
+ try:
42
+ self.client = docker.from_env()
43
+ # Test connection
44
+ self.client.ping()
45
+ except docker.errors.DockerException as e:
46
+ raise SandboxError(f"Could not connect to Docker Daemon. Is Docker running? Details: {e}")
47
+
48
+ self.container = None
49
+
50
+ def __enter__(self) -> DockerSandbox:
51
+ self.start()
52
+ return self
53
+
54
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
55
+ self.stop()
56
+
57
+ def start(self) -> None:
58
+ """Starts the ephemeral container."""
59
+ try:
60
+ # Ensure image exists locally, pull if not
61
+ try:
62
+ self.client.images.get(self.image)
63
+ except docker.errors.ImageNotFound:
64
+ self.client.images.pull(self.image)
65
+
66
+ self.container = self.client.containers.run(
67
+ self.image,
68
+ command="tail -f /dev/null", # Keep alive
69
+ detach=True,
70
+ network_mode="bridge",
71
+ remove=True, # Auto-remove when stopped
72
+ cap_drop=["ALL"], # Drop all privileges for security
73
+ mem_limit="512m"
74
+ )
75
+ except Exception as e:
76
+ raise SandboxError(f"Failed to start Docker sandbox: {e}")
77
+
78
+ def stop(self) -> None:
79
+ """Stops and destroys the container."""
80
+ if self.container:
81
+ try:
82
+ self.container.remove(force=True)
83
+ except Exception:
84
+ pass
85
+ self.container = None
86
+
87
+ def execute(self, cmd: str, timeout: int = 30, environment: dict | None = None) -> str:
88
+ """Execute a command inside the container and return stdout.
89
+
90
+ Args:
91
+ cmd: Command to execute
92
+ timeout: Maximum execution time in seconds.
93
+ environment: Dictionary of environment variables to pass to the command.
94
+ """
95
+ if not self.container:
96
+ raise SandboxError("Sandbox is not running.")
97
+
98
+ import threading
99
+ result = {"exit_code": -1, "output": b""}
100
+ exc = []
101
+
102
+ def run_cmd():
103
+ try:
104
+ # exec_run returns (exit_code, output)
105
+ exit_code, output = self.container.exec_run(cmd, environment=environment)
106
+ result["exit_code"] = exit_code
107
+ result["output"] = output
108
+ except Exception as e:
109
+ exc.append(e)
110
+
111
+ t = threading.Thread(target=run_cmd)
112
+ t.start()
113
+ t.join(timeout=timeout)
114
+
115
+ if t.is_alive():
116
+ # If still alive, it timed out. Force kill the container.
117
+ self.stop()
118
+ raise SandboxError(f"Command execution timed out after {timeout} seconds. Container was destroyed.")
119
+
120
+ if exc:
121
+ raise SandboxError(f"Failed to execute command: {exc[0]}")
122
+
123
+ if result["exit_code"] != 0:
124
+ raise SandboxError(f"Command failed (exit {result['exit_code']}): {result['output'].decode('utf-8', errors='replace')}")
125
+
126
+ return result["output"].decode('utf-8', errors='replace')
127
+
128
+ def copy_quarantined(self, container_path: str, host_path: str | Path) -> None:
129
+ """
130
+ Copy a file from the container to the host, running heuristic scans
131
+ before persisting it to the host filesystem.
132
+ """
133
+ if not self.container:
134
+ raise SandboxError("Sandbox is not running.")
135
+
136
+ try:
137
+ bits, stat = self.container.get_archive(container_path)
138
+ except docker.errors.NotFound:
139
+ raise FileNotFoundError(f"File {container_path} not found in sandbox.")
140
+
141
+ # get_archive returns a tar stream. We extract it in memory.
142
+ tar_stream = io.BytesIO()
143
+ max_size = 100 * 1024 * 1024 # 100 MB limit
144
+ current_size = 0
145
+
146
+ for chunk in bits:
147
+ current_size += len(chunk)
148
+ if current_size > max_size:
149
+ raise SandboxError("File exceeds the 100MB quarantine extraction limit.")
150
+ tar_stream.write(chunk)
151
+
152
+ tar_stream.seek(0)
153
+ with tarfile.open(fileobj=tar_stream, mode='r') as tar:
154
+ members = tar.getmembers()
155
+ if not members:
156
+ raise SandboxError("Empty archive returned from container.")
157
+
158
+ # We only support single file extraction for now
159
+ file_member = members[0]
160
+ if not file_member.isfile():
161
+ raise SandboxError(f"{container_path} is not a file.")
162
+
163
+ extracted = tar.extractfile(file_member)
164
+ if not extracted:
165
+ raise SandboxError(f"Could not read {file_member.name} from archive.")
166
+
167
+ content = extracted.read()
168
+
169
+ # --- QUARANTINE SCAN ---
170
+ # Decode as string to run heuristic prompt injection scan
171
+ try:
172
+ text_content = content.decode('utf-8')
173
+ findings = scan_for_injection(text_content)
174
+ if findings:
175
+ reasons = ", ".join(f.reason for f in findings)
176
+ raise QuarantineViolation(f"Quarantine block! Malicious patterns detected: {reasons}")
177
+ except UnicodeDecodeError:
178
+ # Binary file, skip text injection scanning
179
+ # We could add yara rules or malware scanning here in the future
180
+ pass
181
+
182
+ # --- PERSIST TO HOST ---
183
+ with open(host_path, 'wb') as f:
184
+ f.write(content)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "handcuff"
3
- version = "0.2.3"
3
+ version = "0.3.0"
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
5
  readme = "00_README_START_HERE.md"
6
6
  requires-python = ">=3.10"
@@ -59,6 +59,9 @@ dev = [
59
59
  langchain = [
60
60
  "langchain-core>=0.2",
61
61
  ]
62
+ sandbox = [
63
+ "docker>=7.0.0",
64
+ ]
62
65
 
63
66
  [tool.ruff]
64
67
  line-length = 100
@@ -1,118 +0,0 @@
1
- {
2
- "session": {
3
- "id": "01KX3HRHQKZHGVE0TWJV1SH6MX",
4
- "label": null,
5
- "target_cmd": "python main.py",
6
- "root_pid": 40008,
7
- "started_at": 1783604463347,
8
- "ended_at": 1783604464847,
9
- "status": "CLOSED"
10
- },
11
- "events": [
12
- {
13
- "seq": 0,
14
- "ts": 1783604463387,
15
- "kind": "PROCESS_SPAWN",
16
- "pid": 40008,
17
- "ppid": 40008,
18
- "payload": {
19
- "argv": [
20
- "python",
21
- "main.py"
22
- ],
23
- "cwd": "C:\\Users\\Varun\\Desktop\\Main\\Personal Learning\\Handcuff\\Dummy Agentic Project",
24
- "exe": "C:\\Program Files\\Python310\\python.exe",
25
- "user": "LAPTOP-3FU0HLB5\\Varun"
26
- },
27
- "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
28
- "hash": "047618168c7d6a803b7b76e778b6fd05135ad6c81e469cc00588221e9ad21e57"
29
- },
30
- {
31
- "seq": 1,
32
- "ts": 1783604464262,
33
- "kind": "NET_CONNECT",
34
- "pid": 40008,
35
- "ppid": null,
36
- "payload": {
37
- "bytes": 0,
38
- "domain": "pt-in-f153.1e100.net",
39
- "dst_ip": "2404:6800:4000:100c::99",
40
- "port": 443,
41
- "proto": "tcp"
42
- },
43
- "prev_hash": "047618168c7d6a803b7b76e778b6fd05135ad6c81e469cc00588221e9ad21e57",
44
- "hash": "ed20e99732dc71fbe7128414b9b66d0de07dcc5c3abaf20dbee7f7d394fe1718"
45
- },
46
- {
47
- "seq": 2,
48
- "ts": 1783604464262,
49
- "kind": "ALERT",
50
- "pid": null,
51
- "ppid": null,
52
- "payload": {
53
- "message": "Connection to new domain",
54
- "rule_id": "unknown_domain",
55
- "severity": "warn"
56
- },
57
- "prev_hash": "ed20e99732dc71fbe7128414b9b66d0de07dcc5c3abaf20dbee7f7d394fe1718",
58
- "hash": "ae82f1646e5391bc13d7bd49fd00bae8096cf2fa29368f566c79a17cea7cd89e"
59
- },
60
- {
61
- "seq": 3,
62
- "ts": 1783604464305,
63
- "kind": "FILE_WRITE",
64
- "pid": null,
65
- "ppid": null,
66
- "payload": {
67
- "bytes_delta": 46,
68
- "op": "modify",
69
- "path": "C:\\Users\\Varun\\Desktop\\Main\\Personal Learning\\Handcuff\\Dummy Agentic Project\\agent_output.txt"
70
- },
71
- "prev_hash": "ae82f1646e5391bc13d7bd49fd00bae8096cf2fa29368f566c79a17cea7cd89e",
72
- "hash": "75f7802b56da702651385cfc59f44ba547ecfdce2b9508cb90ec7ed8b81153ff"
73
- },
74
- {
75
- "seq": 4,
76
- "ts": 1783604464305,
77
- "kind": "FILE_WRITE",
78
- "pid": null,
79
- "ppid": null,
80
- "payload": {
81
- "bytes_delta": 46,
82
- "op": "modify",
83
- "path": "C:\\Users\\Varun\\Desktop\\Main\\Personal Learning\\Handcuff\\Dummy Agentic Project\\agent_output.txt"
84
- },
85
- "prev_hash": "75f7802b56da702651385cfc59f44ba547ecfdce2b9508cb90ec7ed8b81153ff",
86
- "hash": "8b4f21a0f221a029643d1e516be579103205ae3f5e4cd08ddeb71f9b86333bb1"
87
- },
88
- {
89
- "seq": 5,
90
- "ts": 1783604464472,
91
- "kind": "PROCESS_EXIT",
92
- "pid": 40008,
93
- "ppid": 40008,
94
- "payload": {
95
- "duration_ms": 1085,
96
- "exit_code": null
97
- },
98
- "prev_hash": "8b4f21a0f221a029643d1e516be579103205ae3f5e4cd08ddeb71f9b86333bb1",
99
- "hash": "7227928e46db3fc85b92f642df1e8f255033769d4949a2bbe99baab11ded5924"
100
- }
101
- ],
102
- "alerts": [
103
- {
104
- "seq": 1,
105
- "rule_id": "unknown_domain",
106
- "severity": "warn",
107
- "message": "Connection to new domain",
108
- "muted": false,
109
- "ts": 1783604464262
110
- }
111
- ],
112
- "checkpoints": [],
113
- "verification": {
114
- "head_hash": "7227928e46db3fc85b92f642df1e8f255033769d4949a2bbe99baab11ded5924",
115
- "pubkey": "7cf9d095c6d73e03e4c6dbba67b5905f9787a9705eb85068b46899642e4a8f9f",
116
- "signature": "c4f1fd20bc4716fe00e47a24ded6bf86f2123a7a8b889b5d25c076917bbd87eec92221a85694761eadca19bd5a013f5e857e174485539bfc70e0847821ac4102"
117
- }
118
- }
@@ -1,36 +0,0 @@
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.
@@ -1 +0,0 @@
1
- What do you call a pile of cats? - A Meowtain.
@@ -1,71 +0,0 @@
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.")
@@ -1,5 +0,0 @@
1
- langchain
2
- langgraph
3
- langchain-community
4
- httpx
5
- handcuff
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