Skip to content

fix(skills): make the read confinement real — a symlink could leave the directory - #81

Merged
kemalelmizan merged 1 commit into
mainfrom
fix/skill-read-confinement
Oct 17, 2026
Merged

kemalelmizan merged 1 commit into
mainfrom
fix/skill-read-confinement

Conversation

@kemalelmizan

Copy link
Copy Markdown
Contributor

The bug

The read tool's confinement was a string check — resolve() is lexical and never touches the filesystem:

const target = resolve(skill.dir, args.file);
if (target !== skill.dir && !target.startsWith(skill.dir + sep)) { refuse }
readFileSync(target, "utf8")

A symlink inside the skill directory is inside by name and points wherever it likes, and readFileSync follows it. So skills/<name>/leak -> /etc/passwd read straight through a guarantee the README, AGENTS.md and the archetype catalogue all state. listSkillFiles had the same hole twice over: statSync follows links, so a symlinked directory let the listing walk out of the tree and advertise files it should never have seen.

A skill you wrote never hits this. A skill you copied — a vendored pack, a submodule, exactly the untrusted case the catalogue discusses — is where links actually appear, which is why this goes first.

The fix

  • resolveInside(root, path) realpaths the target, requires it inside the real skill directory, and requires a regular file (a directory, socket or device is refused rather than read or hung on).
  • Skill.dir is now the realpath of the skill directory, so the checks and the values they protect compare against the same resolved root.
  • Discovery refuses a SKILL.md that is not a regular file inside the skill, and reports it — a symlinked SKILL.md is not an accident.
  • listSkillFiles uses lstat and skips symlinks: neither listed nor recursed through.

Docs that made the claim now say how it is enforced.

Tests

Five new cases: link out of the skill, symlinked SKILL.md, symlinked directory, a directory passed as file, and the one case that stays legal (a link resolving inside the skill). Four of the five fail against the previous loader — verified by stashing only src/skills.ts and re-running.

pnpm build / typecheck / lint green; pnpm test 346 passed / 5 skipped; coverage 90.5 / 84.1 / 89.6 / 90.5.

…he directory

The read tool's confinement was a string check: `resolve()` is lexical and never
consults the filesystem. A symlink sitting in a skill directory is inside by name
and points wherever it likes, and `readFileSync` follows it — so
`…/skills/<anything>/leak -> /etc/passwd` read straight through the guarantee the
docs make. `listSkillFiles` had the same hole twice over: `statSync` follows links,
so a symlinked *directory* let the listing walk out of the tree and advertise files
it had no business seeing.

A skill you wrote yourself never hits this. A skill you copied — a vendored pack, a
submodule, the untrusted case the catalogue is about — is exactly where links
appear, which is why this comes before the trust work.

Now:
- `resolveInside(root, path)` realpaths the target, requires it to stay inside the
  *real* skill directory, and requires a regular file (a directory, socket or device
  is refused rather than read or hung on).
- `Skill.dir` is the realpath of the skill directory, so the checks and the values
  they protect are compared against the same resolved root.
- discovery refuses a `SKILL.md` that is not a regular file inside the skill, and
  *reports* it: a symlinked `SKILL.md` is not an accident.
- `listSkillFiles` uses `lstat` and skips symlinks, so a link is neither listed nor
  walked to.

Tests cover the escapes — link out, symlinked `SKILL.md`, symlinked directory, a
directory passed as a file — and pin the one case that stays legal (a link that
resolves inside the skill). Four of the five fail against the previous loader.
@kemalelmizan
kemalelmizan merged commit c97062f into main Oct 17, 2026
2 checks passed
@kemalelmizan
kemalelmizan deleted the fix/skill-read-confinement branch October 17, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant