fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 23:01:57 +00:00
1 parent ba50ed9b1d
commit bce4b77fa1
2 files changed
+35 -2

No files matched your search

+6 -2
View File
@@ -42,7 +42,8 @@ export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefi
if (!args.includes(CEO_ONLY_FLAG)) return false;
const conflicts = CEO_ONLY_CONFLICTS.filter(f => args.includes(f) || tokens.includes(f));
if (conflicts.length) throw new RunError('CEO_ONLY_CONFLICT', `${CEO_ONLY_FLAG} with ${conflicts.join(', ')}`, 3);
if (plan && out && path.resolve(plan) === path.resolve(out, CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3);
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
if (plan && out && real(plan) === path.join(real(out), CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3);
if (existing && !existing.ceo_only) throw new RunError('CEO_ONLY_AFTER_INIT', `${existing.out} holds full run ${existing.run} (status ${existing.status})`, 3);
return true;
}
@@ -55,6 +56,10 @@ export function refuseCeoOnly(state: RunState, verb: string): void {
/** Every phase closed: a full run writes its gate (carry lines follow); a CEO-only run writes its report and ends reviewed. */
export function finishRun(state: RunState): string[] {
if (!state.ceo_only) { finishGate(state); return carryLines(state); }
const report = path.join(state.out, CEO_ONLY_REPORT);
const at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? fs.statSync(report) : undefined;
const plan = [state.plan_source, state.plan_input, state.active_plan].find(p => p && at && fs.existsSync(p) && fs.statSync(p).ino === at.ino && fs.statSync(p).dev === at.dev);
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
const list = buildGate(state, 1);
const items = list.items.filter(i => i.id !== 'p1');
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
@@ -63,7 +68,6 @@ export function finishRun(state: RunState): string[] {
state.status = 'reviewed';
exportRun(state);
const findings = aggregateFindings(state);
const report = path.join(state.out, CEO_ONLY_REPORT);
const tmp = `${report}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, renderReport(state, items, rows, findings), { flag: 'wx' });
fs.renameSync(tmp, report);
+29
View File
@@ -227,6 +227,35 @@ describe('the CEO-only finish records no answer nobody gave and writes only its
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
});
test('a plan that reaches the report path through a symlink, or through a symlinked --out, refuses too', () => {
const real = path.join(dir('alias'), 'real-run');
fs.mkdirSync(real, { recursive: true });
const report = path.join(real, 'ceo-review.md');
fs.writeFileSync(report, SMALL_PLAN);
const link = path.join(dir('alias'), 'input.md');
fs.symlinkSync(report, link);
const linkedOut = path.join(dir('alias'), 'linked-run');
fs.symlinkSync(real, linkedOut);
for (const [plan, out] of [[link, real], [report, linkedOut]]) {
const r = autoplan(['next', '--out', out!, '--plan', plan!, '--no-ui', '--ceo-only']);
expect(r.status, plan).toBe(3);
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
expect(fs.existsSync(path.join(real, 'run.json'))).toBe(false);
}
expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
});
test('a plan that became the report file after init (a hard link) refuses at the finish and stays intact', () => {
const out = startSmallRun(dir('hardlink'), ['--ceo-only']);
const plan = path.join(dir('hardlink'), 'plan.md');
fs.linkSync(plan, path.join(out, 'ceo-review.md'));
const submit = runPhase(out, 'ceo').outside;
expect(submit.status).toBe(3);
expect(submit.stderr).toContain('(CEO_ONLY_CONFLICT)');
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
expect(readRun(out).status).not.toBe('reviewed');
});
test('a symlink at the report path is replaced, never written through', () => {
const out = startSmallRun(dir('symlink'), ['--ceo-only']);
const victim = path.join(dir('symlink'), 'victim.txt');