Files
gstack/lib/design-detect-contract.ts
T
Garry TanandClaude Fable 5.1 b2e67d0097 fix(design-detect): an engine is a file named impeccable outside the project; DOM dumps scan without inline ignores
Second review cycle, security + checklist:

- IMPECCABLE_BIN=/bin/sh (or node) was READY, and `detect` with cwd=repoRoot
  made the interpreter run the repository's own `detect` file. Every engine
  candidate (env override, PATH entry, cache, sibling) is now judged by the
  realpath of the FILE and must be named impeccable[.exe]; PATH and cache
  candidates that resolve into the repository are skipped like the others.
  "Inside the project" means the repository, or cwd when cwd is a project
  directory: HOME and its ancestors are exempt, so a URL-mode review launched
  from HOME still finds the HOME-rooted installs.
- A base for --changed that starts with `-` was spliced into git argv
  (`--output=<file>` made git write a file and report no changes); an option-
  like or missing base is DETECT_REFUSED (not a ref name), exit 1, and the
  parser no longer defaults a missing value to main.
- DOM dumps are the audited page's bytes, so an in-file `impeccable-disable`
  comment there is page-controlled: batches under the designs root run with
  --no-inline-ignores, repository batches keep the project's own ignores.
- neutralizeSentinels covers the shapes it missed (bare sentinels such as
  DETECT_TOP total= and IMPECCABLE_DISABLED, the DETECT_EXIT_CODE= echo, the
  `[rule-id] impact=` group header) in one precompiled alternation instead of
  37 replaceAll passes per field; only kept findings are normalized, and the
  summary's total stays the engine's count.
- The minimal engine environment compares keys case-insensitively on Windows
  (process.env enumerates Path, SystemRoot there) and passes PATHEXT, COMSPEC,
  HOMEDRIVE, HOMEPATH, PROGRAMDATA.
