Skip to content

Commit 9ddc48d

Browse files
node9aiclaude
andauthored
fix: merge latest dev updates into main (#6)
* python sdk * redesign * fix(e2e): update Part 2 to test offline mode instead of DaemonNotFoundError 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 <noreply@anthropic.com> * adding feature to sdk * fix: address code review issues — DLP, thread safety, API naming - 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() * fix: second round — DLP, Python 3.9 compat, run_id, API surface - 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 * adding feature to sdk * adding feature to sdk * Fix code review issues: safe_path keyword-only, NODE9_SKIP import warning, 13 new tests - 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 <noreply@anthropic.com> * Fix code review round 4: path safety for all args, README warnings, env var tests - 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 <noreply@anthropic.com> * adding feature to sdk * adding feature to sdk * adding feature to sdk * adding feature to sdk * adding feature to sdk * adding feature to sdk * adding feature to sdk * adding feature to sdk --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5856df7 commit 9ddc48d

19 files changed

Lines changed: 2012 additions & 124 deletions

‎.githooks/pre-commit‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
# Run tests before every commit. Blocks the commit if any test fails.
3+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4+
# shellcheck source=run-tests.sh
5+
source "$SCRIPT_DIR/run-tests.sh" commit

‎.githooks/pre-push‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
# Run tests before every push. Last safety net before hitting the remote.
3+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4+
# shellcheck source=run-tests.sh
5+
source "$SCRIPT_DIR/run-tests.sh" push

‎.githooks/run-tests.sh‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
# Shared test runner sourced by pre-commit and pre-push hooks.
3+
# Usage: source run-tests.sh <context> (context = "commit" | "push")
4+
#
5+
# NOTE: uses `return` not `exit` — this script is sourced, not executed.
6+
# `exit` in a sourced script terminates the parent shell; `return` only
7+
# exits the script's scope, leaving the caller's shell intact.
8+
set -euo pipefail
9+
10+
CONTEXT="${1:-commit}"
11+
12+
# Use the virtualenv Python if active, otherwise fall back to system python3.
13+
# Check python3 first inside the venv (most venvs only create python3, not python).
14+
if [[ -n "${VIRTUAL_ENV:-}" ]]; then
15+
if [[ -x "$VIRTUAL_ENV/bin/python3" ]]; then
16+
PYTHON="$VIRTUAL_ENV/bin/python3"
17+
elif [[ -x "$VIRTUAL_ENV/bin/python" ]]; then
18+
PYTHON="$VIRTUAL_ENV/bin/python"
19+
else
20+
PYTHON="$(command -v python3 2>/dev/null)"
21+
fi
22+
else
23+
PYTHON="$(command -v python3 2>/dev/null)"
24+
fi
25+
26+
if [[ -z "$PYTHON" ]]; then
27+
echo "❌ node9: python3 not found on PATH. Install Python 3.10+ and try again." >&2
28+
return 1
29+
fi
30+
31+
# Sanity-check: require Python 3.10+ (matches pyproject.toml requires-python)
32+
PYVER=$("$PYTHON" -c 'import sys; print(sys.version_info >= (3,10))' 2>/dev/null)
33+
if [[ "$PYVER" != "True" ]]; then
34+
echo "❌ node9: Python 3.10+ required (found: $("$PYTHON" --version 2>&1))" >&2
35+
return 1
36+
fi
37+
38+
echo "🧪 node9: running tests before ${CONTEXT}..."
39+
40+
if ! "$PYTHON" -m pytest tests/ -p no:anyio -q --tb=short; then
41+
echo ""
42+
echo "❌ Tests failed — ${CONTEXT} blocked. Fix the failures above and try again."
43+
echo " To skip (unsafe): git ${CONTEXT} --no-verify"
44+
return 1
45+
fi
46+
47+
echo "✅ All tests passed."

‎README.md‎

Lines changed: 182 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
# node9-python
22

3-
Execution security for Python AI agents, one decorator, zero config.
3+
Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. One decorator, zero config.
44

5-
Works with any framework: plain Python, LangChain, CrewAI, LangGraph, or custom agents.
5+
Works two ways:
6+
- **`@protect`** — add governance to any existing agent (LangChain, CrewAI, AutoGen, plain Python)
7+
- **`Node9Agent`** — build a governed agent from scratch with tools, DLP, and audit built-in
68

79
## Install
810

911
```bash
1012
pip install node9
1113
```
1214

13-
## Quick Start
15+
## Routing
1416

15-
**1. Start the Node9 daemon** (ships with `@node9/proxy`):
17+
node9 automatically routes to the right backend:
1618

17-
```bash
18-
npx @node9/proxy daemon
19-
```
19+
| Environment | Routing |
20+
|---|---|
21+
| `NODE9_API_KEY` set | → node9 SaaS (cloud / CI — no local daemon needed) |
22+
| Local daemon running | → node9-proxy on `localhost:7391` |
23+
| Neither | → offline audit log at `~/.node9/audit.log` (auto-approve, never blocks) |
24+
25+
No config required — it just works wherever your agent runs.
26+
27+
---
28+
29+
## Option 1 — `@protect`: Add governance to any agent
2030

21-
**2. Add `@protect` to any function your agent calls:**
31+
Drop `@protect` on any function your agent calls. node9 intercepts the call, logs it, and enforces policy before the function runs.
2232

2333
```python
2434
from node9 import protect, ActionDeniedException
@@ -28,84 +38,47 @@ def write_file(path: str, content: str) -> None:
2838
with open(path, "w") as f:
2939
f.write(content)
3040

31-
@protect("bash")
32-
def run_shell(command: str) -> str:
41+
_ALLOWED_COMMANDS = {"pytest", "ruff", "mypy", "black"}
42+
43+
@protect("run_tests")
44+
def run_tests(tool: str) -> str:
45+
# Allowlist-based: only pre-approved CLI tools can be invoked.
46+
# Never pass raw LLM strings to subprocess — enumerate safe commands explicitly.
47+
if tool not in _ALLOWED_COMMANDS:
48+
raise ValueError(f"Tool {tool!r} is not in the allowed list: {_ALLOWED_COMMANDS}")
3349
import subprocess
34-
return subprocess.check_output(command, shell=True, text=True)
50+
return subprocess.check_output([tool], text=True)
3551

36-
# When your agent calls this, Node9 intercepts it and asks for approval.
37-
# The call blocks until a human approves or denies — in the dashboard or Slack.
3852
try:
39-
write_file("/etc/hosts", "malicious content")
53+
write_file("/etc/hosts", "bad content")
4054
except ActionDeniedException as e:
4155
print(f"Blocked: {e}")
4256
```
4357

44-
That's it. All function arguments are captured automatically — no config needed.
45-
46-
## How It Works
47-
48-
```
49-
Agent calls write_file()
50-
↓
51-
@protect intercepts
52-
↓
53-
POST /check → Node9 daemon (localhost:7391)
54-
↓
55-
Daemon shows approval popup / sends Slack message
56-
↓
57-
Human approves or denies
58-
↓
59-
Function runs (or ActionDeniedException is raised)
60-
```
61-
62-
## Async Support
58+
Works with `async def` out of the box.
6359

64-
`@protect` works with `async def` out of the box. The blocking HTTP call runs in a thread so it never freezes your event loop:
60+
### Set agent identity (optional but recommended)
6561

6662
```python
67-
@protect("write_file")
68-
async def write_file(path: str, content: str) -> str:
69-
async with aiofiles.open(path, "w") as f:
70-
await f.write(content)
71-
return f"Written to {path}"
72-
```
63+
from node9 import configure
7364

74-
This makes it compatible with LangGraph, FastMCP, and any other async agent framework.
75-
76-
## Custom Tool Name
77-
78-
By default, the tool name sent to Node9 is the function name. Override it:
79-
80-
```python
81-
@protect("postgres_query")
82-
def execute_sql(sql: str, db: str = "prod") -> list:
83-
...
65+
configure(agent_name="my-langchain-agent", policy="audit")
8466
```
8567

86-
## Custom Params
87-
88-
Control exactly what gets sent to the approval UI:
89-
90-
```python
91-
@protect("deploy", params=lambda service, env="prod", **_: {"service": service, "env": env})
92-
def deploy(service: str, env: str = "prod", dry_run: bool = False) -> str:
93-
...
68+
Or via environment variables:
69+
```bash
70+
NODE9_AGENT_NAME=my-langchain-agent
71+
NODE9_AGENT_POLICY=audit
9472
```
9573

96-
## Handling Denials in LLM Feedback Loops
97-
98-
`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:
99-
100-
```python
101-
try:
102-
delete_file("/etc/hosts")
103-
except ActionDeniedException as e:
104-
# e.negotiation = "Action 'delete_file' was blocked by Node9: Too dangerous. Choose a different approach."
105-
response = llm.invoke(e.negotiation)
106-
```
74+
### Policy values
10775

108-
## Framework Examples
76+
| Policy | Behaviour |
77+
|---|---|
78+
| `audit` | Log everything, auto-approve. Never blocks. Good for CI. |
79+
| `require_approval` | Block + notify human. Good for production actions. |
80+
| `block_on_rules` | Auto-block if rules match, audit otherwise. |
81+
| _(empty)_ | SaaS default behaviour. |
10982

11083
### LangChain
11184

@@ -139,17 +112,150 @@ def write_file(path: str, content: str) -> str:
139112
return f"Written to {path}"
140113
```
141114

142-
See [`examples/`](examples/) for full runnable examples.
115+
See [`examples/`](examples/) for full runnable examples including AutoGen and LangGraph.
116+
117+
---
118+
119+
## Option 2 — `Node9Agent`: Build a governed agent from scratch
120+
121+
`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.
143122

144-
## Environment Variables
123+
```python
124+
import anthropic
125+
from node9 import Node9Agent, tool, internal
126+
127+
class CiAgent(Node9Agent):
128+
agent_name = "ci-code-review"
129+
policy = "audit"
130+
131+
_ALLOWED_SUITES = {"pytest", "pytest --tb=short", "ruff check ."}
132+
133+
@tool("run_tests")
134+
def run_tests(self, suite: str) -> str:
135+
"""Run an allowlisted test suite and return output."""
136+
import shlex, subprocess
137+
if suite not in self._ALLOWED_SUITES:
138+
raise ValueError(f"Suite {suite!r} not in allowed list: {self._ALLOWED_SUITES}")
139+
return subprocess.check_output(shlex.split(suite), text=True)
140+
141+
@tool("write_code")
142+
def write_code(self, filename: str, content: str) -> str:
143+
"""Write content to a file in the workspace."""
144+
from node9 import safe_path
145+
path = safe_path(filename, workspace=self._workspace) # traversal-safe
146+
with open(path, "w") as f:
147+
f.write(content)
148+
return f"Written {filename}"
149+
150+
@internal
151+
def _git_push(self, branch: str) -> str:
152+
"""Push to remote — infrastructure, not a governed action."""
153+
import subprocess
154+
subprocess.run(["git", "push", "origin", branch], check=True)
155+
return f"Pushed {branch}"
156+
157+
agent = CiAgent(workspace="/path/to/repo")
158+
client = anthropic.Anthropic()
159+
160+
# Get tool specs in the format your LLM expects
161+
tools = agent.build_tools_anthropic() # → input_schema format
162+
# tools = agent.build_tools_openai() # → {type: function, function: {...}}
163+
# tools = agent._build_tools() # → neutral (parameters key)
164+
165+
# Your LLM loop — use whichever client you want
166+
messages = [{"role": "user", "content": "Fix the failing tests in this diff: ..."}]
167+
while True:
168+
response = client.messages.create(model="claude-opus-4-6", tools=tools, messages=messages)
169+
messages.append({"role": "assistant", "content": response.content})
170+
if response.stop_reason != "tool_use":
171+
break
172+
results = []
173+
for block in response.content:
174+
if block.type == "tool_use":
175+
result = agent.dispatch(block.name, block.input) # DLP + audit happen here
176+
results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
177+
messages.append({"role": "user", "content": results})
178+
```
179+
180+
See [`examples/`](examples/) for complete runnable implementations per framework.
181+
182+
### What `@tool` does automatically
183+
184+
Every `@tool`-decorated method, before the function runs:
185+
1. **DLP scan** — blocks if `filename` or `content` contains a secret or sensitive path
186+
2. **Path safety** — rejects `../` traversal attempts, raises `ActionDeniedException`
187+
3. **Audit / approval** — calls `evaluate()` which respects the agent's `policy`
188+
4. **Run ID** — injects a UUID grouping all tool calls from one session in the dashboard
189+
190+
### What `@internal` does
191+
192+
`@internal` is for git operations, workspace setup, and other infrastructure:
193+
- Never calls `evaluate()` — no SaaS call, no blocking
194+
- Logs locally only: `[node9 internal] _git_push(branch='main')`
195+
196+
### Tool specs are auto-generated
197+
198+
`Node9Agent` introspects `@tool` methods and builds tool specs automatically — parameter names, types from annotations, and descriptions from docstrings. No manual schema writing.
199+
200+
---
201+
202+
## DLP and path safety as standalone utilities
203+
204+
```python
205+
from node9 import dlp_scan, safe_path
206+
207+
# Scan content for secrets before writing to disk
208+
hit = dlp_scan("output.txt", content)
209+
if hit:
210+
raise ValueError(f"Blocked: {hit}")
211+
212+
# Resolve a path safely within a workspace directory
213+
path = safe_path("src/main.py", workspace="/tmp/repo")
214+
```
215+
216+
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.).
217+
218+
---
219+
220+
## Handling denials in LLM feedback loops
221+
222+
`ActionDeniedException` has a `negotiation` property — feed it back to the LLM so it can try a different approach:
223+
224+
```python
225+
try:
226+
agent.dispatch("delete_file", {"path": "/etc/hosts"})
227+
except ActionDeniedException as e:
228+
# e.negotiation = "Action 'delete_file' was blocked by Node9: policy. Choose a different approach."
229+
response = llm.invoke(e.negotiation)
230+
```
231+
232+
---
233+
234+
## Environment variables
145235

