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
47 changes: 1 addition & 46 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { CommonOptions as FactoryCommonOptions } from '../lib/client-factor
import { CLIError, localValidationError } from '../lib/errors.js';
import type { OutputMode } from '../lib/output.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js';
import { inspectTargetPath } from '../lib/target-path.js';
import { promptText } from '../lib/prompt.js';
import { FALLBACK_TARGET, resolveAgentTargets, type DetectAgentDeps } from '../lib/agent-detect.js';
import {
Expand Down Expand Up @@ -81,52 +82,6 @@ const defaultAgentFs: AgentFs = {
// Path safety
// ---------------------------------------------------------------------------

/**
* Walk each component of `relPath` beneath `root`, refusing to traverse or
* write through a symlink. `fs.mkdir`/`writeFile` follow symlinks, so a planted
* symlink at any existing path component (e.g. `.claude` -> /etc, or the final
* `SKILL.md` -> ~/.bashrc) could place or clobber files outside `--dir`. The
* lexical containment guard in `runInstall` is a string compare and cannot see
* this; only an `lstat`-per-component walk can. Fail-closed: any symlink is
* rejected (exit 5).
*
* Returns the target's `{ isFile }` when it already exists, or `null` when it
* (or any ancestor) does not yet exist — in which case the missing tail is
* created fresh and cannot be a pre-planted symlink. A small TOCTOU window
* remains between this check and the write; that is acceptable for a local,
* single-user CLI and avoids non-portable O_NOFOLLOW / rename gymnastics.
*/
async function inspectTargetPath(
agentFs: AgentFs,
root: string,
relPath: string,
): Promise<{ isFile: boolean } | null> {
const segments = relPath.split(/[/\\]+/).filter(Boolean);
let current = root;
let finalIsFile = false;
for (const [i, seg] of segments.entries()) {
current = path.join(current, seg);
const ls = await agentFs.lstat(current);
if (ls === null) {
// This component and everything below it does not exist yet.
return null;
}
if (ls.isSymbolicLink) {
const shown = segments.slice(0, i + 1).join('/');
throw new CLIError(
`refusing to write through a symlink: "${shown}" — installing here could place files outside --dir. Remove the symlink or choose a different --dir.`,
5,
);
}
if (i < segments.length - 1 && ls.isFile) {
const shown = segments.slice(0, i + 1).join('/');
throw new CLIError(`cannot create ${relPath}: "${shown}" exists and is not a directory.`, 5);
}
finalIsFile = ls.isFile;
}
return { isFile: finalIsFile };
}

/**
* Back up the current bytes at `abs` next to it without clobbering any existing
* backup or writing through a symlink. Exclusive create (`wx`) fails with
Expand Down
10 changes: 10 additions & 0 deletions src/commands/ci.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ function makeFakeFs(seed: Record<string, string> = {}): CiFs & { files: Map<stri
const files = new Map<string, string>(Object.entries(seed));
return {
files,
async lstat(target) {
if (files.has(target)) return { isFile: true, isSymbolicLink: false };
if ([...files.keys()].some(file => file.startsWith(`${target}${sep}`))) {
return { isFile: false, isSymbolicLink: false };
}
return null;
},
async realpath(target) {
return target;
},
async writeFile(target, data, opts) {
if (opts?.exclusive && files.has(target)) {
const err = new Error('EEXIST') as NodeJS.ErrnoException;
Expand Down
43 changes: 40 additions & 3 deletions src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { loadConfig, resolveProfileName } from '../lib/config.js';
import { type FetchImpl, type HttpClient } from '../lib/http.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js';
import type { Page } from '../lib/pagination.js';
import { inspectTargetPath, type TargetPathFs } from '../lib/target-path.js';
import { assertNotLocal } from '../lib/target-url.js';
import {
classifyLinkage,
Expand Down Expand Up @@ -67,10 +68,11 @@ const DEFAULT_API_URL = 'https://api.testsprite.com';

/**
* Minimal fs seam (mirrors `agent.ts`'s `AgentFs` semantics: exclusive writes to
* refuse clobbering, recursive mkdir to create parent dirs). Kept local so `ci`
* doesn't couple to the agent-install internals.
* refuse clobbering, recursive mkdir to create parent dirs). Path inspection
* shares the agent installer's component walk.
*/
export interface CiFs {
export interface CiFs extends TargetPathFs {
realpath(target: string): Promise<string>;
writeFile(target: string, data: string, opts?: { exclusive?: boolean }): Promise<void>;
mkdir(dir: string): Promise<void>;
readFile(target: string): Promise<string>;
Expand All @@ -79,6 +81,18 @@ export interface CiFs {
}

const defaultCiFs: CiFs = {
async lstat(target) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- inspect each component before writing the caller-selected workflow path.
const stat = await nodeFs.lstat(target);
return { isFile: stat.isFile(), isSymbolicLink: stat.isSymbolicLink() };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
},
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolve the repository and workflow symlink for containment validation.
realpath: target => nodeFs.realpath(target),
// `target`/`dir` are the workflow output path (default `.github/workflows/testsprite.yml`,
// or the caller's `--path`), resolved from cwd — a scaffold target the user is writing into
// their own repo, never external/network input. Same risk profile as agent-install scaffolding.
Expand Down Expand Up @@ -628,6 +642,29 @@ async function runCiInitScaffold(
if (opts.project) assertSafeWorkflowValue('project', opts.project);
if (opts.env) assertSafeWorkflowValue('env', opts.env);
const { relPath, absPath } = resolveWorkflowPath(opts.workflowPath, cwd);
const allowInternalSymlink = async (target: string): Promise<void> => {
let resolved: string;
let root: string;
try {
[root, resolved] = await Promise.all([fs.realpath(cwd), fs.realpath(target)]);
} catch {
throw new CLIError(
`cannot resolve symlink at "${target}"; refusing to write the workflow.`,
5,
);
}
const relative = path.relative(root, resolved);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new CLIError(
`refusing to write through symlink "${target}": "${resolved}" is outside the repository.`,
5,
);
}
};
await inspectTargetPath(fs, cwd, relPath, allowInternalSymlink);
if (opts.force) {
await inspectTargetPath(fs, cwd, `${relPath}.bak`, allowInternalSymlink);
}

const projectId = await resolveProjectId(opts, deps);
const resolvedEnv = await resolveEnvironment(opts, deps, projectId);
Expand Down
46 changes: 46 additions & 0 deletions src/lib/target-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import path from 'node:path';
import { CLIError } from './errors.js';

export interface TargetPathFs {
lstat(p: string): Promise<{ isFile: boolean; isSymbolicLink: boolean } | null>;
}

/**
* Inspect existing components before creating a target. Symlinks are refused
* unless the caller supplies a policy that validates their destination.
* Returns null at the first missing component. A race between inspection and
* writing remains, as with the agent installer's original path guard.
*/
export async function inspectTargetPath(
agentFs: TargetPathFs,
root: string,
relPath: string,
allowSymlink?: (target: string) => Promise<void>,
): Promise<{ isFile: boolean } | null> {
const segments = relPath.split(process.platform === 'win32' ? /[/\\]+/ : /\/+/).filter(Boolean);
let current = root;
let finalIsFile = false;
for (const [i, seg] of segments.entries()) {
current = path.join(current, seg);
const ls = await agentFs.lstat(current);
if (ls === null) {
// This component and everything below it does not exist yet.
return null;
}
if (ls.isSymbolicLink && allowSymlink) {
await allowSymlink(current);
} else if (ls.isSymbolicLink) {
const shown = segments.slice(0, i + 1).join('/');
throw new CLIError(
`refusing to write through a symlink: "${shown}" — installing here could place files outside --dir. Remove the symlink or choose a different --dir.`,
5,
);
}
if (i < segments.length - 1 && ls.isFile) {
const shown = segments.slice(0, i + 1).join('/');
throw new CLIError(`cannot create ${relPath}: "${shown}" exists and is not a directory.`, 5);
}
finalIsFile = ls.isFile;
}
return { isFile: finalIsFile };
}
160 changes: 160 additions & 0 deletions test/e2e/ci-init-symlinks.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/* eslint-disable security/detect-non-literal-fs-filename -- Every filesystem path belongs to the isolated mkdtemp fixture; symlinks deliberately exercise the real CLI boundary. */
import { execFile } from 'node:child_process';
import {
mkdtemp,
mkdir,
readFile,
readdir,
realpath,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';

const binary = resolve('dist/index.js');
const fetchFixture = resolve('test/helpers/ci-init-fetch.mjs');
const execute = promisify(execFile);
let fixture: string;

afterEach(async () => {
if (fixture) await rm(fixture, { recursive: true, force: true });
});

describe.skipIf(process.platform === 'win32')('ci init symlink containment', () => {
async function setup() {
fixture = await realpath(await mkdtemp(join(tmpdir(), 'testsprite-ci-path-')));
const repo = join(fixture, 'repo');
const outside = join(fixture, 'outside');
const home = join(fixture, 'home');
await Promise.all([repo, outside, home].map(dir => mkdir(dir)));
return { repo, outside, home };
}

function run(cwd: string, home: string, force = false, workflowPath?: string) {
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home };
for (const key of Object.keys(env)) {
if (key.startsWith('TESTSPRITE_')) delete env[key];
}
return execute(
process.execPath,
[
'--import',
fetchFixture,
binary,
'--endpoint-url',
'http://127.0.0.1:9',
'ci',
'init',
'github',
'--project',
'dummy-project',
'--env',
'staging',
...(force ? ['--force'] : []),
...(workflowPath ? ['--path', workflowPath] : []),
],
{
cwd,
env: {
...env,
TESTSPRITE_API_KEY: 'sk-user-local-only',
TESTSPRITE_NO_TELEMETRY: '1',
TESTSPRITE_NO_UPDATE_NOTIFIER: '1',
},
timeout: 10000,
},
).then(
result => ({ ...result, code: 0 }),
error => ({
stdout: error.stdout as string,
stderr: error.stderr as string,
code: error.code as number,
}),
);
}

for (const [location, force] of [
['.github', false],
['.github', true],
['.github/workflows', false],
['.github/workflows', true],
['.github/workflows/testsprite.yml', true],
['.github/workflows/testsprite.yml.bak', true],
] as const) {
it(`refuses external ${location} ${force ? 'with' : 'without'} --force before writing`, async () => {
const { repo, outside, home } = await setup();
const target =
location.endsWith('.yml') || location.endsWith('.bak')
? join(outside, 'sentinel')
: outside;
if (target !== outside) await writeFile(target, 'unchanged');
const link = join(repo, location);
await mkdir(dirname(link), { recursive: true });
if (location.endsWith('.bak'))
await writeFile(join(repo, '.github/workflows/testsprite.yml'), 'original workflow');
await symlink(target, link);
const result = await run(repo, home, force);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(result.code, result.stderr).toBe(5);
expect(result.stderr).toContain(target);
expect(await readdir(outside)).toEqual(target === outside ? [] : ['sentinel']);
if (target !== outside) expect(await readFile(target, 'utf8')).toBe('unchanged');
if (location.endsWith('.bak'))
expect(await readFile(join(repo, '.github/workflows/testsprite.yml'), 'utf8')).toBe(
'original workflow',
);
});
}

for (const location of ['.github', '.github/workflows', '.github/workflows/testsprite.yml']) {
it(`allows internal ${location}`, async () => {
const { repo, home } = await setup();
const target = join(repo, 'internal');
if (location.endsWith('.yml')) await writeFile(target, 'original workflow');
else await mkdir(target);
const link = join(repo, location);
await mkdir(dirname(link), { recursive: true });
await symlink(target, link);
const result = await run(repo, home, true);
expect(result.code, result.stderr).toBe(0);
expect(await readFile(join(repo, '.github/workflows/testsprite.yml'), 'utf8')).toContain(
'dummy-project',
);
});
}

it('refuses a dangling external ancestor without creating its target', async () => {
const { repo, outside, home } = await setup();
await symlink(join(outside, 'missing'), join(repo, '.github'));
const result = await run(repo, home);
expect(result.code, result.stderr).toBe(5);
expect(await readdir(outside)).toEqual([]);
});

it('checks a literal backslash in a POSIX workflow filename', async () => {
const { repo, outside, home } = await setup();
const workflowPath = '.github/workflows/a\\b.yml';
const target = join(outside, 'sentinel');
await mkdir(join(repo, '.github/workflows'), { recursive: true });
await writeFile(target, 'unchanged');
await symlink(target, join(repo, workflowPath));
const result = await run(repo, home, true, workflowPath);
expect(result.code, result.stderr).toBe(5);
expect(result.stderr).toContain(target);
expect(await readFile(target, 'utf8')).toBe('unchanged');
});

it('allows a symlinked working directory', async () => {
const { repo, home } = await setup();
const alias = join(fixture, 'alias');
await symlink(repo, alias);
const result = await run(alias, home);
expect(result.code, result.stderr).toBe(0);
expect(await readFile(join(repo, '.github/workflows/testsprite.yml'), 'utf8')).toContain(
'dummy-project',
);
});
});
12 changes: 12 additions & 0 deletions test/helpers/ci-init-fetch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Preload in the CLI child so the real command runs without opening API sockets.
globalThis.fetch = async (url, init) => {
if (
url !== 'http://127.0.0.1:9/api/cli/v1/projects/dummy-project/env' ||
init?.method !== 'GET'
) {
throw new Error(`Unexpected request in ci init fixture: ${init?.method} ${url}`);
}
return new Response(JSON.stringify({ environments: [{ name: 'staging', isDefault: true }] }), {
headers: { 'content-type': 'application/json' },
});
};
Loading