DEV Community

Cover image for You Approved `project_settings.json`. The OS Was About to Write `~/.ssh/authorized_keys`.
Alexey Spinov
Alexey Spinov

Posted on • Originally published at finops.spinov.online

You Approved `project_settings.json`. The OS Was About to Write `~/.ssh/authorized_keys`.

AI coding agent approval path resolution is the gap between the path an approval dialog shows and the file the OS actually opens. Before the write lands, the OS expands ~ and $VAR, follows symlinks, and collapses .., so the resolved target can escape your workspace. approval_path_resolve_gate.py resolves it first, offline, and blocks the mismatch.

AI disclosure: I wrote approval_path_resolve_gate.py with an AI assistant and ran it myself, offline, on Python 3.13.5, standard library only, no network. Every verdict, exit code, and hash in the output blocks below is pasted from a real local run. I ran each scenario twice to confirm STDOUT is byte-for-byte identical, and the tool prints a sha256 of its own report so you can reproduce the exact bytes. The GhostApproval disclosure I cite is Wiz's work and other people's reporting, attributed inline — their findings and vendor names, not mine, and their numbers stay out of my fixtures. My fixture writes to a stand-in file I named outside/authorized_keys; it never touches your real ~/.ssh.

In short:

  • An approval dialog is tracking: it shows you the path the model typed. os.path.realpath() before the write is control: the file the OS will actually open after it resolves symlinks, .., ~, and $VAR. The gap between the two is the whole bug.
  • The demo that matters: one approved string, project_settings.json, flips the verdict. In a clean repo it is an ordinary file — PASS, exit 0. In a poisoned repo the same name is a symlink pointing out of the workspace — BLOCK, exit 1, resolved-escapes-workspace. You approved the same string both times.
  • When the resolved target leaves the workspace via a symlink, that is CWE-451 (UI misrepresentation) plus CWE-61 (symlink following). Via .., it is CWE-451 plus CWE-22 (path traversal). The gate names which one fired.
  • It resolves the target the way the OS would, compares it to what you read off the dialog, and to the workspace root. Read-only: it opens nothing, writes nothing, hits no network. Fail-closed: bad input exits 2, never a silent green.
  • Standard library only (os, sys, hashlib). The run is byte-for-byte deterministic; the whole reproduction harness — rebuild fixtures, five scenarios, each run twice — finished in about a third of a second on my laptop. The tool and its fixtures are in this post.

How does AI coding agent approval path resolution differ from the shown path?

A path string is not a file. It is a request for the OS to go find a file, and the finding involves steps you do not see. ~ becomes your home directory. $HOME or $PROJECT becomes whatever the environment says. A .. walks up a level. And a symlink — the quiet one — hands the whole lookup off to a target written somewhere else, at some earlier time, by someone who may not be you.

So project_settings.json in an approval dialog is a claim, not a fact. The claim is "I am about to write a file here, in your project, called project_settings.json." Whether that claim is true depends on what project_settings.json resolves to on disk right now. If it is a regular file in the repo, the claim holds. If it is a symlink to ../outside/authorized_keys, the claim is a costume.

This is the same split I keep hammering across the pre-execution gate this series is built on: tracking is not control. The dialog tracks intent — it shows the string the model meant to write. It does not control the write, because the OS does not consult the dialog. It consults the filesystem. Control means resolving the target the OS will actually open and deciding on that, before the human is ever asked to click Approve.

The official name for the gap has existed for years. CWE-451 is "User Interface (UI) Misrepresentation of Critical Information" — the interface shows you one thing while the system acts on another. When the misrepresentation is powered by a symlink, you also have CWE-61, "UNIX Symbolic Link (Symlink) Following." When it is powered by .., you have CWE-22, "Path Traversal." The gate reports which one it caught.

The GhostApproval disclosure (their findings, not mine)

I did not invent this failure. In July 2026 the research team at Wiz disclosed a class of bug they named GhostApproval, described in their writeup as a trust-boundary gap in AI coding assistants: the permission dialog shows the path the model typed, but the write resolves to a different path. They classify it as CWE-451 plus CWE-61 — the exact two identifiers above. Those are their words and their classification, not mine.

