node9 1.1.1__tar.gz → 1.1.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 (29) hide show
  1. {node9-1.1.1 → node9-1.1.2}/.github/workflows/ai-review.yml +3 -0
  2. {node9-1.1.1 → node9-1.1.2}/CHANGELOG.md +8 -0
  3. {node9-1.1.1 → node9-1.1.2}/PKG-INFO +1 -1
  4. {node9-1.1.1 → node9-1.1.2}/node9/__init__.py +2 -12
  5. node9-1.1.2/node9/_client.py +244 -0
  6. {node9-1.1.1 → node9-1.1.2}/pyproject.toml +1 -1
  7. node9-1.1.1/node9/_client.py +0 -108
  8. {node9-1.1.1 → node9-1.1.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {node9-1.1.1 → node9-1.1.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  10. {node9-1.1.1 → node9-1.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {node9-1.1.1 → node9-1.1.2}/.github/workflows/auto-pr.yml +0 -0
  12. {node9-1.1.1 → node9-1.1.2}/.github/workflows/ci.yml +0 -0
  13. {node9-1.1.1 → node9-1.1.2}/.github/workflows/release.yml +0 -0
  14. {node9-1.1.1 → node9-1.1.2}/.gitignore +0 -0
  15. {node9-1.1.1 → node9-1.1.2}/LICENSE +0 -0
  16. {node9-1.1.1 → node9-1.1.2}/README.md +0 -0
  17. {node9-1.1.1 → node9-1.1.2}/conftest.py +0 -0
  18. {node9-1.1.1 → node9-1.1.2}/examples/basic.py +0 -0
  19. {node9-1.1.1 → node9-1.1.2}/examples/crewai_agent.py +0 -0
  20. {node9-1.1.1 → node9-1.1.2}/examples/langchain_agent.py +0 -0
  21. {node9-1.1.1 → node9-1.1.2}/node9/_config.py +0 -0
  22. {node9-1.1.1 → node9-1.1.2}/node9/_decorator.py +0 -0
  23. {node9-1.1.1 → node9-1.1.2}/node9/_exceptions.py +0 -0
  24. {node9-1.1.1 → node9-1.1.2}/scripts/ai-review.mjs +0 -0
  25. {node9-1.1.1 → node9-1.1.2}/scripts/e2e.sh +0 -0
  26. {node9-1.1.1 → node9-1.1.2}/tests/test_client.py +0 -0
  27. {node9-1.1.1 → node9-1.1.2}/tests/test_config.py +0 -0
  28. {node9-1.1.1 → node9-1.1.2}/tests/test_decorator.py +0 -0
  29. {node9-1.1.1 → node9-1.1.2}/tests/test_exceptions.py +0 -0
@@ -3,6 +3,9 @@ name: AI Code Review
3
3
  on:
4
4
  pull_request:
5
5
  branches: [main]
6
+ paths-ignore:
7
+ - '.github/workflows/ai-review.yml'
8
+ - 'scripts/ai-review.mjs'
6
9
 
7
10
  jobs:
8
11
  review:
@@ -2,6 +2,14 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.1.2 (2026-04-04)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Merge latest dev updates into main ([#3](https://github.com/node9-ai/node9-python/pull/3),
10
+ [`f5659b0`](https://github.com/node9-ai/node9-python/commit/f5659b0eca4182afdcf58070b0ec4c1ffccccc09))
11
+
12
+
5
13
  ## v1.1.1 (2026-03-17)
6
14
 
7
15
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: node9
3
- Version: 1.1.1
3
+ Version: 1.1.2
4
4
  Summary: Execution security for Python AI agents — seatbelt for LangChain, CrewAI, and plain Python.
5
5
  Project-URL: Homepage, https://node9.ai
6
6
  Project-URL: Repository, https://github.com/node9-ai/node9-python
@@ -1,20 +1,10 @@
1
1
  """
2
2
  node9 — Execution security for Python AI agents.
3
-
4
- Quick start:
5
- from node9 import protect
6
-
7
- @protect("write_file")
8
- def write_file(path: str, content: str):
9
- ...
10
-
11
- @protect("bash")
12
- def run_shell(cmd: str):
13
- ...
3
+ Bundled version with CI cloud routing support (NODE9_API_KEY).
14
4
  """
15
5
 
16
6
  from ._decorator import protect
17
7
  from ._exceptions import ActionDeniedException, DaemonNotFoundError
18
8
 
19
9
  __all__ = ["protect", "ActionDeniedException", "DaemonNotFoundError"]
20
- __version__ = "0.1.0"
10
+ __version__ = "0.1.1"
@@ -0,0 +1,244 @@
1
+ """
2
+ Thin HTTP client — talks to the local Node9 daemon on localhost:7391.
3
+
4
+ Flow:
5
+ POST /check → { id } registers the action
6
+ GET /wait/:id → { decision, reason? } blocks until approved / denied
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import time
16
+ import http.client
17
+ import urllib.error
18
+ import urllib.request
19
+ from typing import Any
20
+
21
+ from ._config import DAEMON_PORT
22
+ from ._exceptions import ActionDeniedException, DaemonNotFoundError
23
+
24
+ _DAEMON_BASE = f"http://127.0.0.1:{DAEMON_PORT}"
25
+ # The daemon auto-denies after ~55s; we wait slightly longer to get that response.
26
+ _CHECK_TIMEOUT = 5 # seconds to establish connection
27
+ _WAIT_TIMEOUT = 65 # seconds to wait for human decision
28
+
29
+ _CI_CONTEXT_MAX_BYTES = 10_000
30
+ _CI_CONTEXT_ALLOWED_KEYS = {
31
+ "tests_after", "files_changed", "issues_found", "issues_fixed",
32
+ "github_repository", "github_head_ref", "iteration",
33
+ "draft_pr_number", "draft_pr_url",
34
+ }
35
+ _REQUEST_ID_RE = re.compile(r'^[a-zA-Z0-9_\-]{1,128}$')
36
+
37
+
38
+ def _daemon_reachable() -> bool:
39
+ try:
40
+ req = urllib.request.Request(f"{_DAEMON_BASE}/settings", method="GET")
41
+ with urllib.request.urlopen(req, timeout=1.0):
42
+ return True
43
+ except urllib.error.URLError:
44
+ return False
45
+
46
+
47
+ def _auto_start_daemon() -> None:
48
+ """Opt-in: start the daemon in the background when NODE9_AUTO_START=1."""
49
+ if shutil.which("node9"):
50
+ cmd = ["node9", "daemon"]
51
+ elif shutil.which("npx"):
52
+ cmd = ["npx", "@node9/proxy", "daemon"]
53
+ else:
54
+ raise DaemonNotFoundError(DAEMON_PORT)
55
+ subprocess.Popen(
56
+ cmd,
57
+ stdout=subprocess.DEVNULL,
58
+ stderr=subprocess.DEVNULL,
59
+ )
60
+ for _ in range(10):
61
+ time.sleep(0.5)
62
+ if _daemon_reachable():
63
+ return
64
+ raise DaemonNotFoundError(DAEMON_PORT)
65
+
66
+
67
+ def _post(path: str, payload: dict) -> dict:
68
+ # default=str: safely serialize non-JSON-native objects (loggers, datetimes, etc.)
69
+ data = json.dumps(payload, default=str).encode()
70
+ req = urllib.request.Request(
71
+ f"{_DAEMON_BASE}{path}",
72
+ data=data,
73
+ headers={"Content-Type": "application/json"},
74
+ method="POST",
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=_CHECK_TIMEOUT) as resp:
78
+ return json.loads(resp.read())
79
+ except urllib.error.URLError:
80
+ raise DaemonNotFoundError(DAEMON_PORT)
81
+
82
+
83
+ def _get(path: str) -> dict:
84
+ req = urllib.request.Request(f"{_DAEMON_BASE}{path}", method="GET")
85
+ try:
86
+ with urllib.request.urlopen(req, timeout=_WAIT_TIMEOUT) as resp:
87
+ return json.loads(resp.read())
88
+ except (urllib.error.URLError, http.client.HTTPException):
89
+ # URLError = timeout / connection error
90
+ # HTTPException (incl. RemoteDisconnected) = daemon closed connection → treat as deny
91
+ return {"decision": "deny", "reason": "Node9 daemon connection timed out or closed."}
92
+
93
+
94
+ def _read_ci_context() -> dict | None:
95
+ """Read ~/.node9/ci-context.json if present (written by the CI agent before git push).
96
+ Size-capped and key-allowlisted so an attacker-controlled file cannot poison the payload."""
97
+ ci_context_path = os.path.join(os.path.expanduser("~"), ".node9", "ci-context.json")
98
+ try:
99
+ with open(ci_context_path, "rb") as f:
100
+ raw_bytes = f.read(_CI_CONTEXT_MAX_BYTES + 1)
101
+ if len(raw_bytes) > _CI_CONTEXT_MAX_BYTES:
102
+ return None
103
+ raw = json.loads(raw_bytes)
104
+ if not isinstance(raw, dict):
105
+ return None
106
+ return {k: v for k, v in raw.items() if k in _CI_CONTEXT_ALLOWED_KEYS}
107
+ except (OSError, json.JSONDecodeError, ValueError):
108
+ return None
109
+
110
+
111
+ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None:
112
+ """
113
+ Cloud routing: POST directly to node9 SaaS when NODE9_API_KEY is set.
114
+ Used in CI environments where the local daemon is not running.
115
+ """
116
+ api_key = os.environ.get("NODE9_API_KEY", "")
117
+ if not api_key:
118
+ raise RuntimeError("[Node9] NODE9_API_KEY is set but empty — cannot authenticate.")
119
+
120
+ api_url = os.environ.get("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept").rstrip("/")
121
+
122
+ if not api_url.startswith("https://"):
123
+ raise RuntimeError(
124
+ f"[Node9] NODE9_API_URL must use HTTPS to protect credentials (got: {api_url!r})"
125
+ )
126
+
127
+ payload: dict = {
128
+ "toolName": tool_name,
129
+ "args": args,
130
+ "context": {
131
+ "agent": "Python SDK",
132
+ "hostname": platform.node(),
133
+ "platform": platform.system().lower(),
134
+ "cwd": os.getcwd(),
135
+ },
136
+ }
137
+
138
+ ci_context = _read_ci_context()
139
+ if ci_context:
140
+ payload["ciContext"] = ci_context
141
+
142
+ data = json.dumps(payload, default=str).encode()
143
+ req = urllib.request.Request(
144
+ api_url,
145
+ data=data,
146
+ headers={
147
+ "Content-Type": "application/json",
148
+ "Authorization": f"Bearer {api_key}",
149
+ },
150
+ method="POST",
151
+ )
152
+ try:
153
+ with urllib.request.urlopen(req, timeout=_CHECK_TIMEOUT) as resp:
154
+ result = json.loads(resp.read())
155
+ except urllib.error.HTTPError as e:
156
+ body = ""
157
+ try:
158
+ body = e.read().decode("utf-8", errors="replace")[:500]
159
+ except Exception:
160
+ pass
161
+ raise RuntimeError(
162
+ f"[Node9] SaaS returned HTTP {e.code} {e.reason} — body: {body}"
163
+ ) from e
164
+ except urllib.error.URLError as e:
165
+ raise RuntimeError(f"[Node9] Failed to reach node9 SaaS: {e}") from e
166
+
167
+ if result.get("approved"):
168
+ return
169
+
170
+ if not result.get("pending"):
171
+ reason = result.get("reason", "Denied by Node9 policy")
172
+ raise ActionDeniedException(tool_name, reason)
173
+
174
+ request_id = result.get("requestId")
175
+ if not request_id:
176
+ raise RuntimeError(f"[Node9] Unexpected SaaS response: {result}")
177
+ if not _REQUEST_ID_RE.match(str(request_id)):
178
+ raise RuntimeError(f"[Node9] Invalid requestId format: {request_id!r}")
179
+
180
+ print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True)
181
+
182
+ poll_timeout = max(30, min(3600, int(os.environ.get("NODE9_CLOUD_TIMEOUT", "600"))))
183
+ status_url = f"{api_url}/status/{request_id}"
184
+ poll_deadline = time.time() + poll_timeout
185
+
186
+ while time.time() < poll_deadline:
187
+ time.sleep(1)
188
+ try:
189
+ poll_req = urllib.request.Request(
190
+ status_url,
191
+ headers={"Authorization": f"Bearer {api_key}"},
192
+ method="GET",
193
+ )
194
+ with urllib.request.urlopen(poll_req, timeout=5) as resp:
195
+ status_result = json.loads(resp.read())
196
+ except urllib.error.HTTPError as e:
197
+ if e.code in (401, 403):
198
+ raise RuntimeError(
199
+ f"[Node9] Authentication failed during polling (HTTP {e.code}) — check NODE9_API_KEY."
200
+ ) from e
201
+ continue
202
+ except (urllib.error.URLError, http.client.HTTPException):
203
+ continue
204
+
205
+ status = status_result.get("status", "").upper()
206
+ if status == "APPROVED":
207
+ return
208
+ if status in ("DENIED", "AUTO_BLOCKED", "TIMED_OUT", "FIX"):
209
+ reason = status_result.get("reason", "Denied by Node9 policy")
210
+ raise ActionDeniedException(tool_name, reason)
211
+
212
+ raise ActionDeniedException(tool_name, f"Cloud approval timed out after {poll_timeout}s.")
213
+
214
+
215
+ def evaluate(tool_name: str, args: dict[str, Any]) -> None:
216
+ """
217
+ Sends the action to the daemon and blocks until a decision is made.
218
+ Raises ActionDeniedException if the action is denied.
219
+ Does nothing if NODE9_SKIP=1 is set (unsafe bypass for testing).
220
+ Set NODE9_AUTO_START=1 to automatically launch the daemon if it's not running.
221
+ When NODE9_API_KEY is set, routes directly to node9 SaaS (no local daemon needed).
222
+ """
223
+ if os.environ.get("NODE9_SKIP") == "1":
224
+ return
225
+
226
+ if os.environ.get("NODE9_API_KEY"):
227
+ _evaluate_cloud(tool_name, args)
228
+ return
229
+
230
+ if os.environ.get("NODE9_AUTO_START") == "1" and not _daemon_reachable():
231
+ _auto_start_daemon()
232
+
233
+ result = _post("/check", {"toolName": tool_name, "args": args, "cwd": os.getcwd(), "agent": "Python SDK"})
234
+ request_id = result.get("id")
235
+ if not request_id:
236
+ raise RuntimeError(f"[Node9] Unexpected daemon response: {result}")
237
+
238
+ print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True)
239
+ decision_result = _get(f"/wait/{request_id}")
240
+ decision = decision_result.get("decision", "deny")
241
+
242
+ if decision != "allow":
243
+ reason = decision_result.get("reason", "Denied by Node9 policy")
244
+ raise ActionDeniedException(tool_name, reason)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "node9"
7
- version = "1.1.1"
7
+ version = "1.1.2"
8
8
  description = "Execution security for Python AI agents — seatbelt for LangChain, CrewAI, and plain Python."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -1,108 +0,0 @@
1
- """
2
- Thin HTTP client — talks to the local Node9 daemon on localhost:7391.
3
-
4
- Flow:
5
- POST /check → { id } registers the action
6
- GET /wait/:id → { decision, reason? } blocks until approved / denied
7
- """
8
-
9
- import json
10
- import os
11
- import shutil
12
- import subprocess
13
- import time
14
- import http.client
15
- import urllib.error
16
- import urllib.request
17
- from typing import Any
18
-
19
- from ._config import DAEMON_PORT
20
- from ._exceptions import ActionDeniedException, DaemonNotFoundError
21
-
22
- _DAEMON_BASE = f"http://127.0.0.1:{DAEMON_PORT}"
23
- # The daemon auto-denies after ~55s; we wait slightly longer to get that response.
24
- _CHECK_TIMEOUT = 5 # seconds to establish connection
25
- _WAIT_TIMEOUT = 65 # seconds to wait for human decision
26
-
27
-
28
- def _daemon_reachable() -> bool:
29
- try:
30
- req = urllib.request.Request(f"{_DAEMON_BASE}/settings", method="GET")
31
- with urllib.request.urlopen(req, timeout=1.0):
32
- return True
33
- except urllib.error.URLError:
34
- return False
35
-
36
-
37
- def _auto_start_daemon() -> None:
38
- """Opt-in: start the daemon in the background when NODE9_AUTO_START=1."""
39
- if shutil.which("node9"):
40
- cmd = ["node9", "daemon"]
41
- elif shutil.which("npx"):
42
- cmd = ["npx", "@node9/proxy", "daemon"]
43
- else:
44
- raise DaemonNotFoundError(DAEMON_PORT)
45
- subprocess.Popen(
46
- cmd,
47
- stdout=subprocess.DEVNULL,
48
- stderr=subprocess.DEVNULL,
49
- )
50
- for _ in range(10):
51
- time.sleep(0.5)
52
- if _daemon_reachable():
53
- return
54
- raise DaemonNotFoundError(DAEMON_PORT)
55
-
56
-
57
- def _post(path: str, payload: dict) -> dict:
58
- # default=str: safely serialize non-JSON-native objects (loggers, datetimes, etc.)
59
- data = json.dumps(payload, default=str).encode()
60
- req = urllib.request.Request(
61
- f"{_DAEMON_BASE}{path}",
62
- data=data,
63
- headers={"Content-Type": "application/json"},
64
- method="POST",
65
- )
66
- try:
67
- with urllib.request.urlopen(req, timeout=_CHECK_TIMEOUT) as resp:
68
- return json.loads(resp.read())
69
- except urllib.error.URLError:
70
- raise DaemonNotFoundError(DAEMON_PORT)
71
-
72
-
73
- def _get(path: str) -> dict:
74
- req = urllib.request.Request(f"{_DAEMON_BASE}{path}", method="GET")
75
- try:
76
- with urllib.request.urlopen(req, timeout=_WAIT_TIMEOUT) as resp:
77
- return json.loads(resp.read())
78
- except (urllib.error.URLError, http.client.HTTPException):
79
- # URLError = timeout / connection error
80
- # HTTPException (incl. RemoteDisconnected) = daemon closed connection → treat as deny
81
- return {"decision": "deny", "reason": "Node9 daemon connection timed out or closed."}
82
-
83
-
84
- def evaluate(tool_name: str, args: dict[str, Any]) -> None:
85
- """
86
- Sends the action to the daemon and blocks until a decision is made.
87
- Raises ActionDeniedException if the action is denied.
88
- Does nothing if NODE9_SKIP=1 is set (unsafe bypass for testing).
89
- Set NODE9_AUTO_START=1 to automatically launch the daemon if it's not running.
90
- """
91
- if os.environ.get("NODE9_SKIP") == "1":
92
- return
93
-
94
- if os.environ.get("NODE9_AUTO_START") == "1" and not _daemon_reachable():
95
- _auto_start_daemon()
96
-
97
- result = _post("/check", {"toolName": tool_name, "args": args, "cwd": os.getcwd(), "agent": "Python SDK"})
98
- request_id = result.get("id")
99
- if not request_id:
100
- raise RuntimeError(f"[Node9] Unexpected daemon response: {result}")
101
-
102
- print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True)
103
- decision_result = _get(f"/wait/{request_id}")
104
- decision = decision_result.get("decision", "deny")
105
-
106
- if decision != "allow":
107
- reason = decision_result.get("reason", "Denied by Node9 policy")
108
- raise ActionDeniedException(tool_name, reason)
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