GhostApproval: When AI Agent Approval Dialogs Lie
Approve file edit?
The dialog names ./project_settings.json. The diff is one line. Nothing in the prompt mentions ~/.ssh/authorized_keys, a shell startup file, or any path outside the repository. The operator clicks Approve.
The kernel writes somewhere else.
That short sequence is a composite scenario, not a claim about an incident I observed. It captures the failure behind GhostApproval, the symlink trust-boundary pattern that Wiz disclosed on July 8, 2026 across six AI coding assistants. The old primitive is CWE-61: symbolic-link following. The newer and more consequential failure is that an approval interface may describe the path the agent was given, not the destination the operating system will modify.
This is not another argument that “AI can be tricked.” It is a narrower engineering problem: what object did the human actually authorize?
A pathname is not a file
Suppose a repository contains this entry:
project_settings.json -> /home/developer/.ssh/authorized_keys
The string project_settings.json is only a name in one directory. When a process opens it, the filesystem resolves the symbolic link and operates on the target. A workspace policy that checks the lexical path—“does it begin with /work/repo?”—can therefore authorize an operation whose resolved object sits outside that tree.
Wiz’s GhostApproval research demonstrated this pattern against Amazon Q Developer, Anthropic Claude Code, Augment, Cursor, Google Antigravity, and Windsurf. The exact control failure differed by product. Some interfaces reportedly displayed the benign workspace name while concealing the resolved destination. In other cases, the write occurred before the confirmation UI appeared, reducing “approval” to an undo opportunity.
The vendor advisories make the filesystem mechanics concrete. AWS states that Language Servers for AWS before 1.69.0 could follow a malicious workspace symlink and write outside the trust boundary without asking for approval; the fix is tracked as CVE-2026-12958. Cursor’s CVE-2026-50549 advisory describes a particularly dangerous fallback: when canonicalization failed, versions before 3.0 used the original path and continued the write. Failure to establish where a path landed became permission to proceed.
That fallback is the detail an operator should circle. A security check that fails open does not merely miss an edge case; it converts attacker-controlled ambiguity into authority.
The approval box is part of the security boundary
Human-in-the-loop is often treated as a property: an action either has human approval or it does not. In practice, approval is a protocol with at least five required fields:
- Actor: which agent and process will perform the action?
- Object: which canonical resource will be read or modified?
- Operation: create, append, truncate, execute, transmit, or delete?
- Scope: does the action remain inside the workspace and sandbox?
- Timing: is consent checked before the side effect?
If the interface says “edit project settings” while the file descriptor points to an SSH authorization file, the user did not meaningfully approve the action. MITRE’s CWE-451 describes interfaces that obscure or misrepresent security-critical information and thereby induce the wrong decision. GhostApproval joins CWE-61 and CWE-451: the path resolver changes the object; the UI hides the change.