Per Wiz's disclosure, and the coverage in The Hacker News, the pattern showed up across six AI coding agents they name: Claude Code, Amazon Q Developer, Cursor, Google Antigravity, Augment, and Windsurf. As reported, some vendors shipped fixes (the reporting names AWS, Cursor, and Google), two stayed quiet, and one called it outside its threat model. @leobaniak's Dev.to summary put the mechanism in one line: the dialog was reading the wrong path.

I want to be precise about what is theirs and what is mine, because it is easy to launder someone else's incident into your own credibility here. The six agents, the vendor responses, the "six agents" count — those are Wiz's and the press's, and I have not independently reproduced any vendor bug. What follows is my own thing: a small, honest reproduction of the mechanism on synthetic fixtures, plus a gate that decides on the resolved target before the human approves. Different question, deliberately narrow, and every number below comes from running that gate.

What os.path.realpath() resolves before the write

The core of the gate is four lines of resolution, and it is worth reading them slowly, because the whole verdict turns on the difference between two of them:

expanded = os.path.expandvars(os.path.expanduser(approved))   # ~ and $VAR
base = expanded if os.path.isabs(expanded) else os.path.join(root_real, expanded)
resolved = os.path.realpath(base)   # follows symlinks + collapses ".."
lexical  = os.path.normpath(base)   # collapses ".." only, no symlink follow
Enter fullscreen mode Exit fullscreen mode

os.path.realpath is the OS's own answer: expand, join to the workspace root if relative, follow every symlink, collapse every .., and return the real absolute target. os.path.normpath does the lexical cleanup only — it collapses .. but does not touch symlinks. When those two disagree, a symlink was traversed. When realpath lands outside the workspace root, the write is about to escape. That is the entire mechanic. Everything else in the file is presentation and honest bookkeeping.

Run it in sixty seconds

No keys. No network. No install beyond Python 3. Two files: the gate, and a fixture builder that creates a clean repo and a poisoned one under /tmp. The gate itself writes nothing; the fixture builder writes only under one namespaced /tmp directory and never touches your real ~/.ssh.

Here is the whole gate. One file, standard library only.

#!/usr/bin/env python3
"""approval_path_resolve_gate.py - what did the human actually approve?

An approval dialog shows a string: "write project_settings.json". The human reads
that string and clicks Approve. But the string is not what the OS opens. Before the
write lands, the OS resolves symlinks, "..", "~", and environment variables. The
resolved target can be a completely different file, possibly outside the workspace.

The dialog is TRACKING: the path the model typed, shown back to you.
os.path.realpath() before the write is CONTROL: the file the OS will actually open.
The gap between the two is CWE-451 (UI misrepresentation). When the resolved target
also leaves the workspace via a symlink, that is CWE-61 (symlink following).

This gate takes the approved display string plus the workspace root, resolves the
real target the way the OS would, and decides whether what you approved matches what
the OS would open, and whether it stays inside the workspace.

It is READ-ONLY. It calls os.path.realpath / os.readlink / os.path.islink /
os.path.expanduser / os.path.expandvars only. It never opens the target file, never
writes anything, never touches the network. Standard library only.

Usage:
  approval_path_resolve_gate.py <workspace_root> <approved_path> [more_approved ...]

Each approved path is a string exactly as it would appear in the approval dialog. A
relative path is interpreted against the workspace root (as the agent's write would be).

Verdicts (per approved path):
  PASS  exit 0  resolved target is inside the workspace AND equals the approved path
  WARN  exit 1  resolved target stayed inside the workspace, but resolution changed it
                (a symlink redirected it, or "..", "~", or $VAR normalized it): SHOWN != RESOLVED
  BLOCK exit 1  resolved target escapes the workspace (symlink or "..") : the OS would
                write outside the root the human thought they were approving
  ERROR exit 2  bad input (fail-closed): missing root, root is not a directory, empty
                approved path, unusable path

With several approved paths the process exit is the worst verdict: a BLOCK or WARN (1)
outranks unusable input (2) outranks a clean pass (0). STDOUT is deterministic for a
fixed workspace, and the last line is a sha256 of the report body.

The approved string is untrusted data. It is resolved and compared, never executed.
"""

