Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 2 additions & 12 deletions node9/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
136 changes: 136 additions & 0 deletions node9/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import json
import os
import platform
import re
import shutil
import subprocess
import time
Expand All @@ -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:
Expand Down Expand Up @@ -81,16 +91,142 @@ 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).
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:
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
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}
except (OSError, json.JSONDecodeError, ValueError):
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", "")
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": platform.node(),
"platform": platform.system().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}")
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_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

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.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

status = status_result.get("status", "").upper()
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, f"Cloud approval timed out after {poll_timeout}s.")


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()

Expand Down
Loading