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.
- {node9-1.1.1 → node9-1.1.2}/.github/workflows/ai-review.yml +3 -0
- {node9-1.1.1 → node9-1.1.2}/CHANGELOG.md +8 -0
- {node9-1.1.1 → node9-1.1.2}/PKG-INFO +1 -1
- {node9-1.1.1 → node9-1.1.2}/node9/__init__.py +2 -12
- node9-1.1.2/node9/_client.py +244 -0
- {node9-1.1.1 → node9-1.1.2}/pyproject.toml +1 -1
- node9-1.1.1/node9/_client.py +0 -108
- {node9-1.1.1 → node9-1.1.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {node9-1.1.1 → node9-1.1.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {node9-1.1.1 → node9-1.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {node9-1.1.1 → node9-1.1.2}/.github/workflows/auto-pr.yml +0 -0
- {node9-1.1.1 → node9-1.1.2}/.github/workflows/ci.yml +0 -0
- {node9-1.1.1 → node9-1.1.2}/.github/workflows/release.yml +0 -0
- {node9-1.1.1 → node9-1.1.2}/.gitignore +0 -0
- {node9-1.1.1 → node9-1.1.2}/LICENSE +0 -0
- {node9-1.1.1 → node9-1.1.2}/README.md +0 -0
- {node9-1.1.1 → node9-1.1.2}/conftest.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/examples/basic.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/examples/crewai_agent.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/examples/langchain_agent.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/node9/_config.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/node9/_decorator.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/node9/_exceptions.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/scripts/ai-review.mjs +0 -0
- {node9-1.1.1 → node9-1.1.2}/scripts/e2e.sh +0 -0
- {node9-1.1.1 → node9-1.1.2}/tests/test_client.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/tests/test_config.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/tests/test_decorator.py +0 -0
- {node9-1.1.1 → node9-1.1.2}/tests/test_exceptions.py +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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" }
|
node9-1.1.1/node9/_client.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|