From a79124bb7cac70bae95cc0fa058bbe8f26224a33 Mon Sep 17 00:00:00 2001 From: node9 Date: Sat, 4 Apr 2026 03:02:37 +0300 Subject: [PATCH 1/3] sdk adding error handeling --- .github/workflows/ai-review.yml | 3 + node9/__init__.py | 14 +---- node9/_client.py | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 2c59310..e52ab62 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -3,6 +3,9 @@ name: AI Code Review on: pull_request: branches: [main] + paths-ignore: + - '.github/workflows/ai-review.yml' + - 'scripts/ai-review.mjs' jobs: review: diff --git a/node9/__init__.py b/node9/__init__.py index ff27f9e..e1f7dc7 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -1,20 +1,10 @@ """ node9 — Execution security for Python AI agents. - -Quick start: - from node9 import protect - - @protect("write_file") - def write_file(path: str, content: str): - ... - - @protect("bash") - def run_shell(cmd: str): - ... +Bundled version with CI cloud routing support (NODE9_API_KEY). """ from ._decorator import protect from ._exceptions import ActionDeniedException, DaemonNotFoundError __all__ = ["protect", "ActionDeniedException", "DaemonNotFoundError"] -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/node9/_client.py b/node9/_client.py index f8dc1b7..7bd05e8 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -81,16 +81,121 @@ def _get(path: str) -> dict: return {"decision": "deny", "reason": "Node9 daemon connection timed out or closed."} +def _read_ci_context() -> dict | None: + """Read ~/.node9/ci-context.json if present (written by the CI agent before git push).""" + ci_context_path = os.path.join(os.path.expanduser("~"), ".node9", "ci-context.json") + try: + with open(ci_context_path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None + + +def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: + """ + Cloud routing: POST directly to node9 SaaS when NODE9_API_KEY is set. + Used in CI environments where the local daemon is not running. + """ + api_key = os.environ.get("NODE9_API_KEY", "") + # NODE9_API_URL should point to the intercept endpoint, e.g.: + # https://api.node9.ai/api/v1/intercept + api_url = os.environ.get("NODE9_API_URL", "https://dev-api.node9.ai/api/v1/intercept") + + payload: dict = { + "toolName": tool_name, + "args": args, + "context": { + "agent": "Python SDK", + "hostname": os.uname().nodename, + "platform": os.uname().sysname.lower(), + "cwd": os.getcwd(), + }, + } + + ci_context = _read_ci_context() + if ci_context: + payload["ciContext"] = ci_context + + data = json.dumps(payload, default=str).encode() + req = urllib.request.Request( + api_url, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=_CHECK_TIMEOUT) as resp: + result = json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = "" + try: + body = e.read().decode("utf-8", errors="replace")[:500] + except Exception: + pass + raise RuntimeError( + f"[Node9] SaaS returned HTTP {e.code} {e.reason} — body: {body}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"[Node9] Failed to reach node9 SaaS: {e}") from e + + if result.get("approved"): + return + + if not result.get("pending"): + reason = result.get("reason", "Denied by Node9 policy") + raise ActionDeniedException(tool_name, reason) + + request_id = result.get("requestId") + if not request_id: + raise RuntimeError(f"[Node9] Unexpected SaaS response: {result}") + + print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True) + + # Poll SaaS /intercept/status/:id until decided + status_url = f"{api_url}/status/{request_id}" + poll_deadline = time.time() + 10 * 60 + + while time.time() < poll_deadline: + time.sleep(1) + try: + poll_req = urllib.request.Request( + status_url, + headers={"Authorization": f"Bearer {api_key}"}, + method="GET", + ) + with urllib.request.urlopen(poll_req, timeout=5) as resp: + status_result = json.loads(resp.read()) + except (urllib.error.URLError, http.client.HTTPException): + continue + + status = status_result.get("status") + if status == "APPROVED": + return + if status in ("DENIED", "AUTO_BLOCKED", "TIMED_OUT", "FIX"): + reason = status_result.get("reason", "Denied by Node9 policy") + raise ActionDeniedException(tool_name, reason) + + raise ActionDeniedException(tool_name, "Cloud approval timed out after 10 minutes.") + + def evaluate(tool_name: str, args: dict[str, Any]) -> None: """ Sends the action to the daemon and blocks until a decision is made. Raises ActionDeniedException if the action is denied. Does nothing if NODE9_SKIP=1 is set (unsafe bypass for testing). Set NODE9_AUTO_START=1 to automatically launch the daemon if it's not running. + When NODE9_API_KEY is set, routes directly to node9 SaaS (no local daemon needed). """ if os.environ.get("NODE9_SKIP") == "1": return + if os.environ.get("NODE9_API_KEY"): + _evaluate_cloud(tool_name, args) + return + if os.environ.get("NODE9_AUTO_START") == "1" and not _daemon_reachable(): _auto_start_daemon() From 1460262a265936189bb254776680094a2c3aad25 Mon Sep 17 00:00:00 2001 From: node9 Date: Sat, 4 Apr 2026 03:09:31 +0300 Subject: [PATCH 2/3] sdk adding error handeling --- node9/_client.py | 52 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/node9/_client.py b/node9/_client.py index 7bd05e8..b2cd01f 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -8,6 +8,8 @@ import json import os +import platform +import re import shutil import subprocess import time @@ -24,6 +26,14 @@ _CHECK_TIMEOUT = 5 # seconds to establish connection _WAIT_TIMEOUT = 65 # seconds to wait for human decision +_CI_CONTEXT_MAX_BYTES = 10_000 +_CI_CONTEXT_ALLOWED_KEYS = { + "tests_after", "files_changed", "issues_found", "issues_fixed", + "github_repository", "github_head_ref", "iteration", + "draft_pr_number", "draft_pr_url", +} +_REQUEST_ID_RE = re.compile(r'^[a-zA-Z0-9_\-]{1,128}$') + def _daemon_reachable() -> bool: try: @@ -82,12 +92,18 @@ def _get(path: str) -> dict: def _read_ci_context() -> dict | None: - """Read ~/.node9/ci-context.json if present (written by the CI agent before git push).""" + """Read ~/.node9/ci-context.json if present (written by the CI agent before git push). + Size-capped and key-allowlisted so an attacker-controlled file cannot poison the payload.""" ci_context_path = os.path.join(os.path.expanduser("~"), ".node9", "ci-context.json") try: + if os.path.getsize(ci_context_path) > _CI_CONTEXT_MAX_BYTES: + return None with open(ci_context_path) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): + raw = json.load(f) + if not isinstance(raw, dict): + return None + return {k: v for k, v in raw.items() if k in _CI_CONTEXT_ALLOWED_KEYS} + except (OSError, json.JSONDecodeError, ValueError): return None @@ -97,17 +113,23 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: Used in CI environments where the local daemon is not running. """ api_key = os.environ.get("NODE9_API_KEY", "") - # NODE9_API_URL should point to the intercept endpoint, e.g.: - # https://api.node9.ai/api/v1/intercept - api_url = os.environ.get("NODE9_API_URL", "https://dev-api.node9.ai/api/v1/intercept") + if not api_key: + raise RuntimeError("[Node9] NODE9_API_KEY is set but empty — cannot authenticate.") + + api_url = os.environ.get("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept").rstrip("/") + + if not api_url.startswith("https://"): + raise RuntimeError( + f"[Node9] NODE9_API_URL must use HTTPS to protect credentials (got: {api_url!r})" + ) payload: dict = { "toolName": tool_name, "args": args, "context": { "agent": "Python SDK", - "hostname": os.uname().nodename, - "platform": os.uname().sysname.lower(), + "hostname": platform.node(), + "platform": platform.system().lower(), "cwd": os.getcwd(), }, } @@ -151,12 +173,14 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: request_id = result.get("requestId") if not request_id: raise RuntimeError(f"[Node9] Unexpected SaaS response: {result}") + if not _REQUEST_ID_RE.match(str(request_id)): + raise RuntimeError(f"[Node9] Invalid requestId format: {request_id!r}") print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True) - # Poll SaaS /intercept/status/:id until decided + poll_timeout = int(os.environ.get("NODE9_CLOUD_TIMEOUT", "600")) status_url = f"{api_url}/status/{request_id}" - poll_deadline = time.time() + 10 * 60 + poll_deadline = time.time() + poll_timeout while time.time() < poll_deadline: time.sleep(1) @@ -168,6 +192,12 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: ) with urllib.request.urlopen(poll_req, timeout=5) as resp: status_result = json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise RuntimeError( + f"[Node9] Authentication failed during polling (HTTP {e.code}) — check NODE9_API_KEY." + ) from e + continue except (urllib.error.URLError, http.client.HTTPException): continue @@ -178,7 +208,7 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: reason = status_result.get("reason", "Denied by Node9 policy") raise ActionDeniedException(tool_name, reason) - raise ActionDeniedException(tool_name, "Cloud approval timed out after 10 minutes.") + raise ActionDeniedException(tool_name, f"Cloud approval timed out after {poll_timeout}s.") def evaluate(tool_name: str, args: dict[str, Any]) -> None: From 523aff3e9073d2ccd058edc0e01add25f27448da Mon Sep 17 00:00:00 2001 From: node9 Date: Sat, 4 Apr 2026 03:14:31 +0300 Subject: [PATCH 3/3] sdk adding error handeling --- node9/_client.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/node9/_client.py b/node9/_client.py index b2cd01f..3ad8838 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -96,10 +96,11 @@ def _read_ci_context() -> dict | None: Size-capped and key-allowlisted so an attacker-controlled file cannot poison the payload.""" ci_context_path = os.path.join(os.path.expanduser("~"), ".node9", "ci-context.json") try: - if os.path.getsize(ci_context_path) > _CI_CONTEXT_MAX_BYTES: + with open(ci_context_path, "rb") as f: + raw_bytes = f.read(_CI_CONTEXT_MAX_BYTES + 1) + if len(raw_bytes) > _CI_CONTEXT_MAX_BYTES: return None - with open(ci_context_path) as f: - raw = json.load(f) + raw = json.loads(raw_bytes) if not isinstance(raw, dict): return None return {k: v for k, v in raw.items() if k in _CI_CONTEXT_ALLOWED_KEYS} @@ -178,7 +179,7 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True) - poll_timeout = int(os.environ.get("NODE9_CLOUD_TIMEOUT", "600")) + poll_timeout = max(30, min(3600, int(os.environ.get("NODE9_CLOUD_TIMEOUT", "600")))) status_url = f"{api_url}/status/{request_id}" poll_deadline = time.time() + poll_timeout @@ -201,7 +202,7 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: except (urllib.error.URLError, http.client.HTTPException): continue - status = status_result.get("status") + status = status_result.get("status", "").upper() if status == "APPROVED": return if status in ("DENIED", "AUTO_BLOCKED", "TIMED_OUT", "FIX"):