@@ -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 )
0 commit comments