Scope Guards for AI Agents: Decoding Hidden IPs and Domains Before Running
Scope guards for AI agents: decode nested base64, hex and URL encoding, normalize odd IP formats and filter file names so out-of-scope targets never run.
This article is published in English.
Agent designs usually treat the agent as the safe component. It executes code inside a sandbox the team owns, edits files it was given and reads logs the team recorded. One situation breaks that trust: the agent works through text written by someone else, and part of that text is built to coax it into doing something beyond its assignment.
The scenario
Consider a recursive language model hired for a sanctioned penetration test. Its brief names the IP ranges and domains it may touch, and anything outside that list is forbidden regardless of the route the model takes to reach it. At some point the model emits a shell command. A checkpoint has to sit between that output and the actual shell, and it has to enforce the boundary itself instead of hoping the model remembers.
Comparing the command with a whitelist of strings falls short. Attackers, and models pushed sideways by text they've read, seldom write a prohibited address openly. They conceal it, so the checkpoint must be as good at revealing as they are at concealing.
Hidden in encoding
Suppose the only permitted target is target.htb. A naive scope check scans the command for domain-looking substrings and compares them to that one value. It works until a command looks like this:
curl http://localhost/exec?cmd=ping%20192.168.2.1
No 192.168.2.1 appears in plain text, because it's percent-encoded inside the URL. Decode once and it's right there. Wrap it in base64, break it across hex escapes, or nest several layers, and a check that reads only the literal text passes every variant, even though each resolves to the same out-of-scope host.
This isn't a quirk of one encoding. A value can usually be written in many equivalent ways, and a validator that inspects the surface string checks just one of them.
The remedy is to decode first and judge afterward. Before the scope check asks whether an address is allowed, it peels off every encoding it can find, recursively, and evaluates the plain values that fall out:
def _de_cloak_payloads(cmd: str, depth: int = 0, max_depth: int = 3) -> set[str]:
"""
Recursively searches for base64, hex, and URL encodings inside cmd.
Returns a set of all extracted/decoded plain strings.
"""
extracted = {cmd}
if depth >= max_depth:
return extracted
# 1. URL Decoding
decoded_url = urllib.parse.unquote(cmd)
if decoded_url != cmd:
extracted.update(_de_cloak_payloads(decoded_url, depth + 1, max_depth))
# 2. Hex escape and raw hex string decoding
for match in re.findall(r"(?:\\x[0-9a-fA-F]{2})+", cmd):
hex_bytes = bytes.fromhex(match.replace("\\x", ""))
extracted.update(_de_cloak_payloads(hex_bytes.decode("utf-8", errors="ignore"), depth + 1, max_depth))
# 3. Base64 decoding
for match in re.findall(r"\b[A-Za-z0-9+/]{12,}={0,2}\b", cmd):
padded = match + "=" * ((4 - len(match) % 4) % 4)
decoded = base64.b64decode(padded.encode("ascii")).decode("utf-8", errors="ignore")
extracted.update(_de_cloak_payloads(decoded, depth + 1, max_depth))
return extracted
The recursion feeds the output of each decode attempt back into the same function, with a cap on depth. Imagine URL encoding wrapped around base64 wrapped around hex escapes: it unravels step by step, and every string produced en route, not only the innermost, is added to the pool that later checks examine.
What comes out is a set: the original command plus every plain value recoverable by decoding, up to three layers deep. Each one is tested against scope. If any contains an address outside the allowed range, the entire command is rejected, even if the model's literal text never mentioned that address.
Hidden in number formats
Encoding isn't the only disguise. An IP address is a number, and numbers have several valid spellings. 192.168.2.1 can be a single 32-bit integer, four octal or hexadecimal parts, or a padded mix that most tools accept. A regular expression tuned for dotted decimal misses all of these, yet a real network stack resolves them to the same machine.
def _normalize_ip_token(token: str) -> str | None:
token = token.strip().lower()
# A bare integer or hex integer standing in for a full IP
if token.isdigit() or (token.startswith("0x") and all(c in "0123456789abcdef" for c in token[2:])):
val = int(token, 16) if token.startswith("0x") else int(token)
if 0 <= val <= 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:
if val > 1024 or val in (0,):
return str(ipaddress.ip_address(val))
# Dotted octal or dotted hex, one part at a time
if "." in token:
parts = token.split(".")
if len(parts) == 4:
normalized_parts = []
for p in parts:
val = int(p, 16) if p.startswith("0x") else int(p, 8) if p.startswith("0") and len(p) > 1 else int(p)
if 0 <= val <= 255:
normalized_parts.append(str(val))
if len(normalized_parts) == 4:
return ".".join(normalized_parts)
return None
Every odd spelling gets rewritten into standard dotted notation before anyone compares it with the permitted ranges. That means 0xC0A80201 and 192.168.2.1 count as the same host wearing different clothes.
Domains and file names
IP checking is about catching disguises. Domain checking has the reverse difficulty: the net must be wide enough to catch real domain references without flagging every filename that contains a dot.
A regex built to find domain-shaped tokens matches config.yaml or report.pdf as readily as evil.example.com, since each has the same shape of letters, a dot and more letters. More decoding doesn't help here. A second list does, namely common file extensions, compared against whatever sits where a top-level domain would:
tld = token.rsplit(".", 1)[-1]
if tld in _FILE_EXTENSIONS:
continue
if not any(token == d or token.endswith("." + d) for d in self.domains):
raise ScopeViolation(f"Domain {token!r} is outside engagement scope.")
The list has to be tuned, since both extremes hurt. Make it too short and ordinary file paths keep raising alarms, so users learn to dismiss the tool altogether. Make it too long and a genuine out-of-scope domain can hide behind a suffix that happens to be excluded. The real list covers several dozen extensions, py and sh through pem and elf, because engagements involve all sorts of files and each one risks a false alert.
The complete check sends every decoded candidate through both filters before letting a command run:
def check(self, cmd: str) -> None:
payloads = _de_cloak_payloads(cmd)
for payload in payloads:
if self.networks:
for m in _IPV4_RE.finditer(payload):
self._validate_ip(m.group(1))
for token in re.split(r"[\s\"'$,;()|&<>`\\/]", payload):
normalized = _normalize_ip_token(token)
if normalized:
self._validate_ip(normalized)
if self.domains:
for m in _DOMAIN_RE.finditer(payload):
token = m.group(0).lower()
tld = token.rsplit(".", 1)[-1]
if tld in _FILE_EXTENSIONS:
continue
if not any(token == d or token.endswith("." + d) for d in self.domains):
raise ScopeViolation(f"Domain {token!r} is outside engagement scope.")
One offending value in any decoded variant halts the command. Proving what the model meant isn't required. It's enough that one plausible interpretation of the command's content reaches the forbidden address.
Beyond security testing
Remove the penetration-testing context and what remains is a pattern shared by every agent that acts on untrusted input. Think of an approval flow comparing an amount to a spending cap, a filter matching submitted text against a blocklist, or an access check testing a requested resource against an allow list. The naive implementation tests whatever literal string arrived. The dependable one canonicalizes every equivalent form of the value first and tests the result. A shell-command guard teaches a lesson that applies just as well to JSON fields, file paths and URL parameters whenever an agent decides whether to proceed.
Cost is the next question
Everything above concerns correctness and containment: whether the agent stays inside the lines, whether a patched file still runs and whether a decoded value can be judged safely. None of it answers a more mundane question that appears as soon as real users show up. Recursive systems make many model calls, and many calls cost time and money. Where that cost comes from, and what actually reduces it, is a separate matter.
The practical reading for anyone building a similar gate: treat the decoder and the normalizer as part of the security boundary, test them with adversarial inputs such as triple-nested encodings and mixed-radix addresses, and keep the allowed-extension list under review. A guard that was correct last quarter can quietly become porous once new file types and tools enter the workflow.