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.
Files changed (43) hide show
  1. node9-1.1.4/.githooks/pre-commit +5 -0
  2. node9-1.1.4/.githooks/pre-push +5 -0
  3. node9-1.1.4/.githooks/run-tests.sh +47 -0
  4. node9-1.1.4/CHANGELOG.md +81 -0
  5. node9-1.1.4/PKG-INFO +273 -0
  6. node9-1.1.4/README.md +261 -0
  7. {node9-1.1.3 → node9-1.1.4}/examples/basic.py +7 -2
  8. {node9-1.1.3 → node9-1.1.4}/examples/crewai_agent.py +8 -3
  9. {node9-1.1.3 → node9-1.1.4}/examples/langchain_agent.py +7 -2
  10. node9-1.1.4/examples/manual_test.py +129 -0
  11. node9-1.1.4/node9/__init__.py +51 -0
  12. node9-1.1.4/node9/_agent.py +374 -0
  13. {node9-1.1.3 → node9-1.1.4}/node9/_client.py +94 -17
  14. node9-1.1.4/node9/_config.py +25 -0
  15. node9-1.1.4/node9/_dlp.py +67 -0
  16. {node9-1.1.3 → node9-1.1.4}/pyproject.toml +4 -1
  17. {node9-1.1.3 → node9-1.1.4}/scripts/e2e.sh +9 -11
  18. node9-1.1.4/tests/test_agent.py +525 -0
  19. node9-1.1.4/tests/test_client.py +344 -0
  20. node9-1.1.4/tests/test_config.py +87 -0
  21. node9-1.1.4/tests/test_dlp.py +231 -0
  22. node9-1.1.3/CHANGELOG.md +0 -72
  23. node9-1.1.3/PKG-INFO +0 -176
  24. node9-1.1.3/README.md +0 -154
  25. node9-1.1.3/node9/__init__.py +0 -10
  26. node9-1.1.3/node9/_config.py +0 -3
  27. node9-1.1.3/tests/test_client.py +0 -165
  28. node9-1.1.3/tests/test_config.py +0 -23
  29. {node9-1.1.3 → node9-1.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  30. {node9-1.1.3 → node9-1.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  31. {node9-1.1.3 → node9-1.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {node9-1.1.3 → node9-1.1.4}/.github/workflows/ai-review.yml +0 -0
  33. {node9-1.1.3 → node9-1.1.4}/.github/workflows/auto-pr.yml +0 -0
  34. {node9-1.1.3 → node9-1.1.4}/.github/workflows/ci.yml +0 -0
  35. {node9-1.1.3 → node9-1.1.4}/.github/workflows/release.yml +0 -0
  36. {node9-1.1.3 → node9-1.1.4}/.gitignore +0 -0
  37. {node9-1.1.3 → node9-1.1.4}/LICENSE +0 -0
  38. {node9-1.1.3 → node9-1.1.4}/conftest.py +0 -0
  39. {node9-1.1.3 → node9-1.1.4}/node9/_decorator.py +0 -0
  40. {node9-1.1.3 → node9-1.1.4}/node9/_exceptions.py +0 -0
  41. {node9-1.1.3 → node9-1.1.4}/scripts/ai-review.mjs +0 -0
  42. {node9-1.1.3 → node9-1.1.4}/tests/test_decorator.py +0 -0
  43. {node9-1.1.3 → node9-1.1.4}/tests/test_exceptions.py +0 -0
@@ -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
@@ -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
@@ -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."
@@ -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
- 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)
34
39
 
35
40
 
36
41
  # --- Custom tool name + params lambda ---