import hashlib
import os
import sys

SEP = os.sep


class BadInput(Exception):
    pass


def is_inside(path, root):
    """True if path is root itself or lives under root. Both must be realpath'd."""
    if path == root:
        return True
    return path.startswith(root + SEP)


def clean(s):
    """Render an untrusted path string on one line so a crafted approved path
    cannot forge extra report lines with embedded newlines."""
    return str(s).replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")


def classify(approved, root_real):
    """Resolve one approved display string against the real workspace root and
    return a dict describing the verdict. Read-only; opens nothing.

    root_real must already be os.path.realpath(root).
    """
    if not isinstance(approved, str) or approved.strip() == "":
        raise BadInput("approved path is empty")

    # what the OS would actually expand and open ----------------------------
    expanded = os.path.expandvars(os.path.expanduser(approved))
    if os.path.isabs(expanded):
        base = expanded
    else:
        base = os.path.join(root_real, expanded)

    try:
        resolved = os.path.realpath(base)         # follows symlinks + collapses ".."
        lexical = os.path.normpath(base)          # collapses ".." only, no symlink follow
    except (ValueError, OSError) as exc:
        raise BadInput(f"unresolvable path {clean(approved)!r}: {exc}")

    # what a human reads off the dialog, joined to the workspace, taken at face value
    display_raw = approved if os.path.isabs(approved) else os.path.join(root_real, approved)

    # immediate symlink text, if the top-level candidate is itself a symlink
    link_text = None
    try:
        if os.path.islink(base):
            link_text = os.readlink(base)
    except OSError:
        link_text = None

    inside = is_inside(resolved, root_real)
    symlink_followed = resolved != lexical        # realpath diverged from lexical => a symlink was traversed
    expansion_changed = expanded != approved      # "~" or "$VAR" was present
    normalization_changed = lexical != base       # ".." / "." / "//" collapsed

    out = {
        "approved": approved,
        "resolved": resolved,
        "link_text": link_text,
        "inside": inside,
    }

    if not inside:
        if symlink_followed:
            cwe = "CWE-451 display mismatch + CWE-61 symlink escape"
            how = "a symlink"
        else:
            cwe = "CWE-451 display mismatch + CWE-22 path traversal"
            how = '".." traversal'
        out["verdict"] = "BLOCK"
        out["code"] = "resolved-escapes-workspace"
        out["reason"] = (
            f"approved '{clean(approved)}', resolves to '{clean(resolved)}' outside "
            f"workspace via {how} ({cwe})"
        )
        return out

    if resolved != display_raw:
        if symlink_followed:
            why = "a symlink redirected the target within the workspace"
        elif expansion_changed:
            why = "shell expansion (~ or $VAR) changed the displayed path"
        elif normalization_changed:
            why = "path normalization (.. or . or //) changed the displayed path"
        else:
            why = "resolution changed the displayed path"
        out["verdict"] = "WARN"
        out["code"] = "display-differs-from-resolved"
        out["reason"] = (
            f"approved '{clean(approved)}' but the OS would open '{clean(resolved)}'; "
            f"{why} (CWE-451 display mismatch, stays inside workspace)"
        )
        return out

    out["verdict"] = "PASS"
    out["code"] = "display-matches-resolved"
    return out


def render_case(index, res):
    lines = [f"[{index}] approved: '{clean(res['approved'])}'"]
    if res.get("link_text") is not None:
        lines.append(f"    link     : {clean(res['approved'])} -> {clean(res['link_text'])}")
    lines.append(f"    resolved : {clean(res['resolved'])}")
    v = res["verdict"]
    if v == "PASS":
        lines.append("    verdict  : PASS (display-matches-resolved) exit 0")
        lines.append("    detail   : resolved target is inside the workspace and equals the approved path")
    elif v == "WARN":
        lines.append(f"    verdict  : WARN ({res['code']}) exit 1")
        lines.append(f"    reason   : {res['reason']}")
        lines.append(f"    contrast : dialog shows '{clean(res['approved'])}' / OS opens '{clean(res['resolved'])}'")
    else:  # BLOCK
        lines.append(f"    verdict  : BLOCK ({res['code']}) exit 1")
        lines.append(f"    reason   : {res['reason']}")
        lines.append(f"    contrast : dialog shows '{clean(res['approved'])}' / OS opens '{clean(res['resolved'])}'")
    return lines


