Skip to content

Commit 03c4f11

Browse files
ahndohunclaude
andauthored
fix(agent): apply symlink fail-close guard to own-file --dry-run installs (#129)
The codex managed-section branch already runs inspectTargetPath during --dry-run ([P2]) so a planted symlink is refused the same way the real install refuses it. The own-file targets (claude, cursor, cline, antigravity) skipped that guard in dry-run and reported the write as successful, so 'agent install --dry-run' could claim success for an install that would actually exit 5. Run the same guard in the own-file dry-run branch and cover both the symlinked-target and symlinked-parent cases with regression tests. Co-authored-by: ahndohun <19940813+ahndohun@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent be9784a commit 03c4f11

2 files changed

Lines changed: 55 additions & 0 deletions

File tree

src/commands/agent.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,50 @@ describe('runInstall — symlink safety', () => {
13111311
expect(writeCalls.length).toBe(0); // never wrote a .bak nor through the link
13121312
});
13131313

1314+
it('dry-run: refuses (exit 5) when the target file is a symlink (parity with real install)', async () => {
1315+
const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs();
1316+
// Same planted SKILL.md symlink as the real-install case above: dry-run
1317+
// must report the same refusal the real install would, not a success.
1318+
seedSymlink(path.resolve(CWD, TARGETS.claude.path));
1319+
const { deps } = makeCapture();
1320+
1321+
let thrown: unknown;
1322+
try {
1323+
await runInstall(
1324+
{ ...BASE_OPTS, target: ['claude'], force: false, dryRun: true },
1325+
{ cwd: CWD, fs: agentFs, ...deps },
1326+
);
1327+
} catch (err) {
1328+
thrown = err;
1329+
}
1330+
1331+
expect(thrown).toBeInstanceOf(CLIError);
1332+
expect((thrown as CLIError).exitCode).toBe(5);
1333+
expect((thrown as CLIError).message).toContain('symlink');
1334+
expect(writeCalls.length).toBe(0);
1335+
});
1336+
1337+
it('dry-run: refuses (exit 5) when a parent path component is a symlink', async () => {
1338+
const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs();
1339+
seedSymlink(path.resolve(CWD, '.claude'));
1340+
const { deps } = makeCapture();
1341+
1342+
let thrown: unknown;
1343+
try {
1344+
await runInstall(
1345+
{ ...BASE_OPTS, target: ['claude'], force: false, dryRun: true },
1346+
{ cwd: CWD, fs: agentFs, ...deps },
1347+
);
1348+
} catch (err) {
1349+
thrown = err;
1350+
}
1351+
1352+
expect(thrown).toBeInstanceOf(CLIError);
1353+
expect((thrown as CLIError).exitCode).toBe(5);
1354+
expect((thrown as CLIError).message).toContain('symlink');
1355+
expect(writeCalls.length).toBe(0);
1356+
});
1357+
13141358
it('does not write through a symlinked .bak slot — backs up to a numbered slot', async () => {
13151359
const { store, fs: agentFs, seedFile, seedSymlink } = makeMemFs();
13161360
const abs = path.resolve(CWD, TARGETS.claude.path);

src/commands/agent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,17 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
638638
const content = renderForTarget(t, skill, bodyForSkill(skill)).content;
639639

640640
if (opts.dryRun) {
641+
// Apply the SAME symlink fail-close guard as the real install path
642+
// below (the codex managed-section branch already does this). Without
643+
// it, dry-run reports success for a planted symlink that the real
644+
// install would refuse with exit 5.
645+
const dryRunSt = await inspectTargetPath(agentFs, root, relPath);
646+
if (dryRunSt !== null && !dryRunSt.isFile) {
647+
throw new CLIError(
648+
`${relPath} exists but is not a regular file — remove it and re-run.`,
649+
5,
650+
);
651+
}
641652
const bytes = Buffer.byteLength(content, 'utf8');
642653
dryRunLines.push({ abs, bytes, note: '' });
643654
results.push({ target: t, path: relPath, action: 'dry-run', skills: [skill] });

0 commit comments

Comments
 (0)