mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
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:
co-authored by
Claude Fable 5.1
parent
982a738663
commit
8d709c8f29
+61
-28
@@ -36,8 +36,10 @@
|
||||
* cache, and PATH entries outside the repo qualify, all by realpath of the FILE,
|
||||
* and every engine is named impeccable[.exe]: an env override pointing at an
|
||||
* interpreter (/bin/sh, node) would otherwise run the repository's own `detect`
|
||||
* file from cwd. "cwd" means a project directory: when cwd is HOME or above it,
|
||||
* only the repository rule applies (a URL-mode review can run from anywhere).
|
||||
* file from cwd. "Repository" and "cwd" count only when they are project
|
||||
* directories, strictly below HOME: from HOME itself (a URL-mode review can run
|
||||
* from anywhere) only the designs allow-list qualifies as a target and every
|
||||
* HOME-rooted install stays trusted.
|
||||
*
|
||||
* Sentinel contract: lib/design-detect-contract.ts (one owner, imported here and
|
||||
* by the gen-time resolvers). Scan output: stdout is one JSON document
|
||||
@@ -48,7 +50,10 @@
|
||||
* Scan hardening: every target, explicit or derived from `--changed`, must be an
|
||||
* existing regular file or directory whose realpath lies under the repo root (or
|
||||
* cwd) or under ${GSTACK_HOME:-~/.gstack}/projects/<slug>/designs/ (where design-
|
||||
* review keeps rendered-DOM dumps); symlinks are never followed out of those roots
|
||||
* review keeps rendered-DOM dumps: `designs/<audit>/dom/**` are page dumps and
|
||||
* scan with --no-inline-ignores, because an `impeccable-disable` comment there is
|
||||
* page-controlled; other designs/ files are gstack-authored artifacts and keep
|
||||
* them); symlinks are never followed out of those roots
|
||||
* and are skipped when git names them (a directory target is handed to the engine
|
||||
* as-is: its own walk decides what inside it is read); URLs are refused (the one engine path that
|
||||
* talks to the network); the engine runs with a minimal environment (PATH, HOME,
|
||||
@@ -77,7 +82,7 @@ import { spawnSync } from 'child_process';
|
||||
import {
|
||||
SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS,
|
||||
UNTRUSTED_BEGIN, UNTRUSTED_END, neutralizeSentinels,
|
||||
type NormalizedFinding, type ScanResult,
|
||||
type NormalizedFinding, type ScanResult, SCAN_UNTRUSTED_PATHS,
|
||||
} from '../lib/design-detect-contract';
|
||||
import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog';
|
||||
import { isFrontendPath } from '../lib/frontend-scope';
|
||||
@@ -153,6 +158,7 @@ interface Probe {
|
||||
hookOther: string[];
|
||||
ignoredRules: string[];
|
||||
ignoredFiles: string[];
|
||||
ignoredValues: string[];
|
||||
notes: string[]; // extra sentinel lines (CONFIG_UNREADABLE, ENV_IGNORED, ENGINE_UNTESTED, HINT)
|
||||
steps: string[]; // --verbose trail
|
||||
repoRoot: string;
|
||||
@@ -200,14 +206,18 @@ function isEngineName(realFile: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Under the project the agent is reviewing: inside the repository, or inside cwd
|
||||
* when cwd is itself a project directory. HOME and its ancestors are not projects
|
||||
* (a URL-mode review can run from HOME, where every HOME-rooted install lives).
|
||||
* A project directory is one strictly below HOME. HOME itself and its ancestors
|
||||
* are never projects: a URL-mode review can run from HOME, where every
|
||||
* HOME-rooted install lives, and `git init ~` (a dotfiles repo) must not turn
|
||||
* the user's own installs into "repository-controlled" files.
|
||||
*/
|
||||
function isProjectDir(dir: string): boolean {
|
||||
return !isInside(HOME, dir);
|
||||
}
|
||||
|
||||
/** Under the project the agent is reviewing: the repository, or cwd, when each is a project directory. */
|
||||
function underProject(real: string, repoRoot: string, cwd: string): boolean {
|
||||
if (isInside(real, repoRoot)) return true;
|
||||
if (isInside(HOME, cwd)) return false;
|
||||
return isInside(real, cwd);
|
||||
return (isProjectDir(repoRoot) && isInside(real, repoRoot)) || (isProjectDir(cwd) && isInside(real, cwd));
|
||||
}
|
||||
|
||||
function semverKey(v: string): number[] | null {
|
||||
@@ -266,7 +276,7 @@ function probe(host: string, verbose = false): Probe {
|
||||
const repoRoot = gitTopLevel(cwd) ?? cwd;
|
||||
const p: Probe = {
|
||||
sentinel: SENTINEL.NOT_AVAILABLE, skillPresent: false, hook: 'absent', hookOther: [],
|
||||
ignoredRules: [], ignoredFiles: [], notes: [], steps: [], repoRoot, cwd,
|
||||
ignoredRules: [], ignoredFiles: [], ignoredValues: [], notes: [], steps: [], repoRoot, cwd,
|
||||
};
|
||||
const step = (s: string) => { if (verbose) p.steps.push(s); };
|
||||
|
||||
@@ -316,17 +326,20 @@ function probe(host: string, verbose = false): Probe {
|
||||
if (!r.missing) p.notes.push(`${SENTINEL.CONFIG_UNREADABLE}: ${file}`);
|
||||
continue;
|
||||
}
|
||||
const v = r.value as { hook?: { enabled?: unknown }; detector?: { ignoreRules?: unknown; ignoreFiles?: unknown } };
|
||||
const v = r.value as { hook?: { enabled?: unknown }; detector?: { ignoreRules?: unknown; ignoreFiles?: unknown; ignoreValues?: unknown } };
|
||||
if (v && typeof v === 'object') {
|
||||
if (v.hook && typeof v.hook === 'object' && 'enabled' in v.hook) hookEnabled = v.hook.enabled !== false;
|
||||
const rules = Array.isArray(v.detector?.ignoreRules) ? v.detector!.ignoreRules : [];
|
||||
const files = Array.isArray(v.detector?.ignoreFiles) ? v.detector!.ignoreFiles : [];
|
||||
const values = Array.isArray(v.detector?.ignoreValues) ? v.detector!.ignoreValues : [];
|
||||
for (const x of rules) if (typeof x === 'string') p.ignoredRules.push(sanitizeId(x) ?? 'unmapped');
|
||||
for (const x of files) if (typeof x === 'string') p.ignoredFiles.push(clip(stripControl(x), DETECT_LIMITS.field.file));
|
||||
for (const x of values) if (typeof x === 'string') p.ignoredValues.push(clip(stripControl(x), DETECT_LIMITS.field.value));
|
||||
}
|
||||
}
|
||||
p.ignoredRules = [...new Set(p.ignoredRules)];
|
||||
p.ignoredFiles = [...new Set(p.ignoredFiles)];
|
||||
p.ignoredValues = [...new Set(p.ignoredValues)];
|
||||
let unknown = false;
|
||||
for (const [h, manifests] of Object.entries(HOSTS_WITH_HOOKS)) {
|
||||
for (const rel of manifests) {
|
||||
@@ -370,9 +383,9 @@ function probe(host: string, verbose = false): Probe {
|
||||
const cand = path.join(real, `impeccable${ext}`);
|
||||
if (!fs.existsSync(cand)) continue;
|
||||
const realCand = realpathOrNull(cand);
|
||||
if (!realCand || underProject(realCand, repoRoot, cwd) || !isEngineName(realCand)) { step(`PATH ${cand} resolves to ${realCand ?? 'nothing'}: not an engine`); continue; }
|
||||
if (isEngineBinary(realCand)) { p.engine = realCand; p.sentinel = `${SENTINEL.READY}: ${realCand}`; break; }
|
||||
launcherOnPath ??= cand; // node shim or .cmd wrapper: launcher present, engine not proven
|
||||
if (!realCand || underProject(realCand, repoRoot, cwd)) { step(`PATH ${cand} resolves to ${realCand ?? 'nothing'}: inside the project, never run`); continue; }
|
||||
if (isEngineName(realCand) && isEngineBinary(realCand)) { p.engine = realCand; p.sentinel = `${SENTINEL.READY}: ${realCand}`; break; }
|
||||
launcherOnPath ??= cand; // node shim, .cmd wrapper, or a differently named real file: launcher present, engine not proven
|
||||
}
|
||||
if (p.engine) break;
|
||||
}
|
||||
@@ -461,6 +474,7 @@ function probeLines(p: Probe): string[] {
|
||||
if (p.hookOther.length) lines.push(`${SENTINEL.HOOK_OTHER}: ${p.hookOther.join(',')}`);
|
||||
lines.push(`${SENTINEL.IGNORED_RULES}: ${p.ignoredRules.join(',')}`);
|
||||
lines.push(`${SENTINEL.IGNORED_FILES}: ${p.ignoredFiles.join(',')}`);
|
||||
lines.push(`${SENTINEL.IGNORED_VALUES}: ${p.ignoredValues.join(',')}`);
|
||||
lines.push(...p.notes);
|
||||
if (p.steps.length) lines.push(...p.steps.map(s => `${SENTINEL.PROBE_STEP}: ${s}`));
|
||||
return lines;
|
||||
@@ -496,13 +510,25 @@ function designsRoot(): string {
|
||||
return path.join(gstackHome(), 'projects');
|
||||
}
|
||||
|
||||
/** realpath under the repo root / cwd, or under <gstack home>/projects/<slug>/designs/. */
|
||||
function allowedTarget(real: string, p: Probe): boolean {
|
||||
if (isInside(real, p.repoRoot) || isInside(real, p.cwd)) return true;
|
||||
type TargetClass = 'project' | 'artifact' | 'dom-dump';
|
||||
|
||||
/**
|
||||
* Where a target lives, or null when it is outside every allowed root:
|
||||
* project under the repository or cwd (project directories only, see isProjectDir)
|
||||
* dom-dump <gstack home>/projects/<slug>/designs/<audit>/dom/** (a page's own bytes)
|
||||
* artifact any other file under <gstack home>/projects/<slug>/designs/ (gstack-authored: finalized.html, previews)
|
||||
*/
|
||||
function targetClass(real: string, p: Probe): TargetClass | null {
|
||||
if (underProject(real, p.repoRoot, p.cwd)) return 'project';
|
||||
const projects = realpathOrNull(designsRoot());
|
||||
if (!projects || !isInside(real, projects)) return false;
|
||||
if (!projects || !isInside(real, projects)) return null;
|
||||
const rel = path.relative(projects, real).split(path.sep);
|
||||
return rel.length >= 3 && rel[1] === 'designs';
|
||||
if (rel.length < 3 || rel[1] !== 'designs') return null;
|
||||
return rel[3] === 'dom' ? 'dom-dump' : 'artifact';
|
||||
}
|
||||
|
||||
function allowedTarget(real: string, p: Probe): boolean {
|
||||
return targetClass(real, p) !== null;
|
||||
}
|
||||
|
||||
function resolveTargets(args: ScanArgs, p: Probe): { targets: string[]; refusedBase: boolean } {
|
||||
@@ -597,7 +623,7 @@ function normalize(raw: unknown): NormalizedFinding {
|
||||
const rawKind = str(f.category);
|
||||
const base: NormalizedFinding = {
|
||||
id: entry?.id ?? id ?? 'unmapped',
|
||||
impeccableId: id ?? (clip(stripControl(str(idRaw)), lim.id) || 'unmapped'),
|
||||
impeccableId: id ?? 'unmapped', // an id that fails the shape check is never used as a key or printed
|
||||
file: clip(stripControl(str(f.file ?? f.path)), lim.file),
|
||||
line: Number.isFinite(Number(f.line)) ? Number(f.line) : 0,
|
||||
snippet: clip(stripControl(str(f.snippet)), lim.snippet),
|
||||
@@ -638,15 +664,21 @@ function scan(args: ScanArgs): number {
|
||||
let diagnosticsTotal = 0;
|
||||
let exit = 0;
|
||||
const started = Date.now();
|
||||
// Repo files honor the project's own inline `impeccable-disable` comments. DOM dumps
|
||||
// under the designs root are the audited page's bytes: an inline ignore there is
|
||||
// page-controlled, never a decision the user made, so those batches disable them.
|
||||
const inProject = (t: string) => isInside(t, p.repoRoot) || isInside(t, p.cwd);
|
||||
// Repository files and gstack-authored artifacts honor inline `impeccable-disable`
|
||||
// comments (the user's or the agent's). DOM dumps are the audited page's bytes:
|
||||
// an inline ignore there is page-controlled, so those batches disable them.
|
||||
const isDump = (t: string) => targetClass(t, p) === 'dom-dump';
|
||||
const batches: Array<{ files: string[]; extra: string[] }> = [];
|
||||
for (const [files, extra] of [[targets.filter(inProject), []], [targets.filter(t => !inProject(t)), ['--no-inline-ignores']]] as Array<[string[], string[]]>) {
|
||||
for (const [files, extra] of [[targets.filter(t => !isDump(t)), []], [targets.filter(isDump), ['--no-inline-ignores']]] as Array<[string[], string[]]>) {
|
||||
for (let i = 0; i < files.length; i += DETECT_LIMITS.batch) batches.push({ files: files.slice(i, i + DETECT_LIMITS.batch), extra });
|
||||
}
|
||||
for (const { files: batch, extra } of batches) {
|
||||
const totalBudgetMs = timeoutMs * DETECT_LIMITS.totalTimeoutFactor;
|
||||
for (const [k, { files: batch, extra }] of batches.entries()) {
|
||||
if (Date.now() - started > totalBudgetMs) {
|
||||
process.stderr.write(`${SENTINEL.DETECT_TIMEOUT}: whole-scan budget ${totalBudgetMs}ms exceeded, ${batches.length - k} of ${batches.length} batches not run\n`);
|
||||
exit = 1;
|
||||
break;
|
||||
}
|
||||
const run = runEngine(p.engine, batch, p.repoRoot, timeoutMs, extra);
|
||||
for (const line of run.stderr.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -676,7 +708,7 @@ function scan(args: ScanArgs): number {
|
||||
const truncated = rawFindings.length > DETECT_LIMITS.findings;
|
||||
const findings = (truncated ? rawFindings.slice(0, DETECT_LIMITS.findings) : rawFindings).map(normalize);
|
||||
const total = rawFindings.length;
|
||||
const byRule: Record<string, number> = {};
|
||||
const byRule: Record<string, number> = Object.create(null); // engine ids are untrusted keys: no prototype members to collide with
|
||||
let advisory = 0, high = 0, medium = 0, polish = 0, slop = 0, quality = 0;
|
||||
for (const f of findings) {
|
||||
byRule[f.impeccableId] = (byRule[f.impeccableId] ?? 0) + 1;
|
||||
@@ -688,6 +720,7 @@ function scan(args: ScanArgs): number {
|
||||
schemaVersion: 1, engine: p.engine, engineVersion: p.engineVersion ?? 'unknown', targets: targets.length,
|
||||
exit, total, counted: findings.length - advisory, advisory, ignoredRules: p.ignoredRules, byRule, findings, truncated,
|
||||
diagnostics: diagnosticsTotal > diagnostics.length ? [...diagnostics, `… ${diagnosticsTotal - diagnostics.length} more engine stderr lines not kept`] : diagnostics,
|
||||
untrusted: SCAN_UNTRUSTED_PATHS,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
writeTop(findings, total, truncated);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// asserts that every sentinel-shaped token in generated docs exists here.
|
||||
//
|
||||
// probe ──► one of: IMPECCABLE_READY | IMPECCABLE_NOT_CACHED | IMPECCABLE_NOT_AVAILABLE | IMPECCABLE_DISABLED
|
||||
// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES
|
||||
// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES,
|
||||
// IMPECCABLE_IGNORED_VALUES
|
||||
// ──► maybe: IMPECCABLE_HOOK_OTHER, IMPECCABLE_CONFIG_UNREADABLE, IMPECCABLE_ENV_IGNORED,
|
||||
// IMPECCABLE_ENGINE_UNTESTED, DESIGN_DETECTOR_HINT
|
||||
// scan ──► stdout: one JSON document (--format gstack) or engine bytes (--format raw)
|
||||
@@ -26,6 +27,7 @@ export const SENTINEL = {
|
||||
HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER',
|
||||
IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES',
|
||||
IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES',
|
||||
IGNORED_VALUES: 'IMPECCABLE_IGNORED_VALUES',
|
||||
CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE',
|
||||
ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED',
|
||||
ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED',
|
||||
@@ -55,6 +57,7 @@ export const SENTINEL = {
|
||||
DESIGN_MD_REASON: 'DESIGN_MD_REASON',
|
||||
DESIGN_MD_WRITTEN: 'DESIGN_MD_WRITTEN',
|
||||
DESIGN_MD_BACKUP: 'DESIGN_MD_BACKUP',
|
||||
DESIGN_MD_EDIT_REFUSED: 'DESIGN_MD_EDIT_REFUSED',
|
||||
/** printed by the wrapper: --verbose probe trail, forwarded engine stderr */
|
||||
PROBE_STEP: 'PROBE_STEP',
|
||||
ENGINE_STDERR: 'ENGINE_STDERR',
|
||||
@@ -68,10 +71,10 @@ export const SENTINEL = {
|
||||
* reads.
|
||||
*/
|
||||
export const SELF_DESCRIBING_SENTINELS: readonly string[] = [
|
||||
SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, SENTINEL.CONFIG_UNREADABLE, SENTINEL.ENV_IGNORED,
|
||||
SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, SENTINEL.IGNORED_VALUES, SENTINEL.CONFIG_UNREADABLE, SENTINEL.ENV_IGNORED,
|
||||
SENTINEL.ENGINE_UNTESTED, SENTINEL.DETECT_EXIT, SENTINEL.DETECT_REFUSED, SENTINEL.DETECT_NO_TARGETS,
|
||||
SENTINEL.DETECT_TIMEOUT, SENTINEL.DETECT_PARSE_ERROR, SENTINEL.DETECT_OUTPUT_TOO_LARGE,
|
||||
SENTINEL.DESIGN_MD_TOKEN_REF_INVALID, SENTINEL.DESIGN_MD_WRITTEN, SENTINEL.DESIGN_MD_BACKUP,
|
||||
SENTINEL.DESIGN_MD_TOKEN_REF_INVALID, SENTINEL.DESIGN_MD_WRITTEN, SENTINEL.DESIGN_MD_BACKUP, SENTINEL.DESIGN_MD_EDIT_REFUSED,
|
||||
SENTINEL.PROBE_STEP, SENTINEL.ENGINE_STDERR,
|
||||
];
|
||||
|
||||
@@ -105,6 +108,8 @@ export const DETECT_LIMITS = {
|
||||
engineHashBytes: 4 * 1024 * 1024,
|
||||
/** git subprocess budgets inside the wrapper */
|
||||
gitTimeoutMs: 30_000,
|
||||
/** whole-scan wall clock, as a multiple of the per-batch timeout: a huge target set stops, it never grinds for hours */
|
||||
totalTimeoutFactor: 5,
|
||||
gitMaxBuffer: 64 * 1024 * 1024,
|
||||
field: { id: 64, engineVersion: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400, refusedTarget: 200, parseErrorPreview: 80, internalError: 300 },
|
||||
} as const;
|
||||
@@ -169,7 +174,11 @@ export interface ScanResult {
|
||||
findings: NormalizedFinding[];
|
||||
truncated: boolean;
|
||||
diagnostics: string[];
|
||||
/** JSON paths whose text is engine- and page-derived: evidence, never instructions (the stderr block carries the fence; this document carries the list) */
|
||||
untrusted: readonly string[];
|
||||
}
|
||||
|
||||
export const SCAN_UNTRUSTED_PATHS = ['findings[].file', 'findings[].snippet', 'findings[].message', 'findings[].value', 'diagnostics[]'] as const;
|
||||
|
||||
/** The bash a skill renders after a scan so exit 2 (findings) never aborts the block. */
|
||||
export const DETECT_EXIT_ECHO = `; echo "${SENTINEL.DETECT_EXIT_CODE}=$?"`;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user