security: contain path traversal in check-resolvable and doctor#156
Open
garagon wants to merge 1 commit intogarrytan:masterfrom
Open
security: contain path traversal in check-resolvable and doctor#156garagon wants to merge 1 commit intogarrytan:masterfrom
garagon wants to merge 1 commit intogarrytan:masterfrom
Conversation
checkResolvable (src/core/check-resolvable.ts) and
checkSkillConformance (src/commands/doctor.ts) both called
join(skillsDir, untrustedPath)
on string values read from manifest.json and RESOLVER.md, then passed
the result into existsSync/readFileSync without a containment check.
A manifest entry like
{ "name": "evil", "path": "../../../etc/passwd" }
or a RESOLVER.md row like
| bad | `skills/../../etc/hosts/SKILL.md` |
causes fs to stat/read files outside skillsDir. On a host where
'gbrain doctor --json' output is shared (CI log, support ticket,
issue paste), this leaks filesystem structure.
Fix:
- Add a private resolveInsideSkills(base, candidate) helper that
path.resolve's the join, then requires the result to equal base
or start with base + sep. Separator-terminated prefix avoids the
classic /foo vs /foobar false accept.
- Every previously-unsafe join(skillsDir, ...) in check-resolvable.ts
and doctor.ts goes through the helper. A null result becomes an
'invalid_path' issue on the report instead of a stat/read.
No legitimate skill paths are rejected: foo/../query/SKILL.md
normalizes under skillsDir and is still accepted.
New ResolvableIssue type 'invalid_path' (error severity) is added
to the union. Existing consumers of the report that switch on
issue.type get compile-time nudges; the `type` column is the
stable surface.
Tests (test/check-resolvable.test.ts):
- malicious manifest path → rejected, sentinel file outside
skillsDir is NOT read (assertion keys off a unique trigger in
the sentinel that must not appear in the trigger-map analysis).
- absolute path in manifest → rejected.
- malicious RESOLVER.md row → surfaces as invalid_path, not as
missing_file (missing_file would imply an existsSync was done
against the outside path).
- benign normalized path (tmp/../query/SKILL.md) → still accepted.
Each test builds an isolated sandbox with mkdtempSync and tears
down with rmSync. Negative control: reverting src/core/check-resolvable.ts
to master turns 3 of these into failures.
This was referenced Apr 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The skill resolver (
checkResolvable) and the doctor command's skill conformance check both trust two files that users routinely hand-edit or pull from a PR:skills/manifest.jsonandskills/RESOLVER.md. Both files carry paths that end up on the filesystem asexistsSync/readFileSyncarguments, and today nothing stops those paths from pointing outside theskills/directory.The practical effect: if someone opens a PR (or edits a local checkout, or publishes a skill bundle) that puts
"path": "../../../etc/passwd"inmanifest.json, or writes a RESOLVER.md row like`skills/../../etc/hosts/SKILL.md`, the nextgbrain doctororbun testrun happily stats and reads those outside files.gbrain doctor --jsonthen echoes the result back in its report. On any host where doctor output gets shared — CI logs, a support paste, a GitHub issue — that leaks parts of the host's filesystem structure. It is also a useful primitive for a subsequent attack: confirm which sensitive files exist before chaining into something heavier.The vulnerable pattern, same in both files:
path.joinpreserves../segments, so the joined path can land anywhere the process has read access. This PR adds a containment check at every such site so the filesystem boundary is the defence, not the trust put in the input files.Root cause
path.joinkeeps../segments intact when they appear mid-path —join('skills', '../../../etc/passwd')becomes../../etc/passwdrelative to cwd, whichexistsSynchappily follows.\(skills/[^\`]+/SKILL.md)`) acceptsskills/../../etc/...because[^\`]+does not forbid..`.Fix
Private helper in each file:
path.resolvenormalizes..segments before the comparison, sofoo/../query/SKILL.mdresolves to<base>/query/SKILL.mdand is still accepted.base + sep) avoids the/foovs/foobarfalse accept.resolve()never leaves a trailing separator on a non-root path, so the check is exact.nulland the caller records a newinvalid_pathissue on the report instead of callingexistsSync/readFileSync.The helper is deliberately duplicated in
doctor.tsrather than exported fromcheck-resolvable.ts. The public surface ofcheck-resolvable.tsis consumed bybun test,skill-creator, and other callers; growing that surface for a 10-line helper is more risk than benefit.ResolvableIssue surface
Adds one value to the
typeunion:invalid_pathisseverity: 'error'. Consumers that switch ontypeget a compile-time nudge; consumers that only readmessage/actioncontinue unchanged.Reproduction
Before the fix, against a fresh
skills/directory with a malicious manifest:Sibling review
Grepped
src/for alljoin(skillsDir, …)callsites and classified:src/core/check-resolvable.ts'manifest.json'literalsrc/core/check-resolvable.ts'RESOLVER.md'literalsrc/core/check-resolvable.tsrelPathfrom RESOLVER.md regexsrc/core/check-resolvable.tsskill.pathfrom manifestsrc/core/check-resolvable.tsskill.pathfrom manifestsrc/core/check-resolvable.tsskill.pathfrom manifestsrc/commands/doctor.tsskill.pathfrom manifestsrc/commands/init.tssrc/core/file-resolver.tsTest results
test/check-resolvable.test.ts— 17 tests, all passing (13 pre-existing, 4 new).New regression tests
malicious manifest path is rejected as invalid_path, not read— plants a sentinel file outsideskillsDirwith a unique trigger; asserts the report hasinvalid_pathfor the malicious entry AND that the sentinel's trigger did not make it into the MECE map (proving the file was never read).absolute path in manifest is rejected—/etc/hostsasskill.pathbecomes aninvalid_pathissue.malicious RESOLVER.md entry is rejected, no stat on outside path— asserts the escape shows up asinvalid_path, not asmissing_file.missing_filewould prove anexistsSyncran against/etc/hosts/SKILL.md, which would be a leak.benign normalized paths still resolve (e.g. foo/../query)— asserts the helper does not over-reject paths that normalize back insideskillsDir.Negative control
Reverting
src/core/check-resolvable.tsto master and rerunning the same test file fails 3 of the 4 new tests. TheexistsSyncoutsideskillsDirruns andmissing_fileshows up instead ofinvalid_path, confirming the pre-fix code really did stat outside the skills directory.Full suite
What stayed in place
\(skills/[^\`]+/SKILL.md)`) is unchanged. Narrowing it (rejecting..`) would move the fix into the parser, but the canonical containment belongs at the filesystem boundary — parser changes are more likely to silently break legitimate rows. The resolver now defends at the point where the harm would actually occur.checkResolvable(skillsDir)andcheckSkillConformance(skillsDir)unchanged.Files
How to verify