Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/artifact-exchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@ The `file` input must be the native value supplied by the MCP host. DevSpace doe
not accept pasted download URLs or local source paths. It validates the complete
file-object shape, trusted OpenAI download hosts, and redirects before streaming.
Malformed references, unknown fields, absolute paths, traversal, and symlinked
parents are rejected.
parents are rejected. Windows junctions, other reparse points, alternate data
streams, device names, and ambiguous reserved path segments are also rejected.

Downloads are streamed under `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` and published as
owner-only files without overwriting an existing destination. The tool is
currently available on Linux. It is not registered on macOS, Windows, or BSD
because Node.js does not expose the required descriptor-relative filesystem
operations there.
private files without overwriting an existing destination. POSIX platforms use
mode `0600`; Windows preserves the destination directory's ACL inheritance. The
tool is available on Linux, macOS, and Windows. Linux and Windows clean bounded,
stale partial downloads opportunistically; macOS never path-walks to clean an
abruptly abandoned partial because doing so would weaken descriptor-relative
publication guarantees.
10 changes: 7 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ an attached or generated file into an already-open workspace:
DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve
```

This feature currently supports Linux. It is not registered on macOS, Windows,
or BSD because the secure publication path depends on traversable,
descriptor-anchored directory paths provided by Linux procfs.
This feature supports Linux, macOS, and Windows. Linux uses procfs-backed
directory descriptors, macOS uses descriptor-relative filesystem operations,
and Windows pins each destination directory with a non-delete-sharing handle
while rejecting junctions and other reparse points. The tool is not registered
on the remaining BSD platforms.

| Variable | Default | Purpose |
| --- | --- | --- |
Expand All @@ -64,6 +66,8 @@ The same settings may be persisted in `~/.devspace/config.json` as
a `workspaceId` returned by `open_workspace`, and a relative workspace `path`.
DevSpace safely creates missing parent directories, refuses to overwrite an
existing destination, and returns only the normalized workspace-relative path.
Windows alternate data streams, device names, ambiguous trailing dots or spaces,
and other reserved path segments are rejected.
It does not accept conflict modes, expected hashes, arbitrary URL strings, local
paths, embedded credentials, or extra object fields.

Expand Down
8 changes: 5 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ download hosts and redirects. Arbitrary URL strings, local source paths,
credentials, malformed references, and unknown object fields are rejected.

Absolute paths, traversal, symlinked parents, and existing destinations also
fail closed. Downloads stream under the configured per-file limit and are
published without overwrite as owner-only files. DevSpace does not extract or
execute transferred content.
fail closed. Windows additionally rejects junctions and other reparse points,
alternate data streams, device names, and ambiguous reserved path segments.
Downloads stream under the configured per-file limit and are published without
overwrite. POSIX files use mode `0600`; Windows files inherit the destination
directory's ACL. DevSpace does not extract or execute transferred content.

## Logs

Expand Down
220 changes: 220 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"diff": "^8.0.3",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"koffi": "^3.1.2",
"lucide": "^1.24.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
Expand Down
33 changes: 26 additions & 7 deletions src/artifact-download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ function testOneToolContract(): void {

function testPlatformSupportContract(): void {
assert.equal(isArtifactDownloadSupportedPlatform("linux"), true);
assert.equal(isArtifactDownloadSupportedPlatform("darwin"), false);
assert.equal(isArtifactDownloadSupportedPlatform("darwin"), true);
assert.equal(isArtifactDownloadSupportedPlatform("freebsd"), false);
assert.equal(isArtifactDownloadSupportedPlatform("openbsd"), false);
assert.equal(isArtifactDownloadSupportedPlatform("netbsd"), false);
assert.equal(isArtifactDownloadSupportedPlatform("win32"), false);
assert.equal(isArtifactDownloadSupportedPlatform("win32"), true);
}

async function testUnsupportedPlatform(testRoot: string): Promise<void> {
Expand Down Expand Up @@ -165,7 +165,18 @@ async function testDestinationValidation(testRoot: string): Promise<void> {
const workspaceRoot = join(testRoot, "workspace");
await mkdir(workspaceRoot, { recursive: true });

for (const path of ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"]) {
const invalidPaths = ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"];
if (process.platform === "win32") {
invalidPaths.push(
"file.txt:stream",
"CON.txt",
"folder./file.txt",
"bad<name>.txt",
"C:drive-relative.txt",
);
}

for (const path of invalidPaths) {
await expectArtifactError(
downloadIncomingArtifact({
registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }),
Expand Down Expand Up @@ -219,6 +230,8 @@ async function testSizeLimitAndCleanup(testRoot: string): Promise<void> {
}

async function testCrashLeftoverCleanup(testRoot: string): Promise<void> {
if (process.platform === "darwin") return;

const workspaceRoot = join(testRoot, "workspace");
await mkdir(workspaceRoot, { recursive: true });
await downloadIncomingArtifact({
Expand Down Expand Up @@ -258,13 +271,11 @@ async function testCrashLeftoverCleanup(testRoot: string): Promise<void> {
}

async function testSymlinkRejection(testRoot: string): Promise<void> {
if (process.platform === "win32") return;

const outside = join(testRoot, "outside");
await mkdir(outside, { recursive: true, mode: 0o700 });

const linkedWorkspaceRoot = join(testRoot, "linked-workspace");
await symlink(outside, linkedWorkspaceRoot, "dir");
await symlink(outside, linkedWorkspaceRoot, process.platform === "win32" ? "junction" : "dir");
await expectArtifactError(
downloadIncomingArtifact({
registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }),
Expand All @@ -279,7 +290,11 @@ async function testSymlinkRejection(testRoot: string): Promise<void> {

const linkedDestinationRoot = join(testRoot, "linked-destination-workspace");
await mkdir(linkedDestinationRoot, { recursive: true });
await symlink(outside, join(linkedDestinationRoot, "assets"), "dir");
await symlink(
outside,
join(linkedDestinationRoot, "assets"),
process.platform === "win32" ? "junction" : "dir",
);
await expectArtifactError(
downloadIncomingArtifact({
registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }),
Expand All @@ -294,6 +309,8 @@ async function testSymlinkRejection(testRoot: string): Promise<void> {
}

async function testPublicationFailurePreservesReplacement(testRoot: string): Promise<void> {
if (process.platform === "darwin") return;

const workspaceRoot = join(testRoot, "workspace");
await mkdir(workspaceRoot, { recursive: true });
const destinationPath = join(workspaceRoot, "generated.txt");
Expand Down Expand Up @@ -323,6 +340,8 @@ async function testPublicationFailurePreservesReplacement(testRoot: string): Pro
}

async function testPublishedPermissions(testRoot: string): Promise<void> {
if (process.platform === "win32") return;

const workspaceRoot = join(testRoot, "workspace");
await mkdir(workspaceRoot, { recursive: true });
const previousUmask = process.umask(0o077);
Expand Down
Loading
Loading