Skip to content

Commit 767deb8

Browse files
committed
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()
1 parent b689cae commit 767deb8

4 files changed

Lines changed: 79 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
# node9-python
22

3-
<<<<<<< dev
4-
Execution security for Python AI agents — audit, policy enforcement, and DLP in one package.
5-
=======
6-
Execution security for Python AI agents, one decorator, zero config.
7-
>>>>>>> main
3+
Execution security for Python AI agents — audit, policy enforcement, and DLP in one package. One decorator, zero config.
84

95
Works two ways:
106
- **`@protect`** — add governance to any existing agent (LangChain, CrewAI, AutoGen, plain Python)
@@ -45,6 +41,8 @@ def write_file(path: str, content: str) -> None:
4541
@protect("bash")
4642
def run_shell(command: str) -> str:
4743
import subprocess
44+
# Note: @protect gates on human approval but does NOT sanitize `command`.
45+
# Use shlex.split() + shell=False for untrusted input.
4846
return subprocess.check_output(command, shell=True, text=True)
4947

5048
try:
@@ -130,7 +128,9 @@ class CiAgent(Node9Agent):
130128
def run_tests(self, command: str) -> str:
131129
"""Run the test suite and return output."""
132130
import subprocess
133-
return subprocess.check_output(command, shell=True, text=True)
131+
# Note: @protect gates on human approval but does NOT sanitize `command`.
132+
# Use shlex.split() + shell=False for untrusted input.
133+
return subprocess.check_output(command, shell=True, text=True)
134134

135135
@tool("write_code")
136136
def write_code(self, filename: str, content: str) -> str:
@@ -164,7 +164,7 @@ while True:
164164
results = []
165165
for block in response.content:
166166
if block.type == "tool_use":
167-
result = agent._dispatch(block.name, block.input) # DLP + audit happen here
167+
result = agent.dispatch(block.name, block.input) # DLP + audit happen here
168168
results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
169169
messages.append({"role": "user", "content": results})
170170
```
@@ -215,7 +215,7 @@ Patterns detected: AWS keys, GitHub tokens, Slack tokens, OpenAI keys, Stripe ke
215215

216216
```python
217217
try:
218-
agent._dispatch("delete_file", {"path": "/etc/hosts"})
218+
agent.dispatch("delete_file", {"path": "/etc/hosts"})
219219
except ActionDeniedException as e:
220220
# e.negotiation = "Action 'delete_file' was blocked by Node9: policy. Choose a different approach."
221221
response = llm.invoke(e.negotiation)

‎node9/__init__.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,30 @@
22
node9 — Execution security for Python AI agents.
33
"""
44

5+
import threading
6+
57
from ._decorator import protect
68
from ._exceptions import ActionDeniedException, DaemonNotFoundError
79
from ._dlp import dlp_scan, safe_path
810
from ._agent import Node9Agent, tool, internal
911
from . import _config
1012

13+
_configure_lock = threading.Lock()
14+
1115

1216
def configure(*, agent_name: str = "", policy: str = "") -> None:
1317
"""
1418
Set agent identity at runtime. Alternative to NODE9_AGENT_NAME / NODE9_AGENT_POLICY env vars.
15-
Call before the first evaluate() / @protect / agent._dispatch().
19+
Call before the first evaluate() / @protect / agent.dispatch().
20+
Thread-safe — safe to call from concurrent async frameworks (LangGraph, FastMCP).
1621
1722
policy values: "audit" | "require_approval" | "block_on_rules" | "" (SaaS default)
1823
"""
19-
if agent_name:
20-
_config.AGENT_NAME = agent_name
21-
if policy:
22-
_config.AGENT_POLICY = policy
24+
with _configure_lock:
25+
if agent_name:
26+
_config.AGENT_NAME = agent_name
27+
if policy:
28+
_config.AGENT_POLICY = policy
2329

2430

2531
__all__ = [

‎node9/_agent.py‎

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,24 @@ def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any:
7878
bound.apply_defaults()
7979
call_args = {k: v for k, v in bound.arguments.items() if k != "self"}
8080

81-
# DLP scan
82-
filename = call_args.get("filename") or call_args.get("path") or ""
83-
content = call_args.get("content") or ""
84-
if filename or content:
85-
hit = dlp_scan(str(filename), str(content))
86-
if hit:
87-
raise ActionDeniedException(name, f"DLP blocked: {hit}")
88-
89-
# Path safety
90-
if filename and hasattr(self, "_workspace") and self._workspace:
91-
try:
92-
safe_path(str(filename), self._workspace)
93-
except ValueError as e:
94-
raise ActionDeniedException(name, str(e)) from e
81+
# DLP scan — all string args, not just well-known names
82+
path_arg = call_args.get("filename") or call_args.get("path") or ""
83+
all_content = "\n".join(
84+
str(v) for v in call_args.values() if isinstance(v, str)
85+
)
86+
hit = dlp_scan(str(path_arg), all_content)
87+
if hit:
88+
raise ActionDeniedException(name, f"DLP blocked: {hit}")
89+
90+
# Path safety — any arg that looks like a file path
91+
if hasattr(self, "_workspace") and self._workspace:
92+
for v in call_args.values():
93+
if isinstance(v, str) and ("/" in v or "\\" in v):
94+
try:
95+
safe_path(v, self._workspace)
96+
except ValueError as e:
97+
raise ActionDeniedException(name, str(e)) from e
98+
break
9599

96100
run_id = getattr(self, "_run_id", "")
97101
evaluate(name, call_args, run_id=run_id)
@@ -114,8 +118,10 @@ def internal(fn: Callable) -> Callable:
114118
"""
115119
Marks a Node9Agent method as infrastructure (git plumbing, workspace setup).
116120
117-
- Never calls evaluate() — no SaaS call, no blocking
118-
- Logs locally only: [node9 internal] method_name(args)
121+
- Never calls evaluate() — no SaaS call, no blocking, no DLP scan
122+
- Logs to stdout: [node9 internal] method_name(args)
123+
- Use only for non-agent-decision code (git, workspace setup, file plumbing).
124+
Do NOT use to bypass governance on agent-controlled actions.
119125
"""
120126
@functools.wraps(fn)
121127
def wrapper(self: "Node9Agent", *args: Any, **kwargs: Any) -> Any:
@@ -138,21 +144,33 @@ class Node9Agent:
138144
Provides:
139145
- Agent identity and policy (set once, applied to every tool call)
140146
- Per-run UUID for grouping audit entries in the dashboard
141-
- _build_tools() — neutral tool spec for use with any LLM
142147
- build_tools_anthropic() — Anthropic input_schema format
143148
- build_tools_openai() — OpenAI parameters format
144-
- _dispatch() — route LLM tool calls to @tool methods
149+
- dispatch() — route LLM tool calls to @tool methods (primary integration point)
145150
146151
The LLM loop is NOT here — implement it in your subclass using whichever
147152
framework or API client you need.
153+
154+
When to use Node9Agent vs @protect:
155+
- Node9Agent: greenfield agents where you control the tool definitions
156+
- @protect: retrofitting governance onto existing functions/classes
148157
"""
149158

