Skip to content

fix: skills CRUD broken on Windows due to hardcoded path separator#492

Merged
Nikhil (shadowfax92) merged 1 commit into
mainfrom
fix/skills-windows
Mar 12, 2026
Merged

fix: skills CRUD broken on Windows due to hardcoded path separator#492
Nikhil (shadowfax92) merged 1 commit into
mainfrom
fix/skills-windows

Conversation

@shadowfax92
Copy link
Copy Markdown
Contributor

Summary

  • Fix safeSkillDir() path traversal check that used a hardcoded / instead of path.sep
  • On Windows, path.resolve() returns backslash paths (C:\Users\...), so the startsWith check always failed
  • This blocked all single-skill operations: get, create, update, delete (the list endpoint was unaffected)

Design

Replace ${skillsDir}/ with ${skillsDir}${sep} using path.sep from node:path. This returns \ on Windows and / on POSIX, matching what path.resolve() produces. The path traversal security guarantee is preserved.

Test plan

  • Added unit test verifying GET /:id returns 404 (not 500) for non-existent skills
  • Added unit test verifying path traversal attempts are rejected
  • Typecheck passes
  • Lint passes
  • All tests pass

🤖 Generated with Claude Code

safeSkillDir() used a hardcoded `/` in the startsWith path traversal
check. On Windows, path.resolve() returns backslash paths, so the check
always failed — blocking getSkill, createSkill, updateSkill, deleteSkill.

Replace `${skillsDir}/` with `${skillsDir}${sep}` using path.sep from
node:path, which returns `\` on Windows and `/` on POSIX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@shadowfax92
Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai) review

@github-actions github-actions Bot added the fix label Mar 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 12, 2026

Greptile Summary

This PR fixes a real Windows bug in safeSkillDir: the hardcoded '/' separator in the startsWith check caused every single-skill operation (get, create, update, delete) to throw "Invalid skill id" on Windows, because path.resolve() returns backslash-separated paths there. Replacing '/' with path.sep is the correct, minimal fix and the security invariant is unchanged.

Key changes:

  • apps/server/src/skills/service.ts: imports sep from node:path and uses ${skillsDir}${sep} instead of ${skillsDir}/ — correct and complete fix.
  • apps/server/tests/skills/service.test.ts: new test file that covers the 404 path for valid-but-missing skills, but has test quality issues (see inline comments):
    • The path traversal test uses a URL that HTTP normalises to /etc/passwd before Hono extracts :id, so safeSkillDir's security check is never actually invoked — the test passes for the wrong reason.
    • The "platform separator" describe block is a tautology that can never fail.
    • The traversal assertion (notStrictEqual(status, 200)) is too weak to distinguish a clean 4xx rejection from an unhandled 5xx error.

Confidence Score: 4/5

  • Safe to merge — the production fix is correct and the security invariant is preserved; test quality issues are non-blocking.
  • The one-line production change (sep vs /) is correct and well-scoped. The security check continues to work on both platforms. Score is 4 rather than 5 because the path traversal test doesn't actually exercise safeSkillDir's guard (URL normalization bypasses it), meaning the claimed test coverage of the security mechanism is not real.
  • apps/server/tests/skills/service.test.ts — path traversal test does not validate the security check it claims to cover.

Important Files Changed

Filename Overview
apps/server/src/skills/service.ts Minimal, correct one-line fix replacing hardcoded '/' with sep (from node:path) in the safeSkillDir path traversal guard; the security invariant is fully preserved on both POSIX and Windows.
apps/server/tests/skills/service.test.ts New test file with three issues: the path-traversal test doesn't actually exercise safeSkillDir (URL normalization makes :id = "etc/passwd", which passes the guard), the "platform separator" describe block is a tautology, and the traversal assertion is too weak (!== 200 also passes on 500).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["HTTP Request\nGET /skills/:id"] --> B["Hono router extracts :id\n(URL-normalised)"]
    B --> C["getSkill(id)"]
    C --> D["safeSkillDir(id)"]
    D --> E["path.resolve(skillsDir, id)"]
    E --> F{{"resolved.startsWith\n(skillsDir + path.sep)?"}}
    F -- "No (traversal / same dir)" --> G["throw 'Invalid skill id'\n→ 500 (unhandled in GET route)"]
    F -- "Yes" --> H["fileExists(SKILL.md)?"]
    H -- "No" --> I["return null → 404"]
    H -- "Yes" --> J["read & parse SKILL.md\n→ 200"]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 17-20

Comment:
**Path traversal test doesn't actually exercise the security check**

The URL `'/../../../etc/passwd'` is normalized by the URL parser before Hono routes it. Per the URL standard, `new URL('/../../../etc/passwd', 'http://localhost')` resolves to `http://localhost/etc/passwd`, so `:id` becomes `"etc/passwd"` — not `"../../../etc/passwd"`.