def render_error(index, approved, message):
    return [
        f"[{index}] approved: '{clean(approved)}'",
        "    verdict  : ERROR (bad-input) exit 2",
        f"    detail   : {message}",
    ]


def worst_exit(n_flag, n_error):
    # worst verdict wins: a BLOCK/WARN (1) outranks unusable input (2) outranks a pass (0)
    if n_flag:
        return 1
    if n_error:
        return 2
    return 0


def emit(body, exit_code):
    report = "\n".join(body)
    digest = hashlib.sha256(report.encode("utf-8")).hexdigest()
    sys.stdout.write(report + "\n")
    sys.stdout.write(f"report-sha256: {digest}\n")
    return exit_code


def main(argv):
    if len(argv) < 3:
        sys.stderr.write(
            "usage: approval_path_resolve_gate.py <workspace_root> <approved_path> [more ...]\n"
        )
        return 2

    root, approved_list = argv[1], argv[2:]

    body = ["approval-path-resolve-gate: you approved a string; what will the OS open?"]

    if not os.path.isdir(root):
        body.append(f"root : {clean(root)}")
        body.append("ERROR (bad-input) exit 2: workspace root does not exist or is not a directory")
        return emit(body, 2)

    root_real = os.path.realpath(root)
    body.append(f"root : {clean(root_real)}")
    body.append(f"paths: {len(approved_list)}")
    body.append("")

    n_pass = n_warn = n_block = n_error = 0

    for i, approved in enumerate(approved_list, start=1):
        try:
            res = classify(approved, root_real)
        except BadInput as exc:
            body.extend(render_error(i, approved, str(exc)))
            body.append("")
            n_error += 1
            continue
        body.extend(render_case(i, res))
        body.append("")
        if res["verdict"] == "PASS":
            n_pass += 1
        elif res["verdict"] == "WARN":
            n_warn += 1
        else:
            n_block += 1

    exit_code = worst_exit(n_warn + n_block, n_error)
    body.append(
        f"summary: {n_pass} PASS, {n_warn} WARN, {n_block} BLOCK, {n_error} ERROR  ->  "
        f"overall exit {exit_code}"
    )
    return emit(body, exit_code)


if __name__ == "__main__":
    sys.exit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

And here is the fixture builder. It is the only script here that writes to disk, and it writes only under one fixed /tmp directory. The path is fixed on purpose — a fixed path is what makes the gate's STDOUT byte-for-byte reproducible, so the sha256 self-digest is stable across runs.

#!/usr/bin/env python3
"""make_fixtures.py - build the demo workspace for approval_path_resolve_gate.py.

This is the ONLY script here that touches the disk, and it only writes under one
fixed, namespaced directory in the system temp tree. It never touches your real
~/.ssh or anything outside its own base. The gate itself (approval_path_resolve_gate.py)
writes nothing.

The base path is FIXED (not randomized) on purpose: a fixed path makes the gate's
STDOUT byte-for-byte reproducible, so the sha256 self-digest is stable across runs.

Layout built under BASE (= /tmp/approval-path-resolve-gate, realpath'd by the OS):

  BASE/repo_clean/
      project_settings.json          regular file (the honest case)
      config/real.json               regular file
  BASE/repo_poisoned/
      project_settings.json  ->  ../outside/authorized_keys   (symlink ESCAPING the repo)
      config/real.json               regular file
      inner_link.json        ->  config/real.json             (symlink, stays IN the repo)
      logs/                          empty dir (for the ../ normalization case)
  BASE/outside/
      authorized_keys                a fixture "sensitive" file. NOT your real ssh key.

Run:  python3 make_fixtures.py
"""

import os
import shutil

BASE = os.path.join(os.path.realpath("/tmp"), "approval-path-resolve-gate")


def write(path, text):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)


