AI News HubLIVE
In-site rewrite6 min read

Build a Basic AI Agent from Scratch: Security III

This article continues securing an AI agent by implementing a three-layer policy gate (path scoping, shell denylist, SSRF guard), always-confirm patterns for destructive actions, principle of least privilege via tool allowlist, and resource/cost controls to prevent runaway loops.

SourceHacker News AIAuthor: ruxudev

27 Jul 2026

Build a Basic AI Agent From Scratch: Security III

106 minute read · 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( tool_name: str, args: dict[str, Any], working_dir: Path, ) -> tuple[bool, str | None]: """Run all policy layers. Returns (allowed, reason).

Called by `agent.check_permission` BEFORE the mode-based decision. A False here is a hard block that no mode can override. """

Layer 1: path scope.

ok, reason = check_path_scope(tool_name, args, working_dir) if not ok: return False, reason

Layer 2: shell policy.

if tool_name == "run_bash": ok, reason = check_shell_policy(args.get("command", "")) if not ok: return False, reason

Layer 3: web / SSRF policy.

if tool_name == "webfetch": ok, reason = check_web_policy(args.get("url", "")) if not ok: return False, reason

return True, None

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] = { "read_file": "path", "glob_files": "path", "grep": "path", "write_file": "path", "edit_file": "path", }

def check_path_scope( tool_name: str, args: dict[str, Any], working_dir: Path, ) -> tuple[bool, str | None]: """Reject path-bearing tool calls whose target escapes working_dir.

Note: the Docker mount already constrains the *container's* view of the filesystem; this check is a defense-in-depth layer on the host side so a malicious path is rejected before it ever reaches docker. """ if tool_name not in PATH_TOOLS: return True, None raw = args.get(PATH_TOOLS[tool_name]) if not raw: return True, None # missing arg is a schema problem, not a scope problem try: target = Path(raw) if not target.is_absolute(): target = working_dir / target target.resolve().relative_to(working_dir.resolve()) return True, None except (ValueError, OSError, RuntimeError) as e: return False, ( f"Path '{raw}' is outside the working directory " f"({working_dir}). File tools may only touch paths inside " f"the project root. ({e})" )

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 = [ (re.compile(r"\brm\s+-rf?\s+(/|~|\*|\$HOME|\.\.)", re.I), "recursive delete of a broad or root target"), (re.compile(r">\s*/etc/", re.I), "redirect into /etc/ (system files)"), (re.compile(r"\bmkfs\b", re.I), "filesystem format command"), (re.compile(r"\bdd\b\s+if=", re.I), "raw disk write via dd"), (re.compile(r":\(\)\s*\{", re.I), "fork-bomb pattern"), (re.compile(r"\b(eval|exec)\b", re.I), "eval/exec in a shell command (injection risk)"), (re.compile(r">\s*/dev/sd", re.I), "write to a block device"), (re.compile(r"\bhistory\s+-c\b", re.I), "history wipe"), (re.compile(r"\bexport\s+PATH=", re.I), "PATH override (could shadow binaries)"), ]

Then, the command is lexically parsed and every token is checked against a deny list. The agent should never use any of those binaries in any command:

