DEV Community

Cover image for A symlink walks straight out of an agent's write allow-list
Fewparts
Fewparts

Posted on Originally published at fewparts.co.uk

A symlink walks straight out of an agent's write allow-list

Here is the path check almost every write guard ends up with, including the one I ship. It is better than the obvious version, and it is still wrong.

import { isAbsolute, relative, resolve } from "node:path";

function contains(root, target) {
  const rel = relative(resolve(root), resolve(target));
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
Enter fullscreen mode Exit fullscreen mode

It correctly refuses ../../etc/passwd. It correctly refuses src-secret/keys.json when only src is allowed, which the naive target.startsWith(root) gets wrong. If you have written this function you probably tested both of those, watched them deny, and moved on.

Now put a symlink in the allowed directory:

project/
  products/          <- the only directory the agent may write to
    cache -> ../../secrets
secrets/
  notes.txt
Enter fullscreen mode Exit fullscreen mode

And ask the guard about project/products/cache/notes.txt:

1. lexical guard  -> defer (approved)
2. write landed in C:\...\escape-UlmQ6t\secrets\notes.txt — outside products/
Enter fullscreen mode Exit fullscreen mode

That is the real transcript from the reproduction, and line 2 is the part that matters: the file was written, and it was written outside the allow-list. The guard did not fail to run. It ran, considered the path, and said yes.

Why it says yes

path.resolve and path.relative are string operations. They collapse . and .., they normalise separators, and they never open anything. resolve on a path is a claim about the text, not about the filesystem.

So products/cache/notes.txt normalises to products/cache/notes.txt, which sits under products, which is allowed. Every step of that is correct. The mistake is upstream of the comparison: the guard is comparing two names when the thing it wants to constrain is two locations, and on a filesystem with links those are not the same thing.

This is not the classic traversal bug. Traversal is .. in the input, and the check above catches it. Here the input contains nothing suspicious at all — no .., no absolute path, no encoding trick, nothing a reviewer would circle. The redirection lives on disk, not in the string.

Who puts a link there

Worth being precise about this, because "an attacker plants a symlink" is not the common case and pretending otherwise makes the risk easy to dismiss.

Links inside project directories are ordinary. node_modules is full of them, and pnpm's entire layout is symlinks by design. Build tools link caches into place. Monorepo tooling links packages to each other. Anyone who has ever pointed a data/ directory at a bigger disk has one. If your allow-list is products/ and something under it is a link, the guard has a hole in it and nobody did anything wrong.

Then there is the agent itself. An unattended agent runs shell commands, and a shell command that creates a directory link is not a Write — the hook never sees it, because the matcher matches tool names and Bash isn't one of the file-writing ones. A single ln -s establishes the route, and every subsequent write through it is approved by a guard doing exactly what it was written to do.

I am not claiming this makes a hook into a sandbox. It never was one — an agent with shell access can write anywhere it likes without consulting your hook at all, which is why the git-diff check exists. The claim is narrower and worse: the guard is silent here. It reports the write as in-scope, which is the same thing it reports when everything is fine.

Windows makes it easier, not harder

The instinct is that this is a Unix problem, because creating a symlink on Windows needs administrator rights or Developer Mode. That is true for symlinks and irrelevant, because it isn't the only kind of link Windows has.

From the probe, on Windows 10 with Developer Mode off:

FAILED TO CREATE: EPERM  dir link (symlink)
FAILED TO CREATE: EPERM  file link (symlink)
defer                    dir link (junction)
Enter fullscreen mode Exit fullscreen mode

Both symlink types were refused. The directory junction was created without a prompt, without elevation, by an ordinary user process — and the junction is the one that defeats the guard. fs.symlinkSync(target, path, "junction") is one line of Node, and mklink /J is one line of shell. The permission gate everyone remembers guards the door that was already locked.

The diff doesn't catch this either

The standard answer to "a hook can't see everything" is to also diff git status before committing, and check the changed paths against the same allow-list. I ran that against the escape to see what it caught:

git status --porcelain:
?? products/cache/

checkScope -> {"ok":true,"changed":["products/cache/"],"violations":[]}
Enter fullscreen mode Exit fullscreen mode

Nothing. Git reports the path as products/cache/, which is inside the allow-list, so the scope check passes. Both halves of a two-layer guard are comparing the same names in the same way, so they share the same blind spot — and two independent checks that fail identically are one check wearing a disguise.

The fix, and the bug it invites

Ask the filesystem where the path actually goes:

import { realpathSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";

export function resolveLinks(path, realpath = realpathSync) {
  let current = resolve(path);
  const tail = [];
  for (;;) {
    try {
      return tail.length ? join(realpath(current), ...tail) : realpath(current);
    } catch (err) {
      if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") return null;
      const parent = dirname(current);
      if (parent === current) return resolve(path);
      tail.unshift(basename(current));
      current = parent;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The loop exists because the target of a Write usually doesn't exist yet — that is the entire point of a Write — and realpath on a non-existent path throws ENOENT. So walk up to the nearest ancestor that does exist, resolve that, and re-attach the segments you skipped. Those segments cannot be hiding a link, because they aren't there.

Now the important part, which took a second reproduction to see. Feed the resolved target into the old comparison and legitimate writes start getting denied:

5. resolving the target only -> deny on a legitimate write
   both sides resolved       -> defer
Enter fullscreen mode Exit fullscreen mode

If the project root is itself reached through a link, the target resolves to the real location and the allow-list root doesn't, so they no longer share a prefix and everything is refused. This is not exotic: /tmp is /private/tmp on macOS, /home is often a mount point behind a link, and a CI checkout path is frequently a symlink to a workspace directory. Resolve both sides or neither. Resolving one is worse than resolving none, because it fails on the ordinary case instead of the rare one.

Two smaller decisions, both worth making deliberately:

When realpath refuses to answer, deny. A symlink loop gives ELOOP; an unreadable directory gives EACCES. Everywhere else in a hook, the right failure policy is to proceed — a guard that blocks every write the moment you typo the allow-list is a guard you will disable by lunchtime, and every failure path in the hook contract ends in "proceed" anyway. This one case is the exception, and the reason is that it's not a failure of the guard, it's the filesystem declining to say where a path leads. An unknown location is not one to approve.

Put the resolved path in the deny message. The interesting denials are the ones where it differs from what was asked for, because that difference is the link:

Write to C:\...\escape-UlmQ6t\project\products\cache\notes.txt is outside this
agent's write scope. It resolves to C:\...\escape-UlmQ6t\secrets\notes.txt.
Allowed: products. If this file genuinely needs changing, say so and stop — do
not work around the guard.
Enter fullscreen mode Exit fullscreen mode

The model reads that string, and so do you at 3am.

What this still doesn't fix

The check happens in the hook; the write happens after the hook returns. Between those two moments the link can be created or repointed, and nothing in a PreToolUse hook can close that window — it is a time-of-check-to-time-of-use gap and it is inherent to checking a path by name at all. A real boundary is an OS-level one: a container, a user account that cannot see the rest of the disk, a filesystem namespace. If what you have is a hook, what you have is a guard against mistakes and against instructions your agent picked up from a file it read, and it is worth having, and it is not a sandbox.

What changed is the silence. Before, the guard approved the write and reported it as in-scope. Now it refuses and says where the path really went.

Test that it denies

The reason this bug survived review is that the test suite only ever asserted the guard allows the right things and denies the obvious ones. Every assertion passed, both before and after the hole existed, because neither knew links were a category.

So: create a real link in a temp directory, aim a write through it, and assert deny. Not a mocked realpath — a link on a real filesystem, junction on Windows and dir elsewhere, skipped with a printed SKIP if the platform refuses to create one. And before you trust the new test, break the guard on purpose and watch it fail. A check that has never been seen to fail is not evidence of anything.


Originally published at fewparts.co.uk.

Agent Guardrails Kit is the free, assembled version of this code — same modules, wired together, with the tests.

Top comments (0)