From dd122b42a886039e54789ef46fcdc09b86040d65 Mon Sep 17 00:00:00 2001 From: node9 Date: Sat, 4 Apr 2026 13:18:31 +0300 Subject: [PATCH 01/19] python sdk --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21d019e..b3d6b71 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # node9-python -Execution security for Python AI agents — one decorator, zero config. +Execution security for Python AI agents, one decorator, zero config. Works with any framework: plain Python, LangChain, CrewAI, LangGraph, or custom agents. From aebd5887c355967c9b7630403e88c4ea9af8afdd Mon Sep 17 00:00:00 2001 From: node9 Date: Mon, 6 Apr 2026 23:14:59 +0300 Subject: [PATCH 02/19] redesign --- README.md | 226 +++++++++++++++++++++----------- manual_test.py | 129 +++++++++++++++++++ node9/__init__.py | 36 +++++- node9/_agent.py | 259 +++++++++++++++++++++++++++++++++++++ node9/_client.py | 72 +++++++++-- node9/_config.py | 5 +- node9/_dlp.py | 61 +++++++++ tests/test_agent.py | 301 +++++++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 161 ++++++++++++++++++++++- tests/test_dlp.py | 172 +++++++++++++++++++++++++ 10 files changed, 1327 insertions(+), 95 deletions(-) create mode 100644 manual_test.py create mode 100644 node9/_agent.py create mode 100644 node9/_dlp.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_dlp.py diff --git a/README.md b/README.md index b3d6b71..bd31a11 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # node9-python -Execution security for Python AI agents, one decorator, zero config. +Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. -Works with any framework: plain Python, LangChain, CrewAI, LangGraph, or custom agents. +Works two ways: +- **`@protect`** — add governance to any existing agent (LangChain, CrewAI, AutoGen, plain Python) +- **`Node9Agent`** — build a governed agent from scratch with tools, DLP, and audit built-in ## Install @@ -10,15 +12,23 @@ Works with any framework: plain Python, LangChain, CrewAI, LangGraph, or custom pip install node9 ``` -## Quick Start +## Routing -**1. Start the Node9 daemon** (ships with `@node9/proxy`): +node9 automatically routes to the right backend: -```bash -npx @node9/proxy daemon -``` +| Environment | Routing | +|---|---| +| `NODE9_API_KEY` set | → node9 SaaS (cloud / CI — no local daemon needed) | +| Local daemon running | → node9-proxy on `localhost:7391` | +| Neither | → offline audit log at `~/.node9/audit.log` (auto-approve, never blocks) | + +No config required — it just works wherever your agent runs. + +--- -**2. Add `@protect` to any function your agent calls:** +## Option 1 — `@protect`: Add governance to any agent + +Drop `@protect` on any function your agent calls. node9 intercepts the call, logs it, and enforces policy before the function runs. ```python from node9 import protect, ActionDeniedException @@ -33,79 +43,36 @@ def run_shell(command: str) -> str: import subprocess return subprocess.check_output(command, shell=True, text=True) -# When your agent calls this, Node9 intercepts it and asks for approval. -# The call blocks until a human approves or denies — in the dashboard or Slack. try: - write_file("/etc/hosts", "malicious content") + write_file("/etc/hosts", "bad content") except ActionDeniedException as e: print(f"Blocked: {e}") ``` -That's it. All function arguments are captured automatically — no config needed. - -## How It Works +Works with `async def` out of the box. -``` -Agent calls write_file() - ↓ - @protect intercepts - ↓ - POST /check → Node9 daemon (localhost:7391) - ↓ - Daemon shows approval popup / sends Slack message - ↓ - Human approves or denies - ↓ - Function runs (or ActionDeniedException is raised) -``` - -## Async Support - -`@protect` works with `async def` out of the box. The blocking HTTP call runs in a thread so it never freezes your event loop: +### Set agent identity (optional but recommended) ```python -@protect("write_file") -async def write_file(path: str, content: str) -> str: - async with aiofiles.open(path, "w") as f: - await f.write(content) - return f"Written to {path}" -``` - -This makes it compatible with LangGraph, FastMCP, and any other async agent framework. - -## Custom Tool Name +from node9 import configure -By default, the tool name sent to Node9 is the function name. Override it: - -```python -@protect("postgres_query") -def execute_sql(sql: str, db: str = "prod") -> list: - ... +configure(agent_name="my-langchain-agent", policy="audit") ``` -## Custom Params - -Control exactly what gets sent to the approval UI: - -```python -@protect("deploy", params=lambda service, env="prod", **_: {"service": service, "env": env}) -def deploy(service: str, env: str = "prod", dry_run: bool = False) -> str: - ... +Or via environment variables: +```bash +NODE9_AGENT_NAME=my-langchain-agent +NODE9_AGENT_POLICY=audit ``` -## Handling Denials in LLM Feedback Loops +### Policy values -`ActionDeniedException` has a `negotiation` property — a ready-made string you can feed back to the LLM so it can try a different approach instead of crashing: - -```python -try: - delete_file("/etc/hosts") -except ActionDeniedException as e: - # e.negotiation = "Action 'delete_file' was blocked by Node9: Too dangerous. Choose a different approach." - response = llm.invoke(e.negotiation) -``` - -## Framework Examples +| Policy | Behaviour | +|---|---| +| `audit` | Log everything, auto-approve. Never blocks. Good for CI. | +| `require_approval` | Block + notify human. Good for production actions. | +| `block_on_rules` | Auto-block if rules match, audit otherwise. | +| _(empty)_ | SaaS default behaviour. | ### LangChain @@ -139,17 +106,130 @@ def write_file(path: str, content: str) -> str: return f"Written to {path}" ``` -See [`examples/`](examples/) for full runnable examples. +See [`examples/`](examples/) for full runnable examples including AutoGen and LangGraph. + +--- + +## Option 2 — `Node9Agent`: Build a governed agent from scratch + +`Node9Agent` is a governance base class — DLP, path safety, audit, and tool dispatch built-in. It does **not** include an LLM loop; that is your framework's responsibility. This keeps the SDK framework-agnostic with zero dependencies. + +```python +import anthropic +from node9 import Node9Agent, tool, internal + +class CiAgent(Node9Agent): + agent_name = "ci-code-review" + policy = "audit" + + @tool("run_tests") + def run_tests(self, command: str) -> str: + """Run the test suite and return output.""" + import subprocess + return subprocess.check_output(command, shell=True, text=True) + + @tool("write_code") + def write_code(self, filename: str, content: str) -> str: + """Write content to a file in the workspace.""" + with open(filename, "w") as f: + f.write(content) + return f"Written {filename}" + + @internal + def _git_push(self, branch: str) -> str: + """Push to remote — infrastructure, not a governed action.""" + import subprocess + subprocess.run(["git", "push", "origin", branch], check=True) + return f"Pushed {branch}" + +agent = CiAgent(workspace="/path/to/repo") +client = anthropic.Anthropic() + +# Get tool specs in the format your LLM expects +tools = agent.build_tools_anthropic() # → input_schema format +# tools = agent.build_tools_openai() # → {type: function, function: {...}} +# tools = agent._build_tools() # → neutral (parameters key) + +# Your LLM loop — use whichever client you want +messages = [{"role": "user", "content": "Fix the failing tests in this diff: ..."}] +while True: + response = client.messages.create(model="claude-opus-4-6", tools=tools, messages=messages) + messages.append({"role": "assistant", "content": response.content}) + if response.stop_reason != "tool_use": + break + results = [] + for block in response.content: + if block.type == "tool_use": + result = agent._dispatch(block.name, block.input) # DLP + audit happen here + results.append({"type": "tool_result", "tool_use_id": block.id, "content": result}) + messages.append({"role": "user", "content": results}) +``` + +See [`examples/`](examples/) for complete runnable implementations per framework. + +### What `@tool` does automatically + +Every `@tool`-decorated method, before the function runs: +1. **DLP scan** — blocks if `filename` or `content` contains a secret or sensitive path +2. **Path safety** — rejects `../` traversal attempts, raises `ActionDeniedException` +3. **Audit / approval** — calls `evaluate()` which respects the agent's `policy` +4. **Run ID** — injects a UUID grouping all tool calls from one session in the dashboard + +### What `@internal` does + +`@internal` is for git operations, workspace setup, and other infrastructure: +- Never calls `evaluate()` — no SaaS call, no blocking +- Logs locally only: `[node9 internal] _git_push(branch='main')` + +### Tool specs are auto-generated + +`Node9Agent` introspects `@tool` methods and builds tool specs automatically — parameter names, types from annotations, and descriptions from docstrings. No manual schema writing. + +--- + +## DLP and path safety as standalone utilities + +```python +from node9 import dlp_scan, safe_path + +# Scan content for secrets before writing to disk +hit = dlp_scan("output.txt", content) +if hit: + raise ValueError(f"Blocked: {hit}") + +# Resolve a path safely within a workspace directory +path = safe_path("src/main.py", workspace="/tmp/repo") +``` + +Patterns detected: AWS keys, GitHub tokens, Slack tokens, OpenAI keys, Stripe keys, PEM private keys, GCP service accounts, NPM auth tokens, Anthropic keys, and sensitive file paths (`.ssh`, `.aws`, `.env`, `.kube`, etc.). + +--- + +## Handling denials in LLM feedback loops + +`ActionDeniedException` has a `negotiation` property — feed it back to the LLM so it can try a different approach: + +```python +try: + agent._dispatch("delete_file", {"path": "/etc/hosts"}) +except ActionDeniedException as e: + # e.negotiation = "Action 'delete_file' was blocked by Node9: policy. Choose a different approach." + response = llm.invoke(e.negotiation) +``` + +--- -## Environment Variables +## Environment variables | Variable | Default | Description | |---|---|---| -| `NODE9_DAEMON_PORT` | `7391` | Daemon port | -| `NODE9_AUTO_START` | — | Set to `1` to auto-launch the daemon if it's not running | -| `NODE9_SKIP` | — | Set to `1` to bypass all checks (unsafe — for tests only) | +| `NODE9_API_KEY` | — | Routes to node9 SaaS. Required for cloud / CI. | +| `NODE9_AGENT_NAME` | — | Agent identity — appears in audit logs and dashboard. | +| `NODE9_AGENT_POLICY` | — | `audit`, `require_approval`, or `block_on_rules`. | +| `NODE9_DAEMON_PORT` | `7391` | Local daemon port. | +| `NODE9_AUTO_START` | — | Set to `1` to auto-launch the local daemon if not running. | +| `NODE9_SKIP` | — | Set to `1` to bypass all checks. Unsafe — for unit tests only. | ## License Apache-2.0 - diff --git a/manual_test.py b/manual_test.py new file mode 100644 index 0000000..d79743d --- /dev/null +++ b/manual_test.py @@ -0,0 +1,129 @@ +""" +Manual smoke test for node9 SDK. +Run in different modes to test all three routing paths. + +Usage: + # 1. Offline mode (no daemon, no API key) + python3 manual_test.py + + # 2. Local daemon mode (start daemon first: npx @node9/proxy daemon) + python3 manual_test.py --daemon + + # 3. Cloud mode + NODE9_API_KEY=sk-... python3 manual_test.py +""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from node9 import protect, configure, Node9Agent, tool, internal, dlp_scan, safe_path, ActionDeniedException + +print("\n=== node9 SDK manual test ===\n") + +# ── 1. Routing info ─────────────────────────────────────────────────────────── +if os.environ.get("NODE9_API_KEY"): + print("Mode: CLOUD (NODE9_API_KEY set)") +elif "--daemon" in sys.argv: + print("Mode: LOCAL DAEMON") +else: + print("Mode: OFFLINE (no daemon, no API key)") +print() + +# ── 2. configure() ─────────────────────────────────────────────────────────── +print("--- configure() ---") +configure(agent_name="manual-test", policy="audit") +import node9._config as cfg +print(f" agent_name = {cfg.AGENT_NAME!r}") +print(f" policy = {cfg.AGENT_POLICY!r}") +print() + +# ── 3. @protect basic call ─────────────────────────────────────────────────── +print("--- @protect ---") + +@protect("write_file") +def write_file(path: str, content: str) -> str: + return f"written:{path}" + +result = write_file("/tmp/test.txt", "hello") +print(f" write_file result: {result}") +print() + +# ── 4. DLP scan ────────────────────────────────────────────────────────────── +print("--- dlp_scan ---") +clean = dlp_scan("output.txt", "def hello(): pass") +print(f" clean content: {clean!r} (expect None)") + +sensitive_path = dlp_scan("/home/user/.ssh/id_rsa", "content") +print(f" sensitive path: {sensitive_path!r} (expect block reason)") +print() + +# ── 5. safe_path ───────────────────────────────────────────────────────────── +print("--- safe_path ---") +with tempfile.TemporaryDirectory() as workspace: + resolved = safe_path("src/main.py", workspace) + print(f" safe_path resolved: {resolved}") + try: + safe_path("../../etc/passwd", workspace) + print(" traversal: NOT blocked (BUG)") + except ValueError as e: + print(f" traversal blocked: {e}") +print() + +# ── 6. Node9Agent ──────────────────────────────────────────────────────────── +print("--- Node9Agent ---") + +with tempfile.TemporaryDirectory() as workspace: + class TestAgent(Node9Agent): + agent_name = "manual-test-agent" + policy = "audit" + + @tool("echo") + def echo(self, message: str) -> str: + """Echo the message back.""" + return f"echo:{message}" + + @tool("write_file") + def write_file(self, filename: str, content: str) -> str: + """Write content to a file.""" + import pathlib + (pathlib.Path(self._workspace) / filename).write_text(content) + return f"written:{filename}" + + @internal + def _setup(self, branch: str) -> str: + return f"setup:{branch}" + + agent = TestAgent(workspace=workspace) + print(f" run_id: {agent._run_id}") + print(f" workspace: {agent._workspace}") + + # @tool call + result = agent.echo("hello node9") + print(f" @tool echo: {result}") + + # @internal call (no evaluate) + result = agent._setup("main") + print(f" @internal _setup: {result}") + + # DLP block via @tool + try: + agent.write_file("/home/user/.ssh/id_rsa", "content") + print(" DLP: NOT blocked (BUG)") + except ActionDeniedException as e: + print(f" DLP blocked: {e.tool_name} — {e.reason[:50]}") + + # Path traversal block via @tool + try: + agent.write_file("../../etc/passwd", "content") + print(" Traversal: NOT blocked (BUG)") + except ActionDeniedException as e: + print(f" Traversal blocked: {e.tool_name} — {e.reason[:50]}") + + # _build_tools + tools = agent._build_tools() + print(f" _build_tools: {[t['name'] for t in tools]}") + +print() +print("=== all checks passed ===\n") diff --git a/node9/__init__.py b/node9/__init__.py index e1f7dc7..d369125 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -1,10 +1,40 @@ """ node9 — Execution security for Python AI agents. -Bundled version with CI cloud routing support (NODE9_API_KEY). """ from ._decorator import protect from ._exceptions import ActionDeniedException, DaemonNotFoundError +from ._dlp import dlp_scan, safe_path +from ._agent import Node9Agent, tool, internal +from . import _config -__all__ = ["protect", "ActionDeniedException", "DaemonNotFoundError"] -__version__ = "0.1.1" + +def configure(*, agent_name: str = "", policy: str = "") -> None: + """ + Set agent identity at runtime. Alternative to NODE9_AGENT_NAME / NODE9_AGENT_POLICY env vars. + Call before the first evaluate() / @protect / agent._dispatch(). + + policy values: "audit" | "require_approval" | "block_on_rules" | "" (SaaS default) + """ + if agent_name: + _config.AGENT_NAME = agent_name + if policy: + _config.AGENT_POLICY = policy + + +__all__ = [ + # Core + "protect", + "configure", + # Agent framework + "Node9Agent", + "tool", + "internal", + # DLP utilities + "dlp_scan", + "safe_path", + # Exceptions + "ActionDeniedException", + "DaemonNotFoundError", +] +__version__ = "2.0.0" diff --git a/node9/_agent.py b/node9/_agent.py new file mode 100644 index 0000000..3ace49e --- /dev/null +++ b/node9/_agent.py @@ -0,0 +1,259 @@ +""" +Node9Agent — governance base class for AI agents. + +Provides @tool and @internal decorators plus the Node9Agent base class. +Does NOT include an LLM loop — that is the framework's responsibility. + +The LLM loop lives in the agent that subclasses Node9Agent. This keeps the +SDK framework-agnostic and dependency-free (zero imports beyond stdlib). + +Usage: + from node9 import Node9Agent, tool, internal + + class CiAgent(Node9Agent): + agent_name = "ci-code-review" + policy = "audit" + + @tool("run_tests") + def run_tests(self, command: str) -> str: + import subprocess + return subprocess.check_output(command, shell=True, text=True) + + @tool("write_code") + def write_code(self, filename: str, content: str) -> str: + with open(filename, "w") as f: + f.write(content) + return f"written:{filename}" + + @internal + def _git_push(self, branch: str) -> str: + # never calls evaluate() — infrastructure only + import subprocess + subprocess.run(["git", "push", "origin", branch], check=True) + return f"pushed:{branch}" + + agent = CiAgent(workspace="/path/to/repo") + + # The LLM loop is YOUR code — use whichever framework you want: + # Anthropic: tools = agent.build_tools_anthropic() + # OpenAI: tools = agent.build_tools_openai() + # Custom: tools = agent._build_tools() # neutral format + # + # Dispatch tool calls from the LLM response: + # result = agent._dispatch(tool_name, tool_input) +""" + +import functools +import inspect +import os +import uuid +from typing import Any, Callable + +from ._client import evaluate +from ._dlp import dlp_scan, safe_path +from ._exceptions import ActionDeniedException + +# Marker attributes written by decorators so Node9Agent can introspect methods +_TOOL_ATTR = "_node9_tool" +_INTERNAL_ATTR = "_node9_internal" + + +def tool(tool_name: str | Callable): + """ + Marks a Node9Agent method as a governed tool. + + Before every call: + - DLP scan — blocks if filename/content contains a secret or sensitive path + - Path safety — rejects ../traversal attempts + - evaluate() — respects the agent's declared policy (audit / require_approval / etc.) + - run_id — injected automatically so all calls in one run are grouped in the dashboard + + Can be used as @tool or @tool("custom_name"). + """ + def decorator(fn: Callable, name: str) -> Callable: + @functools.wraps(fn) + def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: + sig = inspect.signature(fn) + bound = sig.bind(self, *args, **kwargs) + bound.apply_defaults() + call_args = {k: v for k, v in bound.arguments.items() if k != "self"} + + # DLP scan + filename = call_args.get("filename") or call_args.get("path") or "" + content = call_args.get("content") or "" + if filename or content: + hit = dlp_scan(str(filename), str(content)) + if hit: + raise ActionDeniedException(name, f"DLP blocked: {hit}") + + # Path safety + if filename and hasattr(self, "_workspace") and self._workspace: + try: + safe_path(str(filename), self._workspace) + except ValueError as e: + raise ActionDeniedException(name, str(e)) from e + + run_id = getattr(self, "_run_id", "") + evaluate(name, call_args, run_id=run_id) + return fn(self, *args, **kwargs) + + setattr(wrapper, _TOOL_ATTR, name) + return wrapper + + if callable(tool_name): + fn = tool_name + return decorator(fn, fn.__name__) + + def outer(fn: Callable) -> Callable: + return decorator(fn, tool_name) + + return outer + + +def internal(fn: Callable) -> Callable: + """ + Marks a Node9Agent method as infrastructure (git plumbing, workspace setup). + + - Never calls evaluate() — no SaaS call, no blocking + - Logs locally only: [node9 internal] method_name(args) + """ + @functools.wraps(fn) + def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: + sig = inspect.signature(fn) + bound = sig.bind(self, *args, **kwargs) + bound.apply_defaults() + call_args = {k: v for k, v in bound.arguments.items() if k != "self"} + arg_summary = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in call_args.items()) + print(f" [node9 internal] {fn.__name__}({arg_summary})", flush=True) + return fn(self, *args, **kwargs) + + setattr(wrapper, _INTERNAL_ATTR, True) + return wrapper + + +class Node9Agent: + """ + Governance base class for AI agents. Framework-agnostic, zero dependencies. + + Provides: + - Agent identity and policy (set once, applied to every tool call) + - Per-run UUID for grouping audit entries in the dashboard + - _build_tools() — neutral tool spec for use with any LLM + - build_tools_anthropic() — Anthropic input_schema format + - build_tools_openai() — OpenAI parameters format + - _dispatch() — route LLM tool calls to @tool methods + + The LLM loop is NOT here — implement it in your subclass using whichever + framework or API client you need. + """ + + agent_name: str = "" + policy: str = "audit" + + def __init__(self, workspace: str = ""): + self._run_id = str(uuid.uuid4()) + self._workspace = os.path.realpath(workspace) if workspace else os.getcwd() + + from . import _config + _config.AGENT_NAME = self.agent_name or type(self).__name__ + _config.AGENT_POLICY = self.policy + + # ------------------------------------------------------------------------- + # Tool spec builders — pick the format your LLM expects + # ------------------------------------------------------------------------- + + def _build_tools(self) -> list[dict]: + """ + Neutral tool spec list. Keys: name, description, parameters (JSON Schema). + + Convert for your LLM: + Anthropic: rename 'parameters' → 'input_schema' + OpenAI: wrap in {"type": "function", "function": spec} + """ + tools = [] + for attr_name in dir(type(self)): + method = getattr(type(self), attr_name, None) + if method is None: + continue + tool_name = getattr(method, _TOOL_ATTR, None) + if tool_name is None: + continue + + original = inspect.unwrap(method) + sig = inspect.signature(original) + description = (inspect.getdoc(original) or tool_name).split("\n")[0] + + properties: dict[str, Any] = {} + required: list[str] = [] + + for param_name, param in sig.parameters.items(): + if param_name == "self": + continue + prop: dict[str, Any] = {"type": "string"} + ann = param.annotation + if ann is not inspect.Parameter.empty: + if ann is int: prop["type"] = "integer" + elif ann is float: prop["type"] = "number" + elif ann is bool: prop["type"] = "boolean" + properties[param_name] = prop + if param.default is inspect.Parameter.empty: + required.append(param_name) + + tools.append({ + "name": tool_name, + "description": description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }) + return tools + + def build_tools_anthropic(self) -> list[dict]: + """Tool specs in Anthropic format (input_schema key).""" + result = [] + for spec in self._build_tools(): + result.append({ + "name": spec["name"], + "description": spec["description"], + "input_schema": spec["parameters"], + }) + return result + + def build_tools_openai(self) -> list[dict]: + """Tool specs in OpenAI format (type=function wrapper).""" + result = [] + for spec in self._build_tools(): + result.append({ + "type": "function", + "function": { + "name": spec["name"], + "description": spec["description"], + "parameters": spec["parameters"], + }, + }) + return result + + # ------------------------------------------------------------------------- + # Dispatch + # ------------------------------------------------------------------------- + + def _dispatch(self, tool_name: str, tool_input: dict) -> str: + """ + Route a tool call by name to the matching @tool method. + Returns a string result — or negotiation text if the action was denied. + """ + for attr_name in dir(type(self)): + method = getattr(type(self), attr_name, None) + if method is None: + continue + if getattr(method, _TOOL_ATTR, None) == tool_name: + try: + result = getattr(self, attr_name)(**tool_input) + return str(result) if result is not None else "" + except ActionDeniedException as e: + return e.negotiation + except Exception as e: + return f"Error: {e}" + return f"Unknown tool: {tool_name}" diff --git a/node9/_client.py b/node9/_client.py index 3ad8838..7a33c17 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -1,9 +1,10 @@ """ -Thin HTTP client — talks to the local Node9 daemon on localhost:7391. +Thin HTTP client — talks to either the local Node9 daemon or node9 SaaS. -Flow: - POST /check → { id } registers the action - GET /wait/:id → { decision, reason? } blocks until approved / denied +Routing: + NODE9_API_KEY set → node9 SaaS (api.node9.ai) cloud / CI + daemon reachable → local proxy (localhost:7391) persona 1 / local dev + neither → offline audit log dev / test, never blocks """ import json @@ -18,6 +19,7 @@ import urllib.request from typing import Any +from . import _config from ._config import DAEMON_PORT from ._exceptions import ActionDeniedException, DaemonNotFoundError @@ -108,7 +110,35 @@ def _read_ci_context() -> dict | None: return None -def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: +def _offline_audit(tool_name: str, args: dict[str, Any], run_id: str) -> None: + """ + Offline audit mode — no daemon, no SaaS. + Writes a local audit entry and auto-approves. Never blocks. + Used when neither NODE9_API_KEY nor local daemon is available. + """ + import datetime + audit_dir = os.path.join(os.path.expanduser("~"), ".node9") + os.makedirs(audit_dir, exist_ok=True) + audit_path = os.path.join(audit_dir, "audit.log") + entry = { + "ts": datetime.datetime.utcnow().isoformat() + "Z", + "mode": "offline", + "agent": _config.AGENT_NAME or "Python SDK", + "policy": _config.AGENT_POLICY or "offline", + "runId": run_id, + "toolName": tool_name, + "args": args, + "decision": "allow", + } + try: + with open(audit_path, "a") as f: + f.write(json.dumps(entry, default=str) + "\n") + except OSError: + pass # never crash the agent due to audit failure + print(f" [node9 offline] {tool_name} — logged to {audit_path}", flush=True) + + +def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> 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. @@ -127,8 +157,11 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: payload: dict = { "toolName": tool_name, "args": args, + "agentName": _config.AGENT_NAME or "Python SDK", + "policy": _config.AGENT_POLICY, + "runId": run_id, "context": { - "agent": "Python SDK", + "agent": _config.AGENT_NAME or "Python SDK", "hostname": platform.node(), "platform": platform.system().lower(), "cwd": os.getcwd(), @@ -212,25 +245,38 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any]) -> None: raise ActionDeniedException(tool_name, f"Cloud approval timed out after {poll_timeout}s.") -def evaluate(tool_name: str, args: dict[str, Any]) -> None: +def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None: """ - Sends the action to the daemon and blocks until a decision is made. + Sends the action to node9 for audit / approval. Routing: + NODE9_SKIP=1 → no-op (unsafe bypass for testing) + NODE9_API_KEY set → node9 SaaS + daemon reachable → local proxy + neither → offline audit log (auto-approve, never blocks) + 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) + _evaluate_cloud(tool_name, args, run_id=run_id) return if os.environ.get("NODE9_AUTO_START") == "1" and not _daemon_reachable(): _auto_start_daemon() - result = _post("/check", {"toolName": tool_name, "args": args, "cwd": os.getcwd(), "agent": "Python SDK"}) + if not _daemon_reachable(): + _offline_audit(tool_name, args, run_id=run_id) + return + + result = _post("/check", { + "toolName": tool_name, + "args": args, + "cwd": os.getcwd(), + "agent": _config.AGENT_NAME or "Python SDK", + "policy": _config.AGENT_POLICY, + "runId": run_id, + }) request_id = result.get("id") if not request_id: raise RuntimeError(f"[Node9] Unexpected daemon response: {result}") diff --git a/node9/_config.py b/node9/_config.py index c5d6943..91a0912 100644 --- a/node9/_config.py +++ b/node9/_config.py @@ -1,3 +1,6 @@ import os -DAEMON_PORT = int(os.environ.get("NODE9_DAEMON_PORT", "7391")) +DAEMON_PORT = int(os.environ.get("NODE9_DAEMON_PORT", "7391")) +AGENT_NAME = os.environ.get("NODE9_AGENT_NAME", "") +# audit | require_approval | block_on_rules | "" (empty = default SaaS behaviour) +AGENT_POLICY = os.environ.get("NODE9_AGENT_POLICY", "") diff --git a/node9/_dlp.py b/node9/_dlp.py new file mode 100644 index 0000000..d5f004b --- /dev/null +++ b/node9/_dlp.py @@ -0,0 +1,61 @@ +""" +DLP (Data Loss Prevention) and path safety — single source of truth. + +Used by Node9Agent's @tool decorator automatically. +Can also be imported directly: from node9 import dlp_scan, safe_path +""" + +import os +import re + +_DLP_PATTERNS = [ + ("AWS Access Key ID", re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "block"), + ("GitHub Token", re.compile(r"\bgh[pous]_[A-Za-z0-9]{36}\b"), "block"), + ("Slack Bot Token", re.compile(r"\bxoxb-[0-9A-Za-z-]{20,100}\b"), "block"), + ("OpenAI API Key", re.compile(r"\bsk-[a-zA-Z0-9_-]{20,}\b"), "block"), + ("Stripe Secret Key", re.compile(r"\bsk_(?:live|test)_[0-9a-zA-Z]{24}\b"), "block"), + ("Private Key (PEM)", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), "block"), + ("GCP Service Account", re.compile(r'"type"\s*:\s*"service_account"'), "block"), + ("NPM Auth Token", re.compile(r"_authToken\s*=\s*[A-Za-z0-9_\-]{20,}"), "block"), + ("Anthropic API Key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), "block"), +] + +_SENSITIVE_PATH_RE = re.compile( + r"([\\/]\.ssh[\\/]|[\\/]\.aws[\\/]|[\\/]\.config[\\/]gcloud[\\/]" + r"|[\\/]\.azure[\\/]|[\\/]\.kube[\\/]config$|[\\/]\.env(\.|$)" + r"|[\\/]\.git-credentials$|[\\/]\.npmrc$|[\\/]\.docker[\\/]config\.json$" + r"|[\\/][^/\\]+\.(pem|key|p12|pfx)$|[\\/]credentials\.json$" + r"|[\\/]id_(rsa|ed25519|ecdsa)$)", + re.IGNORECASE, +) + +_SCAN_LIMIT_BYTES = 100_000 + + +def dlp_scan(filename: str, content: str) -> str | None: + """ + Returns a human-readable block reason if a secret is detected, None if clean. + Checks sensitive file paths first, then scans content for known secret patterns. + """ + normalized = filename.replace("\\", "/") + if _SENSITIVE_PATH_RE.search(normalized): + return f"sensitive file path blocked: {filename}" + + text = content[:_SCAN_LIMIT_BYTES] + for name, pattern, _ in _DLP_PATTERNS: + if pattern.search(text): + return f"{name} detected in {filename}" + + return None + + +def safe_path(filename: str, workspace: str) -> str: + """ + Resolve filename relative to workspace and verify it stays inside. + Raises ValueError on path traversal attempts. + """ + resolved = os.path.realpath(os.path.join(workspace, filename)) + workspace_root = os.path.realpath(workspace) + os.sep + if not resolved.startswith(workspace_root): + raise ValueError(f"Path traversal rejected: {filename!r} escapes workspace") + return resolved diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..0ac6284 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,301 @@ +"""Tests for Node9Agent base class, @tool and @internal decorators.""" +import uuid +import pytest +from unittest.mock import patch, MagicMock + +from node9 import Node9Agent, tool, internal, ActionDeniedException +from node9._dlp import dlp_scan + + +# --------------------------------------------------------------------------- +# Minimal concrete agent used across all tests +# --------------------------------------------------------------------------- + +class SimpleAgent(Node9Agent): + agent_name = "test-agent" + policy = "audit" + + @tool("write_file") + def write_file(self, filename: str, content: str) -> str: + """Write content to a file.""" + return f"written:{filename}" + + @tool("run_cmd") + def run_cmd(self, command: str) -> str: + """Run a shell command.""" + return f"ran:{command}" + + @internal + def _git_push(self, branch: str) -> str: + return f"pushed:{branch}" + + +EVAL_PATCH = "node9._agent.evaluate" + + +# --------------------------------------------------------------------------- +# Node9Agent initialisation +# --------------------------------------------------------------------------- + +class TestNode9AgentInit: + def test_run_id_is_uuid(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + parsed = uuid.UUID(agent._run_id) + assert str(parsed) == agent._run_id + + def test_each_instance_gets_unique_run_id(self, tmp_path): + a = SimpleAgent(workspace=str(tmp_path)) + b = SimpleAgent(workspace=str(tmp_path)) + assert a._run_id != b._run_id + + def test_workspace_is_set(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + assert str(tmp_path) in agent._workspace + + def test_config_agent_name_set_on_init(self, tmp_path): + import node9._config as cfg + SimpleAgent(workspace=str(tmp_path)) + assert cfg.AGENT_NAME == "test-agent" + + def test_config_policy_set_on_init(self, tmp_path): + import node9._config as cfg + SimpleAgent(workspace=str(tmp_path)) + assert cfg.AGENT_POLICY == "audit" + + def test_default_workspace_is_cwd(self): + import os + agent = SimpleAgent() + assert agent._workspace == os.path.realpath(os.getcwd()) + + +# --------------------------------------------------------------------------- +# @tool decorator +# --------------------------------------------------------------------------- + +class TestToolDecorator: + def test_evaluate_called_on_tool_call(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + agent.write_file("out.txt", "hello") + mock_eval.assert_called_once() + + def test_correct_tool_name_passed_to_evaluate(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + agent.write_file("out.txt", "hello") + call_args = mock_eval.call_args + assert call_args[0][0] == "write_file" + + def test_run_id_passed_to_evaluate(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + agent.write_file("out.txt", "hello") + call_kwargs = mock_eval.call_args[1] + assert call_kwargs.get("run_id") == agent._run_id + + def test_all_tool_calls_share_run_id(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + run_ids = [] + def capture(tool_name, args, *, run_id=""): + run_ids.append(run_id) + with patch(EVAL_PATCH, side_effect=capture): + agent.write_file("a.txt", "hello") + agent.run_cmd("ls") + assert len(run_ids) == 2 + assert run_ids[0] == run_ids[1] == agent._run_id + + def test_tool_return_value_passed_through(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = agent.write_file("out.txt", "hello") + assert result == "written:out.txt" + + def test_denied_raises_action_denied_exception(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH, side_effect=ActionDeniedException("write_file", "blocked")): + with pytest.raises(ActionDeniedException): + agent.write_file("out.txt", "hello") + + def test_tool_without_custom_name_uses_function_name(self, tmp_path): + class Agent2(Node9Agent): + @tool + def my_action(self, x: str) -> str: + return x + + agent = Agent2(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + agent.my_action("test") + assert mock_eval.call_args[0][0] == "my_action" + + +# --------------------------------------------------------------------------- +# @tool DLP integration +# --------------------------------------------------------------------------- + +class TestToolDlp: + def test_dlp_blocks_sensitive_path(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + with pytest.raises(ActionDeniedException, match="DLP"): + agent.write_file("/home/user/.ssh/id_rsa", "content") + + def test_dlp_block_does_not_call_evaluate(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + try: + agent.write_file("/home/user/.aws/credentials", "content") + except ActionDeniedException: + pass + mock_eval.assert_not_called() + + def test_clean_file_passes_dlp(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = agent.write_file("output.txt", "hello world") + assert result == "written:output.txt" + + +# --------------------------------------------------------------------------- +# @tool path safety integration +# --------------------------------------------------------------------------- + +class TestToolPathSafety: + def test_traversal_raises_action_denied(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + with pytest.raises(ActionDeniedException): + agent.write_file("../../etc/passwd", "content") + + def test_traversal_block_does_not_call_evaluate(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + try: + agent.write_file("../../etc/passwd", "content") + except ActionDeniedException: + pass + mock_eval.assert_not_called() + + +# --------------------------------------------------------------------------- +# @internal decorator +# --------------------------------------------------------------------------- + +class TestInternalDecorator: + def test_internal_never_calls_evaluate(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH) as mock_eval: + agent._git_push("main") + mock_eval.assert_not_called() + + def test_internal_return_value_passed_through(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + result = agent._git_push("main") + assert result == "pushed:main" + + def test_internal_logs_to_stdout(self, tmp_path, capsys): + agent = SimpleAgent(workspace=str(tmp_path)) + agent._git_push("dev") + captured = capsys.readouterr() + assert "internal" in captured.out + assert "_git_push" in captured.out + + +# --------------------------------------------------------------------------- +# _build_tools — neutral tool spec +# --------------------------------------------------------------------------- + +class TestBuildTools: + def test_returns_list_of_dicts(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + tools = agent._build_tools() + assert isinstance(tools, list) + assert all(isinstance(t, dict) for t in tools) + + def test_tool_names_present(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + names = {t["name"] for t in agent._build_tools()} + assert "write_file" in names + assert "run_cmd" in names + + def test_internal_not_in_tools(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + names = {t["name"] for t in agent._build_tools()} + assert "_git_push" not in names + + def test_neutral_format_uses_parameters_key(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + for t in agent._build_tools(): + assert "parameters" in t + assert "input_schema" not in t + assert t["parameters"]["type"] == "object" + + def test_required_params_captured(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + write_tool = next(t for t in agent._build_tools() if t["name"] == "write_file") + assert "filename" in write_tool["parameters"]["required"] + assert "content" in write_tool["parameters"]["required"] + + def test_description_from_docstring(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + write_tool = next(t for t in agent._build_tools() if t["name"] == "write_file") + assert "Write content" in write_tool["description"] + + +# --------------------------------------------------------------------------- +# Framework-specific tool spec builders +# --------------------------------------------------------------------------- + +class TestBuildToolsFrameworks: + def test_anthropic_uses_input_schema(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + tools = agent.build_tools_anthropic() + for t in tools: + assert "input_schema" in t + assert "parameters" not in t + + def test_anthropic_preserves_name_and_description(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + tools = agent.build_tools_anthropic() + names = {t["name"] for t in tools} + assert "write_file" in names + write_tool = next(t for t in tools if t["name"] == "write_file") + assert "Write content" in write_tool["description"] + + def test_openai_wraps_in_function(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + tools = agent.build_tools_openai() + for t in tools: + assert t["type"] == "function" + assert "function" in t + assert "name" in t["function"] + assert "parameters" in t["function"] + + def test_openai_preserves_tool_names(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + tools = agent.build_tools_openai() + names = {t["function"]["name"] for t in tools} + assert "write_file" in names + assert "run_cmd" in names + + +# --------------------------------------------------------------------------- +# _dispatch +# --------------------------------------------------------------------------- + +class TestDispatch: + def test_known_tool_dispatched(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = agent._dispatch("write_file", {"filename": "x.txt", "content": "hi"}) + assert "written" in result + + def test_unknown_tool_returns_error_string(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + result = agent._dispatch("nonexistent_tool", {}) + assert "Unknown tool" in result + + def test_denied_tool_returns_negotiation_string(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH, side_effect=ActionDeniedException("write_file", "policy")): + result = agent._dispatch("write_file", {"filename": "x.txt", "content": "hi"}) + assert "blocked" in result.lower() or "write_file" in result diff --git a/tests/test_client.py b/tests/test_client.py index 5853606..cf26a2f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,7 @@ from node9._client import evaluate + def _make_response(data: dict): """Create a mock urllib response.""" m = MagicMock() @@ -18,6 +19,12 @@ def _make_response(data: dict): class TestEvaluate: + @pytest.fixture(autouse=True) + def _daemon_up(self): + """Pretend daemon is reachable so urlopen side_effects go to /check and /wait only.""" + with patch("node9._client._daemon_reachable", return_value=True): + yield + def test_allow_decision_passes(self): check_resp = _make_response({"id": "req-123"}) wait_resp = _make_response({"decision": "allow"}) @@ -135,12 +142,12 @@ def capturing_urlopen(req, timeout): class TestAutoStart: - def test_auto_start_not_triggered_by_default(self, monkeypatch): - """Without NODE9_AUTO_START=1, DaemonNotFoundError is raised immediately.""" + def test_auto_start_not_triggered_by_default(self, monkeypatch, tmp_path): + """Without NODE9_AUTO_START=1 and no daemon, offline audit mode activates (no crash).""" monkeypatch.delenv("NODE9_AUTO_START", raising=False) - with patch("urllib.request.urlopen", side_effect=URLError("Connection refused")): - with pytest.raises(DaemonNotFoundError): - evaluate("write_file", {"path": "/tmp/x"}) + monkeypatch.setenv("HOME", str(tmp_path)) + with patch("node9._client._daemon_reachable", return_value=False): + evaluate("write_file", {"path": "/tmp/x"}) # offline mode — should not raise def test_auto_start_triggered_when_env_set(self, monkeypatch): """NODE9_AUTO_START=1 calls _auto_start_daemon when daemon is unreachable.""" @@ -163,3 +170,147 @@ def urlopen_side_effect(req, timeout): with patch("node9._client._auto_start_daemon") as mock_start: evaluate("write_file", {"path": "/tmp/x"}) mock_start.assert_called_once() + + +class TestAgentIdentityInPayload: + """Agent name, policy, and run_id are injected into local-daemon payloads.""" + + def test_agent_name_in_payload(self, monkeypatch): + import node9._config as cfg + monkeypatch.setattr(cfg, "AGENT_NAME", "ci-agent") + monkeypatch.setattr(cfg, "AGENT_POLICY", "audit") + sent = [] + check_resp = _make_response({"id": "req-1"}) + wait_resp = _make_response({"decision": "allow"}) + + def capture(req, timeout): + if hasattr(req, "data") and req.data: + sent.append(json.loads(req.data)) + return check_resp if req.get_method() == "POST" else wait_resp + + with patch("urllib.request.urlopen", side_effect=capture): + with patch("node9._client._daemon_reachable", return_value=True): + evaluate("bash", {"command": "ls"}) + + assert sent[0]["agent"] == "ci-agent" + + def test_policy_in_payload(self, monkeypatch): + import node9._config as cfg + monkeypatch.setattr(cfg, "AGENT_NAME", "ci-agent") + monkeypatch.setattr(cfg, "AGENT_POLICY", "audit") + sent = [] + check_resp = _make_response({"id": "req-1"}) + wait_resp = _make_response({"decision": "allow"}) + + def capture(req, timeout): + if hasattr(req, "data") and req.data: + sent.append(json.loads(req.data)) + return check_resp if req.get_method() == "POST" else wait_resp + + with patch("urllib.request.urlopen", side_effect=capture): + with patch("node9._client._daemon_reachable", return_value=True): + evaluate("bash", {"command": "ls"}) + + assert sent[0]["policy"] == "audit" + + def test_run_id_in_payload(self, monkeypatch): + import node9._config as cfg + monkeypatch.setattr(cfg, "AGENT_NAME", "") + monkeypatch.setattr(cfg, "AGENT_POLICY", "") + sent = [] + check_resp = _make_response({"id": "req-1"}) + wait_resp = _make_response({"decision": "allow"}) + + def capture(req, timeout): + if hasattr(req, "data") and req.data: + sent.append(json.loads(req.data)) + return check_resp if req.get_method() == "POST" else wait_resp + + with patch("urllib.request.urlopen", side_effect=capture): + with patch("node9._client._daemon_reachable", return_value=True): + evaluate("bash", {"command": "ls"}, run_id="run-abc-123") + + assert sent[0]["runId"] == "run-abc-123" + + def test_fallback_agent_name_when_empty(self, monkeypatch): + import node9._config as cfg + monkeypatch.setattr(cfg, "AGENT_NAME", "") + monkeypatch.setattr(cfg, "AGENT_POLICY", "") + sent = [] + check_resp = _make_response({"id": "req-1"}) + wait_resp = _make_response({"decision": "allow"}) + + def capture(req, timeout): + if hasattr(req, "data") and req.data: + sent.append(json.loads(req.data)) + return check_resp if req.get_method() == "POST" else wait_resp + + with patch("urllib.request.urlopen", side_effect=capture): + with patch("node9._client._daemon_reachable", return_value=True): + evaluate("bash", {"command": "ls"}) + + assert sent[0]["agent"] == "Python SDK" + + +class TestOfflineMode: + """When neither API key nor daemon is available, offline audit mode activates.""" + + def test_offline_mode_does_not_raise(self, monkeypatch, tmp_path): + monkeypatch.delenv("NODE9_API_KEY", raising=False) + monkeypatch.delenv("NODE9_AUTO_START", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + with patch("node9._client._daemon_reachable", return_value=False): + evaluate("bash", {"command": "ls"}) # must not raise + + def test_offline_mode_writes_audit_log(self, monkeypatch, tmp_path): + import os, json as _json + monkeypatch.delenv("NODE9_API_KEY", raising=False) + monkeypatch.delenv("NODE9_AUTO_START", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + with patch("node9._client._daemon_reachable", return_value=False): + evaluate("bash", {"command": "ls"}, run_id="test-run-1") + + audit_path = tmp_path / ".node9" / "audit.log" + assert audit_path.exists() + entry = _json.loads(audit_path.read_text().strip()) + assert entry["toolName"] == "bash" + assert entry["runId"] == "test-run-1" + assert entry["decision"] == "allow" + assert entry["mode"] == "offline" + + def test_offline_mode_auto_approves(self, monkeypatch, tmp_path): + monkeypatch.delenv("NODE9_API_KEY", raising=False) + monkeypatch.delenv("NODE9_AUTO_START", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + with patch("node9._client._daemon_reachable", return_value=False): + # Should return normally (not raise ActionDeniedException) + result = evaluate("bash", {"command": "ls"}) + assert result is None + + +class TestConfigure: + def test_configure_sets_agent_name(self): + from node9 import configure + import node9._config as cfg + configure(agent_name="my-agent") + assert cfg.AGENT_NAME == "my-agent" + + def test_configure_sets_policy(self): + from node9 import configure + import node9._config as cfg + configure(policy="require_approval") + assert cfg.AGENT_POLICY == "require_approval" + + def test_configure_empty_string_does_not_overwrite(self): + from node9 import configure + import node9._config as cfg + cfg.AGENT_NAME = "existing-agent" + configure(agent_name="") # empty → should not overwrite + assert cfg.AGENT_NAME == "existing-agent" + + def test_configure_both_at_once(self): + from node9 import configure + import node9._config as cfg + configure(agent_name="batch-agent", policy="audit") + assert cfg.AGENT_NAME == "batch-agent" + assert cfg.AGENT_POLICY == "audit" diff --git a/tests/test_dlp.py b/tests/test_dlp.py new file mode 100644 index 0000000..34d062a --- /dev/null +++ b/tests/test_dlp.py @@ -0,0 +1,172 @@ +"""Tests for DLP scanning and path safety. + +Real credential strings cannot appear as literals here — node9's own DLP +scanner blocks the file write. Pattern-matching logic is tested with injected +mock patterns; the actual regexes in _dlp.py are verified by inspecting the +registry (name/count), not by running matches against real credential strings. +""" +import re +import pytest +from unittest.mock import patch + +from node9._dlp import dlp_scan, safe_path, _DLP_PATTERNS, _SENSITIVE_PATH_RE + +# Simple non-sensitive patterns used to test scan logic +_MOCK_PATTERNS = [ + ("Test Token", re.compile(r"\bTOKEN_[A-Z]{8}\b"), "block"), + ("Test Secret", re.compile(r"\bSECRET_[0-9]{6}\b"), "block"), +] + + +class TestDlpPatternRegistry: + """Verify the real pattern list is correctly populated.""" + + EXPECTED_PATTERN_NAMES = [ + "AWS Access Key ID", + "GitHub Token", + "Slack Bot Token", + "OpenAI API Key", + "Stripe Secret Key", + "Private Key (PEM)", + "GCP Service Account", + "NPM Auth Token", + "Anthropic API Key", + ] + + def test_all_expected_patterns_registered(self): + names = [name for name, _, _ in _DLP_PATTERNS] + for expected in self.EXPECTED_PATTERN_NAMES: + assert expected in names, f"Missing DLP pattern: {expected}" + + def test_all_patterns_are_compiled_regex(self): + for name, pattern, action in _DLP_PATTERNS: + assert hasattr(pattern, "search"), f"{name}: pattern is not a compiled regex" + + def test_all_actions_are_block(self): + for name, _, action in _DLP_PATTERNS: + assert action == "block", f"{name}: unexpected action {action!r}" + + +class TestDlpScanLogic: + """Test scan logic using mock patterns — no real credential strings.""" + + def test_matching_content_returns_reason(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("output.txt", "value=TOKEN_ABCDEFGH here") + assert result is not None + + def test_non_matching_content_returns_none(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("main.py", "def hello():\n return 42\n") + assert result is None + + def test_empty_content_returns_none(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("empty.txt", "") + assert result is None + + def test_reason_contains_pattern_name(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("out.txt", "TOKEN_ABCDEFGH") + assert result is not None + assert "Test Token" in result + + def test_reason_contains_filename(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("report.txt", "TOKEN_ABCDEFGH") + assert result is not None + assert "report.txt" in result + + def test_second_pattern_also_detected(self): + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("data.txt", "value SECRET_123456 here") + assert result is not None + assert "Test Secret" in result + + def test_scan_stops_at_100k_bytes(self): + # Content beyond 100 KB limit should not be scanned + padding = "x" * 100_001 + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("big.txt", padding + "TOKEN_ABCDEFGH") + assert result is None + + def test_content_within_100k_is_scanned(self): + # Content well within 100 KB should be scanned + padding = "x" * 50 + with patch("node9._dlp._DLP_PATTERNS", _MOCK_PATTERNS): + result = dlp_scan("big.txt", padding + " TOKEN_ABCDEFGH") + assert result is not None + + +class TestSensitivePathDetection: + """Test sensitive file path blocking — no credential content needed.""" + + BLOCKED_PATHS = [ + "/home/user/.ssh/id_rsa", + "/home/user/.ssh/id_ed25519", + "/home/user/.aws/credentials", + "/home/user/.aws/config", + "/app/.env", + "/app/.env.local", + "/app/.env.production", + "/certs/server.pem", + "/certs/client.key", + "/home/user/.kube/config", + "/home/user/.docker/config.json", + "/home/user/.npmrc", + "/home/user/.git-credentials", + "/keys/service.p12", + "/keys/client.pfx", + "/credentials.json", + ] + + ALLOWED_PATHS = [ + "/app/src/main.py", + "/app/tests/test_auth.py", + "/home/user/project/config.json", + "/tmp/output.txt", + "/app/.github/workflows/ci.yml", + ] + + def test_sensitive_paths_are_blocked(self): + for path in self.BLOCKED_PATHS: + result = dlp_scan(path, "content") + assert result is not None, f"Expected block for path: {path}" + + def test_normal_paths_are_allowed(self): + for path in self.ALLOWED_PATHS: + with patch("node9._dlp._DLP_PATTERNS", []): # disable content scan + result = dlp_scan(path, "content") + assert result is None, f"Expected allow for path: {path}" + + def test_block_reason_mentions_path(self): + result = dlp_scan("/home/user/.ssh/id_rsa", "content") + assert result is not None + assert "id_rsa" in result + + +class TestSafePath: + def test_normal_file_resolves(self, tmp_path): + result = safe_path("src/main.py", str(tmp_path)) + assert result.startswith(str(tmp_path)) + assert result.endswith("main.py") + + def test_traversal_rejected(self, tmp_path): + with pytest.raises(ValueError, match="Path traversal"): + safe_path("../../etc/passwd", str(tmp_path)) + + def test_absolute_path_outside_workspace_rejected(self, tmp_path): + with pytest.raises(ValueError, match="Path traversal"): + safe_path("/etc/passwd", str(tmp_path)) + + def test_nested_path_allowed(self, tmp_path): + result = safe_path("a/b/c/file.txt", str(tmp_path)) + assert "file.txt" in result + + def test_dot_prefix_stays_inside(self, tmp_path): + result = safe_path("./file.txt", str(tmp_path)) + assert result.startswith(str(tmp_path)) + + def test_error_message_contains_filename(self, tmp_path): + with pytest.raises(ValueError, match="passwd"): + safe_path("../../etc/passwd", str(tmp_path)) From 0d787ade0dbaac3ab4d70654e83e3e04ae537088 Mon Sep 17 00:00:00 2001 From: node9 Date: Mon, 6 Apr 2026 23:19:15 +0300 Subject: [PATCH 03/19] fix(e2e): update Part 2 to test offline mode instead of DaemonNotFoundError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline mode now activates when no daemon is running and no API key is set — calls succeed with an audit log entry instead of raising DaemonNotFoundError. Update the e2e test to assert the new behavior. Co-Authored-By: Claude Sonnet 4.6 --- scripts/e2e.sh | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 7bf4f84..393c259 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -38,27 +38,25 @@ else fi # ============================================================================= -# PART 2 — DaemonNotFoundError when no daemon running +# PART 2 — Offline mode when no daemon running and no API key # ============================================================================= -section "Part 2 · DaemonNotFoundError when daemon is not running" +section "Part 2 · Offline audit mode when no daemon and no API key" out=$(NODE9_DAEMON_PORT=19999 python3 -c " -from node9 import protect, DaemonNotFoundError +from node9 import protect @protect def write_file(path): pass try: - write_file('/tmp/test') - print('no_error') -except DaemonNotFoundError: - print('daemon_not_found') + result = write_file('/tmp/test') + print('offline_ok') except Exception as e: - print(f'wrong_error: {e}') + print(f'unexpected_error: {e}') " 2>&1) -if echo "$out" | grep -q "daemon_not_found"; then - pass "DaemonNotFoundError raised when daemon unreachable" +if echo "$out" | grep -q "offline_ok"; then + pass "Offline audit mode activates when daemon unreachable and no API key" else - fail "Expected DaemonNotFoundError (got: '$out')" + fail "Expected offline mode success (got: '$out')" fi # ============================================================================= From b689cae4f1e56ed948c8625ba1520ebe5b9c28f4 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 18:37:32 +0300 Subject: [PATCH 04/19] adding feature to sdk --- node9/_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node9/_client.py b/node9/_client.py index 7a33c17..b462367 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -149,7 +149,7 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> N api_url = os.environ.get("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept").rstrip("/") - if not api_url.startswith("https://"): + if not api_url.startswith("https://") and not api_url.startswith("http://localhost"): raise RuntimeError( f"[Node9] NODE9_API_URL must use HTTPS to protect credentials (got: {api_url!r})" ) From 767deb88b1ccb60dece2d93405b0c1cef62f28cf Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 18:45:30 +0300 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20DLP,=20thread=20safety,=20API=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve README.md merge conflict - NODE9_SKIP=1 now emits a warning and writes an audit entry (no silent bypass) - configure() is now thread-safe via threading.Lock - DLP scan covers all string args, not just params named filename/content - _dispatch() renamed to dispatch() — public API should not have underscore (_dispatch kept as deprecated alias with DeprecationWarning) - workspace validated at __init__ time with clear error if path doesn't exist - @internal docstring clarifies it logs to stdout, not to audit trail - README: shell=True examples now include sanitization warning - README: _dispatch references updated to dispatch() --- README.md | 16 +++++----- node9/__init__.py | 16 ++++++---- node9/_agent.py | 74 +++++++++++++++++++++++++++++++++-------------- node9/_client.py | 7 +++++ 4 files changed, 79 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index e165478..e046667 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # node9-python -<<<<<<< dev -Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. -======= -Execution security for Python AI agents, one decorator, zero config. ->>>>>>> main +Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. One decorator, zero config. Works two ways: - **`@protect`** — add governance to any existing agent (LangChain, CrewAI, AutoGen, plain Python) @@ -45,6 +41,8 @@ def write_file(path: str, content: str) -> None: @protect("bash") def run_shell(command: str) -> str: import subprocess + # Note: @protect gates on human approval but does NOT sanitize `command`. + # Use shlex.split() + shell=False for untrusted input. return subprocess.check_output(command, shell=True, text=True) try: @@ -130,7 +128,9 @@ class CiAgent(Node9Agent): def run_tests(self, command: str) -> str: """Run the test suite and return output.""" import subprocess - return subprocess.check_output(command, shell=True, text=True) + # Note: @protect gates on human approval but does NOT sanitize `command`. + # Use shlex.split() + shell=False for untrusted input. + return subprocess.check_output(command, shell=True, text=True) @tool("write_code") def write_code(self, filename: str, content: str) -> str: @@ -164,7 +164,7 @@ while True: results = [] for block in response.content: if block.type == "tool_use": - result = agent._dispatch(block.name, block.input) # DLP + audit happen here + result = agent.dispatch(block.name, block.input) # DLP + audit happen here results.append({"type": "tool_result", "tool_use_id": block.id, "content": result}) messages.append({"role": "user", "content": results}) ``` @@ -215,7 +215,7 @@ Patterns detected: AWS keys, GitHub tokens, Slack tokens, OpenAI keys, Stripe ke ```python try: - agent._dispatch("delete_file", {"path": "/etc/hosts"}) + agent.dispatch("delete_file", {"path": "/etc/hosts"}) except ActionDeniedException as e: # e.negotiation = "Action 'delete_file' was blocked by Node9: policy. Choose a different approach." response = llm.invoke(e.negotiation) diff --git a/node9/__init__.py b/node9/__init__.py index d369125..adb7338 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -2,24 +2,30 @@ node9 — Execution security for Python AI agents. """ +import threading + from ._decorator import protect from ._exceptions import ActionDeniedException, DaemonNotFoundError from ._dlp import dlp_scan, safe_path from ._agent import Node9Agent, tool, internal from . import _config +_configure_lock = threading.Lock() + def configure(*, agent_name: str = "", policy: str = "") -> None: """ Set agent identity at runtime. Alternative to NODE9_AGENT_NAME / NODE9_AGENT_POLICY env vars. - Call before the first evaluate() / @protect / agent._dispatch(). + Call before the first evaluate() / @protect / agent.dispatch(). + Thread-safe — safe to call from concurrent async frameworks (LangGraph, FastMCP). policy values: "audit" | "require_approval" | "block_on_rules" | "" (SaaS default) """ - if agent_name: - _config.AGENT_NAME = agent_name - if policy: - _config.AGENT_POLICY = policy + with _configure_lock: + if agent_name: + _config.AGENT_NAME = agent_name + if policy: + _config.AGENT_POLICY = policy __all__ = [ diff --git a/node9/_agent.py b/node9/_agent.py index 3ace49e..e4ab58c 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -78,20 +78,24 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: bound.apply_defaults() call_args = {k: v for k, v in bound.arguments.items() if k != "self"} - # DLP scan - filename = call_args.get("filename") or call_args.get("path") or "" - content = call_args.get("content") or "" - if filename or content: - hit = dlp_scan(str(filename), str(content)) - if hit: - raise ActionDeniedException(name, f"DLP blocked: {hit}") - - # Path safety - if filename and hasattr(self, "_workspace") and self._workspace: - try: - safe_path(str(filename), self._workspace) - except ValueError as e: - raise ActionDeniedException(name, str(e)) from e + # DLP scan — all string args, not just well-known names + path_arg = call_args.get("filename") or call_args.get("path") or "" + all_content = "\n".join( + str(v) for v in call_args.values() if isinstance(v, str) + ) + hit = dlp_scan(str(path_arg), all_content) + if hit: + raise ActionDeniedException(name, f"DLP blocked: {hit}") + + # Path safety — any arg that looks like a file path + if hasattr(self, "_workspace") and self._workspace: + for v in call_args.values(): + if isinstance(v, str) and ("/" in v or "\\" in v): + try: + safe_path(v, self._workspace) + except ValueError as e: + raise ActionDeniedException(name, str(e)) from e + break run_id = getattr(self, "_run_id", "") evaluate(name, call_args, run_id=run_id) @@ -114,8 +118,10 @@ def internal(fn: Callable) -> Callable: """ Marks a Node9Agent method as infrastructure (git plumbing, workspace setup). - - Never calls evaluate() — no SaaS call, no blocking - - Logs locally only: [node9 internal] method_name(args) + - Never calls evaluate() — no SaaS call, no blocking, no DLP scan + - Logs to stdout: [node9 internal] method_name(args) + - Use only for non-agent-decision code (git, workspace setup, file plumbing). + Do NOT use to bypass governance on agent-controlled actions. """ @functools.wraps(fn) def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: @@ -138,21 +144,33 @@ class Node9Agent: Provides: - Agent identity and policy (set once, applied to every tool call) - Per-run UUID for grouping audit entries in the dashboard - - _build_tools() — neutral tool spec for use with any LLM - build_tools_anthropic() — Anthropic input_schema format - build_tools_openai() — OpenAI parameters format - - _dispatch() — route LLM tool calls to @tool methods + - dispatch() — route LLM tool calls to @tool methods (primary integration point) The LLM loop is NOT here — implement it in your subclass using whichever framework or API client you need. + + When to use Node9Agent vs @protect: + - Node9Agent: greenfield agents where you control the tool definitions + - @protect: retrofitting governance onto existing functions/classes """ agent_name: str = "" policy: str = "audit" def __init__(self, workspace: str = ""): - self._run_id = str(uuid.uuid4()) - self._workspace = os.path.realpath(workspace) if workspace else os.getcwd() + self._run_id = str(uuid.uuid4()) + if workspace: + resolved = os.path.realpath(workspace) + if not os.path.isdir(resolved): + raise ValueError( + f"Node9Agent workspace does not exist: {workspace!r}. " + "Create the directory before constructing the agent." + ) + self._workspace = resolved + else: + self._workspace = os.getcwd() from . import _config _config.AGENT_NAME = self.agent_name or type(self).__name__ @@ -239,10 +257,14 @@ def build_tools_openai(self) -> list[dict]: # Dispatch # ------------------------------------------------------------------------- - def _dispatch(self, tool_name: str, tool_input: dict) -> str: + def dispatch(self, tool_name: str, tool_input: dict) -> str: """ Route a tool call by name to the matching @tool method. Returns a string result — or negotiation text if the action was denied. + + This is the primary integration point for LLM loops: + result = agent.dispatch(block.name, block.input) # Anthropic + result = agent.dispatch(call.function.name, json.loads(call.function.arguments)) # OpenAI """ for attr_name in dir(type(self)): method = getattr(type(self), attr_name, None) @@ -257,3 +279,13 @@ def _dispatch(self, tool_name: str, tool_input: dict) -> str: except Exception as e: return f"Error: {e}" return f"Unknown tool: {tool_name}" + + def _dispatch(self, tool_name: str, tool_input: dict) -> str: + """Deprecated alias for dispatch(). Use dispatch() instead.""" + import warnings + warnings.warn( + "Node9Agent._dispatch() is deprecated — use .dispatch() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.dispatch(tool_name, tool_input) diff --git a/node9/_client.py b/node9/_client.py index b462367..6d56726 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -256,6 +256,13 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None: Raises ActionDeniedException if the action is denied. """ if os.environ.get("NODE9_SKIP") == "1": + import warnings + warnings.warn( + f"[Node9] NODE9_SKIP=1 — governance bypassed for '{tool_name}'. " + "Do not use in production.", + stacklevel=3, + ) + _offline_audit(tool_name, {**args, "_skip": True}, run_id=run_id) return if os.environ.get("NODE9_API_KEY"): From f40427315cd746ba5cda5e4f4c858eddd077425f Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 18:55:09 +0300 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20second=20round=20=E2=80=94=20DLP,?= =?UTF-8?q?=20Python=203.9=20compat,=20run=5Fid,=20API=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DLP now checks every string arg as a path candidate, not just params named filename/path — fixes silent bypass on dest/target/output params - tool() annotation uses Union[str, Callable] instead of str | Callable — fixes TypeError on Python 3.9 - Docstring examples: shell=False + shlex.split(), write_code uses safe_path - dispatch referenced consistently (not _dispatch) in all docstrings - Node9Agent.new_session() added for server deployments with multiple users - __all__ documents build_tools_anthropic/openai/dispatch/new_session --- node9/__init__.py | 11 ++++++++--- node9/_agent.py | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/node9/__init__.py b/node9/__init__.py index adb7338..9196403 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -33,9 +33,14 @@ def configure(*, agent_name: str = "", policy: str = "") -> None: "protect", "configure", # Agent framework - "Node9Agent", - "tool", - "internal", + "Node9Agent", # base class — subclass and use @tool / @internal + "tool", # decorator: governed tool (DLP + audit + policy) + "internal", # decorator: infrastructure method (no governance) + # Node9Agent methods (documented here for IDE discoverability) + # .build_tools_anthropic() — Anthropic input_schema format + # .build_tools_openai() — OpenAI function format + # .dispatch(name, input) — route LLM tool call to @tool method + # .new_session() — fresh run_id for server/multi-session deployments # DLP utilities "dlp_scan", "safe_path", diff --git a/node9/_agent.py b/node9/_agent.py index e4ab58c..f33c2a1 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -16,12 +16,15 @@ class CiAgent(Node9Agent): @tool("run_tests") def run_tests(self, command: str) -> str: - import subprocess - return subprocess.check_output(command, shell=True, text=True) + import shlex, subprocess + # Use shell=False to avoid injection — split the command string safely + return subprocess.check_output(shlex.split(command), text=True) @tool("write_code") def write_code(self, filename: str, content: str) -> str: - with open(filename, "w") as f: + from node9 import safe_path + path = safe_path(filename, self._workspace) # workspace-relative, traversal-safe + with open(path, "w") as f: f.write(content) return f"written:{filename}" @@ -40,14 +43,14 @@ def _git_push(self, branch: str) -> str: # Custom: tools = agent._build_tools() # neutral format # # Dispatch tool calls from the LLM response: - # result = agent._dispatch(tool_name, tool_input) + # result = agent.dispatch(tool_name, tool_input) """ import functools import inspect import os import uuid -from typing import Any, Callable +from typing import Any, Callable, Union from ._client import evaluate from ._dlp import dlp_scan, safe_path @@ -58,7 +61,7 @@ def _git_push(self, branch: str) -> str: _INTERNAL_ATTR = "_node9_internal" -def tool(tool_name: str | Callable): +def tool(tool_name: Union[str, Callable]): """ Marks a Node9Agent method as a governed tool. @@ -78,14 +81,14 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: bound.apply_defaults() call_args = {k: v for k, v in bound.arguments.items() if k != "self"} - # DLP scan — all string args, not just well-known names - path_arg = call_args.get("filename") or call_args.get("path") or "" - all_content = "\n".join( - str(v) for v in call_args.values() if isinstance(v, str) - ) - hit = dlp_scan(str(path_arg), all_content) - if hit: - raise ActionDeniedException(name, f"DLP blocked: {hit}") + # DLP scan — run once per string arg as both path and content candidate + # This catches sensitive paths regardless of parameter name (e.g. dest, target) + string_args = [str(v) for v in call_args.values() if isinstance(v, str)] + all_content = "\n".join(string_args) + for candidate in string_args: + hit = dlp_scan(candidate, all_content) + if hit: + raise ActionDeniedException(name, f"DLP blocked: {hit}") # Path safety — any arg that looks like a file path if hasattr(self, "_workspace") and self._workspace: @@ -159,8 +162,17 @@ class Node9Agent: agent_name: str = "" policy: str = "audit" - def __init__(self, workspace: str = ""): + def new_session(self) -> str: + """ + Start a new session — generates a fresh run_id so audit entries are + grouped correctly. Call at the start of each user request in server deployments. + Returns the new run_id. + """ self._run_id = str(uuid.uuid4()) + return self._run_id + + def __init__(self, workspace: str = ""): + self._run_id = str(uuid.uuid4()) # one run_id per instance; call new_session() per request if workspace: resolved = os.path.realpath(workspace) if not os.path.isdir(resolved): From 84e623ba991936b3fe442e6d32faf8e11cec0e6a Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 19:17:15 +0300 Subject: [PATCH 07/19] adding feature to sdk --- README.md | 12 ++++-------- manual_test.py => examples/manual_test.py | 2 +- node9/__init__.py | 10 +--------- node9/_agent.py | 15 +++++++++++++-- node9/_client.py | 19 +++++++++++-------- node9/_config.py | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 28 deletions(-) rename manual_test.py => examples/manual_test.py (98%) diff --git a/README.md b/README.md index e046667..f743444 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,8 @@ def write_file(path: str, content: str) -> None: @protect("bash") def run_shell(command: str) -> str: - import subprocess - # Note: @protect gates on human approval but does NOT sanitize `command`. - # Use shlex.split() + shell=False for untrusted input. - return subprocess.check_output(command, shell=True, text=True) + import shlex, subprocess + return subprocess.check_output(shlex.split(command), text=True) try: write_file("/etc/hosts", "bad content") @@ -127,10 +125,8 @@ class CiAgent(Node9Agent): @tool("run_tests") def run_tests(self, command: str) -> str: """Run the test suite and return output.""" - import subprocess - # Note: @protect gates on human approval but does NOT sanitize `command`. - # Use shlex.split() + shell=False for untrusted input. - return subprocess.check_output(command, shell=True, text=True) + import shlex, subprocess + return subprocess.check_output(shlex.split(command), text=True) @tool("write_code") def write_code(self, filename: str, content: str) -> str: diff --git a/manual_test.py b/examples/manual_test.py similarity index 98% rename from manual_test.py rename to examples/manual_test.py index d79743d..4a69667 100644 --- a/manual_test.py +++ b/examples/manual_test.py @@ -16,7 +16,7 @@ import sys import tempfile -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from node9 import protect, configure, Node9Agent, tool, internal, dlp_scan, safe_path, ActionDeniedException diff --git a/node9/__init__.py b/node9/__init__.py index 9196403..391cd61 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -2,16 +2,12 @@ node9 — Execution security for Python AI agents. """ -import threading - from ._decorator import protect from ._exceptions import ActionDeniedException, DaemonNotFoundError from ._dlp import dlp_scan, safe_path from ._agent import Node9Agent, tool, internal from . import _config -_configure_lock = threading.Lock() - def configure(*, agent_name: str = "", policy: str = "") -> None: """ @@ -21,11 +17,7 @@ def configure(*, agent_name: str = "", policy: str = "") -> None: policy values: "audit" | "require_approval" | "block_on_rules" | "" (SaaS default) """ - with _configure_lock: - if agent_name: - _config.AGENT_NAME = agent_name - if policy: - _config.AGENT_POLICY = policy + _config.set_identity(agent_name=agent_name, policy=policy) __all__ = [ diff --git a/node9/_agent.py b/node9/_agent.py index f33c2a1..7828027 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -72,6 +72,10 @@ def tool(tool_name: Union[str, Callable]): - run_id — injected automatically so all calls in one run are grouped in the dashboard Can be used as @tool or @tool("custom_name"). + + Note: DLP scanning only inspects top-level string arguments. Secrets nested + inside dicts or lists will not be caught — flatten sensitive values or call + dlp_scan() explicitly before passing structured data. """ def decorator(fn: Callable, name: str) -> Callable: @functools.wraps(fn) @@ -185,8 +189,10 @@ def __init__(self, workspace: str = ""): self._workspace = os.getcwd() from . import _config - _config.AGENT_NAME = self.agent_name or type(self).__name__ - _config.AGENT_POLICY = self.policy + _config.set_identity( + agent_name=self.agent_name or type(self).__name__, + policy=self.policy, + ) # ------------------------------------------------------------------------- # Tool spec builders — pick the format your LLM expects @@ -219,6 +225,11 @@ def _build_tools(self) -> list[dict]: for param_name, param in sig.parameters.items(): if param_name == "self": continue + if param.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue prop: dict[str, Any] = {"type": "string"} ann = param.annotation if ann is not inspect.Parameter.empty: diff --git a/node9/_client.py b/node9/_client.py index 6d56726..e14562b 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -28,6 +28,8 @@ _CHECK_TIMEOUT = 5 # seconds to establish connection _WAIT_TIMEOUT = 65 # seconds to wait for human decision +_SKIP = os.environ.get("NODE9_SKIP") == "1" + _CI_CONTEXT_MAX_BYTES = 10_000 _CI_CONTEXT_ALLOWED_KEYS = { "tests_after", "files_changed", "issues_found", "issues_fixed", @@ -123,8 +125,8 @@ def _offline_audit(tool_name: str, args: dict[str, Any], run_id: str) -> None: entry = { "ts": datetime.datetime.utcnow().isoformat() + "Z", "mode": "offline", - "agent": _config.AGENT_NAME or "Python SDK", - "policy": _config.AGENT_POLICY or "offline", + "agent": (_config.get()[0] or "Python SDK"), + "policy": (_config.get()[1] or "offline"), "runId": run_id, "toolName": tool_name, "args": args, @@ -154,14 +156,15 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> N f"[Node9] NODE9_API_URL must use HTTPS to protect credentials (got: {api_url!r})" ) + _agent_name, _agent_policy = _config.get() payload: dict = { "toolName": tool_name, "args": args, - "agentName": _config.AGENT_NAME or "Python SDK", - "policy": _config.AGENT_POLICY, + "agentName": _agent_name or "Python SDK", + "policy": _agent_policy, "runId": run_id, "context": { - "agent": _config.AGENT_NAME or "Python SDK", + "agent": _agent_name or "Python SDK", "hostname": platform.node(), "platform": platform.system().lower(), "cwd": os.getcwd(), @@ -255,7 +258,7 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None: Raises ActionDeniedException if the action is denied. """ - if os.environ.get("NODE9_SKIP") == "1": + if _SKIP: import warnings warnings.warn( f"[Node9] NODE9_SKIP=1 — governance bypassed for '{tool_name}'. " @@ -280,8 +283,8 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None: "toolName": tool_name, "args": args, "cwd": os.getcwd(), - "agent": _config.AGENT_NAME or "Python SDK", - "policy": _config.AGENT_POLICY, + "agent": (_config.get()[0] or "Python SDK"), + "policy": _config.get()[1], "runId": run_id, }) request_id = result.get("id") diff --git a/node9/_config.py b/node9/_config.py index 91a0912..a0ef314 100644 --- a/node9/_config.py +++ b/node9/_config.py @@ -1,6 +1,25 @@ import os +import threading DAEMON_PORT = int(os.environ.get("NODE9_DAEMON_PORT", "7391")) AGENT_NAME = os.environ.get("NODE9_AGENT_NAME", "") # audit | require_approval | block_on_rules | "" (empty = default SaaS behaviour) AGENT_POLICY = os.environ.get("NODE9_AGENT_POLICY", "") + +_lock = threading.RLock() + + +def get() -> tuple[str, str]: + """Thread-safe snapshot of (AGENT_NAME, AGENT_POLICY).""" + with _lock: + return AGENT_NAME, AGENT_POLICY + + +def set_identity(*, agent_name: str = "", policy: str = "") -> None: + """Thread-safe write. Called by node9.configure() and Node9Agent.__init__.""" + global AGENT_NAME, AGENT_POLICY + with _lock: + if agent_name: + AGENT_NAME = agent_name + if policy: + AGENT_POLICY = policy From a121d72fa4a94a07db12f7b37de8e29b3dafbdbe Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 19:21:06 +0300 Subject: [PATCH 08/19] adding feature to sdk --- .githooks/pre-commit | 14 ++++++++++++++ .githooks/pre-push | 14 ++++++++++++++ README.md | 14 ++++++++++++++ tests/test_client.py | 4 ++-- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e0ab4e8 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run tests before every commit. Blocks the commit if any test fails. +set -euo pipefail + +echo "🧪 node9: running tests before commit..." + +if ! python3 -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then + echo "" + echo "❌ Tests failed — commit blocked. Fix the failures above and try again." + echo " To skip (unsafe): git commit --no-verify" + exit 1 +fi + +echo "✅ All tests passed." diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..d1a2435 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run tests before every push. Last safety net before hitting the remote. +set -euo pipefail + +echo "🧪 node9: running tests before push..." + +if ! python3 -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then + echo "" + echo "❌ Tests failed — push blocked. Fix the failures above and try again." + echo " To skip (unsafe): git push --no-verify" + exit 1 +fi + +echo "✅ All tests passed." diff --git a/README.md b/README.md index f743444..156009a 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,20 @@ except ActionDeniedException as e: | `NODE9_AUTO_START` | — | Set to `1` to auto-launch the local daemon if not running. | | `NODE9_SKIP` | — | Set to `1` to bypass all checks. Unsafe — for unit tests only. | +## Development + +After cloning, activate the git hooks (runs tests before every commit and push): + +```bash +git config core.hooksPath .githooks +``` + +Run tests manually: + +```bash +python3 -m pytest tests/ -p no:anyio -q +``` + ## License Apache-2.0 diff --git a/tests/test_client.py b/tests/test_client.py index cf26a2f..438e249 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -84,8 +84,8 @@ def side_effect(req, timeout): evaluate("deploy", {"server": "prod"}) def test_node9_skip_env_bypasses_daemon(self, monkeypatch): - monkeypatch.setenv("NODE9_SKIP", "1") - # No mock needed — should not call urlopen at all + # _SKIP is read once at import time — patch the flag directly + monkeypatch.setattr("node9._client._SKIP", True) with patch("urllib.request.urlopen", side_effect=Exception("should not be called")): evaluate("anything", {"key": "val"}) # should not raise From 917f6a016ba9ba793a876c110710b5ef14fda121 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 19:28:04 +0300 Subject: [PATCH 09/19] Fix code review issues: safe_path keyword-only, NODE9_SKIP import warning, 13 new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safe_path(filename, *, workspace=...) — workspace is now keyword-only to prevent silent positional arg swaps; all call sites updated - NODE9_SKIP=1 now warns at import time (not just per-call) so it's harder to accidentally leave enabled in production - new_session() docstring explicitly documents the one-instance-per-request requirement - 13 new tests: dispatch() unknown tool (returns string, never raises), new_session() UUID validity and concurrent calls, _build_tools() unannotated/int/bool/float params and *args/**kwargs exclusion, safe_path symlink traversal rejection, configure() called twice (second wins) and empty args don't overwrite Co-Authored-By: Claude Sonnet 4.6 --- examples/manual_test.py | 4 +- node9/_agent.py | 8 +++- node9/_client.py | 7 +++ node9/_dlp.py | 3 +- tests/test_agent.py | 100 ++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 27 ++++++++++- tests/test_dlp.py | 22 ++++++--- 7 files changed, 159 insertions(+), 12 deletions(-) diff --git a/examples/manual_test.py b/examples/manual_test.py index 4a69667..0276c0e 100644 --- a/examples/manual_test.py +++ b/examples/manual_test.py @@ -62,10 +62,10 @@ def write_file(path: str, content: str) -> str: # ── 5. safe_path ───────────────────────────────────────────────────────────── print("--- safe_path ---") with tempfile.TemporaryDirectory() as workspace: - resolved = safe_path("src/main.py", workspace) + resolved = safe_path("src/main.py", workspace=workspace) print(f" safe_path resolved: {resolved}") try: - safe_path("../../etc/passwd", workspace) + safe_path("../../etc/passwd", workspace=workspace) print(" traversal: NOT blocked (BUG)") except ValueError as e: print(f" traversal blocked: {e}") diff --git a/node9/_agent.py b/node9/_agent.py index 7828027..49ff5d4 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -23,7 +23,7 @@ def run_tests(self, command: str) -> str: @tool("write_code") def write_code(self, filename: str, content: str) -> str: from node9 import safe_path - path = safe_path(filename, self._workspace) # workspace-relative, traversal-safe + path = safe_path(filename, workspace=self._workspace) # workspace-relative, traversal-safe with open(path, "w") as f: f.write(content) return f"written:{filename}" @@ -99,7 +99,7 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: for v in call_args.values(): if isinstance(v, str) and ("/" in v or "\\" in v): try: - safe_path(v, self._workspace) + safe_path(v, workspace=self._workspace) except ValueError as e: raise ActionDeniedException(name, str(e)) from e break @@ -171,6 +171,10 @@ def new_session(self) -> str: Start a new session — generates a fresh run_id so audit entries are grouped correctly. Call at the start of each user request in server deployments. Returns the new run_id. + + Thread safety: do NOT share a single Node9Agent instance across concurrent + requests. _run_id assignment is not atomic. Use one instance per request + (or per thread) to avoid run_id races in concurrent web servers. """ self._run_id = str(uuid.uuid4()) return self._run_id diff --git a/node9/_client.py b/node9/_client.py index e14562b..2e2b546 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -29,6 +29,13 @@ _WAIT_TIMEOUT = 65 # seconds to wait for human decision _SKIP = os.environ.get("NODE9_SKIP") == "1" +if _SKIP: + import warnings as _warnings + _warnings.warn( + "[Node9] NODE9_SKIP=1 is set — all governance checks are disabled. " + "Never set this in production.", + stacklevel=2, + ) _CI_CONTEXT_MAX_BYTES = 10_000 _CI_CONTEXT_ALLOWED_KEYS = { diff --git a/node9/_dlp.py b/node9/_dlp.py index d5f004b..95df077 100644 --- a/node9/_dlp.py +++ b/node9/_dlp.py @@ -49,9 +49,10 @@ def dlp_scan(filename: str, content: str) -> str | None: return None -def safe_path(filename: str, workspace: str) -> str: +def safe_path(filename: str, *, workspace: str) -> str: """ Resolve filename relative to workspace and verify it stays inside. + Symlinks are resolved via os.path.realpath before the boundary check. Raises ValueError on path traversal attempts. """ resolved = os.path.realpath(os.path.join(workspace, filename)) diff --git a/tests/test_agent.py b/tests/test_agent.py index 0ac6284..4e8d095 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,5 +1,6 @@ """Tests for Node9Agent base class, @tool and @internal decorators.""" import uuid +import threading import pytest from unittest.mock import patch, MagicMock @@ -299,3 +300,102 @@ def test_denied_tool_returns_negotiation_string(self, tmp_path): with patch(EVAL_PATCH, side_effect=ActionDeniedException("write_file", "policy")): result = agent._dispatch("write_file", {"filename": "x.txt", "content": "hi"}) assert "blocked" in result.lower() or "write_file" in result + + def test_dispatch_unknown_tool_returns_error_string(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + result = agent.dispatch("no_such_tool", {}) + assert "Unknown tool" in result + assert "no_such_tool" in result + + def test_dispatch_unknown_tool_does_not_raise(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + # LLM loops must not get an unhandled exception for bad tool names + try: + agent.dispatch("totally_missing", {"x": 1}) + except Exception as e: + pytest.fail(f"dispatch() raised unexpectedly: {e}") + + +# --------------------------------------------------------------------------- +# new_session +# --------------------------------------------------------------------------- + +class TestNewSession: + def test_new_session_returns_new_uuid(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + old_id = agent._run_id + new_id = agent.new_session() + assert new_id != old_id + assert uuid.UUID(new_id) # valid UUID + + def test_new_session_updates_run_id(self, tmp_path): + agent = SimpleAgent(workspace=str(tmp_path)) + new_id = agent.new_session() + assert agent._run_id == new_id + + def test_concurrent_new_session_calls_produce_unique_ids(self, tmp_path): + """Each new_session() call produces a unique ID — no UUID collision.""" + agent = SimpleAgent(workspace=str(tmp_path)) + ids = [] + errors = [] + + def call_new_session(): + try: + ids.append(agent.new_session()) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=call_new_session) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + # All IDs are valid UUIDs + for run_id in ids: + uuid.UUID(run_id) + # Note: concurrent calls race on _run_id — documented as "one instance per request" + + +# --------------------------------------------------------------------------- +# _build_tools — unannotated and complex-typed parameters +# --------------------------------------------------------------------------- + +class TestBuildToolsEdgeCases: + def test_unannotated_param_defaults_to_string_type(self, tmp_path): + class Agent3(Node9Agent): + @tool("unannotated") + def unannotated(self, x, y) -> str: + return str(x) + + agent = Agent3(workspace=str(tmp_path)) + spec = next(t for t in agent._build_tools() if t["name"] == "unannotated") + assert spec["parameters"]["properties"]["x"]["type"] == "string" + assert spec["parameters"]["properties"]["y"]["type"] == "string" + + def test_int_annotation_maps_to_integer(self, tmp_path): + class Agent4(Node9Agent): + @tool("typed") + def typed(self, count: int, flag: bool, ratio: float) -> str: + return "" + + agent = Agent4(workspace=str(tmp_path)) + spec = next(t for t in agent._build_tools() if t["name"] == "typed") + props = spec["parameters"]["properties"] + assert props["count"]["type"] == "integer" + assert props["flag"]["type"] == "boolean" + assert props["ratio"]["type"] == "number" + + def test_varargs_not_included_in_schema(self, tmp_path): + class Agent5(Node9Agent): + @tool("varargs") + def varargs(self, x: str, *args, **kwargs) -> str: + return x + + agent = Agent5(workspace=str(tmp_path)) + spec = next(t for t in agent._build_tools() if t["name"] == "varargs") + props = spec["parameters"]["properties"] + assert "x" in props + assert "args" not in props + assert "kwargs" not in props diff --git a/tests/test_config.py b/tests/test_config.py index 741521c..7b84bb9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,32 @@ -"""Tests for daemon port configuration.""" +"""Tests for daemon port configuration and configure() behaviour.""" import importlib import pytest +from node9 import configure +import node9._config as cfg + + +class TestConfigure: + def test_configure_sets_agent_name(self): + configure(agent_name="test-agent", policy="") + assert cfg.AGENT_NAME == "test-agent" + + def test_configure_sets_policy(self): + configure(agent_name="", policy="audit") + assert cfg.AGENT_POLICY == "audit" + + def test_configure_called_twice_second_wins(self): + configure(agent_name="first", policy="audit") + configure(agent_name="second", policy="require_approval") + assert cfg.AGENT_NAME == "second" + assert cfg.AGENT_POLICY == "require_approval" + + def test_configure_empty_string_does_not_overwrite(self): + configure(agent_name="kept", policy="audit") + configure(agent_name="", policy="") # empty args should not clear existing values + assert cfg.AGENT_NAME == "kept" + assert cfg.AGENT_POLICY == "audit" + class TestDaemonPort: def test_default_port(self, monkeypatch): diff --git a/tests/test_dlp.py b/tests/test_dlp.py index 34d062a..35ece86 100644 --- a/tests/test_dlp.py +++ b/tests/test_dlp.py @@ -147,26 +147,36 @@ def test_block_reason_mentions_path(self): class TestSafePath: def test_normal_file_resolves(self, tmp_path): - result = safe_path("src/main.py", str(tmp_path)) + result = safe_path("src/main.py", workspace=str(tmp_path)) assert result.startswith(str(tmp_path)) assert result.endswith("main.py") def test_traversal_rejected(self, tmp_path): with pytest.raises(ValueError, match="Path traversal"): - safe_path("../../etc/passwd", str(tmp_path)) + safe_path("../../etc/passwd", workspace=str(tmp_path)) def test_absolute_path_outside_workspace_rejected(self, tmp_path): with pytest.raises(ValueError, match="Path traversal"): - safe_path("/etc/passwd", str(tmp_path)) + safe_path("/etc/passwd", workspace=str(tmp_path)) def test_nested_path_allowed(self, tmp_path): - result = safe_path("a/b/c/file.txt", str(tmp_path)) + result = safe_path("a/b/c/file.txt", workspace=str(tmp_path)) assert "file.txt" in result def test_dot_prefix_stays_inside(self, tmp_path): - result = safe_path("./file.txt", str(tmp_path)) + result = safe_path("./file.txt", workspace=str(tmp_path)) assert result.startswith(str(tmp_path)) def test_error_message_contains_filename(self, tmp_path): with pytest.raises(ValueError, match="passwd"): - safe_path("../../etc/passwd", str(tmp_path)) + safe_path("../../etc/passwd", workspace=str(tmp_path)) + + def test_symlink_traversal_rejected(self, tmp_path): + """A symlink pointing outside the workspace must be rejected.""" + import os + outside = tmp_path.parent / "outside.txt" + outside.write_text("secret") + link = tmp_path / "escape.txt" + os.symlink(str(outside), str(link)) + with pytest.raises(ValueError, match="Path traversal"): + safe_path("escape.txt", workspace=str(tmp_path)) From dd9e4e460c0aa71675c11727abde7396577f3474 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 19:32:31 +0300 Subject: [PATCH 10/19] Fix code review round 4: path safety for all args, README warnings, env var tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Path safety now checks ALL path-like args in @tool methods, not just the first; a method with src+dest params now validates both (removed the break) - dispatch() docstring clarifies the lookup is strictly registry-based (@tool marker) - README run_shell example gets explicit warning that shlex.split still allows arbitrary executables — callers must allowlist commands in production - NODE9_SKIP env var table entry strengthened: "Never set in production" - Git hooks now use the active virtualenv Python instead of hardcoded python3 - New tests: traversal in second path arg is caught, configure() wins over env var, env var sets baseline before configure() is called Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-commit | 6 +++++- .githooks/pre-push | 6 +++++- README.md | 5 ++++- node9/_agent.py | 10 ++++++++-- tests/test_agent.py | 13 +++++++++++++ tests/test_config.py | 16 ++++++++++++++++ 6 files changed, 51 insertions(+), 5 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e0ab4e8..c4946d1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,9 +2,13 @@ # Run tests before every commit. Blocks the commit if any test fails. set -euo pipefail +# Use the virtualenv Python if active, otherwise fall back to python3. +PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON="${PYTHON:-$(command -v python3)}" + echo "🧪 node9: running tests before commit..." -if ! python3 -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then +if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then echo "" echo "❌ Tests failed — commit blocked. Fix the failures above and try again." echo " To skip (unsafe): git commit --no-verify" diff --git a/.githooks/pre-push b/.githooks/pre-push index d1a2435..4d6fe3a 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -2,9 +2,13 @@ # Run tests before every push. Last safety net before hitting the remote. set -euo pipefail +# Use the virtualenv Python if active, otherwise fall back to python3. +PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON="${PYTHON:-$(command -v python3)}" + echo "🧪 node9: running tests before push..." -if ! python3 -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then +if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then echo "" echo "❌ Tests failed — push blocked. Fix the failures above and try again." echo " To skip (unsafe): git push --no-verify" diff --git a/README.md b/README.md index 156009a..e569f05 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ def write_file(path: str, content: str) -> None: @protect("bash") def run_shell(command: str) -> str: + # WARNING: @protect gates on human approval but does NOT sanitize `command`. + # shlex.split prevents shell injection but still allows arbitrary executables + # and arguments. In production, validate/allowlist commands before calling this. import shlex, subprocess return subprocess.check_output(shlex.split(command), text=True) @@ -228,7 +231,7 @@ except ActionDeniedException as e: | `NODE9_AGENT_POLICY` | — | `audit`, `require_approval`, or `block_on_rules`. | | `NODE9_DAEMON_PORT` | `7391` | Local daemon port. | | `NODE9_AUTO_START` | — | Set to `1` to auto-launch the local daemon if not running. | -| `NODE9_SKIP` | — | Set to `1` to bypass all checks. Unsafe — for unit tests only. | +| `NODE9_SKIP` | — | Set to `1` to bypass all checks. **Never set in production** — disables all governance. For unit tests only. If set, a warning is emitted at import time. | ## Development diff --git a/node9/_agent.py b/node9/_agent.py index 49ff5d4..a534761 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -94,7 +94,9 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: if hit: raise ActionDeniedException(name, f"DLP blocked: {hit}") - # Path safety — any arg that looks like a file path + # Path safety — check ALL string args that look like file paths. + # We check every arg regardless of parameter name so that parameters + # named dest, output, filepath, etc. are protected the same as filename. if hasattr(self, "_workspace") and self._workspace: for v in call_args.values(): if isinstance(v, str) and ("/" in v or "\\" in v): @@ -102,7 +104,6 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: safe_path(v, workspace=self._workspace) except ValueError as e: raise ActionDeniedException(name, str(e)) from e - break run_id = getattr(self, "_run_id", "") evaluate(name, call_args, run_id=run_id) @@ -292,7 +293,12 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: This is the primary integration point for LLM loops: result = agent.dispatch(block.name, block.input) # Anthropic result = agent.dispatch(call.function.name, json.loads(call.function.arguments)) # OpenAI + + Lookup is strictly registry-based: only methods decorated with @tool are + reachable. Undecorated methods and arbitrary attribute names are never called. """ + # Lookup is strictly against the @tool decorator registry (_TOOL_ATTR marker). + # Only methods explicitly decorated with @tool are callable via dispatch(). for attr_name in dir(type(self)): method = getattr(type(self), attr_name, None) if method is None: diff --git a/tests/test_agent.py b/tests/test_agent.py index 4e8d095..16b01c5 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -176,6 +176,19 @@ def test_traversal_block_does_not_call_evaluate(self, tmp_path): pass mock_eval.assert_not_called() + def test_all_path_args_are_checked_not_just_first(self, tmp_path): + """Path traversal in any arg (not just the first) must be caught.""" + class MultiPathAgent(Node9Agent): + @tool("copy") + def copy(self, src: str, dest: str) -> str: + return f"copied:{src}->{dest}" + + agent = MultiPathAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + # First arg safe, second arg is traversal — must still be blocked + with pytest.raises(ActionDeniedException): + agent.copy("safe.txt", "../../etc/passwd") + # --------------------------------------------------------------------------- # @internal decorator diff --git a/tests/test_config.py b/tests/test_config.py index 7b84bb9..6c3c71c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,6 +27,22 @@ def test_configure_empty_string_does_not_overwrite(self): assert cfg.AGENT_NAME == "kept" assert cfg.AGENT_POLICY == "audit" + def test_configure_wins_over_env_var(self, monkeypatch): + """configure() called after import overrides env var defaults.""" + monkeypatch.setenv("NODE9_AGENT_NAME", "env-agent") + importlib.reload(cfg) + assert cfg.AGENT_NAME == "env-agent" + configure(agent_name="runtime-agent") + assert cfg.AGENT_NAME == "runtime-agent" + + def test_env_var_is_baseline_before_configure(self, monkeypatch): + """Without configure(), env var sets the identity.""" + monkeypatch.setenv("NODE9_AGENT_NAME", "from-env") + monkeypatch.setenv("NODE9_AGENT_POLICY", "require_approval") + importlib.reload(cfg) + assert cfg.AGENT_NAME == "from-env" + assert cfg.AGENT_POLICY == "require_approval" + class TestDaemonPort: def test_default_port(self, monkeypatch): From 163789a86ec0f530d3b233cdf48de1c59cb9cb15 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 20:50:50 +0300 Subject: [PATCH 11/19] adding feature to sdk --- .githooks/pre-commit | 19 +++---------------- .githooks/pre-push | 19 +++---------------- .githooks/run-tests.sh | 21 +++++++++++++++++++++ README.md | 4 ++-- node9/_agent.py | 21 ++++++++++++++++++++- tests/test_agent.py | 24 ++++++++++++++++++++++++ tests/test_client.py | 11 +++++++++++ tests/test_config.py | 23 +++++++++++++++++++++++ 8 files changed, 107 insertions(+), 35 deletions(-) create mode 100755 .githooks/run-tests.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index c4946d1..0b23594 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,18 +1,5 @@ #!/usr/bin/env bash # Run tests before every commit. Blocks the commit if any test fails. -set -euo pipefail - -# Use the virtualenv Python if active, otherwise fall back to python3. -PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" -PYTHON="${PYTHON:-$(command -v python3)}" - -echo "🧪 node9: running tests before commit..." - -if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then - echo "" - echo "❌ Tests failed — commit blocked. Fix the failures above and try again." - echo " To skip (unsafe): git commit --no-verify" - exit 1 -fi - -echo "✅ All tests passed." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=run-tests.sh +source "$SCRIPT_DIR/run-tests.sh" commit diff --git a/.githooks/pre-push b/.githooks/pre-push index 4d6fe3a..0355767 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,18 +1,5 @@ #!/usr/bin/env bash # Run tests before every push. Last safety net before hitting the remote. -set -euo pipefail - -# Use the virtualenv Python if active, otherwise fall back to python3. -PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" -PYTHON="${PYTHON:-$(command -v python3)}" - -echo "🧪 node9: running tests before push..." - -if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short 2>&1; then - echo "" - echo "❌ Tests failed — push blocked. Fix the failures above and try again." - echo " To skip (unsafe): git push --no-verify" - exit 1 -fi - -echo "✅ All tests passed." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=run-tests.sh +source "$SCRIPT_DIR/run-tests.sh" push diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh new file mode 100755 index 0000000..0d06768 --- /dev/null +++ b/.githooks/run-tests.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Shared test runner sourced by pre-commit and pre-push hooks. +# Usage: source run-tests.sh (context = "commit" | "push") +set -euo pipefail + +CONTEXT="${1:-commit}" + +# Use the virtualenv Python if active, otherwise fall back to python3. +PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON="${PYTHON:-$(command -v python3)}" + +echo "🧪 node9: running tests before ${CONTEXT}..." + +if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short; then + echo "" + echo "❌ Tests failed — ${CONTEXT} blocked. Fix the failures above and try again." + echo " To skip (unsafe): git ${CONTEXT} --no-verify" + exit 1 +fi + +echo "✅ All tests passed." diff --git a/README.md b/README.md index e569f05..b5678bb 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ def write_file(path: str, content: str) -> None: @protect("bash") def run_shell(command: str) -> str: # WARNING: @protect gates on human approval but does NOT sanitize `command`. - # shlex.split prevents shell injection but still allows arbitrary executables - # and arguments. In production, validate/allowlist commands before calling this. + # shlex.split avoids invoking a shell interpreter, but does NOT prevent the LLM + # from running arbitrary executables. In production, validate/allowlist commands. import shlex, subprocess return subprocess.check_output(shlex.split(command), text=True) diff --git a/node9/_agent.py b/node9/_agent.py index a534761..787d4f0 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -130,7 +130,20 @@ def internal(fn: Callable) -> Callable: - Logs to stdout: [node9 internal] method_name(args) - Use only for non-agent-decision code (git, workspace setup, file plumbing). Do NOT use to bypass governance on agent-controlled actions. + + WARNING: @internal skips all governance. By convention, @internal methods + should have names starting with '_' to make the bypass visible at call sites. + A RuntimeWarning is raised if a public method name is decorated with @internal. """ + import warnings + if not fn.__name__.startswith("_"): + warnings.warn( + f"@internal applied to public method '{fn.__name__}' — @internal skips all " + "governance checks. Rename to '_{fn.__name__}' or use @tool instead.", + RuntimeWarning, + stacklevel=2, + ) + @functools.wraps(fn) def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: sig = inspect.signature(fn) @@ -311,7 +324,13 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: return e.negotiation except Exception as e: return f"Error: {e}" - return f"Unknown tool: {tool_name}" + available = sorted( + getattr(m, _TOOL_ATTR) + for attr in dir(type(self)) + if (m := getattr(type(self), attr, None)) is not None + and getattr(m, _TOOL_ATTR, None) is not None + ) + return f"Unknown tool: {tool_name!r}. Available tools: {available}" def _dispatch(self, tool_name: str, tool_input: dict) -> str: """Deprecated alias for dispatch(). Use dispatch() instead.""" diff --git a/tests/test_agent.py b/tests/test_agent.py index 16b01c5..6971043 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -213,6 +213,30 @@ def test_internal_logs_to_stdout(self, tmp_path, capsys): assert "internal" in captured.out assert "_git_push" in captured.out + def test_internal_on_public_method_warns(self): + """@internal on a public method (no leading _) emits RuntimeWarning.""" + import warnings + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + class BadAgent(Node9Agent): + @internal + def public_infra(self) -> str: # public name, missing underscore + return "ok" + + assert any( + issubclass(w.category, RuntimeWarning) and "public_infra" in str(w.message) + for w in caught + ), "Expected RuntimeWarning for @internal on public method" + + def test_dispatch_error_message_includes_available_tools(self, tmp_path): + """dispatch() with unknown tool name returns the list of available tools.""" + agent = SimpleAgent(workspace=str(tmp_path)) + result = agent.dispatch("no_such_tool", {}) + assert "no_such_tool" in result + assert "write_file" in result + assert "run_cmd" in result + # --------------------------------------------------------------------------- # _build_tools — neutral tool spec diff --git a/tests/test_client.py b/tests/test_client.py index 438e249..d67655f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -89,6 +89,17 @@ def test_node9_skip_env_bypasses_daemon(self, monkeypatch): with patch("urllib.request.urlopen", side_effect=Exception("should not be called")): evaluate("anything", {"key": "val"}) # should not raise + def test_node9_skip_emits_warning_per_call(self, monkeypatch, tmp_path): + """evaluate() warns on every call when NODE9_SKIP=1 so misuse is visible in logs.""" + import warnings + monkeypatch.setattr("node9._client._SKIP", True) + monkeypatch.setenv("HOME", str(tmp_path)) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + evaluate("any_tool", {"x": 1}) + assert any("NODE9_SKIP" in str(w.message) for w in caught), \ + "Expected a NODE9_SKIP warning but none was emitted" + def test_unknown_decision_treated_as_deny(self): check_resp = _make_response({"id": "req-123"}) wait_resp = _make_response({"decision": "unknown_value"}) diff --git a/tests/test_config.py b/tests/test_config.py index 6c3c71c..e000f88 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Tests for daemon port configuration and configure() behaviour.""" import importlib +import threading import pytest from node9 import configure @@ -43,6 +44,28 @@ def test_env_var_is_baseline_before_configure(self, monkeypatch): assert cfg.AGENT_NAME == "from-env" assert cfg.AGENT_POLICY == "require_approval" + def test_configure_thread_safe(self): + """Concurrent configure() calls must not corrupt module globals.""" + import node9._config as cfg + errors = [] + + def set_name(name): + try: + configure(agent_name=name, policy="audit") + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=set_name, args=(f"agent-{i}",)) for i in range(50)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"configure() raised in threads: {errors}" + # Final value is one of the valid names (not corrupted) + assert cfg.AGENT_NAME.startswith("agent-") + assert cfg.AGENT_POLICY == "audit" + class TestDaemonPort: def test_default_port(self, monkeypatch): From 68a9e95232d5be990585a17dea2c587952cdf5f8 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 20:55:49 +0300 Subject: [PATCH 12/19] adding feature to sdk --- README.md | 27 ++++++++++++++++---------- node9/_agent.py | 14 ++++++++++++++ node9/_client.py | 10 +++++++--- node9/_dlp.py | 5 +++++ tests/test_agent.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b5678bb..89abfa9 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,16 @@ def write_file(path: str, content: str) -> None: with open(path, "w") as f: f.write(content) -@protect("bash") -def run_shell(command: str) -> str: - # WARNING: @protect gates on human approval but does NOT sanitize `command`. - # shlex.split avoids invoking a shell interpreter, but does NOT prevent the LLM - # from running arbitrary executables. In production, validate/allowlist commands. - import shlex, subprocess - return subprocess.check_output(shlex.split(command), text=True) +_ALLOWED_COMMANDS = {"pytest", "ruff", "mypy", "black"} + +@protect("run_tests") +def run_tests(tool: str) -> str: + # Allowlist-based: only pre-approved CLI tools can be invoked. + # Never pass raw LLM strings to subprocess — enumerate safe commands explicitly. + if tool not in _ALLOWED_COMMANDS: + raise ValueError(f"Tool {tool!r} is not in the allowed list: {_ALLOWED_COMMANDS}") + import subprocess + return subprocess.check_output([tool], text=True) try: write_file("/etc/hosts", "bad content") @@ -125,11 +128,15 @@ class CiAgent(Node9Agent): agent_name = "ci-code-review" policy = "audit" + _ALLOWED_SUITES = {"pytest", "pytest --tb=short", "ruff check ."} + @tool("run_tests") - def run_tests(self, command: str) -> str: - """Run the test suite and return output.""" + def run_tests(self, suite: str) -> str: + """Run an allowlisted test suite and return output.""" import shlex, subprocess - return subprocess.check_output(shlex.split(command), text=True) + if suite not in self._ALLOWED_SUITES: + raise ValueError(f"Suite {suite!r} not in allowed list: {self._ALLOWED_SUITES}") + return subprocess.check_output(shlex.split(suite), text=True) @tool("write_code") def write_code(self, filename: str, content: str) -> str: diff --git a/node9/_agent.py b/node9/_agent.py index 787d4f0..00a1ba1 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -319,6 +319,20 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: if getattr(method, _TOOL_ATTR, None) == tool_name: try: result = getattr(self, attr_name)(**tool_input) + if inspect.iscoroutine(result): + import asyncio + try: + asyncio.get_running_loop() + # Already inside an async event loop — dispatch() cannot + # await here. The caller should await the method directly: + # result = await agent.method(**tool_input) + result.close() # prevent "coroutine was never awaited" warning + return ( + f"Error: '{tool_name}' is async. " + "In an async context, call it directly with 'await'." + ) + except RuntimeError: + result = asyncio.run(result) return str(result) if result is not None else "" except ActionDeniedException as e: return e.negotiation diff --git a/node9/_client.py b/node9/_client.py index 2e2b546..f8e1982 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -142,9 +142,13 @@ def _offline_audit(tool_name: str, args: dict[str, Any], run_id: str) -> None: try: with open(audit_path, "a") as f: f.write(json.dumps(entry, default=str) + "\n") - except OSError: - pass # never crash the agent due to audit failure - print(f" [node9 offline] {tool_name} — logged to {audit_path}", flush=True) + except OSError as e: + # Audit write failed (read-only fs, container, permissions). + # Never crash the agent, but surface the failure so it's not silent. + import sys + print(f" [node9 offline] WARNING: audit write failed ({e})", file=sys.stderr, flush=True) + else: + print(f" [node9 offline] {tool_name} — logged to {audit_path}", flush=True) def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> None: diff --git a/node9/_dlp.py b/node9/_dlp.py index 95df077..60c91a3 100644 --- a/node9/_dlp.py +++ b/node9/_dlp.py @@ -54,6 +54,11 @@ def safe_path(filename: str, *, workspace: str) -> str: Resolve filename relative to workspace and verify it stays inside. Symlinks are resolved via os.path.realpath before the boundary check. Raises ValueError on path traversal attempts. + + Exception contract: + - Called directly: raises ValueError (standard Python convention for bad input). + - Called inside a @tool method: the @tool wrapper catches ValueError and + re-raises it as ActionDeniedException so LLM tool loops get a uniform type. """ resolved = os.path.realpath(os.path.join(workspace, filename)) workspace_root = os.path.realpath(workspace) + os.sep diff --git a/tests/test_agent.py b/tests/test_agent.py index 6971043..bc6da76 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -353,6 +353,53 @@ def test_dispatch_unknown_tool_does_not_raise(self, tmp_path): pytest.fail(f"dispatch() raised unexpectedly: {e}") +# --------------------------------------------------------------------------- +# async @tool via dispatch() +# --------------------------------------------------------------------------- + +class TestAsyncTool: + def test_async_tool_direct_call_works(self, tmp_path): + """Calling an async @tool method directly returns an awaitable.""" + import asyncio + + class AsyncAgent(Node9Agent): + @tool("async_task") + async def async_task(self, x: str) -> str: + return f"done:{x}" + + agent = AsyncAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = asyncio.run(agent.async_task("hi")) + assert result == "done:hi" + + def test_async_tool_via_dispatch_no_loop(self, tmp_path): + """dispatch() runs async tools to completion when no event loop is running.""" + class AsyncAgent(Node9Agent): + @tool("async_task") + async def async_task(self, x: str) -> str: + return f"done:{x}" + + agent = AsyncAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = agent.dispatch("async_task", {"x": "hello"}) + assert result == "done:hello" + + def test_async_tool_dispatch_returns_string_not_coroutine(self, tmp_path): + """dispatch() must not return a raw coroutine object.""" + import inspect + + class AsyncAgent(Node9Agent): + @tool("async_task") + async def async_task(self, x: str) -> str: + return f"done:{x}" + + agent = AsyncAgent(workspace=str(tmp_path)) + with patch(EVAL_PATCH): + result = agent.dispatch("async_task", {"x": "test"}) + assert not inspect.iscoroutine(result), "dispatch() returned a raw coroutine" + assert isinstance(result, str) + + # --------------------------------------------------------------------------- # new_session # --------------------------------------------------------------------------- From 639615420aa11e09f542b3851091c9dad1d97fc4 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 21:03:24 +0300 Subject: [PATCH 13/19] adding feature to sdk --- .githooks/run-tests.sh | 14 +++++++++++++- node9/_agent.py | 22 +++++++++++++++------- node9/_client.py | 8 ++++---- tests/test_agent.py | 31 ++++++++++++++++++++++++++++--- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh index 0d06768..9b9ac40 100755 --- a/.githooks/run-tests.sh +++ b/.githooks/run-tests.sh @@ -7,7 +7,19 @@ CONTEXT="${1:-commit}" # Use the virtualenv Python if active, otherwise fall back to python3. PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" -PYTHON="${PYTHON:-$(command -v python3)}" +PYTHON="${PYTHON:-$(command -v python3 2>/dev/null)}" + +if [[ -z "$PYTHON" ]]; then + echo "❌ node9: python3 not found on PATH. Install Python 3.9+ and try again." >&2 + exit 1 +fi + +# Sanity-check: require Python 3.9+ +PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,9))' 2>/dev/null) +if [[ "$PYVER" != "True" ]]; then + echo "❌ node9: Python 3.9+ required (found: $("$PYTHON" --version 2>&1))" >&2 + exit 1 +fi echo "🧪 node9: running tests before ${CONTEXT}..." diff --git a/node9/_agent.py b/node9/_agent.py index 00a1ba1..aa13034 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -14,11 +14,15 @@ class CiAgent(Node9Agent): agent_name = "ci-code-review" policy = "audit" + _ALLOWED_SUITES = {"pytest", "pytest --tb=short", "ruff check ."} + @tool("run_tests") - def run_tests(self, command: str) -> str: + def run_tests(self, suite: str) -> str: import shlex, subprocess - # Use shell=False to avoid injection — split the command string safely - return subprocess.check_output(shlex.split(command), text=True) + # Always allowlist LLM-controlled commands — never pass raw strings to subprocess. + if suite not in self._ALLOWED_SUITES: + raise ValueError(f"Suite {suite!r} not in allowed list") + return subprocess.check_output(shlex.split(suite), text=True) @tool("write_code") def write_code(self, filename: str, content: str) -> str: @@ -46,10 +50,13 @@ def _git_push(self, branch: str) -> str: # result = agent.dispatch(tool_name, tool_input) """ +import asyncio import functools import inspect import os +import sys import uuid +import warnings from typing import Any, Callable, Union from ._client import evaluate @@ -135,7 +142,6 @@ def internal(fn: Callable) -> Callable: should have names starting with '_' to make the bypass visible at call sites. A RuntimeWarning is raised if a public method name is decorated with @internal. """ - import warnings if not fn.__name__.startswith("_"): warnings.warn( f"@internal applied to public method '{fn.__name__}' — @internal skips all " @@ -151,7 +157,9 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any: bound.apply_defaults() call_args = {k: v for k, v in bound.arguments.items() if k != "self"} arg_summary = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in call_args.items()) - print(f" [node9 internal] {fn.__name__}({arg_summary})", flush=True) + # Write to stderr, not stdout — LLM frameworks parse stdout for tool results + # and a print() mid-execution would corrupt the JSON/text output stream. + print(f" [node9 internal] {fn.__name__}({arg_summary})", file=sys.stderr, flush=True) return fn(self, *args, **kwargs) setattr(wrapper, _INTERNAL_ATTR, True) @@ -320,7 +328,6 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: try: result = getattr(self, attr_name)(**tool_input) if inspect.iscoroutine(result): - import asyncio try: asyncio.get_running_loop() # Already inside an async event loop — dispatch() cannot @@ -332,6 +339,8 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: "In an async context, call it directly with 'await'." ) except RuntimeError: + # RuntimeError here means "no running event loop" — + # the only error get_running_loop() raises. Safe to run. result = asyncio.run(result) return str(result) if result is not None else "" except ActionDeniedException as e: @@ -348,7 +357,6 @@ def dispatch(self, tool_name: str, tool_input: dict) -> str: def _dispatch(self, tool_name: str, tool_input: dict) -> str: """Deprecated alias for dispatch(). Use dispatch() instead.""" - import warnings warnings.warn( "Node9Agent._dispatch() is deprecated — use .dispatch() instead.", DeprecationWarning, diff --git a/node9/_client.py b/node9/_client.py index f8e1982..876437b 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -13,6 +13,7 @@ import re import shutil import subprocess +import sys import time import http.client import urllib.error @@ -145,10 +146,9 @@ def _offline_audit(tool_name: str, args: dict[str, Any], run_id: str) -> None: except OSError as e: # Audit write failed (read-only fs, container, permissions). # Never crash the agent, but surface the failure so it's not silent. - import sys print(f" [node9 offline] WARNING: audit write failed ({e})", file=sys.stderr, flush=True) else: - print(f" [node9 offline] {tool_name} — logged to {audit_path}", flush=True) + print(f" [node9 offline] {tool_name} — logged to {audit_path}", file=sys.stderr, flush=True) def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> None: @@ -224,7 +224,7 @@ def _evaluate_cloud(tool_name: str, args: dict[str, Any], run_id: str = "") -> N 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) + print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", file=sys.stderr, flush=True) poll_timeout = max(30, min(3600, int(os.environ.get("NODE9_CLOUD_TIMEOUT", "600")))) status_url = f"{api_url}/status/{request_id}" @@ -302,7 +302,7 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None: if not request_id: raise RuntimeError(f"[Node9] Unexpected daemon response: {result}") - print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", flush=True) + print(f"🛡️ Node9: waiting for approval of '{tool_name}'...", file=sys.stderr, flush=True) decision_result = _get(f"/wait/{request_id}") decision = decision_result.get("decision", "deny") diff --git a/tests/test_agent.py b/tests/test_agent.py index bc6da76..53f6832 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -206,12 +206,14 @@ def test_internal_return_value_passed_through(self, tmp_path): result = agent._git_push("main") assert result == "pushed:main" - def test_internal_logs_to_stdout(self, tmp_path, capsys): + def test_internal_logs_to_stderr(self, tmp_path, capsys): + # @internal writes to stderr so LLM parsers reading stdout are not corrupted agent = SimpleAgent(workspace=str(tmp_path)) agent._git_push("dev") captured = capsys.readouterr() - assert "internal" in captured.out - assert "_git_push" in captured.out + assert "internal" in captured.err + assert "_git_push" in captured.err + assert "internal" not in captured.out # must NOT appear on stdout def test_internal_on_public_method_warns(self): """@internal on a public method (no leading _) emits RuntimeWarning.""" @@ -260,6 +262,29 @@ def test_internal_not_in_tools(self, tmp_path): names = {t["name"] for t in agent._build_tools()} assert "_git_push" not in names + def test_multi_level_inheritance(self, tmp_path): + """@tool methods on all levels of the MRO are visible in _build_tools().""" + class Base(Node9Agent): + @tool("base_op") + def base_op(self, x: str) -> str: + return x + + class Mid(Base): + @tool("mid_op") + def mid_op(self, y: str) -> str: + return y + + class Leaf(Mid): + @tool("leaf_op") + def leaf_op(self, z: str) -> str: + return z + + agent = Leaf(workspace=str(tmp_path)) + names = {t["name"] for t in agent._build_tools()} + assert "base_op" in names + assert "mid_op" in names + assert "leaf_op" in names + def test_neutral_format_uses_parameters_key(self, tmp_path): agent = SimpleAgent(workspace=str(tmp_path)) for t in agent._build_tools(): From d9e6651aa619e2fdeaf53198651078f0d69ae25b Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 21:17:19 +0300 Subject: [PATCH 14/19] adding feature to sdk --- .githooks/run-tests.sh | 10 +++++++--- README.md | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh index 9b9ac40..cdc22a2 100755 --- a/.githooks/run-tests.sh +++ b/.githooks/run-tests.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # Shared test runner sourced by pre-commit and pre-push hooks. # Usage: source run-tests.sh (context = "commit" | "push") +# +# NOTE: uses `return` not `exit` — this script is sourced, not executed. +# `exit` in a sourced script terminates the parent shell; `return` only +# exits the script's scope, leaving the caller's shell intact. set -euo pipefail CONTEXT="${1:-commit}" @@ -11,14 +15,14 @@ PYTHON="${PYTHON:-$(command -v python3 2>/dev/null)}" if [[ -z "$PYTHON" ]]; then echo "❌ node9: python3 not found on PATH. Install Python 3.9+ and try again." >&2 - exit 1 + return 1 fi # Sanity-check: require Python 3.9+ PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,9))' 2>/dev/null) if [[ "$PYVER" != "True" ]]; then echo "❌ node9: Python 3.9+ required (found: $("$PYTHON" --version 2>&1))" >&2 - exit 1 + return 1 fi echo "🧪 node9: running tests before ${CONTEXT}..." @@ -27,7 +31,7 @@ if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short; then echo "" echo "❌ Tests failed — ${CONTEXT} blocked. Fix the failures above and try again." echo " To skip (unsafe): git ${CONTEXT} --no-verify" - exit 1 + return 1 fi echo "✅ All tests passed." diff --git a/README.md b/README.md index 89abfa9..0925973 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,9 @@ class CiAgent(Node9Agent): @tool("write_code") def write_code(self, filename: str, content: str) -> str: """Write content to a file in the workspace.""" - with open(filename, "w") as f: + from node9 import safe_path + path = safe_path(filename, workspace=self._workspace) # traversal-safe + with open(path, "w") as f: f.write(content) return f"Written {filename}" From f831414cc8108838e71ede7764d01ff26f36773e Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 21:49:46 +0300 Subject: [PATCH 15/19] adding feature to sdk --- .githooks/run-tests.sh | 4 ++-- examples/basic.py | 9 +++++++-- examples/crewai_agent.py | 11 ++++++++--- examples/langchain_agent.py | 9 +++++++-- node9/_client.py | 10 ++++++++++ pyproject.toml | 5 ++++- tests/test_client.py | 17 +++++++++++++++++ 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh index cdc22a2..603a0b4 100755 --- a/.githooks/run-tests.sh +++ b/.githooks/run-tests.sh @@ -19,9 +19,9 @@ if [[ -z "$PYTHON" ]]; then fi # Sanity-check: require Python 3.9+ -PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,9))' 2>/dev/null) +PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,10))' 2>/dev/null) if [[ "$PYVER" != "True" ]]; then - echo "❌ node9: Python 3.9+ required (found: $("$PYTHON" --version 2>&1))" >&2 + echo "❌ node9: Python 3.10+ required (found: $("$PYTHON" --version 2>&1))" >&2 return 1 fi diff --git a/examples/basic.py b/examples/basic.py index 8a309b4..1a3b940 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -27,10 +27,15 @@ def delete_file(path: str) -> None: print(f"Deleted: {path}") +_ALLOWED_COMMANDS = {"ls", "pwd", "git status", "git log --oneline"} + @protect("bash") def run_shell(command: str) -> str: - import subprocess - return subprocess.check_output(command, shell=True, text=True) + import shlex, subprocess + # Allowlist-only: never pass LLM-controlled strings to shell=True. + if command not in _ALLOWED_COMMANDS: + raise ValueError(f"Command {command!r} not in allowed list") + return subprocess.check_output(shlex.split(command), text=True) # --- Custom tool name + params lambda --- diff --git a/examples/crewai_agent.py b/examples/crewai_agent.py index f5f52ae..67d7151 100644 --- a/examples/crewai_agent.py +++ b/examples/crewai_agent.py @@ -25,11 +25,16 @@ def write_file(path: str, content: str) -> str: @tool("run_shell") +_ALLOWED_COMMANDS = {"pytest", "ruff check .", "mypy src/"} + @protect("bash") def run_shell(command: str) -> str: - """Execute a shell command.""" - import subprocess - return subprocess.check_output(command, shell=True, text=True) + """Execute an allowlisted shell command.""" + import shlex, subprocess + # Allowlist-only: never pass LLM-controlled strings to shell=True. + if command not in _ALLOWED_COMMANDS: + raise ValueError(f"Command {command!r} not in allowed list") + return subprocess.check_output(shlex.split(command), text=True) @tool("deploy_service") diff --git a/examples/langchain_agent.py b/examples/langchain_agent.py index ad56f28..d462190 100644 --- a/examples/langchain_agent.py +++ b/examples/langchain_agent.py @@ -32,10 +32,15 @@ class RunShellTool(BaseTool): name: str = "bash" description: str = "Run a shell command and return its output." + _ALLOWED_COMMANDS = {"pytest", "ruff check .", "mypy ."} + @protect("bash") def _run(self, command: str) -> str: - import subprocess - return subprocess.check_output(command, shell=True, text=True) + import shlex, subprocess + # Allowlist-only: never pass LLM-controlled strings to shell=True. + if command not in self._ALLOWED_COMMANDS: + raise ValueError(f"Command {command!r} not in allowed list") + return subprocess.check_output(shlex.split(command), text=True) class DeleteFileTool(BaseTool): diff --git a/node9/_client.py b/node9/_client.py index 876437b..92f7dc6 100644 --- a/node9/_client.py +++ b/node9/_client.py @@ -127,6 +127,16 @@ def _offline_audit(tool_name: str, args: dict[str, Any], run_id: str) -> None: Used when neither NODE9_API_KEY nor local daemon is available. """ import datetime + _, policy = _config.get() + if policy == "require_approval": + import warnings + warnings.warn( + f"[Node9] Governance degraded to offline/auto-approve for '{tool_name}' — " + "policy is 'require_approval' but no daemon or API key is available. " + "Start the node9 daemon or set NODE9_API_KEY to enforce approvals.", + RuntimeWarning, + stacklevel=4, + ) audit_dir = os.path.join(os.path.expanduser("~"), ".node9") os.makedirs(audit_dir, exist_ok=True) audit_path = os.path.join(audit_dir, "audit.log") diff --git a/pyproject.toml b/pyproject.toml index 422416f..bec0026 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,14 @@ build-backend = "hatchling.build" [project] name = "node9" -version = "1.0.0" +version = "2.0.0" description = "Execution security for Python AI agents — seatbelt for LangChain, CrewAI, and plain Python." readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] keywords = ["ai", "agents", "security", "langchain", "crewai", "llm"] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/tests/test_client.py b/tests/test_client.py index d67655f..0b48e7b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -298,6 +298,23 @@ def test_offline_mode_auto_approves(self, monkeypatch, tmp_path): result = evaluate("bash", {"command": "ls"}) assert result is None + def test_offline_with_require_approval_policy_warns(self, monkeypatch, tmp_path): + """Offline auto-approve must warn loudly when policy is require_approval.""" + import warnings + import node9._config as cfg + monkeypatch.delenv("NODE9_API_KEY", raising=False) + monkeypatch.delenv("NODE9_AUTO_START", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(cfg, "AGENT_POLICY", "require_approval") + with patch("node9._client._daemon_reachable", return_value=False): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + evaluate("deploy", {"target": "prod"}) + assert any( + issubclass(w.category, RuntimeWarning) and "require_approval" in str(w.message) + for w in caught + ), "Expected RuntimeWarning for offline degradation under require_approval policy" + class TestConfigure: def test_configure_sets_agent_name(self): From 087d4fb0b40d0e493a1d3968987b90105554b2a0 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 21:54:03 +0300 Subject: [PATCH 16/19] adding feature to sdk --- .githooks/run-tests.sh | 4 ++-- examples/crewai_agent.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh index 603a0b4..2519aad 100755 --- a/.githooks/run-tests.sh +++ b/.githooks/run-tests.sh @@ -14,11 +14,11 @@ PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" PYTHON="${PYTHON:-$(command -v python3 2>/dev/null)}" if [[ -z "$PYTHON" ]]; then - echo "❌ node9: python3 not found on PATH. Install Python 3.9+ and try again." >&2 + echo "❌ node9: python3 not found on PATH. Install Python 3.10+ and try again." >&2 return 1 fi -# Sanity-check: require Python 3.9+ +# Sanity-check: require Python 3.10+ (matches pyproject.toml requires-python) PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,10))' 2>/dev/null) if [[ "$PYVER" != "True" ]]; then echo "❌ node9: Python 3.10+ required (found: $("$PYTHON" --version 2>&1))" >&2 diff --git a/examples/crewai_agent.py b/examples/crewai_agent.py index 67d7151..c8d59fd 100644 --- a/examples/crewai_agent.py +++ b/examples/crewai_agent.py @@ -24,9 +24,9 @@ def write_file(path: str, content: str) -> str: return f"Written to {path}" -@tool("run_shell") _ALLOWED_COMMANDS = {"pytest", "ruff check .", "mypy src/"} +@tool("run_shell") @protect("bash") def run_shell(command: str) -> str: """Execute an allowlisted shell command.""" From 046eb79f97bb38968e3a42716887fc789edb7b82 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 21:58:46 +0300 Subject: [PATCH 17/19] adding feature to sdk --- node9/__init__.py | 8 ++++++++ node9/_agent.py | 4 +++- tests/test_agent.py | 15 ++++++++++++++ tests/test_dlp.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/node9/__init__.py b/node9/__init__.py index 391cd61..e872f0d 100644 --- a/node9/__init__.py +++ b/node9/__init__.py @@ -16,6 +16,14 @@ def configure(*, agent_name: str = "", policy: str = "") -> None: Thread-safe — safe to call from concurrent async frameworks (LangGraph, FastMCP). policy values: "audit" | "require_approval" | "block_on_rules" | "" (SaaS default) + + Configuration precedence (highest wins): + 1. configure() called at runtime — highest priority + 2. Node9Agent.agent_name / .policy class attributes + 3. NODE9_AGENT_NAME / NODE9_AGENT_POLICY env vars — baseline at import time + + Calling configure() after the first tool call is allowed but not recommended — + in-flight calls will have already used the previous identity. """ _config.set_identity(agent_name=agent_name, policy=policy) diff --git a/node9/_agent.py b/node9/_agent.py index aa13034..0d94a61 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -134,9 +134,11 @@ def internal(fn: Callable) -> Callable: Marks a Node9Agent method as infrastructure (git plumbing, workspace setup). - Never calls evaluate() — no SaaS call, no blocking, no DLP scan - - Logs to stdout: [node9 internal] method_name(args) + - Logs to stderr: [node9 internal] method_name(args) - Use only for non-agent-decision code (git, workspace setup, file plumbing). Do NOT use to bypass governance on agent-controlled actions. + - NOT reachable via dispatch() — the LLM cannot invoke @internal methods + through the tool-call interface. dispatch() only routes to @tool methods. WARNING: @internal skips all governance. By convention, @internal methods should have names starting with '_' to make the bypass visible at call sites. diff --git a/tests/test_agent.py b/tests/test_agent.py index 53f6832..22ef35c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -215,6 +215,21 @@ def test_internal_logs_to_stderr(self, tmp_path, capsys): assert "_git_push" in captured.err assert "internal" not in captured.out # must NOT appear on stdout + def test_internal_not_reachable_via_dispatch(self, tmp_path): + """LLM cannot invoke @internal methods through dispatch() — they are invisible to the tool router.""" + agent = SimpleAgent(workspace=str(tmp_path)) + result = agent.dispatch("_git_push", {"branch": "main"}) + assert "Unknown tool" in result, ( + "@internal method '_git_push' should not be reachable via dispatch()" + ) + + def test_internal_not_reachable_via_dispatch_by_function_name(self, tmp_path): + """dispatch() ignores @internal even if called by the underlying function name.""" + agent = SimpleAgent(workspace=str(tmp_path)) + # SimpleAgent._git_push is @internal — must not be callable via dispatch + result = agent.dispatch("_git_push", {}) + assert "Unknown tool" in result + def test_internal_on_public_method_warns(self): """@internal on a public method (no leading _) emits RuntimeWarning.""" import warnings diff --git a/tests/test_dlp.py b/tests/test_dlp.py index 35ece86..a79b2da 100644 --- a/tests/test_dlp.py +++ b/tests/test_dlp.py @@ -98,6 +98,55 @@ def test_content_within_100k_is_scanned(self): assert result is not None +class TestRealPatternMatching: + """Verify the actual DLP regexes match the credential formats they target. + + Strings are constructed from fragments joined at runtime so no complete + credential pattern appears as a literal in this source file — which would + trigger node9's own DLP hook when writing or committing this file. + """ + + def test_aws_key_in_content_is_detected(self): + # AWS Access Key ID: AKIA + 16 uppercase alphanumeric chars + # Split to avoid a complete match in source text + prefix = "AKI" + "A" # "AKIA" in source is never complete + suffix = "X" * 8 + "0" * 8 # 16-char body, clearly synthetic + fake_key = prefix + suffix + result = dlp_scan("config.txt", f"aws_key={fake_key}") + assert result is not None + assert "AWS" in result + + def test_github_token_in_content_is_detected(self): + # GitHub token: ghp_ + 36 alphanumeric + fake_token = "gh" + "p_" + "A" * 36 + result = dlp_scan("env.txt", f"GH_TOKEN={fake_token}") + assert result is not None + assert "GitHub" in result + + def test_pem_key_in_content_is_detected(self): + # PEM private key header — split across fragments so it never appears whole + pem_begin = "-----BEGIN " + "RSA " + pem_end = "PRIVATE KEY-----" + result = dlp_scan("key.txt", pem_begin + pem_end) + assert result is not None + assert "Private Key" in result + + def test_clean_content_with_normal_path_passes(self): + result = dlp_scan("output.txt", "def hello():\n return 42\n") + assert result is None + + def test_sensitive_path_detected_regardless_of_content(self): + result = dlp_scan("/home/user/.ssh/" + "id_rsa", "normal content") + assert result is not None + + def test_both_path_and_content_triggers(self): + # Both path and content are bad — path check runs first, result is non-None + prefix = "AKI" + "A" + fake_key = prefix + "X" * 8 + "0" * 8 + result = dlp_scan("/home/user/.ssh/" + "id_rsa", fake_key) + assert result is not None + + class TestSensitivePathDetection: """Test sensitive file path blocking — no credential content needed.""" From 39f9b1ac7d36ff52ee2f4dc4531391dc5bfec32f Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 22:01:23 +0300 Subject: [PATCH 18/19] adding feature to sdk --- .githooks/run-tests.sh | 16 +++++++++++++--- node9/_agent.py | 13 ++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.githooks/run-tests.sh b/.githooks/run-tests.sh index 2519aad..c53ce0e 100755 --- a/.githooks/run-tests.sh +++ b/.githooks/run-tests.sh @@ -9,9 +9,19 @@ set -euo pipefail CONTEXT="${1:-commit}" -# Use the virtualenv Python if active, otherwise fall back to python3. -PYTHON="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" -PYTHON="${PYTHON:-$(command -v python3 2>/dev/null)}" +# Use the virtualenv Python if active, otherwise fall back to system python3. +# Check python3 first inside the venv (most venvs only create python3, not python). +if [[ -n "${VIRTUAL_ENV:-}" ]]; then + if [[ -x "$VIRTUAL_ENV/bin/python3" ]]; then + PYTHON="$VIRTUAL_ENV/bin/python3" + elif [[ -x "$VIRTUAL_ENV/bin/python" ]]; then + PYTHON="$VIRTUAL_ENV/bin/python" + else + PYTHON="$(command -v python3 2>/dev/null)" + fi +else + PYTHON="$(command -v python3 2>/dev/null)" +fi if [[ -z "$PYTHON" ]]; then echo "❌ node9: python3 not found on PATH. Install Python 3.10+ and try again." >&2 diff --git a/node9/_agent.py b/node9/_agent.py index 0d94a61..491567f 100644 --- a/node9/_agent.py +++ b/node9/_agent.py @@ -311,14 +311,21 @@ def build_tools_openai(self) -> list[dict]: def dispatch(self, tool_name: str, tool_input: dict) -> str: """ Route a tool call by name to the matching @tool method. - Returns a string result — or negotiation text if the action was denied. + + Always returns a str — safe to pass directly as a tool_result to any LLM API. + Never raises: exceptions from the tool are caught and returned as "Error: ..." strings. + + ActionDeniedException → returns e.negotiation (denial reason for the LLM) + Any other exception → returns "Error: " + Unknown tool name → returns "Unknown tool: '...'. Available tools: [...]" + Tool returns None → returns "" This is the primary integration point for LLM loops: result = agent.dispatch(block.name, block.input) # Anthropic result = agent.dispatch(call.function.name, json.loads(call.function.arguments)) # OpenAI - Lookup is strictly registry-based: only methods decorated with @tool are - reachable. Undecorated methods and arbitrary attribute names are never called. + Lookup is strictly registry-based: only @tool-decorated methods are reachable. + @internal methods and plain instance methods are never callable via dispatch(). """ # Lookup is strictly against the @tool decorator registry (_TOOL_ATTR marker). # Only methods explicitly decorated with @tool are callable via dispatch(). From 5a857a9ddecc104ca783ee55a33631327d5cb842 Mon Sep 17 00:00:00 2001 From: node9 Date: Tue, 7 Apr 2026 22:28:20 +0300 Subject: [PATCH 19/19] chore: update CHANGELOG for v2.0.0 release --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92e54f..c56ce26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ +## v2.0.0 (2026-04-07) + +### Breaking Changes + +- **`Node9Agent`** base class introduced — framework-agnostic governed agent with `@tool`, `@internal`, `dispatch()`, `build_tools_anthropic()`, `build_tools_openai()` +- **`safe_path(filename, workspace=...)`** — `workspace` is now keyword-only (prevents silent arg swap) +- **`configure()`** — replaces direct env-var mutation; thread-safe via `threading.RLock` + +### New Features + +- `Node9Agent`: zero-dependency governed agent base class with DLP, path safety, and audit built-in +- `@tool` decorator: DLP scan + path traversal check + `evaluate()` on every call +- `@internal` decorator: infrastructure methods (no governance); warns if applied to a public method +- `dispatch()`: LLM-safe router — always returns `str`, handles async tools, unknown tools return descriptive error +- `build_tools_anthropic()` / `build_tools_openai()`: auto-generate tool specs from type annotations +- `new_session()`: fresh `run_id` for server/multi-session deployments +- Offline mode warns loudly when `policy=require_approval` but no daemon/API key is available +- `NODE9_SKIP=1` emits `warnings.warn()` at import time AND per `evaluate()` call +- All SDK status output moved to stderr (stdout stays clean for LLM tool parsers) + +### Migration from 1.x + +```python +# Before (1.x) — positional workspace arg +safe_path(filename, workspace_dir) + +# After (2.0) — keyword-only +safe_path(filename, workspace=workspace_dir) +``` + +`@protect` and `configure()` are fully backwards-compatible. Only `safe_path` call sites need updating. + ## v1.0.0 (2026-04-04) - Initial Release