def main():
    if os.path.lexists(BASE):
        shutil.rmtree(BASE)
    os.makedirs(BASE)

    # a fixture "sensitive" file OUTSIDE any repo. This stands in for the file an
    # attacker wants your approved write to land on. It is NOT a real ssh key.
    write(os.path.join(BASE, "outside", "authorized_keys"),
          "# fixture only - stands in for a sensitive out-of-workspace file\n")

    # ---- repo_clean: project_settings.json is an ordinary file ----
    clean = os.path.join(BASE, "repo_clean")
    write(os.path.join(clean, "project_settings.json"), '{"theme": "dark"}\n')
    write(os.path.join(clean, "config", "real.json"), '{"real": true}\n')

    # ---- repo_poisoned: same visible name, different on-disk truth ----
    poisoned = os.path.join(BASE, "repo_poisoned")
    write(os.path.join(poisoned, "config", "real.json"), '{"real": true}\n')
    os.makedirs(os.path.join(poisoned, "logs"), exist_ok=True)

    # project_settings.json is a symlink whose target escapes the repo.
    link = os.path.join(poisoned, "project_settings.json")
    os.symlink(os.path.join("..", "outside", "authorized_keys"), link)

    # inner_link.json is a symlink that redirects but stays inside the repo.
    os.symlink(os.path.join("config", "real.json"),
               os.path.join(poisoned, "inner_link.json"))

    print("fixtures built under:", BASE)
    print("  repo_clean    :", os.path.realpath(clean))
    print("  repo_poisoned :", os.path.realpath(poisoned))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Build the fixtures once, then run the gate:

python3 make_fixtures.py
python3 approval_path_resolve_gate.py /tmp/approval-path-resolve-gate/repo_clean project_settings.json
Enter fullscreen mode Exit fullscreen mode

The killer demo: one approved string, two verdicts

Here is the claim, stated so you can break it. Take the exact same approved string — project_settings.json — and run it against two workspaces. In repo_clean it is an ordinary file. In repo_poisoned it is a symlink to ../outside/authorized_keys, a file outside the repo. The human sees the same friendly string in the dialog both times. The gate must PASS the first and BLOCK the second. If you can make it pass a symlink that escapes the workspace, the tool is broken and this post comes down with it.

First, the clean repo. Approved string resolves to a real file inside the workspace:

approval-path-resolve-gate: you approved a string; what will the OS open?
root : /private/tmp/approval-path-resolve-gate/repo_clean
paths: 1

[1] approved: 'project_settings.json'
    resolved : /private/tmp/approval-path-resolve-gate/repo_clean/project_settings.json
    verdict  : PASS (display-matches-resolved) exit 0
    detail   : resolved target is inside the workspace and equals the approved path

summary: 1 PASS, 0 WARN, 0 BLOCK, 0 ERROR  ->  overall exit 0
report-sha256: cc61a5e7291ff6ebffa5cbadffc50dbde64b632ecb33351587755871413a10b3
Enter fullscreen mode Exit fullscreen mode

Now the poisoned repo. Same command, same approved string, one character of the fixture different — project_settings.json is a symlink now:

approval-path-resolve-gate: you approved a string; what will the OS open?
root : /private/tmp/approval-path-resolve-gate/repo_poisoned
paths: 1

[1] approved: 'project_settings.json'
    link     : project_settings.json -> ../outside/authorized_keys
    resolved : /private/tmp/approval-path-resolve-gate/outside/authorized_keys
    verdict  : BLOCK (resolved-escapes-workspace) exit 1
    reason   : approved 'project_settings.json', resolves to '/private/tmp/approval-path-resolve-gate/outside/authorized_keys' outside workspace via a symlink (CWE-451 display mismatch + CWE-61 symlink escape)
    contrast : dialog shows 'project_settings.json' / OS opens '/private/tmp/approval-path-resolve-gate/outside/authorized_keys'

summary: 0 PASS, 0 WARN, 1 BLOCK, 0 ERROR  ->  overall exit 1
report-sha256: 90664de66fe036c2d7fa13057bace1537fb22f31a49c832f9e72db033757e187
Enter fullscreen mode Exit fullscreen mode