150159
agent_name: str = ""
151160
policy: str = "audit"
152161

153162
def __init__(self, workspace: str = ""):
154-
self._run_id = str(uuid.uuid4())
155-
self._workspace = os.path.realpath(workspace) if workspace else os.getcwd()
163+
self._run_id = str(uuid.uuid4())
164+
if workspace:
165+
resolved = os.path.realpath(workspace)
166+
if not os.path.isdir(resolved):
167+
raise ValueError(
168+
f"Node9Agent workspace does not exist: {workspace!r}. "
169+
"Create the directory before constructing the agent."
170+
)
171+
self._workspace = resolved
172+
else:
173+
self._workspace = os.getcwd()
156174

157175
from . import _config
158176
_config.AGENT_NAME = self.agent_name or type(self).__name__
@@ -239,10 +257,14 @@ def build_tools_openai(self) -> list[dict]:
239257
# Dispatch
240258
# -------------------------------------------------------------------------
241259

242-
def _dispatch(self, tool_name: str, tool_input: dict) -> str:
260+
def dispatch(self, tool_name: str, tool_input: dict) -> str:
243261
"""
244262
Route a tool call by name to the matching @tool method.
245263
Returns a string result — or negotiation text if the action was denied.
264+
265+
This is the primary integration point for LLM loops:
266+
result = agent.dispatch(block.name, block.input) # Anthropic
267+
result = agent.dispatch(call.function.name, json.loads(call.function.arguments)) # OpenAI
246268
"""
247269
for attr_name in dir(type(self)):
248270
method = getattr(type(self), attr_name, None)
@@ -257,3 +279,13 @@ def _dispatch(self, tool_name: str, tool_input: dict) -> str:
257279
except Exception as e:
258280
return f"Error: {e}"
259281
return f"Unknown tool: {tool_name}"
282+
283+
def _dispatch(self, tool_name: str, tool_input: dict) -> str:
284+
"""Deprecated alias for dispatch(). Use dispatch() instead."""
285+
import warnings
286+
warnings.warn(
287+
"Node9Agent._dispatch() is deprecated — use .dispatch() instead.",
288+
DeprecationWarning,
289+
stacklevel=2,
290+
)
291+
return self.dispatch(tool_name, tool_input)

‎node9/_client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,13 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None:
256256
Raises ActionDeniedException if the action is denied.
257257
"""
258258
if os.environ.get("NODE9_SKIP") == "1":
259+
import warnings
260+
warnings.warn(
261+
f"[Node9] NODE9_SKIP=1 — governance bypassed for '{tool_name}'. "
262+
"Do not use in production.",
263+
stacklevel=3,
264+
)
265+
_offline_audit(tool_name, {**args, "_skip": True}, run_id=run_id)
259266
return
260267

261268
if os.environ.get("NODE9_API_KEY"):

0 commit comments

Comments
 (0)