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>
This commit is contained in:
Garry Tan
2026-09-08 18:05:21 +00:00
co-authored by Claude Fable 5.1
parent da6f0ff2f6
commit b2e67d0097
5 changed files with 233 additions and 59 deletions
+21 -11
View File
@@ -60,7 +60,6 @@ export const SENTINEL = {
ENGINE_STDERR: 'ENGINE_STDERR',
} as const;
export type SentinelName = keyof typeof SENTINEL;
/**
* Sentinels whose line explains itself after the colon (a path, a version, a
@@ -107,21 +106,32 @@ export const DETECT_LIMITS = {
/** git subprocess budgets inside the wrapper */
gitTimeoutMs: 30_000,
gitMaxBuffer: 64 * 1024 * 1024,
field: { id: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400, refusedTarget: 200, parseErrorPreview: 80, internalError: 300 },
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, '\\$&');
/**
* Break any sentinel or fence marker 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 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 {
const zw = '\u200b';
let out = s.replaceAll(UNTRUSTED_BEGIN, UNTRUSTED_BEGIN[0] + zw + UNTRUSTED_BEGIN.slice(1))
.replaceAll(UNTRUSTED_END, UNTRUSTED_END[0] + zw + UNTRUSTED_END.slice(1));
for (const v of Object.values(SENTINEL)) out = out.replaceAll(v + ':', v[0] + zw + v.slice(1) + ':');
return out;
return s.replace(NEUTRALIZE_RE, m => m[0] + '\u200b' + m.slice(1));
}