SHELL_DENYLIST_BINARIES = frozenset({ "docker", # sandbox escape / host control "sudo", "su", # privilege escalation "nc", "netcat", "ncat", # reverse shells / exfil "curl", "wget", # exfil / SSRF "chmod", "chown", # permission tampering "mkfs", "dd", # destructive disk ops "shutdown", "reboot", "halt", "poweroff", "systemctl", "service", "crontab", "at", })

Web Policy (SSRF Guard)

webfetch is screened against server-side request forgery. Cloud metadata endpoints, localhost, and private IP ranges are all blocked:

WEB_DENYLIST_HOSTS = frozenset({ "169.254.169.254", # AWS / GCP / Azure cloud metadata "metadata.google.internal", # GCP metadata "metadata.azure.com", # Azure metadata "0.0.0.0", "::1", "localhost", })

def check_web_policy(url: str) -> tuple[bool, str | None]: """Reject URLs that target loopback / link-local / private ranges.""" from urllib.parse import urlparse try: parsed = urlparse(url) except ValueError as e: return False, f"Unparseable URL: {e}" if parsed.scheme not in ("http", "https"): return False, f"Unsupported scheme '{parsed.scheme}'." host = parsed.hostname if not host: return False, "URL has no host component." if host.lower() in WEB_DENYLIST_HOSTS: return False, f"Blocked host '{host}' (loopback / metadata)."

Resolve and check the IP family.

try: infos = socket.getaddrinfo(host, None) except socket.gaierror:

Let the actual fetcher surface the DNS error.

return True, None for info in infos: ip = info[4][0] try: addr = ipaddress.ip_address(ip) except ValueError: continue if addr.is_loopback or addr.is_link_local or addr.is_multicast: return False, f"Blocked IP '{ip}' (loopback / link-local / multicast)." if addr.is_private: return False, f"Blocked IP '{ip}' (RFC1918 private range SSRF guard)." return True, None

localhost and 127.0.0.1 are where services that aren't exposed to the public network live: databases (Postgres on 5432, Redis on 6379), admin panels, metrics endpoints, internal APIs, and sometimes cloud-metadata proxies. A prompt-injected webfetch("http://localhost:8080/admin/delete-all") could trigger destructive actions on a service that trusts local connections and therefore has no auth. Blocking loopback prevents the agent from being used as a pivot into the host's internal network.

The host is resolved via getaddrinfo and each returned IP is checked with ipaddress. Loopback, link-local, multicast, and RFC1918 private ranges are all blocked. This stops the model from fetching http://169.254.169.254/latest/meta-data/iam/security-credentials/ to steal cloud credentials, or probing internal services on 10.0.0.0/8.

Always-Confirm Patterns

On top of the hard policy gate, some patterns are dangerous enough that they require explicit confirmation regardless of permission mode. These are the irreversible / exfiltration class:

NOTE: for run_bash, anything already covered by SHELL_DENYLIST_BINARIES

or SHELL_DENYLIST_PATTERNS (rm -rf, sudo/su, docker, chmod,

curl/wget/nc/netcat/ncat) is a hard block in layer 1 and never

reaches this layer, so it is deliberately NOT duplicated here. Only

patterns that layer 1 does *not* already hard-block belong in this list.

ALWAYS_CONFIRM_ARG_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [

force-push to git (git itself is not on the shell denylist)

("run_bash", re.compile(r"\bgit\s+push\s+(-f|--force)", re.I), "force-push to git (rewrites remote history)"),

overwriting a file with empty content = delete

("write_file", re.compile(r'"content"\s*:\s*"\s*"'), "write_file with empty content on an existing file (delete-via-empty)"), ]

The check_permission function now has a 3-layer structure:

Hard policy gate (check_tool_policy). Checks path scope, shell policy, SSRF guard. A False here blocks the call regardless of mode.

Always-confirm. If always_confirm_required returns (True, reason):

in dangerouslySkipPermissions: the call is refused outright (irreversible actions are never auto-run, even with the user's blanket opt-in);

in default / acceptEdits: the user is prompted with a [DESTRUCTIVE] label.

Mode decision. The original default / acceptEdits / dangerouslySkipPermissions logic, with the addition that write_file emptying an existing file forces a [DELETE-via-empty] confirmation even in acceptEdits.

def check_permission( tool_name: str, args: dict, mode: PermissionMode, working_dir: Path, ) -> tuple[bool, str | None]:

Layer 1: hard policy gate (path scope, shell, SSRF).

ok, reason = tool_policy.check_tool_policy(tool_name, args, working_dir) if not ok: return False, reason

Layer 2: always-confirm & irreversible patterns.

confirm_required, confirm_reason = tool_policy.always_confirm_required( tool_name, args) if confirm_required: if mode == PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS: return False, ( f"Blocked even in dangerouslySkipPermissions: " f"{confirm_reason or 'irreversible action'}. " "Run this command manually outside the agent if it is truly intended." ) return _ask_permission(f"{tool_name} [DESTRUCTIVE]", args), None

Layer 3: mode-based decision (read/planning free, acceptEdits for in-tree writes, etc.)

...

always_confirm_required returns (bool, reason) rather than a bare bool, so this layer carries its own specific reason instead of falling back on whatever layer 1 happened to return (which, on the success path, is always None). check_permission itself also returns (allowed, reason), so rejections carry a machine-readable reason surfaced to the LLM and the audit log.

Principle of Least Privilege

One more control fits here: a --tools CLI flag that accepts a comma-separated allow list of tool names. The harness filters both the in-process registry and the schemas exposed to the LLM, so the model never even sees tools it can't call:

$ python agent.py --tools read_file,glob_files,grep,run_bash

If we know our agent will never need to write files, we can reduce the danger blast radius enormously by limiting the allowed tools.

Resource & Cost Controls

We will add to the harness some resource and cost controls to limit the agent from going out of hand while we are not looking at it.

Hard Iteration Caps

IterationCaps tracks two counters:

turns: incremented once per LLM response within a single user turn. Reset on each new user message. The default is 40 turns, but it can be overwritten with --max-turns.

tool_calls: incremented once per tool d

[truncated for AI cost control]