From 4782cdf24d5eba75c723ce5ec15ed9631842422a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 19:41:41 -0400 Subject: [PATCH 1/3] fix: Allow Trusted Own-Root Environment Symlinks --- packages/code/src/environment.test.ts | 21 +++++++++++++++++++++ packages/code/src/environment.ts | 9 +++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/code/src/environment.test.ts b/packages/code/src/environment.test.ts index 9080f6a2..cc66f7c0 100644 --- a/packages/code/src/environment.test.ts +++ b/packages/code/src/environment.test.ts @@ -193,6 +193,27 @@ test('environment roots resolve relative to the definition and fingerprints cove ); }); +test('accepts an own root through trusted external symlinks without allowing other root identities', async t => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'code-env-own-alias-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project'); + await mkdir(root); + const alias = join(directory, 'alias'); + await symlink(root, alias); + await symlink(alias, join(directory, 'nested-alias')); + const path = join(directory, 'environment.yaml'); + for (const selected of [alias, join(directory, 'nested-alias')]) { + await writeFile(path, `name: app\nroot: ${selected}\n`); + const loaded = await loadCodeEnvironment(path); + assert.equal(loaded.definition.root, root); + await assertEnvironmentDefinitionsOutsideRoots([loaded], [{ id: 'app', root }]); + await assert.rejects( + assertEnvironmentDefinitionsOutsideRoots([loaded], [{ id: 'other', root }]), + /root traversal|mount alias/, + ); + } +}); + test('rejects a trusted definition with an in-workspace hard link', async t => { const directory = await mkdtemp(join(tmpdir(), 'code-env-hardlink-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index 700fc0dc..dfb49e98 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -362,10 +362,15 @@ export async function assertEnvironmentDefinitionsOutsideRoots( { for (const component of environment.rootPaths ?? []) { const path = relative(root.root, component); - if (path === '' && root.id === environment.definition.name) + const sameRoot = (await identity(component)) === rootIdentity; + const descendant = path !== '' && !isAbsolute(path) && + path !== '..' && !path.startsWith(`..${sep}`); + // A trusted external alias may select its own root, but a + // link beneath that root is still writable by the workspace. + if (sameRoot && !descendant && root.id === environment.definition.name) continue; if ( - (await identity(component)) === rootIdentity || + sameRoot || path === '' || (!isAbsolute(path) && path !== '..' && From e3f8376a74b189a7fe216b0fa8cc1b1f62642cb3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 19:48:12 -0400 Subject: [PATCH 2/3] fix: Check Alias Parent Ownership by Filesystem Identity --- .github/workflows/ci.yml | 2 ++ packages/code/src/environment.test.ts | 26 ++++++++++++++++++++++++++ packages/code/src/environment.ts | 17 ++++++++++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f041e2d6..bbf9e302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,8 @@ jobs: node-version: 24.16.0 - run: npm ci - run: npm run build + - name: Native environment containment tests + run: node --test dist/environment.test.js - name: Native ACL and credential lifecycle tests run: node --test dist/macos-storage.test.js dist/private-storage.test.js dist/storage.test.js dist/github.test.js diff --git a/packages/code/src/environment.test.ts b/packages/code/src/environment.test.ts index cc66f7c0..44cb5d5c 100644 --- a/packages/code/src/environment.test.ts +++ b/packages/code/src/environment.test.ts @@ -214,6 +214,32 @@ test('accepts an own root through trusted external symlinks without allowing oth } }); +test('rejects own-root links hidden by parent aliases or filesystem casing', async t => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'code-env-parent-alias-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'Project'); + await mkdir(root); + await symlink(root, join(root, 'self')); + const alias = join(directory, 'parent-alias'); + await symlink(root, alias); + const path = join(directory, 'environment.yaml'); + const selectedRoots = [join(alias, 'self')]; + try { + if (await realpath(join(directory, 'project')) === root) + selectedRoots.push(join(directory, 'project', 'self')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + for (const selected of selectedRoots) { + await writeFile(path, `name: app\nroot: ${selected}\n`); + const loaded = await loadCodeEnvironment(path); + await assert.rejects( + assertEnvironmentDefinitionsOutsideRoots([loaded], [{ id: 'app', root }]), + /root traversal|mount alias/, + ); + } +}); + test('rejects a trusted definition with an in-workspace hard link', async t => { const directory = await mkdtemp(join(tmpdir(), 'code-env-hardlink-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index dfb49e98..3565cb62 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -355,6 +355,18 @@ export async function assertEnvironmentDefinitionsOutsideRoots( } return result; }; + // The entry's parent, not its symlink target, determines who can replace it. + // Compare ancestor identities so casing and directory aliases cannot make a + // workspace-controlled entry look external on case-insensitive filesystems. + const controlsEntry = async (component: string, rootIdentity: string): Promise => { + let parent = await realpath(dirname(component)); + while (true) { + if ((await identity(parent)) === rootIdentity) return true; + const next = dirname(parent); + if (next === parent) return false; + parent = next; + } + }; for (const environment of environments) { for (const root of roots) { const rootIdentity = await identity(root.root); @@ -363,11 +375,10 @@ export async function assertEnvironmentDefinitionsOutsideRoots( for (const component of environment.rootPaths ?? []) { const path = relative(root.root, component); const sameRoot = (await identity(component)) === rootIdentity; - const descendant = path !== '' && !isAbsolute(path) && - path !== '..' && !path.startsWith(`..${sep}`); // A trusted external alias may select its own root, but a // link beneath that root is still writable by the workspace. - if (sameRoot && !descendant && root.id === environment.definition.name) + if (sameRoot && root.id === environment.definition.name && + !(await controlsEntry(component, rootIdentity))) continue; if ( sameRoot || From d75bff9fcbb1cd7ee397a990a4e023fc8637fd7f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 19:55:22 -0400 Subject: [PATCH 3/3] fix: Enforce Parent Ownership Across Every Environment Path --- packages/code/src/environment.test.ts | 19 +++++++++++++++++-- packages/code/src/environment.ts | 16 ++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/code/src/environment.test.ts b/packages/code/src/environment.test.ts index 44cb5d5c..a8b5b036 100644 --- a/packages/code/src/environment.test.ts +++ b/packages/code/src/environment.test.ts @@ -220,13 +220,19 @@ test('rejects own-root links hidden by parent aliases or filesystem casing', asy const root = join(directory, 'Project'); await mkdir(root); await symlink(root, join(root, 'self')); + const outside = join(directory, 'outside'); + await mkdir(outside); + await symlink(root, join(outside, 'back')); + await symlink(outside, join(root, 'pivot')); const alias = join(directory, 'parent-alias'); await symlink(root, alias); const path = join(directory, 'environment.yaml'); - const selectedRoots = [join(alias, 'self')]; + const selectedRoots = [join(alias, 'self'), join(alias, 'pivot', 'back')]; try { - if (await realpath(join(directory, 'project')) === root) + if (await realpath(join(directory, 'project')) === root) { selectedRoots.push(join(directory, 'project', 'self')); + selectedRoots.push(join(directory, 'project', 'pivot', 'back')); + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } @@ -238,6 +244,15 @@ test('rejects own-root links hidden by parent aliases or filesystem casing', asy /root traversal|mount alias/, ); } + const definition = join(outside, 'environment.yaml'); + await writeFile(definition, `name: app\nroot: ${root}\n`); + for (const selected of selectedRoots.filter(path => path.endsWith('/back'))) { + const loaded = await loadCodeEnvironment(selected.replace(/back$/, 'environment.yaml')); + await assert.rejects( + assertEnvironmentDefinitionsOutsideRoots([loaded], [{ id: 'app', root }]), + /outside|mount alias/, + ); + } }); test('rejects a trusted definition with an in-workspace hard link', async t => { diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index 3565cb62..c2b2cf1f 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -358,8 +358,15 @@ export async function assertEnvironmentDefinitionsOutsideRoots( // The entry's parent, not its symlink target, determines who can replace it. // Compare ancestor identities so casing and directory aliases cannot make a // workspace-controlled entry look external on case-insensitive filesystems. + const canonicalParents = new Map>(); const controlsEntry = async (component: string, rootIdentity: string): Promise => { - let parent = await realpath(dirname(component)); + const directory = dirname(component); + let canonical = canonicalParents.get(directory); + if (!canonical) { + canonical = realpath(directory); + canonicalParents.set(directory, canonical); + } + let parent = await canonical; while (true) { if ((await identity(parent)) === rootIdentity) return true; const next = dirname(parent); @@ -375,13 +382,13 @@ export async function assertEnvironmentDefinitionsOutsideRoots( for (const component of environment.rootPaths ?? []) { const path = relative(root.root, component); const sameRoot = (await identity(component)) === rootIdentity; + const controlled = await controlsEntry(component, rootIdentity); // A trusted external alias may select its own root, but a // link beneath that root is still writable by the workspace. - if (sameRoot && root.id === environment.definition.name && - !(await controlsEntry(component, rootIdentity))) + if (sameRoot && !controlled && root.id === environment.definition.name) continue; if ( - sameRoot || + controlled || sameRoot || path === '' || (!isAbsolute(path) && path !== '..' && @@ -399,6 +406,7 @@ export async function assertEnvironmentDefinitionsOutsideRoots( ]) { const path = relative(root.root, controlPath); if ( + (await controlsEntry(controlPath, rootIdentity)) || (await identity(controlPath)) === rootIdentity || path === '' || (!isAbsolute(path) &&