As a result, `safeSkillDir("etc/passwd")` resolves to `${skillsDir}/etc/passwd`, which **passes** the traversal check (it's inside the skills dir). The test returns 404 only because no `SKILL.md` exists at that path, not because the security guard fired.

To actually exercise `safeSkillDir`'s protection, test the service function directly or use percent-encoded dots that survive URL normalization:

```ts
import { safeSkillDir } from '../../src/skills/service'  // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)
```

Alternatively, if `safeSkillDir` is kept private, add a unit test that calls it directly with `'../../../etc/passwd'` and asserts it throws.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 23-26

Comment:
**Tautological test adds no value**

This test asserts that `path.sep` is either `'/'` or `'\\'`. This will never fail in any valid Node.js/Bun runtime — it's testing a Node.js built-in constant rather than any application logic.

Consider replacing it with a test that actually validates `safeSkillDir` rejects a crafted traversal ID directly:

```ts
import { safeSkillDir } from '../../src/skills/service'

describe('safeSkillDir', () => {
  it('throws on path traversal attempts', () => {
    assert.throws(() => safeSkillDir('../../../etc/passwd'), /Invalid skill id/)
  })
  it('accepts a valid slug-like id', () => {
    assert.doesNotThrow(() => safeSkillDir('my-skill'))
  })
})
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 18-19

Comment:
**Weak assertion — passes even for 5xx errors**

`assert.notStrictEqual(res.status, 200)` passes for any non-200 response, including `500` (which would indicate an unhandled exception rather than a clean security rejection). Consider asserting a specific 4xx status to confirm the server handles the case gracefully:

```suggestion
    assert.ok(res.status >= 400 && res.status < 500, `expected 4xx, got ${res.status}`)
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 8295213

Comment on lines +17 to +20
it('GET /:id rejects path traversal attempts', async () => {
const res = await app.request('/../../../etc/passwd')
assert.notStrictEqual(res.status, 200)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path traversal test doesn't actually exercise the security check

The URL '/../../../etc/passwd' is normalized by the URL parser before Hono routes it. Per the URL standard, new URL('/../../../etc/passwd', 'http://localhost') resolves to http://localhost/etc/passwd, so :id becomes "etc/passwd" — not "../../../etc/passwd".

As a result, safeSkillDir("etc/passwd") resolves to ${skillsDir}/etc/passwd, which passes the traversal check (it's inside the skills dir). The test returns 404 only because no SKILL.md exists at that path, not because the security guard fired.

To actually exercise safeSkillDir's protection, test the service function directly or use percent-encoded dots that survive URL normalization:

import { safeSkillDir } from '../../src/skills/service'  // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)

Alternatively, if safeSkillDir is kept private, add a unit test that calls it directly with '../../../etc/passwd' and asserts it throws.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 17-20

Comment:
**Path traversal test doesn't actually exercise the security check**

The URL `'/../../../etc/passwd'` is normalized by the URL parser before Hono routes it. Per the URL standard, `new URL('/../../../etc/passwd', 'http://localhost')` resolves to `http://localhost/etc/passwd`, so `:id` becomes `"etc/passwd"` — not `"../../../etc/passwd"`.

As a result, `safeSkillDir("etc/passwd")` resolves to `${skillsDir}/etc/passwd`, which **passes** the traversal check (it's inside the skills dir). The test returns 404 only because no `SKILL.md` exists at that path, not because the security guard fired.

To actually exercise `safeSkillDir`'s protection, test the service function directly or use percent-encoded dots that survive URL normalization:

```ts
import { safeSkillDir } from '../../src/skills/service'  // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)
```

Alternatively, if `safeSkillDir` is kept private, add a unit test that calls it directly with `'../../../etc/passwd'` and asserts it throws.

How can I resolve this? If you propose a fix, please make it concise.

@shadowfax92 Nikhil (shadowfax92) merged commit 32dd42c into main Mar 12, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant