Skip to content

feat(sftp): add the local-path gate for the streaming file tools - #208

Merged
tufantunc merged 2 commits into
mainfrom
feat/sftp-file-tools-takeover
Sep 10, 2026
Merged

tufantunc merged 2 commits into
mainfrom
feat/sftp-file-tools-takeover

Conversation

@tufantunc

Copy link
Copy Markdown
Owner

Second piece of the #186 takeover, after #205. The module is inert — nothing imports it yet, so no tool behaviour changes. Wiring it to sftp-list / sftp-upload-file / sftp-download-file is the next PR.

What differs from #186

1. The path-resolution order (was one of the two blocking test failures)

const root = await configuredRoot(...);   // realpath'd → /private/var/...
const lexical = resolve(root, input);     // ignores `root` when input is absolute
confined(root, lexical);                  // ← threw here
const candidate = await realpath(lexical);// the correct check, never reached

resolve(root, input) ignores root entirely for an absolute input, so a path spelled the way an operator's shell shows it is not yet comparable to a realpath'd root. Confining that form first refused every legitimate absolute path on any host whose transfer root sits behind a symlink — which is every macOS host.

Fixed by checking the candidate against both spellings of the root (canonical and as-configured) lexically, then canonicalizing the parent before the leaf.

2. The refusal stays lexical, on purpose

The obvious fix — delete the early check and rely on realpath-then-confine — would make these tools answer "does this path exist?" for arbitrary locations, since a resolvable parent and an unresolvable one produce different errors. A test pins it:

const existing = localFileForRead(ctx, '/etc/hosts');
const absent   = localFileForRead(ctx, '/ssh-mcp-no-such-root-9f3a/file');
expect(a).toBe(b);   // byte-identical

It fails if the lexical gate is removed. Verified by removing it.

3. Windows is refused outright

Not reused from the config-file ACL posture, which waives a read-exposed directory, never consults the O: owner, and treats a missing or slow icacls.exe as a pass. A root that cannot be verified must disable the tools. The Windows gate is its own piece (C).

4. The audit log and SSH key directories are excludable

Both are conventionally 0700 and owner-owned, so the ownership/permission checks accept them — and a transfer root containing either turns a download into overwrite the record of this transfer and an upload into read the private key. A forbidden directory that does not exist yet still counts, so creating it later cannot re-open the hole.

Verification

Every claim above was checked by mutating the source and re-running:

Mutation Result
restore #186's immediate confined(root, lexical) 2 tests red — the same two failures the review found
drop the lexical gate entirely the existence-oracle test goes red

27 new unit tests; npm run test:unit is 867 passing. windows-acl keeps its 100 (14 win32-only skip locally, they run on the windows job).

Also here

  • a macos CI job, wired into the required ci aggregate — without it the bug in §1 is invisible, since neither ubuntu-latest nor windows-latest has a symlinked temp dir. Lean: no docker, so no integration/e2e, no coverage upload.
  • windows-acl.ts containment now uses the shared helper instead of its own !rel.startsWith('..'), which rejected a legitimately named ..cache as an escape. That one is a real if narrow fix, so it carries the changeset.

Still to come

tufantunc and others added 2 commits September 10, 2026 10:32
First half of the #186 takeover. The module is deliberately inert: nothing
imports it yet, so no tool behaviour changes here. Wiring it to
sftp-list/sftp-upload-file/sftp-download-file is the next piece.

Three things differ from the version proposed in #186.

The path-resolution order is fixed. `resolve(root, input)` ignores `root`
when `input` is absolute, so an absolute path spelled the way an operator's
shell shows it does not lexically sit under a realpath'd root. Confining
that form first therefore refused every legitimate absolute path on any host
whose transfer root is behind a symlink — which is every macOS host, where
os.tmpdir() is /var/folders/..., a symlink to /private/var/folders/....
Two of #186's own tests were red for this reason and no CI job could see it.

The refusal stays lexical rather than being moved after realpath, because
that is what stops these tools answering "does this path exist?" for
arbitrary locations: a path outside the root is rejected before anything
stats it. A test asserts an existing and an absent path outside the root
produce byte-identical errors, and fails if the gate is dropped.

Windows is refused outright instead of reusing the config-file ACL posture,
which waives a read-exposed directory, never consults the `O:` owner, and
treats a missing or slow icacls.exe as a pass. A root that cannot be
verified has to disable the tools, not enable them; the Windows gate is its
own piece.

The audit log's directory and the SSH key directories are excludable by
name. Both are conventionally 0700 and owner-owned, so the ownership and
permission checks accept them — and a transfer root containing either turns
a download into "overwrite the record of this transfer" or an upload into
"read the private key". A forbidden directory that does not exist yet still
counts, so creating it afterwards cannot re-open the hole.

Also here, because the fix is invisible without them:

- a macos CI job, wired into the required `ci` aggregate. Neither
  ubuntu-latest nor windows-latest has a symlinked temp directory, so the
  bug above passes both by construction. Lean on purpose: no docker, so no
  integration or e2e suite, and no coverage upload.
- windows-acl.ts's containment goes through the same shared helper rather
  than its own `!rel.startsWith('..')`, which rejected a legitimately named
  `..cache` as an escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s refusal

The whole suite failed on the windows job: every case went through
configuredRoot, which refuses win32 outright, so 26 tests asserted POSIX
messages against 'not available on Windows'. Running on macOS could not
show this — the windows job is what caught it, which is the reason that
job blocks a merge.

The POSIX cases are now guarded, and the Windows contract has its own
block that only runs there. That block is the point: without it, the
fail-closed branch would have been the one piece of this module with no
coverage at all, on the one platform where it is the entire behaviour.
Its second case pins that the refusal comes before any decision about the
path, so a caller cannot tell an in-root path from an out-of-root one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tufantunc
tufantunc merged commit 5b96290 into main Sep 10, 2026
8 checks passed
@tufantunc
tufantunc deleted the feat/sftp-file-tools-takeover branch September 10, 2026 07:42
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.81481% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.41%. Comparing base (9296af2) to head (1bf6c56).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/tools/local-path.ts 74.04% 20 Missing and 14 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #208      +/-   ##
==========================================
- Coverage   82.90%   82.41%   -0.49%     
==========================================
  Files          35       37       +2     
  Lines        2176     2309     +133     
  Branches      606      640      +34     
==========================================
+ Hits         1804     1903      +99     
- Misses        257      277      +20     
- Partials      115      129      +14     
Files with missing lines Coverage Δ
src/config/path-containment.ts 100.00% <100.00%> (ø)
src/config/windows-acl.ts 88.88% <100.00%> (-0.11%) ⬇️
src/tools/local-path.ts 74.04% <74.04%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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