All Tool Calls Go Through One Function. No Exceptions.
You do not call tool.execute() directly from the agent loop. You call execute_with_approval(). Always.
This function is the single chokepoint where the three access levels are enforced and every action is logged. The agent loop does not need to know whether a tool is READ, WRITE, or ADMIN — the gate handles that.
---
The Audit Log
First, the logging function. Every action — approved or rejected — gets a record.
import json
import datetime
AUDIT_LOG_PATH = "audit.jsonl"
def _log_action(tool_name: str, params: dict, approved: bool, operator: str):
entry = {
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
"tool": tool_name, "params": params,
"approved": approved, "operator": operator,
}
with open(AUDIT_LOG_PATH, "a") as f:
f.write(json.dumps(entry) + "\n")
JSONL format — one JSON object per line. Easy to ship to a SIEM. Easy to search with grep or jq. Each line is self-contained.
Think of this as your debug ip ospf events equivalent — a timestamped trail of everything the agent did or tried to do.
---
The Full Safety Gate
def execute_with_approval(tool: BaseTool, params: dict, operator: str = "unknown") -> ToolResult:
"""The safety gate. Every tool call goes through here."""
if tool.category == READ:
result = tool.execute(**params)
_log_action(tool.name, params, approved=True, operator=operator) return result
if tool.category == WRITE:
print(f"\n{'='*55}")
print(" WRITE OPERATION REQUESTED")
print(f"{'='*55}")
print(f" Params: {json.dumps(params, indent=4)}")
if "diff" in params:
print(f"\n Config diff:\n{params['diff']}")
print(f"{'='*55}")
answer = input(" Approve? (y/n): ").strip().lower()
approved = (answer == "y")
_log_action(tool.name, params, approved=approved, operator=operator) if not approved:
return ToolResult(success=False, data={}, error="Operator rejected.")
return tool.execute(**params)
if tool.category == ADMIN:
print(f"\n{'!'*55}")
print(" ADMIN OPERATION — HIGH IMPACT")
print(" Type 'YES I CONFIRM' to proceed:")
answer = input(" > ").strip()
approved = (answer == "YES I CONFIRM")
_log_action(tool.name, params, approved=approved, operator=operator) if not approved:
return ToolResult(success=False, data={}, error="Admin confirmation not received.")
return tool.execute(**params)
return ToolResult(success=False, data={}, error="Unknown tool category.")
---
Breaking It Down Section by Section
READ block — runs immediately:
if tool.category == READ:
result = tool.execute(**params)
_log_action(tool.name, params, approved=True, operator=operator) return result
No prompt. No delay. Calls the tool, logs it, returns. This is your show command path — fast enough for an interactiv agent loop.
---
WRITE block — requires y/n:
if tool.category == WRITE:
# Print what is about to happen
# If the agent already ran ConfigDiffTool, show the diff here
answer = input(" Approve? (y/n): ").strip().lower()
approved = (answer == "y")
_log_action(...) # log BEFORE executing
if not approved:
return ToolResult(success=False, ...)
return tool.execute(**params)
The log entry is written before execution. If the system crashes mid-execution you still have a record that approval was granted. A rejection is logged as approved=False — that record matters for compliance review.
The if "diff" in params check: if the agent ran ConfigDiffTool first and passed the diff into the params, the gate surfaces it here. The operator sees exactly what lines will change before typing y.
---
ADMIN block — requires YES I CONFIRM exactly:
if tool.category == ADMIN:
answer = input(" > ").strip()
approved = (answer == "YES I CONFIRM")
Not y. Not yes. Not YES. The exact phrase YES I CONFIRM. Case-sensitive, no trailing space.
This is designed so you cannot accidentally approve a factory reset by hitting Enter or typing a lazy y. You have to mean it.
---
Wire It Into Your Agent Loop
In your agent loop from Module 2, replace the direct tool.execute() call with:
result = execute_with_approval(
tool=registry.get(tool_name),
params=parsed_params,
operator="your.name" # pull from session auth in production )
The agent loop does not need to know whether a tool is READ, WRITE, or ADMIN. The gate handles all of it. Your loop stays clean.
---
The Audit Log Is a Client Deliverable
After a typical 20-minute session your audit.jsonl looks like this:
{"timestamp": "2026-03-21T14:02:11Z", "tool": "run_show_command", "params": {"device": "core-sw-01", "command": "show ip ospf neighbor"}, "approved": true, "operator": "ed.dulharu"}
{"timestamp": "2026-03-21T14:03:44Z", "tool": "apply_interface_config", "params": {"device": "core-sw-01", "interface": "Gi0/1", "config": "ip ospf dead-interval 40"}, "approved": true, "operator": "ed.dulharu"}
{"timestamp": "2026-03-21T14:04:01Z", "tool": "apply_interface_config", "params": {"device": "core-sw-01", "interface": "Gi0/2", "config": "shutdown"}, "approved": false, "operator": "ed.dulharu"}
Your client's auditor asks: "Did a human approve every config change?" You hand them this file. Every WRITE and ADMIN operation, timestamped, operator-stamped, with the exact parameters that were submitted.
That is a competitive advantage over MSPs running scripts with no accountability trail.
Colab code:
---
What You Have Built in Module 3
At the end of this module your agent has:
1. Three access levels defined as plain constants — READ, WRITE, ADMIN
2. ToolResult — consistent return shape every tool uses
3. BaseTool — the template every tool inherits from
4. ToolRegistry — stores tools and generates the LLM system prompt description
5. ShowCommandTool — READ category, demo mode, real SSH via Netmiko
6. ConfigDiffTool — READ category, shows exactly what will change before any write
7. execute_with_approval() — the single gate, with audit logging
In Module 4 you will give the agent memory — so it remembers what it found on previous devices, builds context across a troubleshooting session, and stops asking the LLM to re-derive things it already knows.