mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39: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:
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
generateHooksConfig,
|
||||
generateSubagentStopGuardScript,
|
||||
refreshStaleCodemanHooks,
|
||||
settingsWriteBlocker,
|
||||
writeHooksConfig,
|
||||
} from '../src/hooks-config.js';
|
||||
|
||||
@@ -198,6 +199,37 @@ describe('writeHooksConfig', () => {
|
||||
expect(parsed.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked .claude directory (#251 review)', async () => {
|
||||
// Case contents can be foreign (a freshly cloned repository): a symlinked
|
||||
// .claude would redirect the scaffold write outside the case.
|
||||
const outside = join(testDir, 'outside-target');
|
||||
mkdirSync(outside);
|
||||
const caseDir = join(testDir, 'case');
|
||||
mkdirSync(caseDir);
|
||||
symlinkSync(outside, join(caseDir, '.claude'));
|
||||
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
|
||||
await writeHooksConfig(caseDir);
|
||||
expect(existsSync(join(outside, 'settings.local.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked settings.local.json (#251 review)', async () => {
|
||||
const outsideFile = join(testDir, 'victim-settings.json');
|
||||
writeFileSync(outsideFile, '{"model":"precious"}\n');
|
||||
const caseDir = join(testDir, 'case2');
|
||||
mkdirSync(join(caseDir, '.claude'), { recursive: true });
|
||||
symlinkSync(outsideFile, join(caseDir, '.claude', 'settings.local.json'));
|
||||
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
|
||||
await writeHooksConfig(caseDir);
|
||||
// The link target is untouched: no hooks were merged into it.
|
||||
expect(readFileSync(outsideFile, 'utf-8')).toBe('{"model":"precious"}\n');
|
||||
});
|
||||
|
||||
it('reports a real, confined .claude as safe', async () => {
|
||||
const caseDir = join(testDir, 'case3');
|
||||
mkdirSync(join(caseDir, '.claude'), { recursive: true });
|
||||
expect(await settingsWriteBlocker(caseDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('should merge with existing settings.local.json', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user