Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions packages/code/src/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,68 @@ 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 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 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'), join(alias, 'pivot', 'back')];
try {
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;
}
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/,
);
}
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 => {
const directory = await mkdtemp(join(tmpdir(), 'code-env-hardlink-'));
t.after(() => rm(directory, { recursive: true, force: true }));
Expand Down
28 changes: 26 additions & 2 deletions packages/code/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,17 +355,40 @@ 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 canonicalParents = new Map<string, Promise<string>>();
const controlsEntry = async (component: string, rootIdentity: string): Promise<boolean> => {
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);
if (next === parent) return false;
parent = next;
}
};
for (const environment of environments) {
for (const root of roots) {
const rootIdentity = await identity(root.root);
// No granted workspace may control how this root resolves on restart.
{
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 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 && !controlled && root.id === environment.definition.name)
continue;
if (
(await identity(component)) === rootIdentity ||
controlled || sameRoot ||
path === '' ||
(!isAbsolute(path) &&
path !== '..' &&
Expand All @@ -383,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) &&
Expand Down