Skip to content

Run CI on macOS and Windows; fix snippet paths on Windows and symlinked working dirs - #108

Merged
scalvert merged 5 commits into
mainfrom
ci/harden
Oct 10, 2026
Merged

scalvert merged 5 commits into
mainfrom
ci/harden

Conversation

@scalvert

@scalvert scalvert commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

CI now runs on Ubuntu, macOS and Windows, and lints and formats more than src/. Turning Windows on found two product bugs; on its first run, 77 of 317 tests failed.

Bugs fixed

1. Snippets rejected as "path traversal" when the working directory path goes through a symlink or a Windows 8.3 short name. readLocalSnippet realpaths the snippet, but allowedSnippetRoots didn't realpath the roots it compared against. Through the library on macOS:

workingDir: /tmp/x/link  (→ /tmp/x/real)
loadSnippetContent('a.js') → Error: Path traversal attempt detected: a.js

On Windows this hits the CLI whenever the cwd uses a short name, such as the default %TEMP% (C:\Users\RUNNER~1\…). On macOS and Linux, process.cwd() is already a real path, so only library callers are affected.

Fix: realpath the working directory. A snippet root inside it is mapped onto the real working directory by its relative path, so a symlinked snippets/ directory still isn't followed and a committed snippets -> /etc link is rejected as before. A snippet root outside the working directory (../shared, or an absolute path) is realpathed on its own. Mapping it relative to the link target would allow a directory nobody configured.

