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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Run tests before every commit. Blocks the commit if any test fails.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=run-tests.sh
source "$SCRIPT_DIR/run-tests.sh" commit
5 changes: 5 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Run tests before every push. Last safety net before hitting the remote.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=run-tests.sh
source "$SCRIPT_DIR/run-tests.sh" push
47 changes: 47 additions & 0 deletions .githooks/run-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Shared test runner sourced by pre-commit and pre-push hooks.
# Usage: source run-tests.sh <context> (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}"

# 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
return 1
fi

# 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
return 1
fi

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"
return 1
fi

echo "✅ All tests passed."
258 changes: 182 additions & 76 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
# 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. One decorator, zero config.

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

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

---

## Option 1 — `@protect`: Add governance to any agent

**2. Add `@protect` to any function your agent calls:**
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
Expand All @@ -28,84 +38,47 @@ 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:
_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(command, shell=True, text=True)
return subprocess.check_output([tool], 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

```
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
Works with `async def` out of the box.

`@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}"
```
from node9 import configure

This makes it compatible with LangGraph, FastMCP, and any other async agent framework.

## Custom Tool Name

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

`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)
```
### Policy values

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

Expand Down Expand Up @@ -139,17 +112,150 @@ 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.

## Environment Variables
```python
import anthropic
from node9 import Node9Agent, tool, internal

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, suite: str) -> str:
"""Run an allowlisted test suite and return output."""
import shlex, subprocess
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:
"""Write content to a file in the workspace."""
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}"

@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

| 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. **Never set in production** — disables all governance. For unit tests only. If set, a warning is emitted at import time. |

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

9 changes: 7 additions & 2 deletions examples/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Loading
Loading