diff --git a/bin/gstack-config b/bin/gstack-config index f85cffa30..d26d35bdc 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -122,6 +122,10 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # /document-release skip the outside-voice step entirely. # # An invalid value is REJECTED (existing value preserved) so # # a typo cannot silently turn paid Codex calls on or off. +# design_detector_install_prompted: false +# # true once you answered the one-time offer from the +# # design skills to download the impeccable engine with +# # "never ask again"; flip back to false to be asked again. # design_detector: auto # Deterministic design pre-pass through a user-installed # # impeccable engine (/design-review, /review, /ship, # # /design-html). auto = use the engine when the probe @@ -157,6 +161,7 @@ lookup_default() { explain_level) echo "default" ;; codex_reviews) echo "enabled" ;; design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills + design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again" gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; workspace_root) echo "$HOME/conductor/workspaces" ;; @@ -424,6 +429,13 @@ case "${1:-}" in echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi + # design_detector_install_prompted records "never ask again" for the one-time + # engine install offer. Rejecting a typo keeps the offer from silently coming + # back (or never coming back) because of a mistyped value. + if [ "$KEY" = "design_detector_install_prompted" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then + echo "Error: design_detector_install_prompted '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 + exit 1 + fi # design_detector gates a third-party binary the user installed. Reject a typo # rather than coerce it: "of" must not silently re-enable or disable the scan. if [ "$KEY" = "design_detector" ] && [ "$VALUE" != "auto" ] && [ "$VALUE" != "off" ]; then @@ -469,7 +481,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook design_detector; do + timeline_stop_hook design_detector design_detector_install_prompted; do VALUE=$(read_config_value "$KEY" || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -486,7 +498,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook design_detector; do + timeline_stop_hook design_detector design_detector_install_prompted; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index 583343ad6..c4bcb2783 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -5,12 +5,18 @@ * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts probe [--host ] [--verbose] * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts scan [--format gstack|raw] [--changed ] [--host ] * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts rules + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts install [--version ] [--sha256 ] [--base ] * - * Rule zero: gstack never installs, downloads, or executes anything that could - * download. The probe touches the filesystem and the environment only: file - * existence, a first-bytes sniff, JSON parsing. It never runs impeccable's - * launcher (`scripts/impeccable`) or its npm shim, because both fall through to a - * GitHub download when the engine is not cached. + * Rule zero: gstack never runs impeccable's installer, its launcher + * (`scripts/impeccable`), or its npm shim, because all three fall through to a + * GitHub download and the installer also writes hooks. The probe touches the + * filesystem and the environment only: file existence, a first-bytes sniff, + * JSON parsing. The one download gstack itself can make is `install`: the engine + * binary for a version gstack has tested, fetched only after the user said yes + * to the skill's one-time offer (DESIGN_DETECTOR_INSTALL_OFFER), verified + * against the checksum pinned in lib/design-detect-contract.ts, placed under + * ~/.impeccable/bin// (never inside a project), and recorded in the + * egress ledger before the fetch (fail-closed). No skill, no hook, no launcher. * * Probe order (first hit wins; every step is a read): * @@ -83,7 +89,9 @@ import { SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, UNTRUSTED_BEGIN, UNTRUSTED_END, neutralizeSentinels, type NormalizedFinding, type ScanResult, SCAN_UNTRUSTED_PATHS, + ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS, } from '../lib/design-detect-contract'; +import { writeReceipt, writeOutcome } from '../lib/egress-receipt'; import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; import { isFrontendPath } from '../lib/frontend-scope'; @@ -119,24 +127,30 @@ function gitTopLevel(cwd: string): string | null { return top ? realpathOrNull(top) : null; } -/** design_detector, read the way bin/gstack-config resolves it (same STATE_DIR precedence, same default). */ -function configDesignDetector(): 'auto' | 'off' { +/** One flat key from config.yaml, read the way bin/gstack-config resolves it (same STATE_DIR precedence); '' when unset. */ +function configValue(key: string): string { const file = path.join(gstackStateDir(), 'config.yaml'); try { const text = fs.readFileSync(file, 'utf-8'); let value = ''; + const re = new RegExp(`^${key}:\\s*(.*?)\\s*$`); for (const line of text.split('\n')) { - const m = line.match(/^design_detector:\s*(.*?)\s*$/); + const m = line.match(re); if (!m) continue; // flat YAML: drop a trailing comment and surrounding quotes value = m[1].replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1'); } - return value.toLowerCase() === 'off' ? 'off' : 'auto'; // `Off` by hand must not silently re-enable a third-party binary + return value; } catch { - return 'auto'; + return ''; } } +/** design_detector: `Off` by hand must not silently re-enable a third-party binary. */ +function configDesignDetector(): 'auto' | 'off' { + return configValue('design_detector').toLowerCase() === 'off' ? 'off' : 'auto'; +} + // ── Probe ──────────────────────────────────────────────────────────────────── const HOSTS_WITH_HOOKS: Record = { @@ -442,16 +456,155 @@ function probe(host: string, verbose = false): Probe { const how = p.launcher ? `run \`${p.launcher} detect --help\` once; it fetches the engine version pinned by your install` : repoLocalLauncher && !launcherOnPath - ? 'the skill is installed inside this repository, and gstack never runs a repository-local launcher; install it under your home directory (`npx impeccable install` outside the repo) if you want the engine here' - : 'run `npx impeccable detect --help` once; it fetches the engine'; - p.notes.push(`${SENTINEL.HINT}: impeccable is installed but its engine is not cached; ${how} (gstack never downloads it). Silence this: \`gstack-config set design_detector off\`.`); - return p; + ? 'the skill is installed inside this repository, and gstack never runs a repository-local launcher; install it under your home directory (`npx impeccable install --scope global` outside the repo) if you want the engine here' + : 'run `npx impeccable install --scope global` yourself (the engine lands beside the skill under your home directory; `npx impeccable detect --help` alone caches it only for npx)'; + p.notes.push(`${SENTINEL.HINT}: impeccable is installed but its engine is not cached; ${how}, or accept the install offer. Silence this: \`gstack-config set design_detector off\`.`); + return withInstallOffer(p); } p.sentinel = SENTINEL.NOT_AVAILABLE; + return withInstallOffer(p); +} + +/** `${platform}-${arch}` → release asset suffix, or null when impeccable ships no engine for this machine. */ +function enginePlatform(): string | null { + return ENGINE_ASSETS[`${process.platform}-${process.arch}`] ?? null; +} + +function enginePin(version: string, platform: string): { sha256: string; bytes: number } | null { + return ENGINE_PINS[version]?.[platform] ?? null; +} + +/** ~/.impeccable (or a trusted IMPECCABLE_HOME): where `install` puts the engine and where the probe's cache step looks. */ +function engineCacheRoot(repoRoot: string, cwd: string): string { + return trustedEnvPath('IMPECCABLE_HOME', repoRoot, cwd, [], () => {}) ?? path.join(HOME, '.impeccable'); +} + +/** + * A probe that found no engine ends with the one-time install offer: the skill + * asks the user, and only a yes runs `install`. Once the user has answered + * "never ask again" (design_detector_install_prompted=true) the probe prints + * neither the offer nor the NOT_CACHED hint, or the hint would be the nag the + * docs promise does not exist. Machines impeccable ships no pinned engine for get + * no offer. + */ +function withInstallOffer(p: Probe): Probe { + if (configValue('design_detector_install_prompted').toLowerCase() === 'true') { + p.notes = p.notes.filter(n => !n.startsWith(`${SENTINEL.HINT}:`)); + return p; + } + const platform = enginePlatform(); + const version = TESTED_ENGINE_VERSIONS[TESTED_ENGINE_VERSIONS.length - 1]; + const pin = platform ? enginePin(version, platform) : null; + if (!platform || !pin) return p; + const dest = path.join(engineCacheRoot(p.repoRoot, p.cwd), 'bin', version, WIN ? 'impeccable.exe' : 'impeccable'); + p.notes.push(`${SENTINEL.INSTALL_OFFER}: version=${version} platform=${platform} bytes=${pin.bytes} dest=${dest}`); return p; } +// ── Install (the one download gstack makes, after consent) ─────────────────── + +interface InstallArgs { version?: string; sha256?: string; base?: string; host: string } + +function installRefused(reason: string): number { + process.stdout.write(`${SENTINEL.INSTALL_REFUSED}: ${reason}\n`); + analytics({ verb: 'install', sentinel: 'INSTALL_REFUSED', exit: 1 }); + return 1; +} + +function sha256File(file: string): string { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/** + * Download the pinned engine for this machine into the user's cache. Runs only + * after the skill's AskUserQuestion got a yes (the prose never runs it otherwise). + * Fail-closed on the egress receipt, the checksum, and the size cap: on any + * refusal nothing is written. --sha256 accepts a checksum from the release's + * .sha256 sidecar for a version gstack has not pinned; --base allows a mirror + * (https, or http on loopback for tests). + */ +async function install(args: InstallArgs): Promise { + if (configDesignDetector() === 'off') return installRefused('design_detector is off; `gstack-config set design_detector auto` first'); + const platform = enginePlatform(); + if (!platform) return installRefused(`impeccable ships no engine for ${process.platform}-${process.arch}`); + const version = (args.version ?? TESTED_ENGINE_VERSIONS[TESTED_ENGINE_VERSIONS.length - 1]).replace(/^v/, ''); + if (!semverKey(version)) return installRefused(`"${version}" is not a version`); + const pin = enginePin(version, platform); + const expected = (args.sha256 ?? pin?.sha256 ?? '').toLowerCase(); + if (!expected) return installRefused(`gstack pins no checksum for engine ${version} on ${platform}; pass --sha256 from the release's .sha256 sidecar to accept an unpinned download`); + if (!/^[0-9a-f]{64}$/.test(expected)) return installRefused('--sha256 must be 64 hex characters'); + let baseUrl: URL; + try { baseUrl = new URL(args.base ?? ENGINE_RELEASE_BASE); } catch { return installRefused(`--base is not a URL: ${args.base}`); } + const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(baseUrl.hostname); + if (baseUrl.protocol !== 'https:' && !(baseUrl.protocol === 'http:' && loopback)) return installRefused('--base must be https (http only for a loopback mirror)'); + const cwd = realpathOrNull(process.cwd()) ?? process.cwd(); + const repoRoot = gitTopLevel(cwd) ?? cwd; + const destDir = path.join(engineCacheRoot(repoRoot, cwd), 'bin', version); + if (underProject(destDir, repoRoot, cwd)) return installRefused(`${destDir} lies inside the project; the engine lives under your home directory only`); + const dest = path.join(destDir, WIN ? 'impeccable.exe' : 'impeccable'); + const asset = `impeccable-${platform}${platform.startsWith('windows') ? '.exe' : ''}`; + const url = `${baseUrl.toString().replace(/\/$/, '')}/engine-v${version}/${asset}`; + const cap = DETECT_LIMITS.engineDownloadBytes; + + let present = false; + try { present = fs.existsSync(dest) && sha256File(dest) === expected; } catch { present = false; } + if (present) { + process.stdout.write(`${SENTINEL.INSTALLED}: ${dest} version=${version} sha256=${expected} (already present, checksum verified)\n`); + const p = probe(args.host); + process.stdout.write(probeLines(p).join('\n') + '\n'); + analytics({ verb: 'install', sentinel: 'INSTALLED', engine: version, exit: 0 }); + return 0; + } + + // The receipt is written BEFORE the fetch and the install is fail-closed on it: + // an executable arriving on the machine unrecorded is worse than no install. + let receipt: string; + try { + receipt = writeReceipt({ + sink: 'design-detect-engine-download', host: baseUrl.host, payloadClass: 'engine-binary-fetch', bytes: 0, sha256: null, + consent: `design_detector=auto design_detector_install_prompted=false; user accepted the install offer for impeccable engine ${version} (${platform}); checksum ${args.sha256 ? 'from --sha256' : 'pinned in gstack'}`, + }).id; + } catch (err) { + return installRefused(`egress receipt could not be written, nothing downloaded (${clip(stripControl(String((err as Error)?.message ?? err)), 200)})`); + } + const outcome = (status: string | number) => { try { writeOutcome({ receipt, status }); } catch { /* bookkeeping only */ } }; + let res: Response; + try { + res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(DETECT_LIMITS.engineDownloadTimeoutMs) }); + } catch (err) { + outcome('network-error'); + return installRefused(`download failed: ${clip(stripControl(String((err as Error)?.message ?? err)), 200)}`); + } + if (!res.ok || !res.body) { outcome(res.status); return installRefused(`download failed: HTTP ${res.status} for ${url}`); } + const declared = Number(res.headers.get('content-length') ?? '0'); + if (declared > cap) { outcome(`${res.status} oversize`); return installRefused(`asset declares ${declared} bytes, above the ${cap}-byte cap`); } + const chunks: Uint8Array[] = []; + let total = 0; + const reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > cap) { await reader.cancel(); outcome(`${res.status} oversize`); return installRefused(`asset exceeds the ${cap}-byte cap; nothing written`); } + chunks.push(value); + } + outcome(`${res.status} ${total}B`); + const buf = Buffer.concat(chunks); + const actual = createHash('sha256').update(buf).digest('hex'); + if (actual !== expected) return installRefused(`checksum mismatch: expected ${expected}, got ${actual}; nothing written`); + fs.mkdirSync(destDir, { recursive: true, mode: 0o755 }); + const tmp = `${dest}.tmp-${process.pid}`; + fs.writeFileSync(tmp, buf, { mode: 0o755 }); + fs.chmodSync(tmp, 0o755); + fs.renameSync(tmp, dest); + process.stdout.write(`${SENTINEL.INSTALLED}: ${dest} version=${version} sha256=${actual} bytes=${total}\n`); + analytics({ verb: 'install', sentinel: 'INSTALLED', engine: version, exit: 0 }); + const p = probe(args.host); + process.stdout.write(probeLines(p).join('\n') + '\n'); + return 0; +} + /** Identity label for an engine with no version source: size + the first few MB hashed (a whole-binary read per probe is wasted work). */ function engineIdentity(file: string): string { try { @@ -797,12 +950,13 @@ function analytics(rec: Record) { // ── CLI ────────────────────────────────────────────────────────────────────── -function parse(argv: string[]): { verb: string; host: string; verbose: boolean; scan: ScanArgs } { +function parse(argv: string[]): { verb: string; host: string; verbose: boolean; scan: ScanArgs; install: InstallArgs } { const verb = argv[0] ?? ''; let host = 'claude'; let verbose = false; let format: 'gstack' | 'raw' = 'gstack'; let changed: string | undefined; + const install: InstallArgs = { host }; const targets: string[] = []; for (let i = 1; i < argv.length; i++) { const a = argv[i]; @@ -810,15 +964,19 @@ function parse(argv: string[]): { verb: string; host: string; verbose: boolean; else if (a === '--verbose') verbose = true; else if (a === '--format') { const v = argv[++i]; format = v === 'raw' ? 'raw' : 'gstack'; } else if (a === '--changed') changed = argv[++i] ?? ''; // an empty base is refused in resolveTargets, never defaulted + else if (a === '--version') install.version = argv[++i] ?? ''; + else if (a === '--sha256') install.sha256 = argv[++i] ?? ''; + else if (a === '--base') install.base = argv[++i] ?? ''; else if (a === '--') { targets.push(...argv.slice(i + 1)); break; } else if (a.startsWith('--')) process.stderr.write(`ignoring unknown flag ${a}\n`); else targets.push(a); } - return { verb, host, verbose, scan: { format, changed, targets, host } }; + install.host = host; + return { verb, host, verbose, scan: { format, changed, targets, host }, install }; } -export function main(argv = process.argv.slice(2)): number { - const { verb, host, verbose, scan: scanArgs } = parse(argv); +export async function main(argv = process.argv.slice(2)): Promise { + const { verb, host, verbose, scan: scanArgs, install: installArgs } = parse(argv); switch (verb) { case 'probe': { const p = probe(host, verbose); @@ -830,8 +988,10 @@ export function main(argv = process.argv.slice(2)): number { return scan(scanArgs); case 'rules': return rules(); + case 'install': + return install(installArgs); default: - process.stderr.write('usage: gstack-design-detect.ts probe [--host ] [--verbose] | scan [--format gstack|raw] [--changed ] [--host ] | rules\n'); + process.stderr.write('usage: gstack-design-detect.ts probe [--host ] [--verbose] | scan [--format gstack|raw] [--changed ] [--host ] | rules | install [--version ] [--sha256 ] [--base ]\n'); return 2; } } @@ -839,12 +999,10 @@ export function main(argv = process.argv.slice(2)): number { if (import.meta.main) { // exitCode, not process.exit(): a pipe write over 64 KB (a big scan) is still // in flight when process.exit() runs and would be truncated mid-JSON. - try { - process.exitCode = main(); - } catch (err) { + main().then((code) => { process.exitCode = code; }, (err) => { const e = err as Error; process.stderr.write(`${SENTINEL.INTERNAL_ERROR}: ${e?.name ?? 'Error'}: ${clip(stripControl(String(e?.message ?? e)), DETECT_LIMITS.field.internalError)}\n`); analytics({ verb: process.argv[2] ?? '', sentinel: 'INTERNAL_ERROR', exit: 3 }); process.exitCode = 3; - } + }); } diff --git a/lib/design-detect-contract.ts b/lib/design-detect-contract.ts index 0fd49bf30..c4beb4141 100644 --- a/lib/design-detect-contract.ts +++ b/lib/design-detect-contract.ts @@ -11,7 +11,8 @@ // ──► 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 +// IMPECCABLE_ENGINE_UNTESTED, DESIGN_DETECTOR_HINT, DESIGN_DETECTOR_INSTALL_OFFER +// install ──► IMPECCABLE_INSTALLED: ... | IMPECCABLE_INSTALL_REFUSED: (then the probe lines) // 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 @@ -31,6 +32,12 @@ export const SENTINEL = { CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE', ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED', ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED', + /** the probe found no engine and the user has not answered the install offer yet: the skill asks once */ + INSTALL_OFFER: 'DESIGN_DETECTOR_INSTALL_OFFER', + /** `install` placed a checksum-verified engine under the user's home */ + INSTALLED: 'IMPECCABLE_INSTALLED', + /** `install` did not write anything, reason after the colon */ + INSTALL_REFUSED: 'IMPECCABLE_INSTALL_REFUSED', HINT: 'DESIGN_DETECTOR_HINT', DETECT_EXIT: 'DETECT_EXIT', DETECT_EXIT_CODE: 'DETECT_EXIT_CODE', @@ -76,12 +83,43 @@ export const SELF_DESCRIBING_SENTINELS: readonly string[] = [ 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_EDIT_REFUSED, - SENTINEL.PROBE_STEP, SENTINEL.ENGINE_STDERR, SENTINEL.DOM_DUMP_MISSING, + SENTINEL.PROBE_STEP, SENTINEL.ENGINE_STDERR, SENTINEL.DOM_DUMP_MISSING, SENTINEL.INSTALLED, SENTINEL.INSTALL_REFUSED, ]; /** Engine versions the committed fixtures were captured from. */ export const TESTED_ENGINE_VERSIONS: readonly string[] = ['0.1.3']; +/** Where impeccable publishes its engine binaries (GitHub Releases of pbakaus/impeccable, tag engine-v). */ +export const ENGINE_RELEASE_BASE = 'https://github.com/pbakaus/impeccable/releases/download'; + +/** `${process.platform}-${process.arch}` → the release asset's platform suffix (`impeccable-`, `.exe` on Windows). */ +export const ENGINE_ASSETS: Readonly> = { + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64', + 'linux-x64': 'linux-x64', + 'linux-arm64': 'linux-arm64', + 'win32-x64': 'windows-x64', +}; + +/** + * Checksums gstack pins for the engine versions it has tested, per platform: + * the `install` verb refuses a download whose bytes do not hash to the pin. + * Captured 2026-09-09 from the release's own .sha256 sidecars + * (https://github.com/pbakaus/impeccable/releases/tag/engine-v0.1.3); the + * linux-x64 hash also matches the engine gstack's fixtures were captured with. + * A pin recorded in this repo defends against a swapped release asset, which a + * same-origin sidecar cannot; adding a version means re-capturing the fixtures. + */ +export const ENGINE_PINS: Readonly>>> = { + '0.1.3': { + 'darwin-arm64': { sha256: '23821135d4c62f1428fd15ddb9e91d695402727f43b13a6eb3e9f31fc01b4072', bytes: 12677904 }, + 'darwin-x64': { sha256: 'a5bb0ae15d1bd8f61ebd2a6a21d39c2b357a211c39b4b95cc2a947cdb10a4db4', bytes: 14300496 }, + 'linux-x64': { sha256: 'afc7a424e0bd6c606b7be4c773c70e87284afbdb41d748eb9a34f8a4478e57da', bytes: 15991120 }, + 'linux-arm64': { sha256: '523c0a223ac0c1522489759a9f56dccb0b458b42d6a5c66e74e6fe2255af60ce', bytes: 13262480 }, + 'windows-x64': { sha256: '50846da00b48f7df5a82adc6c1ef1c82da0a890ac95e65cdd5da12aab2de6c1d', bytes: 14638984 }, + }, +}; + /** Rules the engine reports but never counts (they never change its exit code). */ export const ADVISORY_RULE_IDS: readonly string[] = ['em-dash-overuse']; @@ -111,6 +149,9 @@ export const DETECT_LIMITS = { 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, + /** the engine download the user consented to: twice the largest pinned asset, and a hard wall clock */ + engineDownloadBytes: 32 * 1024 * 1024, + engineDownloadTimeoutMs: 120_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; diff --git a/test/design-detect-contract.test.ts b/test/design-detect-contract.test.ts index f75964b71..4bde17d45 100644 --- a/test/design-detect-contract.test.ts +++ b/test/design-detect-contract.test.ts @@ -12,7 +12,7 @@ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import { spawnSync } from 'child_process'; -import { SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, DETECT_EXIT_ECHO, SELF_DESCRIBING_SENTINELS, UNTRUSTED_BEGIN, UNTRUSTED_END, neutralizeSentinels } from '../lib/design-detect-contract'; +import { SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, DETECT_EXIT_ECHO, SELF_DESCRIBING_SENTINELS, UNTRUSTED_BEGIN, UNTRUSTED_END, neutralizeSentinels, ENGINE_PINS, ENGINE_ASSETS, ENGINE_RELEASE_BASE } from '../lib/design-detect-contract'; import { catalogEntry } from '../lib/design-catalog'; const ROOT = path.join(import.meta.dir, '..'); @@ -120,3 +120,23 @@ describe('every sentinel-shaped token the agent can read exists in the contract' expect(offenders).toEqual([]); }); }); + +describe('engine pins: every tested version is pinned for every platform impeccable ships', () => { + test('pins are complete and well-formed, and the release base is impeccable\'s own GitHub over https', () => { + expect(ENGINE_RELEASE_BASE).toBe('https://github.com/pbakaus/impeccable/releases/download'); + const platforms = [...new Set(Object.values(ENGINE_ASSETS))].sort(); + expect(platforms).toEqual(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64']); + for (const v of TESTED_ENGINE_VERSIONS) { + const pins = ENGINE_PINS[v]; + expect(pins, `no pins for tested engine ${v}`).toBeDefined(); + expect(Object.keys(pins).sort()).toEqual(platforms); + for (const [platform, pin] of Object.entries(pins)) { + expect(pin.sha256, `${v} ${platform}`).toMatch(/^[0-9a-f]{64}$/); + expect(pin.bytes, `${v} ${platform}`).toBeGreaterThan(1_000_000); + expect(pin.bytes).toBeLessThan(DETECT_LIMITS.engineDownloadBytes); + } + } + // the fixture engine (test/fixtures/impeccable-captures.meta.json: engine 0.1.3, linux-x64) is the pinned one + expect(ENGINE_PINS['0.1.3']['linux-x64'].sha256).toBe('afc7a424e0bd6c606b7be4c773c70e87284afbdb41d748eb9a34f8a4478e57da'); + }); +}); diff --git a/test/egress-receipt-wiring.test.ts b/test/egress-receipt-wiring.test.ts index 207c5de4b..a79f77534 100644 --- a/test/egress-receipt-wiring.test.ts +++ b/test/egress-receipt-wiring.test.ts @@ -52,6 +52,9 @@ const POLARITY: Record = { 'browse-tunnel (ngrok)': 'fail-closed', 'gbrain-mcp-verify': 'fail-closed', 'supabase-provision': 'fail-closed', + // the engine binary the user consented to download: an executable arriving + // on the machine unrecorded is worse than the install failing + 'design-detect-engine-download': 'fail-closed', // fail-open: user-facing operations that must not die over an audit-log // hiccup; they warn on stderr and proceed. 'design-openai': 'fail-open', @@ -81,6 +84,8 @@ const MODULE_SINKS = [ // supabase-provision engine (bin/gstack-gbrain-supabase-provision is a thin // bun-shebang entry over this module; the receipt lives at the api-call layer). 'lib/gbrain-supabase-provision.ts', + // consent-gated engine download (install verb): receipt before the fetch, fail-closed + 'bin/gstack-design-detect.ts', ]; /** Shell sinks: must source the shared lib; every network op receipted. */ @@ -144,12 +149,14 @@ const SCANNER_EXEMPT: Record = { }; // Documented non-sink (not an exemption; nothing here matches the scanner): -// bin/gstack-design-detect.ts spawns a third-party engine binary the USER -// installed (impeccable) over local file paths under the repo root or the -// design-report allow-list. URL targets are refused, so gstack never asks the -// engine to touch the network; the engine's own network behavior is not audited -// by gstack (NOTICE.md says so). This is a class the tripwire cannot see — -// a spawned binary, not curl/fetch/git — recorded here so the posture is explicit. +// bin/gstack-design-detect.ts `scan` spawns a third-party engine binary +// (impeccable) over local file paths under the repo root or the design-report +// allow-list. URL targets are refused, so gstack never asks the engine to touch +// the network; the engine's own network behavior is not audited by gstack +// (NOTICE.md says so). This is a class the tripwire cannot see — a spawned +// binary, not curl/fetch/git — recorded here so the posture is explicit. The +// same file's `install` verb IS a sink (the consented engine download) and is +// registered in MODULE_SINKS above with fail-closed polarity. function isExempt(rel: string): string | undefined { for (const [key, reason] of Object.entries(SCANNER_EXEMPT)) { @@ -323,6 +330,7 @@ describe('egress receipt wiring tripwire', () => { expect(closed.sort()).toEqual([ 'brain-sync', 'browse-tunnel (ngrok)', + 'design-detect-engine-download', 'gbrain-mcp-verify', 'gbrain-sync', 'memory-ingest', diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index 7bd9810bb..d5ad54365 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -173,3 +173,24 @@ describe('design_detector (auto|off, rejecting validator)', () => { expect(get('design_detector').out).toBe('auto'); }); }); + +describe('design_detector_install_prompted (true|false, rejecting validator)', () => { + const env = { ...process.env, GSTACK_STATE_ROOT: STATE }; + test('defaults to false, rejects a typo with the file unchanged, round-trips true/false, and is enumerated', () => { + expect(get('design_detector_install_prompted')).toEqual({ out: 'false', code: 0 }); + const file = path.join(STATE, 'config.yaml'); + const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null; + const bad = spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'yes'], { encoding: 'utf-8', timeout: 30_000, env }); + expect(bad.status).toBe(1); + expect(bad.stderr).toContain("design_detector_install_prompted 'yes' not recognized"); + expect(fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null).toBe(before); + spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'true'], { encoding: 'utf-8', timeout: 30_000, env }); + expect(get('design_detector_install_prompted').out).toBe('true'); + spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector_install_prompted', 'false'], { encoding: 'utf-8', timeout: 30_000, env }); + expect(get('design_detector_install_prompted').out).toBe('false'); + for (const verb of ['list', 'defaults']) { + const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env }); + expect(r.stdout).toMatch(/design_detector_install_prompted:\s+false/); + } + }); +}); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 8aa30c9f9..aa17e85c9 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -11,8 +11,9 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { spawnSync } from 'child_process'; -import { SENTINEL, DETECT_LIMITS, UNTRUSTED_BEGIN, UNTRUSTED_END } from '../lib/design-detect-contract'; +import { createHash } from 'crypto'; +import { spawnSync, spawn } from 'child_process'; +import { SENTINEL, DETECT_LIMITS, UNTRUSTED_BEGIN, UNTRUSTED_END, ENGINE_ASSETS, ENGINE_PINS, TESTED_ENGINE_VERSIONS } from '../lib/design-detect-contract'; import { installFakeImpeccable, DETECT_SAMPLE as SAMPLE } from './helpers/fake-impeccable'; const ROOT = path.join(import.meta.dir, '..'); @@ -78,6 +79,33 @@ function run(args: string[], opts: RunOpts = {}) { function lines(s: string) { return s.split('\n').filter(Boolean); } +/** + * Same as run(), but asynchronous: a test that serves a loopback mirror with + * Bun.serve in THIS process must not block its own event loop with spawnSync, + * or the child's fetch waits forever for a server that can never answer. + */ +function runAsync(args: string[], opts: RunOpts = {}): Promise<{ code: number; out: string; err: string }> { + const env: Record = { + PATH: [BUN_DIR, '/usr/bin', '/bin', '/usr/local/bin'].join(path.delimiter), + HOME: path.join(SANDBOX, 'fake-home'), + GSTACK_HOME, + IMPECCABLE_HOME, + IMPECCABLE_FAKE_OUTPUT: SAMPLE, + }; + for (const [k, v] of Object.entries(opts.env ?? {})) { + if (v === undefined) delete env[k]; else env[k] = v; + } + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--no-env-file', 'run', BIN, ...args], { cwd: opts.cwd ?? REPO, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', err = ''; + child.stdout.setEncoding('utf-8'); child.stderr.setEncoding('utf-8'); + child.stdout.on('data', (d: string) => { out += d; }); + child.stderr.on('data', (d: string) => { err += d; }); + const timer = setTimeout(() => child.kill('SIGKILL'), 60_000); + child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, out, err }); }); + }); +} + describe('probe', () => { test('empty environment → NOT_AVAILABLE, skill/hook absent, no hint', () => { const r = run(['probe', '--host', 'claude']); @@ -1045,3 +1073,128 @@ describe('adversarial round: audit directories, config case, refused base with e expect(r.code).toBe(1); }); }); + +describe('install: the one download gstack makes, after consent', () => { + const PLATFORM = ENGINE_ASSETS[`${process.platform}-${process.arch}`]; + const VERSION = TESTED_ENGINE_VERSIONS[TESTED_ENGINE_VERSIONS.length - 1]; + const ASSET = PLATFORM ? `impeccable-${PLATFORM}${PLATFORM.startsWith('windows') ? '.exe' : ''}` : ''; + const freshHome = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-impeccable-home-')); + const mirror = (body: Uint8Array, hits: string[]) => Bun.serve({ + port: 0, hostname: '127.0.0.1', + fetch(req) { + const u = new URL(req.url); + hits.push(u.pathname); + if (u.pathname === `/engine-v${VERSION}/${ASSET}`) return new Response(body); + return new Response('nope', { status: 404 }); + }, + }); + const ledgerPath = () => path.join(GSTACK_HOME, 'security', 'egress.jsonl'); + const ledger = () => (fs.existsSync(ledgerPath()) ? fs.readFileSync(ledgerPath(), 'utf-8') : ''); + + test('the probe offers the pinned engine for this machine once, and stays silent (no offer, no hint) after "never ask again"', () => { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + if (PLATFORM && ENGINE_PINS[VERSION]?.[PLATFORM]) { + expect(r.out).toContain(`${SENTINEL.INSTALL_OFFER}: version=${VERSION} platform=${PLATFORM} bytes=${ENGINE_PINS[VERSION][PLATFORM].bytes} dest=`); + } else { + expect(r.out).not.toContain(SENTINEL.INSTALL_OFFER); + } + fs.writeFileSync(path.join(GSTACK_HOME, 'config.yaml'), 'design_detector_install_prompted: true\n'); + const home = freshHome(); + try { + const quiet = run(['probe']); + expect(quiet.out).not.toContain(SENTINEL.INSTALL_OFFER); + // NOT_CACHED with a HOME-rooted launcher and no engine: the hint is gone too, or it would nag every run. + const scripts = path.join(home, '.claude', 'skills', 'impeccable', 'scripts'); + fs.mkdirSync(scripts, { recursive: true }); + fs.writeFileSync(path.join(scripts, 'impeccable'), '#!/bin/sh\necho would download\n'); + fs.chmodSync(path.join(scripts, 'impeccable'), 0o755); + const nc = run(['probe'], { env: { HOME: home } }); + expect(lines(nc.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${path.join(scripts, 'impeccable')}`); + expect(nc.out).not.toContain(SENTINEL.HINT); + expect(nc.out).not.toContain(SENTINEL.INSTALL_OFFER); + } finally { + fs.rmSync(path.join(GSTACK_HOME, 'config.yaml'), { force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX || !PLATFORM)('downloads from a mirror, verifies the checksum, installs under IMPECCABLE_HOME, receipts the fetch first, and the probe finds it', async () => { + const body = fs.readFileSync(FAKE); + const hash = createHash('sha256').update(body).digest('hex'); + const hits: string[] = []; + const server = mirror(body, hits); + const home = freshHome(); + try { + const r = await runAsync(['install', '--base', `http://127.0.0.1:${server.port}`, '--sha256', hash], { env: { IMPECCABLE_HOME: home } }); + const installed = path.join(home, 'bin', VERSION, 'impeccable'); + expect(lines(r.out)[0]).toBe(`${SENTINEL.INSTALLED}: ${installed} version=${VERSION} sha256=${hash} bytes=${body.byteLength}`); + expect(r.code).toBe(0); + expect(fs.readFileSync(installed)).toEqual(body); + expect(fs.statSync(installed).mode & 0o111).not.toBe(0); + expect(hits).toEqual([`/engine-v${VERSION}/${ASSET}`]); + expect(r.out).toContain(`${SENTINEL.READY}: ${fs.realpathSync(installed)}`); // the fresh probe after install + const l = ledger(); + expect(l).toContain('"sink":"design-detect-engine-download"'); + expect(l).toContain(`"host":"127.0.0.1:${server.port}"`); + expect(l).toContain('"type":"outcome"'); + expect(l.indexOf('"type":"egress"')).toBeLessThan(l.indexOf('"type":"outcome"')); + const again = await runAsync(['install', '--base', `http://127.0.0.1:${server.port}`, '--sha256', hash], { env: { IMPECCABLE_HOME: home } }); + expect(lines(again.out)[0]).toContain('(already present, checksum verified)'); + expect(hits).toHaveLength(1); // nothing fetched the second time + } finally { + server.stop(true); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX || !PLATFORM)('a checksum mismatch, a 404, an unpinned version, a non-https base, and design_detector off each write nothing', async () => { + const body = fs.readFileSync(FAKE); + const hits: string[] = []; + const server = mirror(body, hits); + const home = freshHome(); + const base = `http://127.0.0.1:${server.port}`; + try { + const bad = await runAsync(['install', '--base', base, '--sha256', '0'.repeat(64)], { env: { IMPECCABLE_HOME: home } }); + expect(lines(bad.out)[0]).toMatch(new RegExp(`^${SENTINEL.INSTALL_REFUSED}: checksum mismatch: expected 0{64}, got [0-9a-f]{64}; nothing written`)); + expect(bad.code).toBe(1); + expect(fs.existsSync(path.join(home, 'bin'))).toBe(false); + const missing = await runAsync(['install', '--base', base, '--version', '9.9.9', '--sha256', 'a'.repeat(64)], { env: { IMPECCABLE_HOME: home } }); + expect(lines(missing.out)[0]).toContain(`${SENTINEL.INSTALL_REFUSED}: download failed: HTTP 404`); + const unpinned = await runAsync(['install', '--base', base, '--version', '9.9.9'], { env: { IMPECCABLE_HOME: home } }); + expect(lines(unpinned.out)[0]).toContain(`${SENTINEL.INSTALL_REFUSED}: gstack pins no checksum for engine 9.9.9`); + expect(hits.filter(h => h.includes('9.9.9'))).toHaveLength(1); // only the --sha256 attempt reached the mirror + const plain = await runAsync(['install', '--base', 'http://example.com'], { env: { IMPECCABLE_HOME: home } }); + expect(lines(plain.out)[0]).toContain(`${SENTINEL.INSTALL_REFUSED}: --base must be https`); + fs.writeFileSync(path.join(GSTACK_HOME, 'config.yaml'), 'design_detector: off\n'); + try { + const off = await runAsync(['install', '--base', base, '--sha256', 'a'.repeat(64)], { env: { IMPECCABLE_HOME: home } }); + expect(lines(off.out)[0]).toContain(`${SENTINEL.INSTALL_REFUSED}: design_detector is off`); + } finally { + fs.rmSync(path.join(GSTACK_HOME, 'config.yaml'), { force: true }); + } + expect(fs.existsSync(path.join(home, 'bin'))).toBe(false); + } finally { + server.stop(true); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX || !PLATFORM)('IMPECCABLE_HOME inside the project is ignored: the engine lands under the real home, never in the repo', async () => { + const body = fs.readFileSync(FAKE); + const hash = createHash('sha256').update(body).digest('hex'); + const server = mirror(body, []); + const fakeUserHome = freshHome(); + try { + const inRepo = path.join(REPO, '.impeccable'); + const r = await runAsync(['install', '--base', `http://127.0.0.1:${server.port}`, '--sha256', hash], { env: { IMPECCABLE_HOME: inRepo, HOME: fakeUserHome } }); + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(inRepo, 'bin'))).toBe(false); + expect(fs.existsSync(path.join(fakeUserHome, '.impeccable', 'bin', VERSION, 'impeccable'))).toBe(true); + } finally { + server.stop(true); + fs.rmSync(fakeUserHome, { recursive: true, force: true }); + fs.rmSync(path.join(REPO, '.impeccable'), { recursive: true, force: true }); + } + }); +});