2. Globs containing \ matched nothing on Windows. fast-glob treats \ as an escape character, so --markdown-glob docs\**\*.md, or any absolute pattern built with path.join, found no files. Every fast-glob call now goes through src/glob.ts → findMarkdownFiles({ markdownGlob, excludeGlob, workingDir }): planSync (used by sync and check), extract, discovery and eject. It converts \ to / on Windows only, because elsewhere \ is a real escape, and it returns platform-native absolute paths. The README now says Windows users should write a literal [ or ( as a bracket expression ([[]), not a backslash escape.

CI

  • Permissions: permissions: contents: read, persist-credentials: false, cancel superseded PR runs, and job timeouts.
  • Lint job: lint, format:check, typecheck.
  • Test matrix: {ubuntu, macos, windows} × Node {22, 24} running build, test and test:package.
  • Line endings: .gitattributes (* text=auto eol=lf) keeps fixtures LF on Windows, for CI and contributors alike. git add --renormalize changed nothing.
  • Check names change: Test (22) / Test (24) become Lint plus Test (<os>, Node <n>). Nothing requires checks today, so nothing breaks, but a future ruleset should use the new names.

Lint and format

  • ESLint scope: npm run lint is now eslint .. That covers src/, test/, scripts/ and config files through a new tsconfig.eslint.json; test/fixtures, dist and coverage are ignored.
  • Lint fixes: what this turned up, mostly unused imports and variables in tests. max-len is off in tests. ANSI stripping uses util.stripVTControlCharacters instead of control-character regexes, and no-unused-vars allows rest-sibling omission.
  • Formatting: new format:check script. .prettierignore now also ignores dist/, coverage/ and test/fixtures/. README.md and SKILL.md had whitespace-only fixes.

Tests

  • New glob.test.ts: Windows conversion; non-Windows untouched; native paths; absolute path.join patterns.

  • Symlink tests: snippet-resolution tests resolve snippets through a symlinked or junctioned working directory, which fails on main. With a snippet root outside that directory, the configured root is allowed and the same relative path under the link target is rejected. Both of those fail with the first version of this fix and pass on main.

  • Portability fixes:

    • Path normalization in the test helpers handles realpaths and \.
    • The absolute-path traversal test uses a file that exists on every platform instead of /etc/passwd.
    • The ENOTDIR case and the chmod read-permission tests are skipped on Windows, where they can't apply.
  • I have run npm run build to verify the project builds.

  • Docs: AGENTS.md describes the CI matrix and says Node 22+ (not 18+). CONTRIBUTING.md lists format:check.

  • I have run npm run lint and npm run format:check.

  • I have run npm test to ensure all tests pass. (324 passed)

…hing

Give the workflow a read-only token, don't persist checkout credentials, cancel superseded PR runs, and add timeouts. Run tests and the package smoke test on Ubuntu, macOS and Windows with Node 22 and 24, with autocrlf off so fixtures keep LF. Move lint, format check and typecheck into one job. Lint src, test, scripts and config files (fixtures ignored) and fix what that found; add format:check and ignore dist, coverage and fixtures in Prettier. Skip read-permission tests on Windows, where chmod can't revoke reads.
… dirs

allowedSnippetRoots compared a realpath'd snippet against roots that weren't realpath'd, so every snippet was rejected as path traversal when the working directory path went through a symlink or a Windows 8.3 short name. Realpath the working directory and map the snippet root onto it lexically, so a symlinked snippets directory is still not followed. Route all Markdown globbing through src/glob.ts, which converts backslashes to glob separators on Windows and returns platform-native paths. Make test path normalization portable.
eject still called fast-glob directly. The absolute-path traversal test now uses a file that exists on every platform, and the ENOTDIR case is POSIX-only.
@scalvert scalvert added bug Something isn't working and removed internal labels Oct 10, 2026
@scalvert scalvert changed the title Harden CI: read-only token, macOS and Windows, lint and format everything Run CI on macOS and Windows; fix snippet paths on Windows and symlinked working dirs Oct 10, 2026
@scalvert
scalvert marked this pull request as ready for review October 10, 2026 20:46
Only map the snippet root lexically onto the real working directory when it's inside the working directory; realpath an outside root on its own, so a symlinked working dir can't allow an unconfigured directory. Pass findMarkdownFiles an options object. Document backslashes in Windows globs. Keep LF via .gitattributes instead of a CI step. Update AGENTS.md and CONTRIBUTING.md, and name skip reasons in tests.
@scalvert

Copy link
Copy Markdown
Owner Author

Second-pass review

Three passes: security (twice) and standards.

Standards: the standards pass built main and this branch and ran every fixture scenario through 5 glob variants. Each variant ran check → sync → check → extract → check → eject, 270 runs per build. stdout, exit codes and file hashes are identical on macOS, so returning resolve()d paths from findMarkdownFiles changes nothing there. The CI permissions, concurrency, timeouts and lint scope all check out, npm run typecheck is unchanged, and no assertions were weakened.

Security, first pass: found a regression in the first fix. It mapped the snippet root relative to the realpathed working directory even when the root was outside it. With workingDir: link → real/a/b and snippetRoot: '../shared', that allowed real/a/shared, which nobody configured, and rejected the configured shared/. This was library-only, since the CLI's process.cwd() is already real.

Fixed in 46ff37c

  • Lexical mapping is used only when the snippet root is inside the working directory. An outside root is realpathed on its own. Two new tests fail with the first fix and pass now.
  • findMarkdownFiles takes an options object.
  • The README covers backslashes in Windows globs. On Windows, \ is a separator, so a literal [ or ( needs a bracket expression such as [[]. A \ escape in an excludeGlob there would otherwise stop matching silently.
  • .gitattributes (eol=lf) replaces the CI autocrlf step.
  • AGENTS.md and CONTRIBUTING.md are updated, and skip reasons are now in the test names.

Security, second pass on 46ff37c: 28 containment cases against main. No case where the branch accepts something main rejected, apart from the intended fixes, and nothing main accepted is now rejected. The intended fixes are a symlinked working dir or parent, an absolute root given through the link, and ../ from nested Markdown.

One accepted inconsistency: a snippet root outside the working directory that is itself a symlink is now followed. That root is user configuration, and config can already point an absolute root anywhere. A symlinked snippets/ inside the working directory is still not followed.

Not changed: npm isn't cached in CI (each job runs a cold npm ci); actions aren't SHA-pinned.

CI green on Ubuntu, macOS and Windows × Node 22 and 24, including test:package. 324 tests pass locally.

@scalvert
scalvert merged commit e073f8d into main Oct 10, 2026
7 checks passed
@scalvert
scalvert deleted the ci/harden branch October 10, 2026 21:05
@scalvert scalvert mentioned this pull request Oct 10, 2026
3 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant