Build a Basic AI Agent From Scratch: Security III
27 Jul 2026<br>Build a Basic AI Agent From Scratch: Security III
106 minute read<br>Artificial Intelligence
Previous parts of Build a Basic AI Agent From Scratch:
Basic Agent
Tools
Long Task Planning
Human in the Loop & Security
Security II
You can find and clone this code in this blog series' Github repo.
In the previous part we started closing the gaps left open by human-in-the-loop: a Docker sandbox to contain runaway commands, prompt-injection defenses so the model stops trusting tool output as instructions, and schema validation so malformed tool calls never reach execution. In this part we finish the job. We will harden the tool policy gate with path scoping, a shell denylist, and an SSRF guard, enforce resource and cost limits so a stuck loop cannot run forever, scrub secrets out of the container environment, and add audit logging and a kill switch so every decision is recorded and any session can be aborted mid-flight.
Tool Policy Hardening
In the Human in the Loop & Security part, check_permission was a single function: read tools and planning tools were always allowed, write tools were allowed inside the working directory in acceptEdits, everything else asked. That was a mode-based decision.
We now add a policy gate that runs before the mode decision. The gate has three layers:
Path scoping : every path argument is resolved (handling relative paths, symlinks, and .. traversal) and rejected if it escapes the project working directory. This stops the agent from touching ~/.ssh/id_rsa, /etc/passwd, or anything outside the project tree.
Shell policy : every run_bash command is screened against a regex denylist of dangerous patterns (fork bombs, dd, mkfs, redirects into /etc/, ...) and a token-level denylist of binaries the agent should never invoke (sudo, nc, curl, chmod, docker, ...). This catches destructive and exfiltration commands.
SSRF guard : Server-Side Request Forgery is an attack where a server-side process is tricked into making requests to internal resources it shouldn't be able to reach. In an agent context, a prompt injection could make webfetch hit internal services or the cloud metadata endpoint. The guard resolves the URL's host, checks the resulting IP against loopback, link-local, multicast, and RFC1918 private ranges, and blocks them.
def check_tool_policy(<br>tool_name: str,<br>args: dict[str, Any],<br>working_dir: Path,<br>) -> tuple[bool, str | None]:<br>"""Run all policy layers. Returns (allowed, reason).
Called by ``agent.check_permission`` BEFORE the mode-based decision.<br>A False here is a hard block that no mode can override.<br>"""<br># Layer 1: path scope.<br>ok, reason = check_path_scope(tool_name, args, working_dir)<br>if not ok:<br>return False, reason
# Layer 2: shell policy.<br>if tool_name == "run_bash":<br>ok, reason = check_shell_policy(args.get("command", ""))<br>if not ok:<br>return False, reason
# Layer 3: web / SSRF policy.<br>if tool_name == "webfetch":<br>ok, reason = check_web_policy(args.get("url", ""))<br>if not ok:<br>return False, reason
return True, None<br>Path Scoping
The old check only ran on write tools. Now, the check is generalized to every tool that uses paths: read_file, glob_files, grep, write_file, edit_file. Each tool's path argument is resolved (handling relative paths, symlinks, and .. traversal) and rejected if it escapes the working directory:
PATH_TOOLS: dict[str, str] = {<br>"read_file": "path",<br>"glob_files": "path",<br>"grep": "path",<br>"write_file": "path",<br>"edit_file": "path",
def check_path_scope(<br>tool_name: str,<br>args: dict[str, Any],<br>working_dir: Path,<br>) -> tuple[bool, str | None]:<br>"""Reject path-bearing tool calls whose target escapes working_dir.
Note: the Docker mount already constrains the *container's* view of<br>the filesystem; this check is a defense-in-depth layer on the host<br>side so a malicious path is rejected before it ever reaches docker.<br>"""<br>if tool_name not in PATH_TOOLS:<br>return True, None<br>raw = args.get(PATH_TOOLS[tool_name])<br>if not raw:<br>return True, None # missing arg is a schema problem, not a scope problem<br>try:<br>target = Path(raw)<br>if not target.is_absolute():<br>target = working_dir / target<br>target.resolve().relative_to(working_dir.resolve())<br>return True, None<br>except (ValueError, OSError, RuntimeError) as e:<br>return False, (<br>f"Path '{raw}' is outside the working directory "<br>f"({working_dir}). File tools may only touch paths inside "<br>f"the project root. ({e})"<br>This is defense-in-depth on the host side. The Docker mount already constrained the container's view of the file system, but a malicious path is rejected here before it even reaches the sandbox.
Shell Policy
By far, run_bash is the most dangerous tool, so it gets its own screening. Two checks run against every command.
First, a regex denylist of dangerous patterns:
SHELL_DENYLIST_PATTERNS = [<br>(re.compile(r"\brm\s+-rf?\s+(/|~|\*|\$HOME|\.\.)", re.I),<br>"recursive delete of a broad or root target"),<br>(re.compile(r">\s*/etc/", re.I),<br>"redirect into /etc/ (system...