mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(clone): harden per review: symlink-safe scaffolding, race-safe cleanup, decode guard, bounded git queue
Addresses all four findings from the #251 review: - Scaffolding no longer writes through repository-controlled symlinks. The guard lives in hooks-config.ts (settingsWriteBlocker) so it also covers quick-start/docker/ralph writers, not just the clone route: refuses a symlinked .claude or settings.local.json, a .claude that is a file, or one resolving outside the case. The clone route surfaces the refusal as a user-visible warning, and the CLAUDE.md write checks presence via lstat so a BROKEN repo-shipped symlink counts as present (existsSync follows links and would have created the outside target). - Failed-clone cleanup can no longer delete a concurrent winner's tree: git clones into an attempt-owned temp sibling (.<name>.cloning-<rand>) which is atomically renamed into place; the loser reports DESTINATION_EXISTS and only ever removes its own temp dir. - decodeURIComponent(url.pathname) is guarded: malformed percent-escapes now come back as BAD_SYNTAX instead of an uncaught URIError 500. - The git pool's waiter queue is bounded (CODEMAN_MAX_GIT_QUEUE, default 16): overflow answers BUSY immediately (HTTP 429 via RATE_LIMITED), and queue time counts against the operation's own deadline. Tests: hostile symlink fixture repo (route level), settingsWriteBlocker units, concurrent same-destination race, temp-dir leak assertions, percent-escape rejection, and a fake-git pool-bounds suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,16 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -146,6 +155,9 @@ describe('POST /api/cases/clone — input rejection', () => {
|
||||
describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
|
||||
let root: string;
|
||||
let origin: string;
|
||||
let hostileOrigin: string;
|
||||
let victimDir: string;
|
||||
let victimFile: string;
|
||||
const created: string[] = [];
|
||||
|
||||
const git = (args: string[], cwd: string) =>
|
||||
@@ -174,6 +186,33 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
|
||||
git(['remote', 'add', 'origin', origin], work);
|
||||
git(['push', '--quiet', 'origin', 'main', '--tags'], work);
|
||||
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
|
||||
|
||||
// A HOSTILE repository: it ships the scaffold paths as symlinks aimed
|
||||
// outside the case, so a scaffolder that follows them writes onto this
|
||||
// machine's own files. victimFile deliberately does NOT exist, because a
|
||||
// BROKEN CLAUDE.md link is the case existsSync gets wrong (it follows the
|
||||
// link, reports "absent", and the scaffold write would then CREATE the
|
||||
// outside file).
|
||||
victimDir = join(root, 'victim-claude');
|
||||
mkdirSync(victimDir);
|
||||
victimFile = join(root, 'victim-file.md');
|
||||
hostileOrigin = join(root, 'hostile.git');
|
||||
mkdirSync(hostileOrigin);
|
||||
git(['init', '--bare', '--quiet'], hostileOrigin);
|
||||
const hostileWork = join(root, 'hostile-work');
|
||||
mkdirSync(hostileWork);
|
||||
git(['init', '--quiet'], hostileWork);
|
||||
git(['config', 'user.email', 'test@example.com'], hostileWork);
|
||||
git(['config', 'user.name', 'Codeman Test'], hostileWork);
|
||||
writeFileSync(join(hostileWork, 'README.md'), '# hostile fixture\n');
|
||||
symlinkSync(victimFile, join(hostileWork, 'CLAUDE.md'));
|
||||
symlinkSync(victimDir, join(hostileWork, '.claude'));
|
||||
git(['add', '.'], hostileWork);
|
||||
git(['commit', '--quiet', '-m', 'hostile'], hostileWork);
|
||||
git(['branch', '-M', 'main'], hostileWork);
|
||||
git(['remote', 'add', 'origin', hostileOrigin], hostileWork);
|
||||
git(['push', '--quiet', 'origin', 'main'], hostileWork);
|
||||
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], hostileOrigin);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -249,6 +288,24 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
|
||||
expect(error).toMatch(/branch or tag/i);
|
||||
});
|
||||
|
||||
it('refuses to scaffold through repository-shipped symlinks (keeps the clone, warns)', async () => {
|
||||
created.push('hostile');
|
||||
const res = await clone({ name: 'hostile', repository: hostileOrigin });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
const casePath = join(CASES_DIR, 'hostile');
|
||||
// The repo's symlinks are still symlinks: nothing wrote through them.
|
||||
expect(lstatSync(join(casePath, 'CLAUDE.md')).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(casePath, '.claude')).isSymbolicLink()).toBe(true);
|
||||
// The outside targets were neither created nor written.
|
||||
expect(existsSync(victimFile)).toBe(false);
|
||||
expect(existsSync(join(victimDir, 'settings.local.json'))).toBe(false);
|
||||
// And the response says the hooks scaffold was skipped, and why.
|
||||
expect(body.data.warnings.join(' ')).toMatch(/hooks were NOT installed/i);
|
||||
expect(body.data.warnings.join(' ')).toMatch(/symlink/i);
|
||||
});
|
||||
|
||||
it('preflights the local fixture for its branches and tags', async () => {
|
||||
const res = await preflight(origin);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
Reference in New Issue
Block a user