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
+12 -3
View File
@@ -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}=$?"`;