Read the contrast line twice. The dialog shows project_settings.json. The OS opens outside/authorized_keys. The exit code went from 0 to 1. The human clicked Approve on the same eight-word string in both worlds. In the fixture the target is my stand-in file; in a real repo the symlink target is arbitrary, and ~/.ssh/authorized_keys is exactly the kind of place a poisoned symlink points. The gate resolves ~ too, so an approved string of ~/.ssh/authorized_keys would land the same BLOCK.

The sweep: five approved paths, five verdicts

The killer demo is one string against two repos. The sweep is five strings against one poisoned repo, to show every state the gate distinguishes — PASS, BLOCK by symlink, WARN by inner symlink, WARN by .. normalization, BLOCK by .. traversal — and to show that with multiple paths the worst verdict drives the process exit:

approval-path-resolve-gate: you approved a string; what will the OS open?
root : /private/tmp/approval-path-resolve-gate/repo_poisoned
paths: 5

[1] approved: 'config/real.json'
    resolved : /private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json
    verdict  : PASS (display-matches-resolved) exit 0
    detail   : resolved target is inside the workspace and equals the approved path

[2] approved: 'project_settings.json'
    link     : project_settings.json -> ../outside/authorized_keys
    resolved : /private/tmp/approval-path-resolve-gate/outside/authorized_keys
    verdict  : BLOCK (resolved-escapes-workspace) exit 1
    reason   : approved 'project_settings.json', resolves to '/private/tmp/approval-path-resolve-gate/outside/authorized_keys' outside workspace via a symlink (CWE-451 display mismatch + CWE-61 symlink escape)
    contrast : dialog shows 'project_settings.json' / OS opens '/private/tmp/approval-path-resolve-gate/outside/authorized_keys'

[3] approved: 'inner_link.json'
    link     : inner_link.json -> config/real.json
    resolved : /private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json
    verdict  : WARN (display-differs-from-resolved) exit 1
    reason   : approved 'inner_link.json' but the OS would open '/private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json'; a symlink redirected the target within the workspace (CWE-451 display mismatch, stays inside workspace)
    contrast : dialog shows 'inner_link.json' / OS opens '/private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json'

