Reading /root/.ssh/id_rsa Through an AI Agent: Path Traversal in Vercel's vercel-optimize Skill (CWE-22/73)
By Kavennesh Balachandar · HackerOne · Vercel Open Source Bug Bounty Program
# TL;DR
vercel-optimize (in vercel-labs/agent-skills) checks the recommendations its
LLM sub-agents produce by reading the files those recommendations reference. The
path helper repoPaths() applies no containment — absolute paths are
returned verbatim, and relative paths are joined without rejecting ../.
Because those paths come from sub-agent output generated while reading a
possibly-untrusted codebase, an attacker who gets content into an analyzed repo
can steer a sub-agent (indirect prompt injection) into emitting paths like
/root/.ssh/id_rsa or ../../../../.aws/credentials. The verifier reads them,
and its pattern_count / pattern_exists results become an existence-and-content
oracle. Concrete local file disclosure via an AI agent — exactly the "prompt
injection with real impact" class the program invites. Severity: Medium
(CVSS 5.0), gated at AC:H because it needs the victim to run the skill on
attacker-influenced content.
# Why this is an AI-agent bug, not a plain path-traversal bug
Path traversal is old. What makes this one interesting is where the tainted
path comes from. vercel-optimize is an "agent skill" — a workflow that spawns
LLM sub-agents to analyze a user's codebase and propose optimizations. To avoid
hallucinated recommendations, it has a nice idea: verify each claim by
actually reading the file it points at and checking the pattern is there.
The catch is that the file paths being verified are authored by the sub-agents, and the sub-agents author them while reading the target repository. So the repo's contents can influence what paths get emitted. A traversal payload doesn't arrive in an HTTP parameter here — it arrives as model output that was shaped by attacker-controlled input the model read. The verifier then treats that output as a trustworthy filesystem path.
# The vulnerable code
skills/vercel-optimize/lib/verify-claim.mjs:
function repoPaths(repoRoot, file, projectRootDirectory = null) {
if (!file) return [];
if (isAbsolute(file)) return [file]; // (1) absolute path returned verbatim
const out = [join(repoRoot, file)]; // (2) '../' in `file` escapes repoRoot
// ...
}
async function readClaimFile(claim) {
const path = await firstAccessiblePath(claim);
return { path, content: await readFile(path, 'utf-8') }; // sink
}
Two independent escapes: an absolute path is returned and read as-is, and a
relative path is joined without a normalize-then-verify step, so
join(repoRoot, "../../../../etc/passwd") walks straight out of repoRoot. The
verifier functions verifyFileExists, verifyPatternCount, and
verifyPatternExists call this on attacker-influenced claim.file /
claim.repoRoot — and pattern_count returns the raw match count in its result,
which is a direct content-exfiltration oracle.
# The taint path — untrusted repo content to file read
vercel-optimizespawns sub-agents to read the user's codebase.scripts/collect-sub-agent-outputs.mjsassemblesrecommendations.jsonverbatim from those sub-agents' output (isRecordObjectonly checks a key exists).lib/extract-claims.mjscopies each recommendation'saffectedFiles[],findingRefs[], andverifiableClaims[].{file,repoRoot}into claims with no sanitization.scripts/verify-and-regen.mjs→verifyClaim()→readClaimFile()→readFile(unconstrainedPath).
So a malicious file in an analyzed repo — a dependency, an external contributor's
PR branch, an MDX doc, any attacker-supplied project — is indirect-prompt-injection
input that can make a sub-agent emit file: "/root/.ssh/id_rsa".
Amplifier:
lib/repo-root.mjsdetectRepoRoot()lets an attacker-controlledaffectedFiles[0](e.g./etc/passwd) pinrepoRootto/, after which even plain relative claim paths resolve from the filesystem root.
# Proof of concept
The PoC drives the real shipped CLI (scripts/verify-finding.mjs →
verifyClaim) with attacker-controlled claims against fixtures — a fake
"analyzed project" plus an out-of-repo .aws/credentials:
git clone https://github.com/vercel-labs/agent-skills
VOPT_DIR="$PWD/agent-skills/skills/vercel-optimize" node poc_file_read.mjs
✓ absolute path /etc/passwd read -> verified ("/etc/passwd exists")
✓ content of out-of-repo credentials read -> match count leaked: actual=1
✓ '../' from repoRoot escaped to the secret -> verified
✓ pattern_exists oracle reflects real bytes (correct guess=verified, wrong guess=failed)
Each verifier mode is a different primitive:
file_existson an absolute out-of-project path → an existence oracle.pattern_countreturns the real match count of an out-of-project file → content read plus a numeric oracle.file_existswithrepoRoot=<project>/app,file="../../.../credentials"→ the../escape.pattern_existsreturnsverifiedfor the correct secret bytes andfailedfor a wrong guess → anchored-regex, byte-by-byte secret reconstruction.
The leaked results are written to verify.json and flow back into the
orchestrating agent's context and the rendered report — so the exfiltrated bytes
don't just sit in a log, they re-enter the agent loop.
# Impact
Disclosure of the existence and content of any file readable by the agent
process — SSH private keys, cloud credentials, .env secrets, tokens, and files
belonging to other projects on the same machine.
Severity: Medium — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N (5.0). The
AC:H / UI:R reflect that exploitation requires the victim to run
vercel-optimize on attacker-influenced content. The honest escalation note: if
this skill is ever run server-side on untrusted repositories, the same
defect becomes a remote, cross-tenant file read (High/Critical). That's flagged
for the team, not claimed here.
# Remediation
Resolve-then-verify containment for every candidate path, and refuse absolute paths:
import { resolve, sep } from 'node:path';
function repoPaths(repoRoot, file) {
if (!file) return [];
const base = resolve(repoRoot);
const p = resolve(base, file); // collapses '../'
if (p !== base && !p.startsWith(base + sep)) return []; // reject escapes + absolute
return [p];
}
Additionally: don't let a recommendation override repoRoot, and don't use
untrusted affectedFiles[0] as a repo-root probe.
# The broader lesson
Any agent step that turns model output into a filesystem, network, or shell operation is a trust boundary, even when the model is "yours." Verification code that reads model-chosen paths has to treat those paths as fully untrusted input — because upstream, they were shaped by whatever the model read. The fix is the same discipline as any traversal sink: normalize, then confine to an allowed root.
# Disclosure timeline
- 2026-06-26 — Reported to Vercel Open Source via HackerOne (#3827691),
against
vercel-labs/agent-skills→skills/vercel-optimize. - Closed as duplicate (independent discovery).
- Public disclosure pending program approval.
# References
- CWE-22 — Improper Limitation of a Pathname to a Restricted Directory
- CWE-73 — External Control of File Name or Path
- OWASP guidance on path traversal prevention (canonicalize, then verify prefix containment).