This is also why the issue belongs beside broader agentjacking risks in AI coding assistants. Prompt injection may supply the intention, but the exploitability comes from ordinary software components: path resolution, sandbox boundaries, tool permissions, and a misleading consent surface. The model is only one link in the chain.
A safe laboratory reproduction
I reproduced the primitive for this article inside a disposable directory under /tmp. The test did not invoke an AI coding assistant, did not touch a real SSH configuration, and did not create remote access. It isolates the filesystem behavior that an unsafe agent wrapper can expose.
Run the following only in an authorized lab. Both the “repository” and the fake home directory live under the same temporary test root:
set -euo pipefail
LAB=$(mktemp -d /tmp/ghostapproval-lab.XXXXXX)
mkdir -p "$LAB/fake-home/.ssh" "$LAB/repo"
printf 'lab-baseline\n' > "$LAB/fake-home/.ssh/authorized_keys"
ln -s "$LAB/fake-home/.ssh/authorized_keys" \
"$LAB/repo/project_settings.json"
python3 - "$LAB/repo/project_settings.json" <<'PY'
import pathlib, sys
requested = pathlib.Path(sys.argv[1])
with requested.open('a') as handle:
handle.write(
'ssh-ed25519 LAB_ONLY_DEMO_KEY [email protected]\n'
)
print('named_path =', requested)
print('resolved_path=', requested.resolve())
PY
printf '%s\n' '--- fake target after unsafe write ---'
cat "$LAB/fake-home/.ssh/authorized_keys"
The executed test appended the canary through repo/project_settings.json, but the resulting content appeared in fake-home/.ssh/authorized_keys:
named_path = /tmp/ghostapproval-lab.../repo/project_settings.json
resolved_path = /tmp/ghostapproval-lab.../fake-home/.ssh/authorized_keys
--- fake target after unsafe write ---
lab-baseline
ssh-ed25519 LAB_ONLY_DEMO_KEY [email protected]
No model failure is required at this layer. Any process that receives the workspace path and opens it normally will follow the link. The agent-specific questions are whether untrusted repository content can influence the requested edit, whether the tool allows that operation, and whether the UI binds approval to the real destination.
From a harmless setup request to host compromise
A realistic chain begins with a repository the operator has a reason to trust: a dependency fork, a coding exercise, an incident-response artifact, or a pull request checked out locally. The attacker does not need to begin with a visibly malicious shell script.
- Placement. The repository records a symlink under a plausible name. Git preserves symbolic links as mode
120000on platforms that support them. - Instruction. A README, comment, issue, or agent rule asks the assistant to “finish setup” by updating that file. This may be explicit social engineering or indirect prompt injection.
- Resolution. The agent’s write tool opens the named path. The operating system follows the link to a file outside the workspace.
- Consent failure. The dialog shows only the repository-relative path, appears after the side effect, or is bypassed by an unsafe mode.
- Execution or persistence. A startup file, tool configuration, Git hook, SSH file, or agent configuration is modified. Impact depends on the target, host configuration, and reachability; an arbitrary write is not automatically remote code execution.
That last qualification matters. Writing an SSH public key yields remote access only if the SSH service is enabled, reachable, and configured to accept that key. Changing a shell startup file executes only when the relevant shell loads it. A strong assessment documents the primitive, the reachable target, and the activation condition separately instead of compressing them into “instant RCE.”
Anthropic’s separate CVE-2026-39861 advisory illustrates how composition changes severity. A sandboxed process could create an out-of-workspace symlink; a later unsandboxed application write followed it without prompting. Neither component independently had the full capability, but together they crossed the boundary. This is the confused-deputy shape security teams should test.
Testing the boundary without weaponizing it
For an authorized assessment, replace sensitive targets with canaries and test invariants rather than persistence. A useful matrix varies four conditions:
- the link points to an existing file versus a nonexistent destination;
- the link is a leaf file versus a directory component in the path;
- the agent proposes create, append, overwrite, and delete operations;
- the session uses default permissions, edit auto-approval, and sandboxed execution.
Capture three artifacts for every case: the path displayed to the operator, the canonical destination immediately before the operation, and the file actually modified. A pass requires all three to agree and the side effect to occur only after consent.
Repository-side screening is inexpensive. The first command detects symlink entries committed to the current Git tree; the second inspects a checked-out directory:
git ls-tree -r HEAD | awk '$1 == "120000" { print }'
find . -type l -printf '%p -> %l\n'
These commands are discovery controls, not proof of malice. Symlinks are legitimate in many projects, and attackers can target path handling through generated files or tool actions after checkout. Conversely, disabling symlink checkout globally can break valid repositories and does not fix an agent that can create links itself.
Teams already building AI threat models with NIST and OWASP guidance should model the filesystem as a capability graph. A tool that can “write inside workspace” does not have a meaningful restriction until path aliases, mounts, junctions, hard links, and race conditions are accounted for.
Engineering controls that survive adversarial paths
Canonicalize and compare. Resolve the requested path and verify that the destination remains beneath an allowed root. If resolution fails, block the operation. Never fall back to the unresolved name.
from pathlib import Path
import os
root = Path('/workspace/project').resolve()
requested = Path('/workspace/project/project_settings.json')
target = requested.resolve(strict=True)
inside = os.path.commonpath([str(root), str(target)]) == str(root)
if not inside:
raise PermissionError(f'write escapes workspace: {target}')
That Python check explains the policy, but it is not a complete implementation against a local adversary. The path can change between validation and open, the classic time-of-check/time-of-use problem. Robust Linux implementations should prefer descriptor-relative operations and kernel-enforced resolution constraints such as openat2() with RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, or RESOLVE_IN_ROOT, selected according to the product’s intended semantics. Where supported, O_NOFOLLOW protects the final component but does not by itself neutralize symlinks in parent directories.
Bind approval to the resolved action. The prompt should display the user-facing name and canonical destination, identify any boundary crossing, show the operation type, and include the effective process identity. If the target changes after approval, invalidate the decision and ask again.
Authorize before touching disk. A post-write Accept/Reject control is not authorization. Stage proposed content in memory or a private temporary object, obtain consent, then commit through a race-resistant filesystem API.
Reduce ambient authority. Run coding agents in disposable development containers or VMs without the developer’s home directory, SSH agent socket, cloud credentials, browser profile, or production kubeconfig. Mount source read-only when the task permits it; expose a narrow writable worktree for edits. This turns an agent mistake into a contained workspace event instead of a workstation compromise.
Patch and verify behavior. AWS fixed the reported issue in Language Servers for AWS 1.69.0 and @aws/lsp-codewhisperer 0.0.117. Cursor fixed CVE-2026-50549 in version 3.0 by treating canonicalization failures as untrusted. Check the product actually running inside each IDE—not just the desktop application label—and retest the boundary after updates.
Current Claude Code documentation also recommends VMs when working with untrusted content and describes a working-directory write boundary, sandboxing, and explicit permissions. Those are useful layers, but the relevant question remains empirical: under the organization’s exact version and permission mode, what destination can an agent cause the kernel to open?
Detection: look for a path mismatch, not an AI signature
There is no universal “GhostApproval event.” Detection comes from correlating repository state, agent activity, and host writes.
- Source intake: flag mode-
120000Git entries and symlinks whose resolved targets leave the checkout root. Prioritize repositories from forks, issue attachments, public contributions, and security samples. - Endpoint telemetry: alert when an IDE, coding-agent process, language server, or descendant modifies shell startup files, SSH configuration, Git configuration, credential stores, agent instruction files, or executable search paths.
- Filesystem audit: monitor writes to
~/.ssh/authorized_keys,~/.zshrc,~/.bashrc,~/.config, and tool-specific configuration directories. Tune by developer role and expected management tooling. - Session evidence: retain the proposed diff, displayed path, resolved path, tool call, approval decision, and final write result. A transcript without kernel-level destination data cannot prove that consent matched execution.
- Containment: if a mismatch is found, stop the agent, isolate the development environment, preserve the repository and session logs, inspect persistence locations, and rotate only the credentials shown to be exposed or modified.
This is where enterprise AI security governance becomes operational. Procurement questionnaires should ask vendors how paths are canonicalized, which component renders approval, whether writes are staged, how TOCTOU is handled, and whether the audit log records the resolved object. “Human approval supported” is too vague to evaluate.
The strategic lesson: consent must name reality
GhostApproval is useful because it strips away the novelty. The exploit does not require a mysterious model behavior. It combines a familiar Unix primitive, a tool with the developer’s filesystem authority, and an interface that asks the human to approve the wrong abstraction.
For Red Teams, the high-value test is no longer “can I make the agent say or attempt something unsafe?” Test whether untrusted repository state can alter the object behind an approved action. For defenders, do not place the entire burden on prompt filters or user vigilance. Enforce the boundary below the model, expose the real destination above it, and preserve enough telemetry to compare intent with effect.
An approval dialog is a security control only when it tells the truth before anything changes.
💜 Enjoyed this content? Support the blog with USDT (TRC20):
TX7obcjHQbDUXb4mGqoASEu1QFTKT2CFGG
