· 6 min read
Our MCP server exposes five read tools: get_agent, get_skill, get_command,
get_bundle, get_rule. Same shape, same contract — take a name, return that
item's markdown.
Four of them reject ../../README. One returned 51,297 bytes of the project's
README.
The difference is one line
The four safe ones all look like this, give or take:
function loadAgents() {
for (const file of listMarkdownFiles(PATHS.agents)) { /* … */ }
// later: list.find(a => a.name === name)
}They enumerate a directory, build a list, then match the requested name
against that list. A caller can only ever select something already in the list.
../../README is not in the list, so it is not found. There is nothing to get
right — the structure does the work.
The fifth did this:
function loadRule(name) {
const file = path.join(PATHS.rules, `${name}.md`);
const raw = safeReadFile(file);
// …
}It constructs a path from the argument. rules/common/../../README.md
resolves to ROOT/README.md, and readFileSync is perfectly happy to read it.
That is the entire bug. Not a missing check — a different shape.
What it could reach
The .md suffix bounds it, which sounds reassuring and is not. /etc/passwd is
safe because /etc/passwd.md does not exist. But every markdown file on the
machine is in scope, with arbitrary traversal depth:
get_rule("../../README") → 51,297 bytes
get_rule("../../CHANGELOG") → 188,170 bytes
get_rule("testing/../../../README") → 51,297 bytesNested traversal works too, which rules out the "just block a leading .." fix.
Why this is not a local-only problem
The instinct is: it's an MCP server running on my machine, under my user, reading files I can already read. Who cares?
The caller is not always the user.
An MCP tool argument can originate in text the agent merely read — a dependency's README, an issue comment, a page it fetched. That is the standard indirect prompt-injection shape. So this turns a read-scoped tool into an arbitrary-markdown-read primitive that untrusted text can aim.
Which is exactly the class our own prompt-injection-hunter agent exists to
catch. The call was coming from inside the house.
The fix, in two layers
const RULE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function loadRule(name) {
if (typeof name !== 'string' || !RULE_NAME.test(name)) return null;
const file = resolveContained(path.join(PATHS.rules, `${name}.md`), PATHS.rules);
if (file === null) return null;
// …
}Two guards, because they fail differently.
The pattern rejects anything that is a path rather than a name, before touching the disk. Rule names are flat basenames — the directory has no subdirectories — so this costs nothing.
resolveContained then proves the target really sits under rules/common
once symlinks are resolved. The pattern cannot know that: escape-probe is a
perfectly valid name, and a symlink under that name reads 51KB. We tested exactly
that — planted the symlink, confirmed fs.readFileSync would follow it, confirmed
loadRule refuses.
The part that actually let it through
loadRule had no test at all. Not a weak test — none.
The other four loaders were covered. This one looked trivially correct, so nobody wrote one, and the shape that made it different from its four siblings was never examined. It now has four tests: the exact payloads that leaked, non-string input (a crash there would kill the stdio server rather than return one tool error), a round-trip over all 15 real rules, and the symlink case.
The rule worth keeping
When resolving a caller-supplied name to a file, enumerate and match. Only construct a path when you cannot, and then prove containment.
Enumeration is safe by structure. Construction is safe by vigilance, and vigilance does not survive six months and three contributors.
If you have a loader that takes a name, go look at it right now. If it calls
path.join with that name, you already know what to test.
Fixed in ECC 2.24.2. The containment helper is in scripts/lib/safe-fs.js — it realpaths both sides, so a symlink is judged by where it points rather than where it sits.