fix(design-detect): project means below HOME; only page dumps drop inline ignores; a whole-scan budget; prototype-safe rule counts

Third review cycle + Red Team, all reproduced before the fix:

- With no repository, the wrapper adopted cwd as the repo root, so a review
  launched from HOME (URL mode can run from anywhere) rejected every
  HOME-rooted install as "repository-local", reported the user's own skill
  install with the wrong hint, and, for targets, accepted all of HOME
  (~/.ssh/id_rsa scanned). A project directory is now one strictly below
  HOME: `git init ~` never turns the user's installs into repository files,
  and from HOME only the designs allow-list qualifies as a target.
- --no-inline-ignores keyed on "not inside the repo", which misclassified
  dumps when GSTACK_HOME sits under the repo and stripped the design-html
  gate's own `<!-- impeccable-disable -->` from finalized.html. Targets are
  classified as project / dom-dump (designs/<audit>/dom/**, the page's bytes)
  / artifact (other designs/ files, gstack-authored); only dumps drop inline
  ignores.
- A repository's .impeccable/config.json can hide rules from the review;
  detector.ignoreValues was never surfaced. The probe prints
  IMPECCABLE_IGNORED_VALUES beside the rules, and the prose stops calling
  repo-config ignores "a decision the user made".
- An engine id named `constructor` corrupted byRule through
  Object.prototype and `__proto__` counts vanished; byRule is a null-
  prototype object and an id that fails the shape check is `unmapped` as a
  key too.
- Batches ran with no total budget (10,000 un-ignored files: hours). The
  scan stops at 5x the per-batch timeout with DETECT_TIMEOUT and exit 1.
- The scan JSON carries an `untrusted` list of the engine- and page-derived
  fields, so the agent reading past the fenced DETECT_TOP block is told what
  is evidence.
- The PATH walk keeps launcher-present for a .cmd wrapper or a differently
  named real file (the name gate applies to READY only).

Tests: probe and scan from a fake HOME (cache READY, HOME file refused, dump
scanned without inline ignores), artifact vs dump batches, prototype-member
ids, the whole-scan budget over 11 batches, ignoreValues surfaced, the
`untrusted` field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 18:33:22 +00:00
co-authored by Claude Fable 5.1
parent 982a738663
commit 8d709c8f29
3 changed files with 158 additions and 39 deletions
+85 -8
View File
@@ -901,24 +901,101 @@ describe('scan: option-like bases and page-controlled inline ignores', () => {
expect(r2.code).toBe(1);
});
test.skipIf(!POSIX)('DOM dumps under the designs root scan with --no-inline-ignores; repository files keep their inline ignores', () => {
const designs = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908', 'dom-ignores');
fs.mkdirSync(designs, { recursive: true });
fs.writeFileSync(path.join(designs, 'home.dom.html'), '<!-- impeccable-disable --><html></html>');
test.skipIf(!POSIX)('page dumps under designs/<audit>/dom/ scan with --no-inline-ignores; repository files and gstack-authored designs artifacts keep their inline ignores', () => {
const audit = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908-ignores');
const dom = path.join(audit, 'dom', 'run1');
fs.mkdirSync(dom, { recursive: true });
fs.writeFileSync(path.join(dom, 'home.dom.html'), '<!-- impeccable-disable --><html></html>');
const artifact = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'hero-20260908', 'finalized.html');
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, '<!-- impeccable-disable ai-color-palette: user agreed --><html></html>');
const log = path.join(SANDBOX, 'argv-ignores.log');
fs.rmSync(log, { force: true });
try {
const r = run(['scan', '--format', 'gstack', 'src/styles.css', path.join(designs, 'home.dom.html')], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
const r = run(['scan', '--format', 'gstack', 'src/styles.css', path.join(dom, 'home.dom.html'), artifact], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
expect(r.code).toBe(2);
const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]);
expect(calls).toHaveLength(2);
const repoCall = calls.find(a => a.some(x => x.endsWith('styles.css')))!;
const plainCall = calls.find(a => a.some(x => x.endsWith('styles.css')))!;
const domCall = calls.find(a => a.some(x => x.endsWith('home.dom.html')))!;
expect(repoCall).not.toContain('--no-inline-ignores');
expect(plainCall).not.toContain('--no-inline-ignores');
expect(plainCall.some(x => x.endsWith('finalized.html'))).toBe(true); // the design-html gate's inline disable keeps working
expect(domCall).toContain('--no-inline-ignores');
expect(domCall.indexOf('--no-inline-ignores')).toBeLessThan(domCall.findIndex(x => x.endsWith('home.dom.html')));
const doc = JSON.parse(r.out);
expect(doc.untrusted).toEqual(['findings[].file', 'findings[].snippet', 'findings[].message', 'findings[].value', 'diagnostics[]']);
} finally {
fs.rmSync(designs, { recursive: true, force: true });
fs.rmSync(audit, { recursive: true, force: true });
fs.rmSync(path.dirname(artifact), { recursive: true, force: true });
}
});
test.skipIf(!POSIX)('from HOME (no repository) HOME-rooted installs are READY, HOME files are refused as targets, and a dump still scans without inline ignores', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fake-home-'));
const cache = path.join(home, '.impeccable', 'bin', '0.1.3');
fs.mkdirSync(cache, { recursive: true });
fs.copyFileSync(FAKE, path.join(cache, 'impeccable'));
fs.chmodSync(path.join(cache, 'impeccable'), 0o755);
fs.writeFileSync(path.join(home, 'secret.css'), 'a{}');
const dom = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908-home', 'dom', 'run1');
fs.mkdirSync(dom, { recursive: true });
fs.writeFileSync(path.join(dom, 'home.dom.html'), '<html></html>');
const log = path.join(SANDBOX, 'argv-home.log');
fs.rmSync(log, { force: true });
try {
const probe = run(['probe'], { cwd: home, env: { HOME: home, IMPECCABLE_HOME: path.join(home, '.impeccable'), IMPECCABLE_BIN: '' } });
expect(lines(probe.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(path.join(cache, 'impeccable'))}`);
const r = run(['scan', '--format', 'gstack', path.join(home, 'secret.css'), path.join(dom, 'home.dom.html')], { cwd: home, env: { HOME: home, IMPECCABLE_HOME: path.join(home, '.impeccable'), IMPECCABLE_BIN: '', IMPECCABLE_FAKE_LOG: log } });
expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${path.join(home, 'secret.css')}`);
const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]);
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('--no-inline-ignores');
expect(calls[0].some(x => x.endsWith('secret.css'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(path.dirname(path.dirname(dom)), { recursive: true, force: true });
}
});
test.skipIf(!POSIX)('engine ids that are prototype members or fail the shape check count as unmapped, never as object keys', () => {
const out = path.join(SANDBOX, 'proto-ids.json');
fs.writeFileSync(out, JSON.stringify([
{ antipattern: 'constructor', file: 'a.css', line: 1 }, { antipattern: '__proto__', file: 'a.css', line: 2 },
{ antipattern: '__proto__', file: 'a.css', line: 3 }, { antipattern: 'low-contrast', file: 'a.css', line: 4 }, { antipattern: 'Bad Id!!', file: 'a.css', line: 5 },
]));
const r = run(['scan', '--format', 'gstack', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_OUTPUT: out } });
const doc = JSON.parse(r.out);
expect(doc.total).toBe(5);
// `__proto__` and `Bad Id!!` fail the id shape and count as unmapped; `constructor` passes it and must be an own key, never Object.prototype's.
expect(Object.entries(doc.byRule).sort()).toEqual([['constructor', 1], ['low-contrast', 1], ['unmapped', 3]]);
expect(doc.findings.map((f: { impeccableId: string }) => f.impeccableId)).toEqual(['constructor', 'unmapped', 'unmapped', 'low-contrast', 'unmapped']);
});
test.skipIf(!POSIX)('the whole-scan budget stops a huge target set instead of grinding batch after batch', () => {
const many = path.join(REPO, 'src', 'many');
fs.mkdirSync(many, { recursive: true });
for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}');
try {
const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '250', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '400' } });
expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files
const files = fs.readdirSync(many).map(f => path.join('src', 'many', f));
const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '250', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '400' } });
expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 2000ms exceeded, \d+ of 11 batches not run/);
expect(r2.code).toBe(1);
} finally {
fs.rmSync(many, { recursive: true, force: true });
}
});
test.skipIf(!POSIX)('detector.ignoreValues from the project config are surfaced on their own line', () => {
const dir = path.join(REPO, '.impeccable');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ detector: { ignoreValues: ['#8b5cf6', 'Inter'] } }));
try {
const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } });
expect(r.out).toContain(`${SENTINEL.IGNORED_VALUES}: #8b5cf6,Inter`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});