Skip to content

Commit 47aff09

Browse files
claude[bot]claude
andauthored
fix(devx): refuse a console injection stamp whose packages array is empty (#10621)
`check:console-injection --require-stamp` passed on a well-formed stamp with an empty `packages` array. readStamp's `Array.isArray` shape check accepts `[]`, evaluate() fell through to "Nothing assertable in this stamp" and exited 0 — so the flag whose entire purpose is to refuse a vacuous pass was satisfied by a stamp that asserts nothing. Measured: all three of the gate's substantive verdicts — the published-only detector present in the bundle, the stamp's own fresh witness missing from it, and probe expiry — are derived per package entry inside the loop over that array. An empty array silences every one of them. A dist literally carrying the PUBLISHED spec, the objectstack#8134 defect this gate exists to end, exited 0 under --require-stamp. That is strictly MORE vacuous than the state objectstack#10428 refused one input over: an unbuilt spec skips only the expiry re-check and leaves the two bundle assertions standing. Refusing the lesser vacuity while tolerating the greater one is incoherent, so this refuses on the same terms — exit 1 under --require-stamp, advisory when bare, matching the no-dist and no-stamp verdicts. Two layers, and they are not redundant: - PRODUCER: writeStamp now refuses to write an empty entries array. It is the one call site every producer passes, and the array shape exists to be GROWN (objectstack#9659), so the day entries are derived from a package list instead of a literal, a filter matching nothing becomes producible. The caller downgrades the throw to a warning and writes no stamp, landing the build in the missing-stamp state the gate already refuses. - CONSUMER: the gate refuses it too, because its input crosses a cache boundary the producer guard cannot reach. The dist is RESTORED FROM CACHE from another run and may be partially restored or modified after it was proved — the gate's own existing failure text says so. Producer and consumer are deliberately not in one trust domain, which is why this gate exists as a separate script at all. Scoped to `packages.length === 0`, NOT to the `asserted === 0` notice: a no-skew entry also leaves `asserted` at 0 but is a positive record that the build looked and found nothing to tell the specs apart. Keying on `asserted` would fail every no-skew run, which objectstack#10428 deliberately kept passing. Reachability re-verified on main: writeStamp has exactly one call site, with a hard-coded single-element array, reached from both stamping paths. No stamp this repo produces can be empty today. The refusal covers hand-assembled, truncated and partially-restored dists, and the derived-entries future the shape invites. Self-test 27 -> 36 assertions. objectstack#10428's four-row table (spec not built / built unchanged / built moved forward / built caught up, both flag modes) is unmoved, as are the no-skew, no-stamp and published-spec controls. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e396cd0 commit 47aff09

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

scripts/check-console-injection.mjs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ import {
164164
readBundle,
165165
readSpecBlob,
166166
readStamp,
167+
writeStamp,
167168
} from './console-spec-probes.mjs';
168169
import { isEntrypoint } from './invoked-as.mjs';
169170

@@ -257,6 +258,46 @@ export function evaluate({ distDir, specDir, requireStamp = false, cacheKey = ''
257258
return { code: 1, out, err };
258259
}
259260

261+
// A stamp whose `packages` array is EMPTY (objectstack#10595). Well-formed —
262+
// readStamp's Array.isArray shape check accepts `[]` — and therefore not a
263+
// code-2 "cannot read"; it is readable and says nothing, which is a different
264+
// failure. Measured: all three of this gate's substantive verdicts (the
265+
// published-only detector in the bundle, the stamp's own fresh witness
266+
// missing from it, and probe expiry) live inside the loop below, so an empty
267+
// array silences every one of them. A dist literally carrying the PUBLISHED
268+
// spec — the objectstack#8134 defect this gate exists to end — passes green.
269+
//
270+
// That is strictly MORE vacuous than the state objectstack#10428 refused one
271+
// input over: an unbuilt spec skips only the expiry re-check and leaves the
272+
// two bundle assertions standing. Refusing the lesser vacuity while tolerating
273+
// the greater one is incoherent, so this refuses on the same terms.
274+
//
275+
// Scoped to `stamp.packages.length === 0`, NOT to the `asserted === 0` notice
276+
// below, which a legitimate no-skew stamp also reaches: a no-skew entry is a
277+
// POSITIVE record that the build looked and found nothing to tell the two
278+
// specs apart, whereas an empty array is no record at all. Keying on
279+
// `asserted` would fail the no-skew runs objectstack#10428 deliberately kept
280+
// passing. Advisory when bare, for the same reason every other refusal here
281+
// is: exit 1 under the flag, matching the no-dist and no-stamp verdicts.
282+
if (stamp.packages.length === 0 && requireStamp) {
283+
err.push(
284+
`✗ Console dist at ${rel(distDir)} carries a stamp with an EMPTY \`packages\` array.`,
285+
'',
286+
' The stamp is well-formed and asserts nothing. Every verdict that actually',
287+
" interrogates the dist — the published-only detector, the stamp's own fresh",
288+
' witness, probe expiry — is derived per package entry, so with no entries',
289+
' this run would report green without making a single assertion about the',
290+
' artifact it is guarding. A dist carrying the PUBLISHED spec would pass.',
291+
'',
292+
' Nothing this repo builds writes such a stamp: assert-console-spec-injection.mjs',
293+
' records one hard-coded @objectstack/spec entry on both of its stamping paths.',
294+
' So this dist was hand-assembled, truncated in transit, or partially restored.',
295+
'',
296+
...remedy(cacheKey),
297+
);
298+
return { code: 1, out, err };
299+
}
300+
260301
// The tree's own spec, for the expiry re-check. Absent when spec is not built
261302
// — a real state for a bare checkout, and not a reason to fail on its own: the
262303
// bundle assertions below stand without it. Under --require-stamp it IS a
@@ -600,6 +641,86 @@ function selfTest() {
600641
);
601642
}
602643

644+
// 7c. THE THIRD VACUITY PATH (objectstack#10595): a well-formed stamp whose
645+
// `packages` array is EMPTY. readStamp's Array.isArray check accepts `[]`,
646+
// and every substantive verdict is derived per entry, so the gate asserts
647+
// nothing at all — strictly more vacuous than 7b, which still ran the two
648+
// bundle assertions.
649+
{
650+
const empty = { stampVersion: 1, generatedBy: 'scripts/assert-console-spec-injection.mjs', packages: [] };
651+
const dist = makeDist(path.join(root, 'empty-stamp'), `console(${JSON.stringify(FRESH)})`, empty);
652+
653+
expect('empty packages is advisory by default', evaluate({ distDir: dist, specDir }).code, 0);
654+
655+
const r = evaluate({ distDir: dist, specDir, requireStamp: true, cacheKey: 'Linux-console-dist-cafe01' });
656+
expect('empty packages is fatal under --require-stamp', r.code, 1);
657+
const text = r.err.join('\n');
658+
checked += 1;
659+
// Branch-unique wording: every refusal prints remedy(), so keying on that
660+
// cannot tell this branch from the no-stamp one it sits next to.
661+
if (!text.includes('EMPTY `packages` array')) {
662+
failures.push('empty-packages failure must name the empty packages array');
663+
}
664+
checked += 1;
665+
if (!text.includes('gh cache delete "Linux-console-dist-cafe01"')) {
666+
failures.push('empty-packages failure must name the exact cache key to delete');
667+
}
668+
669+
// THE REJECT SIDE, ASSERTED POSITIVELY. The point is not the exit code on a
670+
// benign fixture — it is that an empty stamp silences the verdict this whole
671+
// gate exists for. This dist carries the PUBLISHED spec (objectstack#8134's
672+
// defect); before this fix it exited 0 under --require-stamp.
673+
const poisoned = makeDist(
674+
path.join(root, 'empty-stamp-poisoned'),
675+
`console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`,
676+
empty,
677+
);
678+
expect(
679+
'a PUBLISHED-spec bundle under an empty stamp no longer passes --require-stamp',
680+
evaluate({ distDir: poisoned, specDir, requireStamp: true }).code,
681+
1,
682+
);
683+
684+
// PRECISION, and the reason this keys on `packages.length` and not on the
685+
// `asserted === 0` notice further down. A no-skew entry ALSO leaves
686+
// `asserted` at 0 and reaches that same notice, but it is a positive record
687+
// that the build looked and found nothing to tell the specs apart — an
688+
// assertion nobody was owed, which objectstack#10428 deliberately kept
689+
// passing. Keying on `asserted` would silently fail every no-skew run.
690+
expect(
691+
'precision: a no-skew stamp still passes --require-stamp on a built spec',
692+
evaluate({
693+
distDir: makeDist(path.join(root, 'noskew-required'), 'console("anything")', stampFor({ skew: false, freshWitness: null, staleDetector: null })),
694+
specDir,
695+
requireStamp: true,
696+
}).code,
697+
0,
698+
);
699+
}
700+
701+
// 7d. The producer cannot emit that stamp in the first place. writeStamp is
702+
// the one call site every producer passes, and the entries array is meant
703+
// to GROW (objectstack#9659) — the day it is derived rather than literal,
704+
// an empty result becomes producible. Refused at the write.
705+
{
706+
const dir = fs.mkdtempSync(path.join(root, 'writestamp-'));
707+
checked += 1;
708+
let threw = null;
709+
try {
710+
writeStamp(dir, []);
711+
} catch (error) {
712+
threw = error;
713+
}
714+
if (!(threw instanceof ProbeError)) {
715+
failures.push(`writeStamp([]) must throw ProbeError, got ${threw === null ? 'no throw' : threw.constructor.name}`);
716+
}
717+
expect('writeStamp([]) writes no stamp at all', fs.existsSync(path.join(dir, STAMP_BASENAME)), false);
718+
719+
// Positive control: the shape the real producer writes still goes through.
720+
writeStamp(dir, [{ name: '@objectstack/spec', skew: true, freshWitness: FRESH, staleDetector: STALE }]);
721+
expect('writeStamp writes a populated stamp', fs.existsSync(path.join(dir, STAMP_BASENAME)), true);
722+
}
723+
603724
// 8. A build that found no skew records it, and this gate says so honestly.
604725
{
605726
const dist = makeDist(

scripts/console-spec-probes.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,24 @@ export function readBundle(assetsDir) {
146146
* same staleness question. Appending an entry must not need a shape change.
147147
*/
148148
export function writeStamp(distDir, entries) {
149+
// A stamp with no entries asserts NOTHING about the dist it sits beside, and
150+
// check:console-injection's three substantive verdicts — published-only
151+
// detector present, stamped fresh witness missing, probe expired — all live
152+
// inside its loop over this array. Writing an empty one would produce a dist
153+
// that satisfies `--require-stamp` while the gate makes no assertion at all.
154+
//
155+
// Unrepresentable here rather than only detected there: this is the single
156+
// call site every producer must pass, and the array shape above exists to be
157+
// GROWN (objectstack#9659), so the day entries are derived from a package list
158+
// instead of a literal, a filter that matches nothing becomes producible.
159+
// Refusing at the write keeps that from ever reaching a dist.
160+
//
161+
// The caller downgrades this throw to a warning and writes no stamp, which
162+
// lands the build in the missing-stamp state the gate already refuses under
163+
// `--require-stamp` — an unguarded state converted into a guarded one.
164+
if (!Array.isArray(entries) || entries.length === 0) {
165+
bad(`refusing to write an empty ${STAMP_BASENAME}: a stamp with no packages asserts nothing`);
166+
}
149167
const stamp = {
150168
stampVersion: STAMP_VERSION,
151169
generatedBy: 'scripts/assert-console-spec-injection.mjs',

0 commit comments

Comments
 (0)