[4] approved: 'logs/../config/real.json'
    resolved : /private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json
    verdict  : WARN (display-differs-from-resolved) exit 1
    reason   : approved 'logs/../config/real.json' but the OS would open '/private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json'; path normalization (.. or . or //) changed the displayed path (CWE-451 display mismatch, stays inside workspace)
    contrast : dialog shows 'logs/../config/real.json' / OS opens '/private/tmp/approval-path-resolve-gate/repo_poisoned/config/real.json'

[5] approved: '../outside/authorized_keys'
    resolved : /private/tmp/approval-path-resolve-gate/outside/authorized_keys
    verdict  : BLOCK (resolved-escapes-workspace) exit 1
    reason   : approved '../outside/authorized_keys', resolves to '/private/tmp/approval-path-resolve-gate/outside/authorized_keys' outside workspace via ".." traversal (CWE-451 display mismatch + CWE-22 path traversal)
    contrast : dialog shows '../outside/authorized_keys' / OS opens '/private/tmp/approval-path-resolve-gate/outside/authorized_keys'

summary: 1 PASS, 2 WARN, 2 BLOCK, 0 ERROR  ->  overall exit 1
report-sha256: b349309aa4acc91d6c02bfb5e189ab02d29ceef5afd034b84874fd9cd4af73f9
Enter fullscreen mode Exit fullscreen mode

The WARN cases are the ones I'd bikeshed. Path [3], inner_link.json, is a symlink that redirects but stays inside the repo. Path [4], logs/../config/real.json, resolves to a legitimate file inside the repo after .. collapses. Neither escapes, so neither is a BLOCK. But both are cases where the string you approved is not the file the OS opens, and I refuse to call that PASS. SHOWN differs from RESOLVED, even when RESOLVED is still inside the fence. You can argue an inner redirect is benign; I'd rather see it and decide, than have it hidden. Draw that line differently in your own policy — that is why the verdict is WARN and not BLOCK.

Fail-closed, because a gate that errors green is not a gate

Two bad-input paths. A missing workspace root, and an empty approved string. Both must exit 2, never 0:

approval-path-resolve-gate: you approved a string; what will the OS open?
root : /private/tmp/approval-path-resolve-gate/does_not_exist
ERROR (bad-input) exit 2: workspace root does not exist or is not a directory
report-sha256: 6e457546787176bfc328cfe78e9daa7d6208189e743a6edcc4bab2045f93975a
Enter fullscreen mode Exit fullscreen mode
approval-path-resolve-gate: you approved a string; what will the OS open?
root : /private/tmp/approval-path-resolve-gate/repo_poisoned
paths: 1

[1] approved: ''
    verdict  : ERROR (bad-input) exit 2
    detail   : approved path is empty

summary: 0 PASS, 0 WARN, 0 BLOCK, 1 ERROR  ->  overall exit 2
report-sha256: f04e6525ca3aca82cd40f07b600b3a9ee5bdffa7391cc2c47b3c36a7b39af23e
Enter fullscreen mode Exit fullscreen mode

One more thing about untrusted input. The approved string comes from the model, which can be steered by a poisoned file or web page. If a crafted path contains a newline, a naive report could forge fake verdict lines. The clean() helper escapes \r and \n so a hostile path can never inject an extra line into the report. The approved string is data. The gate resolves it and compares it. It never runs it.

Everything above is reproducible. run_demo.sh rebuilds the fixtures, runs each scenario twice, and prints a sha256 of the full STDOUT for each — I ran it and got run1==run2 IDENTICAL on all five. The whole harness finished in about a third of a second on my laptop. If your bytes differ from the hashes in this post, either your Python resolves paths differently or I made a mistake, and I want to hear which.

What this is NOT

I want to be honest about the fence I built, because a gate oversold is worse than no gate.

  • It resolves the target, not your rights. The gate decides whether the file the OS would open matches what you approved and stays in the workspace. It does not check whether the agent is allowed to write there in the first place — that is authorization, a different verdict against a different policy. I built that one separately: your trace proves what the agent did, not that it was allowed.
  • It does not read the content. A PASS means the destination is honest, not that the bytes being written are safe. A write to a legitimate file can still be garbage.
  • It is not a sandbox. It enforces nothing at runtime. It is a pre-approval check that answers one question so a human or an orchestrator can answer the bigger one. If you need containment, containment is a different tool.
  • There is a TOCTOU window, and I will name it. The gate resolves the path, then something else performs the write. Between the resolve and the write, an attacker who can modify the filesystem could swap a benign file for a symlink — the classic time-of-check to time-of-use race. Resolving right before the write shrinks that window; it does not close it. For a true close you resolve and write through the same file descriptor (O_NOFOLLOW, openat with O_DIRECTORY walks), which is a runtime concern, not a static gate's job.
  • It is not cryptographic verification. It trusts the filesystem it reads. It does not prove the file's provenance or integrity; it only reports where a path points right now.

This is one narrow gate in a series I keep building around the same thesis — decide before the irreversible act, not after. If you found this one useful, the pre-execution gate is the pillar, gate-taint-lint checks whether your gate's own inputs are trustworthy, and the lethal-trifecta gate catches the config combination that makes an escape like this catastrophic.

The unresolved part I'd like an argument about

Here is the thing I have not settled. My gate treats an inner symlink — one that redirects but stays inside the workspace — as WARN, not BLOCK. In a monorepo full of legitimate symlinks, BLOCK-on-any-redirect would be unusably noisy; WARN lets you see it and move on. But an inner redirect is still a place where the string you approved is not the file that gets written, and I can imagine an attack that lives entirely inside the fence. So: for an agent writing inside a trusted repo, is SHOWN-differs-from-RESOLVED-but-still-inside a thing you'd block, or a thing you'd log and live with? I keep flipping on it.


I write one of these gates at a time — offline, keyless, runnable in the time it takes to read the post. Follow along if that's your kind of thing, and tell me in the comments how your agent stack decides what a human actually approved before a write lands. I read every reply.

Top comments (0)