node9 1.1.3__tar.gz → 1.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- node9-1.1.4/.githooks/pre-commit +5 -0
- node9-1.1.4/.githooks/pre-push +5 -0
- node9-1.1.4/.githooks/run-tests.sh +47 -0
- node9-1.1.4/CHANGELOG.md +81 -0
- node9-1.1.4/PKG-INFO +273 -0
- node9-1.1.4/README.md +261 -0
- {node9-1.1.3 → node9-1.1.4}/examples/basic.py +7 -2
- {node9-1.1.3 → node9-1.1.4}/examples/crewai_agent.py +8 -3
- {node9-1.1.3 → node9-1.1.4}/examples/langchain_agent.py +7 -2
- node9-1.1.4/examples/manual_test.py +129 -0
- node9-1.1.4/node9/__init__.py +51 -0
- node9-1.1.4/node9/_agent.py +374 -0
- {node9-1.1.3 → node9-1.1.4}/node9/_client.py +94 -17
- node9-1.1.4/node9/_config.py +25 -0
- node9-1.1.4/node9/_dlp.py +67 -0
- {node9-1.1.3 → node9-1.1.4}/pyproject.toml +4 -1
- {node9-1.1.3 → node9-1.1.4}/scripts/e2e.sh +9 -11
- node9-1.1.4/tests/test_agent.py +525 -0
- node9-1.1.4/tests/test_client.py +344 -0
- node9-1.1.4/tests/test_config.py +87 -0
- node9-1.1.4/tests/test_dlp.py +231 -0
- node9-1.1.3/CHANGELOG.md +0 -72
- node9-1.1.3/PKG-INFO +0 -176
- node9-1.1.3/README.md +0 -154
- node9-1.1.3/node9/__init__.py +0 -10
- node9-1.1.3/node9/_config.py +0 -3
- node9-1.1.3/tests/test_client.py +0 -165
- node9-1.1.3/tests/test_config.py +0 -23
- {node9-1.1.3 → node9-1.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/workflows/ai-review.yml +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/workflows/auto-pr.yml +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/workflows/ci.yml +0 -0
- {node9-1.1.3 → node9-1.1.4}/.github/workflows/release.yml +0 -0
- {node9-1.1.3 → node9-1.1.4}/.gitignore +0 -0
- {node9-1.1.3 → node9-1.1.4}/LICENSE +0 -0
- {node9-1.1.3 → node9-1.1.4}/conftest.py +0 -0
- {node9-1.1.3 → node9-1.1.4}/node9/_decorator.py +0 -0
- {node9-1.1.3 → node9-1.1.4}/node9/_exceptions.py +0 -0
- {node9-1.1.3 → node9-1.1.4}/scripts/ai-review.mjs +0 -0
- {node9-1.1.3 → node9-1.1.4}/tests/test_decorator.py +0 -0
- {node9-1.1.3 → node9-1.1.4}/tests/test_exceptions.py +0 -0
|
@@ -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."
|
node9-1.1.4/CHANGELOG.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
<!-- version list -->
|
|
4
|
+
|
|
5
|
+
## v1.1.4 (2026-04-07)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## v2.0.0 (2026-04-07)
|
|
9
|
+
|
|
10
|
+
### Breaking Changes
|
|
11
|
+
|
|
12
|
+
- **`Node9Agent`** base class introduced — framework-agnostic governed agent with `@tool`, `@internal`, `dispatch()`, `build_tools_anthropic()`, `build_tools_openai()`
|
|
13
|
+
- **`safe_path(filename, workspace=...)`** — `workspace` is now keyword-only (prevents silent arg swap)
|
|
14
|
+
- **`configure()`** — replaces direct env-var mutation; thread-safe via `threading.RLock`
|
|
15
|
+
|
|
16
|
+
### New Features
|
|
17
|
+
|
|
18
|
+
- `Node9Agent`: zero-dependency governed agent base class with DLP, path safety, and audit built-in
|
|
19
|
+
- `@tool` decorator: DLP scan + path traversal check + `evaluate()` on every call
|
|
20
|
+
- `@internal` decorator: infrastructure methods (no governance); warns if applied to a public method
|
|
21
|
+
- `dispatch()`: LLM-safe router — always returns `str`, handles async tools, unknown tools return descriptive error
|
|
22
|
+
- `build_tools_anthropic()` / `build_tools_openai()`: auto-generate tool specs from type annotations
|
|
23
|
+
- `new_session()`: fresh `run_id` for server/multi-session deployments
|
|
24
|
+
- Offline mode warns loudly when `policy=require_approval` but no daemon/API key is available
|
|
25
|
+
- `NODE9_SKIP=1` emits `warnings.warn()` at import time AND per `evaluate()` call
|
|
26
|
+
- All SDK status output moved to stderr (stdout stays clean for LLM tool parsers)
|
|
27
|
+
|
|
28
|
+
### Migration from 1.x
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# Before (1.x) — positional workspace arg
|
|
32
|
+
safe_path(filename, workspace_dir)
|
|
33
|
+
|
|
34
|
+
# After (2.0) — keyword-only
|
|
35
|
+
safe_path(filename, workspace=workspace_dir)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`@protect` and `configure()` are fully backwards-compatible. Only `safe_path` call sites need updating.
|
|
39
|
+
|
|
40
|
+
## v1.0.0 (2026-04-04)
|
|
41
|
+
|
|
42
|
+
- Initial Release
|
|
43
|
+
|
|
44
|
+
## v1.1.0 (2026-03-15)
|
|
45
|
+
|
|
46
|
+
### Features
|
|
47
|
+
|
|
48
|
+
- Add Gemini AI code review on PRs to main
|
|
49
|
+
([`50b651d`](https://github.com/node9-ai/node9-python/commit/50b651dc2575dc954def69dd16d7492369a8149a))
|
|
50
|
+
|
|
51
|
+
- Switch AI code review from Gemini to Claude Sonnet
|
|
52
|
+
([`c52fbb4`](https://github.com/node9-ai/node9-python/commit/c52fbb4ee5d1b460ef008b708e3664e0650f93f9))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## v1.0.3 (2026-03-15)
|
|
56
|
+
|
|
57
|
+
### Bug Fixes
|
|
58
|
+
|
|
59
|
+
- Install twine before upload step
|
|
60
|
+
([`4b4e142`](https://github.com/node9-ai/node9-python/commit/4b4e142b02815937551cbbb8569aa72b0ab222bc))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## v1.0.2 (2026-03-15)
|
|
64
|
+
|
|
65
|
+
### Bug Fixes
|
|
66
|
+
|
|
67
|
+
- Publish to PyPI explicitly with twine instead of semantic-release publish
|
|
68
|
+
([`6847fdb`](https://github.com/node9-ai/node9-python/commit/6847fdbbf6c0bbd7a14a743b99745cdf005d73a9))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## v1.0.1 (2026-03-15)
|
|
72
|
+
|
|
73
|
+
### Bug Fixes
|
|
74
|
+
|
|
75
|
+
- Add TWINE credentials and twine to build command for PyPI upload
|
|
76
|
+
([`d71d73d`](https://github.com/node9-ai/node9-python/commit/d71d73d1caa3c05cfd5011edcd3913f5fc976d07))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## v1.0.0 (2026-03-15)
|
|
80
|
+
|
|
81
|
+
- Initial Release
|
node9-1.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: node9
|
|
3
|
+
Version: 1.1.4
|
|
4
|
+
Summary: Execution security for Python AI agents — seatbelt for LangChain, CrewAI, and plain Python.
|
|
5
|
+
Project-URL: Homepage, https://node9.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/node9-ai/node9-python
|
|
7
|
+
Project-URL: Documentation, https://node9.ai/docs
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# node9-python
|
|
14
|
+
|
|
15
|
+
Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. One decorator, zero config.
|
|
16
|
+
|
|
17
|
+
Works two ways:
|
|
18
|
+
- **`@protect`** — add governance to any existing agent (LangChain, CrewAI, AutoGen, plain Python)
|
|
19
|
+
- **`Node9Agent`** — build a governed agent from scratch with tools, DLP, and audit built-in
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install node9
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Routing
|
|
28
|
+
|
|
29
|
+
node9 automatically routes to the right backend:
|
|
30
|
+
|
|
31
|
+
| Environment | Routing |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `NODE9_API_KEY` set | → node9 SaaS (cloud / CI — no local daemon needed) |
|
|
34
|
+
| Local daemon running | → node9-proxy on `localhost:7391` |
|
|
35
|
+
| Neither | → offline audit log at `~/.node9/audit.log` (auto-approve, never blocks) |
|
|
36
|
+
|
|
37
|
+
No config required — it just works wherever your agent runs.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Option 1 — `@protect`: Add governance to any agent
|
|
42
|
+
|
|
43
|
+
Drop `@protect` on any function your agent calls. node9 intercepts the call, logs it, and enforces policy before the function runs.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from node9 import protect, ActionDeniedException
|
|
47
|
+
|
|
48
|
+
@protect
|
|
49
|
+
def write_file(path: str, content: str) -> None:
|
|
50
|
+
with open(path, "w") as f:
|
|
51
|
+
f.write(content)
|
|
52
|
+
|
|
53
|
+
_ALLOWED_COMMANDS = {"pytest", "ruff", "mypy", "black"}
|
|
54
|
+
|
|
55
|
+
@protect("run_tests")
|
|
56
|
+
def run_tests(tool: str) -> str:
|
|
57
|
+
# Allowlist-based: only pre-approved CLI tools can be invoked.
|
|
58
|
+
# Never pass raw LLM strings to subprocess — enumerate safe commands explicitly.
|
|
59
|
+
if tool not in _ALLOWED_COMMANDS:
|
|
60
|
+
raise ValueError(f"Tool {tool!r} is not in the allowed list: {_ALLOWED_COMMANDS}")
|
|
61
|
+
import subprocess
|
|
62
|
+
return subprocess.check_output([tool], text=True)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
write_file("/etc/hosts", "bad content")
|
|
66
|
+
except ActionDeniedException as e:
|
|
67
|
+
print(f"Blocked: {e}")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Works with `async def` out of the box.
|
|
71
|
+
|
|
72
|
+
### Set agent identity (optional but recommended)
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from node9 import configure
|
|
76
|
+
|
|
77
|
+
configure(agent_name="my-langchain-agent", policy="audit")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or via environment variables:
|
|
81
|
+
```bash
|
|
82
|
+
NODE9_AGENT_NAME=my-langchain-agent
|
|
83
|
+
NODE9_AGENT_POLICY=audit
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Policy values
|
|
87
|
+
|
|
88
|
+
| Policy | Behaviour |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `audit` | Log everything, auto-approve. Never blocks. Good for CI. |
|
|
91
|
+
| `require_approval` | Block + notify human. Good for production actions. |
|
|
92
|
+
| `block_on_rules` | Auto-block if rules match, audit otherwise. |
|
|
93
|
+
| _(empty)_ | SaaS default behaviour. |
|
|
94
|
+
|
|
95
|
+
### LangChain
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from langchain.tools import BaseTool
|
|
99
|
+
from node9 import protect
|
|
100
|
+
|
|
101
|
+
class WriteFileTool(BaseTool):
|
|
102
|
+
name = "write_file"
|
|
103
|
+
description = "Write content to a file."
|
|
104
|
+
|
|
105
|
+
@protect("write_file")
|
|
106
|
+
def _run(self, path: str, content: str) -> str:
|
|
107
|
+
with open(path, "w") as f:
|
|
108
|
+
f.write(content)
|
|
109
|
+
return f"Written to {path}"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### CrewAI
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from crewai.tools import tool
|
|
116
|
+
from node9 import protect
|
|
117
|
+
|
|
118
|
+
@tool("write_file")
|
|
119
|
+
@protect("write_file")
|
|
120
|
+
def write_file(path: str, content: str) -> str:
|
|
121
|
+
"""Write content to a file."""
|
|
122
|
+
with open(path, "w") as f:
|
|
123
|
+
f.write(content)
|
|
124
|
+
return f"Written to {path}"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
See [`examples/`](examples/) for full runnable examples including AutoGen and LangGraph.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Option 2 — `Node9Agent`: Build a governed agent from scratch
|
|
132
|
+
|
|
133
|
+
`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.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import anthropic
|
|
137
|
+
from node9 import Node9Agent, tool, internal
|
|
138
|
+
|
|
139
|
+
class CiAgent(Node9Agent):
|
|
140
|
+
agent_name = "ci-code-review"
|
|
141
|
+
policy = "audit"
|
|
142
|
+
|
|
143
|
+
_ALLOWED_SUITES = {"pytest", "pytest --tb=short", "ruff check ."}
|
|
144
|
+
|
|
145
|
+
@tool("run_tests")
|
|
146
|
+
def run_tests(self, suite: str) -> str:
|
|
147
|
+
"""Run an allowlisted test suite and return output."""
|
|
148
|
+
import shlex, subprocess
|
|
149
|
+
if suite not in self._ALLOWED_SUITES:
|
|
150
|
+
raise ValueError(f"Suite {suite!r} not in allowed list: {self._ALLOWED_SUITES}")
|
|
151
|
+
return subprocess.check_output(shlex.split(suite), text=True)
|
|
152
|
+
|
|
153
|
+
@tool("write_code")
|
|
154
|
+
def write_code(self, filename: str, content: str) -> str:
|
|
155
|
+
"""Write content to a file in the workspace."""
|
|
156
|
+
from node9 import safe_path
|
|
157
|
+
path = safe_path(filename, workspace=self._workspace) # traversal-safe
|
|
158
|
+
with open(path, "w") as f:
|
|
159
|
+
f.write(content)
|
|
160
|
+
return f"Written {filename}"
|
|
161
|
+
|
|
162
|
+
@internal
|
|
163
|
+
def _git_push(self, branch: str) -> str:
|
|
164
|
+
"""Push to remote — infrastructure, not a governed action."""
|
|
165
|
+
import subprocess
|
|
166
|
+
subprocess.run(["git", "push", "origin", branch], check=True)
|
|
167
|
+
return f"Pushed {branch}"
|
|
168
|
+
|
|
169
|
+
agent = CiAgent(workspace="/path/to/repo")
|
|
170
|
+
client = anthropic.Anthropic()
|
|
171
|
+
|
|
172
|
+
# Get tool specs in the format your LLM expects
|
|
173
|
+
tools = agent.build_tools_anthropic() # → input_schema format
|
|
174
|
+
# tools = agent.build_tools_openai() # → {type: function, function: {...}}
|
|
175
|
+
# tools = agent._build_tools() # → neutral (parameters key)
|
|
176
|
+
|
|
177
|
+
# Your LLM loop — use whichever client you want
|
|
178
|
+
messages = [{"role": "user", "content": "Fix the failing tests in this diff: ..."}]
|
|
179
|
+
while True:
|
|
180
|
+
response = client.messages.create(model="claude-opus-4-6", tools=tools, messages=messages)
|
|
181
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
182
|
+
if response.stop_reason != "tool_use":
|
|
183
|
+
break
|
|
184
|
+
results = []
|
|
185
|
+
for block in response.content:
|
|
186
|
+
if block.type == "tool_use":
|
|
187
|
+
result = agent.dispatch(block.name, block.input) # DLP + audit happen here
|
|
188
|
+
results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
|
|
189
|
+
messages.append({"role": "user", "content": results})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
See [`examples/`](examples/) for complete runnable implementations per framework.
|
|
193
|
+
|
|
194
|
+
### What `@tool` does automatically
|
|
195
|
+
|
|
196
|
+
Every `@tool`-decorated method, before the function runs:
|
|
197
|
+
1. **DLP scan** — blocks if `filename` or `content` contains a secret or sensitive path
|
|
198
|
+
2. **Path safety** — rejects `../` traversal attempts, raises `ActionDeniedException`
|
|
199
|
+
3. **Audit / approval** — calls `evaluate()` which respects the agent's `policy`
|
|
200
|
+
4. **Run ID** — injects a UUID grouping all tool calls from one session in the dashboard
|
|
201
|
+
|
|
202
|
+
### What `@internal` does
|
|
203
|
+
|
|
204
|
+
`@internal` is for git operations, workspace setup, and other infrastructure:
|
|
205
|
+
- Never calls `evaluate()` — no SaaS call, no blocking
|
|
206
|
+
- Logs locally only: `[node9 internal] _git_push(branch='main')`
|
|
207
|
+
|
|
208
|
+
### Tool specs are auto-generated
|
|
209
|
+
|
|
210
|
+
`Node9Agent` introspects `@tool` methods and builds tool specs automatically — parameter names, types from annotations, and descriptions from docstrings. No manual schema writing.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## DLP and path safety as standalone utilities
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from node9 import dlp_scan, safe_path
|
|
218
|
+
|
|
219
|
+
# Scan content for secrets before writing to disk
|
|
220
|
+
hit = dlp_scan("output.txt", content)
|
|
221
|
+
if hit:
|
|
222
|
+
raise ValueError(f"Blocked: {hit}")
|
|
223
|
+
|
|
224
|
+
# Resolve a path safely within a workspace directory
|
|
225
|
+
path = safe_path("src/main.py", workspace="/tmp/repo")
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
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.).
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Handling denials in LLM feedback loops
|
|
233
|
+
|
|
234
|
+
`ActionDeniedException` has a `negotiation` property — feed it back to the LLM so it can try a different approach:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
try:
|
|
238
|
+
agent.dispatch("delete_file", {"path": "/etc/hosts"})
|
|
239
|
+
except ActionDeniedException as e:
|
|
240
|
+
# e.negotiation = "Action 'delete_file' was blocked by Node9: policy. Choose a different approach."
|
|
241
|
+
response = llm.invoke(e.negotiation)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Environment variables
|
|
247
|
+
|
|
248
|
+
| Variable | Default | Description |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `NODE9_API_KEY` | — | Routes to node9 SaaS. Required for cloud / CI. |
|
|
251
|
+
| `NODE9_AGENT_NAME` | — | Agent identity — appears in audit logs and dashboard. |
|
|
252
|
+
| `NODE9_AGENT_POLICY` | — | `audit`, `require_approval`, or `block_on_rules`. |
|
|
253
|
+
| `NODE9_DAEMON_PORT` | `7391` | Local daemon port. |
|
|
254
|
+
| `NODE9_AUTO_START` | — | Set to `1` to auto-launch the local daemon if not running. |
|
|
255
|
+
| `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. |
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
After cloning, activate the git hooks (runs tests before every commit and push):
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
git config core.hooksPath .githooks
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Run tests manually:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
python3 -m pytest tests/ -p no:anyio -q
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
Apache-2.0
|
node9-1.1.4/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# node9-python
|
|
2
|
+
|
|
3
|
+
Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. One decorator, zero config.
|
|
4
|
+
|
|
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
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install node9
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Routing
|
|
16
|
+
|
|
17
|
+
node9 automatically routes to the right backend:
|
|
18
|
+
|
|
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
|
|
30
|
+
|
|
31
|
+
Drop `@protect` on any function your agent calls. node9 intercepts the call, logs it, and enforces policy before the function runs.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from node9 import protect, ActionDeniedException
|
|
35
|
+
|
|
36
|
+
@protect
|
|
37
|
+
def write_file(path: str, content: str) -> None:
|
|
38
|
+
with open(path, "w") as f:
|
|
39
|
+
f.write(content)
|
|
40
|
+
|
|
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}")
|
|
49
|
+
import subprocess
|
|
50
|
+
return subprocess.check_output([tool], text=True)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
write_file("/etc/hosts", "bad content")
|
|
54
|
+
except ActionDeniedException as e:
|
|
55
|
+
print(f"Blocked: {e}")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Works with `async def` out of the box.
|
|
59
|
+
|
|
60
|
+
### Set agent identity (optional but recommended)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from node9 import configure
|
|
64
|
+
|
|
65
|
+
configure(agent_name="my-langchain-agent", policy="audit")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or via environment variables:
|
|
69
|
+
```bash
|
|
70
|
+
NODE9_AGENT_NAME=my-langchain-agent
|
|
71
|
+
NODE9_AGENT_POLICY=audit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Policy values
|
|
75
|
+
|
|
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. |
|
|
82
|
+
|
|
83
|
+
### LangChain
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from langchain.tools import BaseTool
|
|
87
|
+
from node9 import protect
|
|
88
|
+
|
|
89
|
+
class WriteFileTool(BaseTool):
|
|
90
|
+
name = "write_file"
|
|
91
|
+
description = "Write content to a file."
|
|
92
|
+
|
|
93
|
+
@protect("write_file")
|
|
94
|
+
def _run(self, path: str, content: str) -> str:
|
|
95
|
+
with open(path, "w") as f:
|
|
96
|
+
f.write(content)
|
|
97
|
+
return f"Written to {path}"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### CrewAI
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from crewai.tools import tool
|
|
104
|
+
from node9 import protect
|
|
105
|
+
|
|
106
|
+
@tool("write_file")
|
|
107
|
+
@protect("write_file")
|
|
108
|
+
def write_file(path: str, content: str) -> str:
|
|
109
|
+
"""Write content to a file."""
|
|
110
|
+
with open(path, "w") as f:
|
|
111
|
+
f.write(content)
|
|
112
|
+
return f"Written to {path}"
|
|
113
|
+
```
|
|
114
|
+
|
|
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.
|
|
122
|
+
|
|
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
|
|
235
|
+
|
|
236
|
+
| Variable | Default | Description |
|
|
237
|
+
|---|---|---|
|
|
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
|
+
```
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
Apache-2.0
|
|
@@ -27,10 +27,15 @@ def delete_file(path: str) -> None:
|
|
|
27
27
|
print(f"Deleted: {path}")
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
_ALLOWED_COMMANDS = {"ls", "pwd", "git status", "git log --oneline"}
|
|
31
|
+
|
|
30
32
|
@protect("bash")
|
|
31
33
|
def run_shell(command: str) -> str:
|
|
32
|
-
import subprocess
|
|
33
|
-
|
|
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)
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
# --- Custom tool name + params lambda ---
|