- Bare 64s move into DETECT_LIMITS; the unused SentinelName type is gone; the
  header states the directory-target contract (the engine's own walk).

Tests: an interpreter as IMPECCABLE_BIN never runs the repo's detect file; a
PATH symlink into the repository is never READY; option-like and empty bases
are refused with no file written; the designs-root batch carries
--no-inline-ignores and the repo batch does not; the identity label is
deterministic per binary; the bare-sentinel and header shapes are neutralized;
the installed fake engine works without IMPECCABLE_FAKE_OUTPUT (the helper
copies the sample beside it); two tests clean up in finally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:05:21 +00:00

176 lines
7.7 KiB
TypeScript

// lib/design-detect-contract.ts — the one owner of the design-detector vocabulary.
//
// Pure module: no I/O, no imports from scripts/. Every sentinel the wrapper
// (bin/gstack-design-detect.ts) or the DESIGN.md tool (bin/gstack-design-md.ts)
// prints, and every one the skill prose reads, is a constant here, so the two
// sides cannot drift: gen-time resolvers import these strings into SKILL.md
// prose, the bins import them at runtime, and test/design-detect-contract.test.ts
// 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
// ──► 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)
// ──► stderr: DETECT_TOP block, DETECT_SUMMARY, DETECT_EXIT, DETECT_REFUSED / DETECT_NO_TARGETS /
// DETECT_TIMEOUT / DETECT_PARSE_ERROR / DETECT_OUTPUT_TOO_LARGE
// any ──► exit 3 + DESIGN_DETECT_INTERNAL_ERROR: a gstack bug, never retried
export const SENTINEL = {
READY: 'IMPECCABLE_READY',
NOT_CACHED: 'IMPECCABLE_NOT_CACHED',
NOT_AVAILABLE: 'IMPECCABLE_NOT_AVAILABLE',
DISABLED: 'IMPECCABLE_DISABLED',
SKILL: 'IMPECCABLE_SKILL',
HOOK: 'IMPECCABLE_HOOK',
HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER',
IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES',
IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES',
CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE',
ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED',
ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED',
HINT: 'DESIGN_DETECTOR_HINT',
DETECT_EXIT: 'DETECT_EXIT',
DETECT_EXIT_CODE: 'DETECT_EXIT_CODE',
DETECT_SUMMARY: 'DETECT_SUMMARY',
DETECT_TOP: 'DETECT_TOP',
DETECT_REFUSED: 'DETECT_REFUSED',
DETECT_NO_TARGETS: 'DETECT_NO_TARGETS',
DETECT_TIMEOUT: 'DETECT_TIMEOUT',
DETECT_PARSE_ERROR: 'DETECT_PARSE_ERROR',
DETECT_OUTPUT_TOO_LARGE: 'DETECT_OUTPUT_TOO_LARGE',
INTERNAL_ERROR: 'DESIGN_DETECT_INTERNAL_ERROR',
/** printed by rendered bash: the temp file holding a scan's JSON */
DETECT_JSON: 'DETECT_JSON',
/** printed by rendered bash after a DOM dump is persisted */
DOM_DUMP_OK: 'DOM_DUMP_OK',
DOM_DUMP_REDACTION_BLOCKED: 'DOM_DUMP_REDACTION_BLOCKED',
DOM_DUMP_TOO_LARGE: 'DOM_DUMP_TOO_LARGE',
DESIGN_MD_FORMAT: 'DESIGN_MD_FORMAT',
DESIGN_MD_CONVERT_REFUSED: 'DESIGN_MD_CONVERT_REFUSED',
DESIGN_MD_INTERNAL_ERROR: 'DESIGN_MD_INTERNAL_ERROR',
DESIGN_MD_TOKEN_REF_INVALID: 'DESIGN_MD_TOKEN_REF_INVALID',
/** printed by gstack-design-md check / convert */
DESIGN_MD_MARKER: 'DESIGN_MD_MARKER',
DESIGN_MD_REASON: 'DESIGN_MD_REASON',
DESIGN_MD_WRITTEN: 'DESIGN_MD_WRITTEN',
DESIGN_MD_BACKUP: 'DESIGN_MD_BACKUP',
/** printed by the wrapper: --verbose probe trail, forwarded engine stderr */
PROBE_STEP: 'PROBE_STEP',
ENGINE_STDERR: 'ENGINE_STDERR',
} as const;
/**
* Sentinels whose line explains itself after the colon (a path, a version, a
* reason). Prose need not teach them; the agent notes them and moves on. The
* contract test requires every OTHER sentinel to be taught somewhere the agent
* reads.
*/
export const SELF_DESCRIBING_SENTINELS: readonly string[] = [
SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, 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.PROBE_STEP, SENTINEL.ENGINE_STDERR,
];
/** Engine versions the committed fixtures were captured from. */
export const TESTED_ENGINE_VERSIONS: readonly string[] = ['0.1.3'];
/** Rules the engine reports but never counts (they never change its exit code). */
export const ADVISORY_RULE_IDS: readonly string[] = ['em-dash-overuse'];
/** Markers around any engine text the skill may quote (page text can echo through it). */
export const UNTRUSTED_BEGIN = '═══ BEGIN UNTRUSTED CONTENT (design detector output) ═══';
export const UNTRUSTED_END = '═══ END UNTRUSTED CONTENT ═══';
export const DETECT_LIMITS = {
/** default engine wall clock; GSTACK_DESIGN_DETECT_TIMEOUT_MS overrides */
timeoutMs: 120_000,
/** absolute paths per engine invocation */
batch: 100,
/** engine stdout above this is DETECT_OUTPUT_TOO_LARGE */
stdoutBytes: 50 * 1024 * 1024,
/** normalized findings kept; the rest is `truncated: true` */
findings: 5_000,
/** locations printed in the DETECT_TOP block */
topLocations: 50,
/** rendered-DOM dump above this is DOM_DUMP_TOO_LARGE */
domDumpBytes: 10 * 1024 * 1024,
/** engine stderr lines kept in the JSON (the rest is counted) and echoed to stderr */
diagnosticsKept: 200,
diagnosticsEchoed: 20,
/** bytes of an engine binary hashed for its identity label when no version is known */
engineHashBytes: 4 * 1024 * 1024,
/** git subprocess budgets inside the wrapper */
gitTimeoutMs: 30_000,
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;
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* One pass over every shape the agent reads as gstack's own voice: the two fence
* markers, any sentinel word (whole word, colon or not: `DETECT_TOP total=` and
* `IMPECCABLE_DISABLED` are printed bare), and the `[rule-id] impact=` group
* header. Longest sentinel first so DETECT_EXIT_CODE is not split at DETECT_EXIT.
*/
const NEUTRALIZE_RE = new RegExp(
[escapeRe(UNTRUSTED_BEGIN), escapeRe(UNTRUSTED_END),
'\\b(?:' + [...new Set(Object.values(SENTINEL))].sort((a, b) => b.length - a.length).map(escapeRe).join('|') + ')\\b',
'\\[(?=[a-z0-9-]+\\] impact=)'].join('|'), 'g');
/**
* Break any sentinel, fence marker, or group header that appears INSIDE
* engine-derived text, so page content echoed through a finding cannot close
* the untrusted envelope or forge a probe line. Inserts a zero-width space after
* the first character (the same technique browse/src/content-security.ts uses
* for its markers). One precompiled alternation: this runs on four fields of
* every kept finding.
*/
export function neutralizeSentinels(s: string): string {
return s.replace(NEUTRALIZE_RE, m => m[0] + '\u200b' + m.slice(1));
}
export interface NormalizedFinding {
/** catalog id (equals impeccableId when mapped; the engine's id, sanitized, when not) */
id: string;
impeccableId: string;
file: string;
line: number;
snippet: string;
value?: string;
message: string;
category: string;
kind: 'slop' | 'quality' | 'unknown';
impact: 'high' | 'medium' | 'polish';
tier: 'auto-fix' | 'ask' | 'possible';
handoff?: string;
advisory: boolean;
unmapped?: true;
}
export interface ScanResult {
schemaVersion: 1;
engine: string;
engineVersion: string;
targets: number;
/** engine exit code after precedence (1 over 2 over 0) */
exit: number;
total: number;
counted: number;
advisory: number;
/** rule ids the project config ignores (never present in findings) */
ignoredRules: string[];
byRule: Record<string, number>;
findings: NormalizedFinding[];
truncated: boolean;
diagnostics: string[];
}
/** 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}=$?"`;