fix(gateway): harden build workspace boundaries

This commit is contained in:
2026-07-31 17:58:12 +00:00
parent a112dced68
commit 2596c47fc5
4 changed files with 176 additions and 8 deletions
@@ -17,6 +17,11 @@ export interface BuildRunner {
run(commands: BuildCommand[]): Promise<BuildResult>;
}
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const appendOutputTail = (current: string, chunk: unknown): string =>
`${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS);
const runCommand = (command: BuildCommand): Promise<BuildResult> =>
new Promise((resolve) => {
const child = spawn(command.command, command.args, {
@@ -26,10 +31,17 @@ const runCommand = (command: BuildCommand): Promise<BuildResult> =>
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
output = appendOutputTail(output, chunk);
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
output = appendOutputTail(output, chunk);
});
child.on('error', (error) => {
resolve({
ok: false,
exitCode: null,
output: appendOutputTail(output, error.message),
});
});
child.on('close', (code) => {
resolve({
@@ -45,7 +57,7 @@ export class PnpmBuildRunner implements BuildRunner {
let mergedOutput = '';
for (const command of commands) {
const result = await runCommand(command);
mergedOutput += result.output;
mergedOutput = appendOutputTail(mergedOutput, result.output);
if (!result.ok) {
return {
ok: false,
@@ -28,6 +28,9 @@ const runGit = (args: string[], cwd: string, env?: Record<string, string>): Prom
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
child.on('error', (error) => {
resolve({ ok: false, output: `${output}${error.message}` });
});
child.on('close', (code) => {
resolve({ ok: code === 0, output });
});
@@ -41,6 +44,7 @@ const ensureDir = (dir: string): void => {
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const assertGitRef = (value: string): string => {
const ref = value.trim();
@@ -84,6 +88,9 @@ export class GitWorkspaceManager {
}
async prepare(commitSha: string): Promise<WorkspaceInfo> {
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error('Invalid commit SHA.');
}
const workspacePath = path.join(this.worktreeRoot, commitSha);
ensureDir(this.worktreeRoot);
@@ -101,6 +108,8 @@ export class GitWorkspaceManager {
if (!result.ok) {
throw new Error(result.output || 'Failed to create git worktree.');
}
} else {
await this.assertReusableWorkspace(workspacePath, commitSha);
}
return {
@@ -111,18 +120,61 @@ export class GitWorkspaceManager {
}
async remove(workspacePath: string): Promise<boolean> {
const resolved = path.resolve(workspacePath);
const root = path.resolve(this.worktreeRoot);
if (!resolved.startsWith(root)) {
throw new Error('Workspace path is outside the configured worktree root.');
}
const resolved = this.assertManagedWorkspacePath(workspacePath);
if (!fs.existsSync(resolved)) {
return false;
}
await this.assertRegisteredWorkspace(resolved);
const result = await runGit(['worktree', 'remove', '--force', resolved], this.repoRoot, this.baseEnv);
if (!result.ok) {
fs.rmSync(resolved, { recursive: true, force: true });
}
return true;
}
private assertManagedWorkspacePath(workspacePath: string): string {
const resolved = path.resolve(workspacePath);
const root = path.resolve(this.worktreeRoot);
const relative = path.relative(root, resolved);
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error('Workspace path must be a child of the configured worktree root.');
}
if (relative.includes(path.sep) || !COMMIT_SHA_PATTERN.test(relative)) {
throw new Error('Workspace path is not a managed commit workspace.');
}
return resolved;
}
private async assertRegisteredWorkspace(workspacePath: string, expectedCommitSha?: string): Promise<void> {
const listed = await runGit(['worktree', 'list', '--porcelain'], this.repoRoot, this.baseEnv);
if (!listed.ok) {
throw new Error(listed.output || 'Failed to inspect git worktrees.');
}
const blocks = listed.output.split(/\n\n+/);
const matchingBlock = blocks.find((block) => {
const worktreeLine = block.split('\n').find((line) => line.startsWith('worktree '));
return worktreeLine && path.resolve(worktreeLine.slice('worktree '.length)) === workspacePath;
});
if (!matchingBlock) {
throw new Error('Workspace is not registered as a git worktree.');
}
if (expectedCommitSha) {
const headLine = matchingBlock.split('\n').find((line) => line.startsWith('HEAD '));
if (headLine?.slice('HEAD '.length).toLowerCase() !== expectedCommitSha.toLowerCase()) {
throw new Error('Existing workspace HEAD does not match the requested commit.');
}
}
}
private async assertReusableWorkspace(workspacePath: string, expectedCommitSha: string): Promise<void> {
const resolved = this.assertManagedWorkspacePath(workspacePath);
await this.assertRegisteredWorkspace(resolved, expectedCommitSha);
const status = await runGit(['status', '--porcelain'], resolved, this.baseEnv);
if (!status.ok) {
throw new Error(status.output || 'Failed to inspect existing workspace.');
}
if (status.output.trim()) {
throw new Error('Existing workspace has uncommitted changes.');
}
}
}