146236
| Variable | Default | Description |
147237
|---|---|---|
148-
| `NODE9_DAEMON_PORT` | `7391` | Daemon port |
149-
| `NODE9_AUTO_START` | — | Set to `1` to auto-launch the daemon if it's not running |
150-
| `NODE9_SKIP` | — | Set to `1` to bypass all checks (unsafe — for tests only) |
238+
| `NODE9_API_KEY` | — | Routes to node9 SaaS. Required for cloud / CI. |
239+
| `NODE9_AGENT_NAME` | — | Agent identity — appears in audit logs and dashboard. |
240+
| `NODE9_AGENT_POLICY` | — | `audit`, `require_approval`, or `block_on_rules`. |
241+
| `NODE9_DAEMON_PORT` | `7391` | Local daemon port. |
242+
| `NODE9_AUTO_START` | — | Set to `1` to auto-launch the local daemon if not running. |
243+
| `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. |
244+
245+
## Development
246+
247+
After cloning, activate the git hooks (runs tests before every commit and push):
248+
249+
```bash
250+
git config core.hooksPath .githooks
251+
```
252+
253+
Run tests manually:
254+
255+
```bash
256+
python3 -m pytest tests/ -p no:anyio -q
257+
```
151258

152259
## License
153260

154261
Apache-2.0
155-

‎examples/basic.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ def delete_file(path: str) -> None:
2727
print(f"Deleted: {path}")
2828

2929

30+
_ALLOWED_COMMANDS = {"ls", "pwd", "git status", "git log --oneline"}
31+
3032
@protect("bash")
3133
def run_shell(command: str) -> str:
32-
import subprocess
33-
return subprocess.check_output(command, shell=True, text=True)
34+
import shlex, subprocess
35+
# Allowlist-only: never pass LLM-controlled strings to shell=True.
36+
if command not in _ALLOWED_COMMANDS:
37+
raise ValueError(f"Command {command!r} not in allowed list")
38+
return subprocess.check_output(shlex.split(command), text=True)
3439

3540

3641
# --- Custom tool name + params lambda ---

0 commit comments

Comments
 (0)