4.5 KiB
title, date, category, module, problem_type, component, severity, applies_when, tags
| title | date | category | module | problem_type | component | severity | applies_when | tags | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| A predictable-path cache in shared /tmp is a prompt-injection vector — ownership-check reads | 2026-06-29 | docs/solutions/best-practices/ | repo-grounding-cache | best_practice | tooling | medium |
|
|
A predictable-path cache in shared /tmp is a prompt-injection vector — ownership-check reads
Context
The repo-grounding cache stored profiles at /tmp/compound-engineering/repo-profile/<root-sha>/<head-sha>.json. Both SHAs are knowable for any public or shared repo (the root commit and HEAD), and /tmp/compound-engineering/ is world-traversable. Security review found: on a multi-user host, a local co-tenant can pre-create that exact path and plant a <head-sha>.json that satisfies every validity gate (it sets head_sha to the victim's HEAD, the current schema version, and a profile object — cleanliness is checked against the victim's working tree, not the file's authenticity). The victim's skill then prints HIT and feeds the attacker's JSON into the agent as the "project profile" — attacker-controlled text into the LLM context, i.e. indirect prompt injection.
Impact is calibrated (needs a local co-tenant + predictable SHAs; payload is text, not code-exec or secret disclosure), but the injection elevation is why it is worth fixing rather than dismissing as "just repo metadata."
Guidance
When a cache/scratch file in shared /tmp will be read back into an agent's context, do not trust it by path + content gates alone — verify it is yours:
- Reject any cache file not owned by the current user. After opening,
os.fstat(fd).st_uid != os.geteuid()-> treat as a miss and re-derive. Check via the opened fd (fstat), not a pre-openstat, so it also defeats a symlink an attacker planted pointing at a file they own. Guard the check wheregeteuidis unavailable (non-POSIX) — the shared-/tmpthreat doesn't apply there. - This composes with the cache's existing principle that it is never a correctness dependency: a rejected entry simply degrades to "derive fresh," never blocks.
- Write side is already safe if you use
tempfile.mkstemp(O_EXCL, mode0600) +os.replace(atomic). The exposure is purely on the read path.
Alternatives considered and why ownership-check won: per-uid namespacing the cache root (/tmp/compound-engineering-$(id -u)/...) also works but deviates from the project's /tmp/compound-engineering/ convention and the deliberate choice of /tmp over $TMPDIR for user-inspectability. The fstat-on-read check is minimal, keeps the path convention, and closes both the planted-file and planted-symlink cases.
Why This Matters
Predictable paths in shared /tmp are a classic local attack surface, and the usual framing ("it's just a cache, low impact") misses the new twist: anything fed into an LLM context is an injection sink. A cache of benign-looking metadata becomes an attacker-controlled-text channel into the model. The data being "non-sensitive" does not bound the risk when the data is instructions-adjacent.
When to Apply
- Any agent/skill that reads a
/tmp(or other shared-dir) file at a guessable path into model context. - Caches keyed by values an attacker can compute (commit SHAs, repo names, usernames).
Not needed for per-run mktemp -d scratch with an unguessable path consumed only within the same process, or for files never surfaced to the model.
Examples
# Vulnerable: gates check authenticity-irrelevant facts (head_sha/schema/cleanliness),
# never WHO wrote the file.
with open(path) as f:
doc = json.load(f)
# ... print("HIT"); print(doc["profile"]) # attacker text -> agent context
# Fixed: reject a file we don't own (defeats planted file AND planted symlink via fstat-on-fd).
with open(path) as f:
geteuid = getattr(os, "geteuid", None)
if geteuid is not None and os.fstat(f.fileno()).st_uid != geteuid():
return miss()
doc = json.load(f)
Related
docs/solutions/skill-design/cross-skill-shared-cache-primitive.md— the cache this hardeneddocs/solutions/best-practices/cache-invalidation-input-set-completeness.md— the cache's correctness (separate) property- AGENTS.md "Scratch Space" (the
/tmp/compound-engineering/convention)