fix(3259): non-mutating --help guard for native query handlers (#3272)

* fix(3259): non-mutating --help guard for native query handlers; reject --help as milestone version

Adds a dispatcher-level guard in query-dispatch.ts that short-circuits
to a non-mutating help stub whenever --help/-h appears in args destined
for a native mutating handler (fail-closed by default). Adds defense-
in-depth in milestoneComplete to reject --help/-h as a version value
before any disk write. Regression tests cover: per-handler --help guard,
registry-driven invariant across all mutating commands, handler-level
GSDError for both flags, and preservation of the #3019 CJS fallback
contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset fragment for #3272

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-08 23:53:27 -04:00
committed by GitHub
parent ecd57e622c
commit 288b3b4170
5 changed files with 300 additions and 1 deletions

View File

@@ -1505,6 +1505,68 @@ describe('phasesArchive', () => {
});
});
// ─── milestoneComplete help-flag defense (#3259) ────────────────────────────
describe('milestoneComplete help-flag defense', () => {
it('rejects --help as a version value with GSDError before any disk write', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const { GSDError, ErrorClassification } = await import('../errors.js');
await setupTestProject(tmpDir);
// Capture pre-invocation filesystem state
const planningDir = join(tmpDir, '.planning');
const milestonesPath = join(planningDir, 'MILESTONES.md');
const statePath = join(planningDir, 'STATE.md');
const preStateStat = await import('node:fs').then((m) => m.statSync(statePath));
const milestonesExistedBefore = existsSync(milestonesPath);
let thrown: unknown;
try {
await milestoneComplete(['--help'], tmpDir);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(GSDError);
const err = thrown as InstanceType<typeof GSDError>;
expect(err.classification).toBe(ErrorClassification.Validation);
expect(err.message).toContain('--help');
// Assert no files were written
const postStateStat = await import('node:fs').then((m) => m.statSync(statePath));
expect(postStateStat.mtimeMs).toBe(preStateStat.mtimeMs);
expect(existsSync(milestonesPath)).toBe(milestonesExistedBefore);
});
it('rejects -h as a version value with GSDError before any disk write', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const { GSDError, ErrorClassification } = await import('../errors.js');
await setupTestProject(tmpDir);
const statePath = join(tmpDir, '.planning', 'STATE.md');
const preStateStat = await import('node:fs').then((m) => m.statSync(statePath));
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
const milestonesExistedBefore = existsSync(milestonesPath);
let thrown: unknown;
try {
await milestoneComplete(['-h'], tmpDir);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(GSDError);
const err = thrown as InstanceType<typeof GSDError>;
expect(err.classification).toBe(ErrorClassification.Validation);
expect(err.message).toContain('-h');
// Assert no files were written
const postStateStat = await import('node:fs').then((m) => m.statSync(statePath));
expect(postStateStat.mtimeMs).toBe(preStateStat.mtimeMs);
expect(existsSync(milestonesPath)).toBe(milestonesExistedBefore);
});
});
// ─── Registry integration ──────────────────────────────────────────────────
describe('lifecycle handlers in registry', () => {