diff --git a/scripts/secret-scan.mjs b/scripts/secret-scan.mjs index 1bec995e7..388cc278c 100644 --- a/scripts/secret-scan.mjs +++ b/scripts/secret-scan.mjs @@ -8,6 +8,16 @@ const YELLOW = "\x1b[1;33m"; const NC = "\x1b[0m"; const IGNORE_FILE = ".secretscanignore"; +// Fast-mode contract (sf-mp4w2dij-xm6cwj AC4): the regex-only path is the +// today-default fast path. SF_SECURITY_FAST=1 is the explicit opt-in for +// callers that want to assert "regex-only, no LLM escalation, sub-100ms" +// regardless of any future tiered reviewer landing in this script. When the +// LLM-backed review hook (AC1) lands, the absence of this env var becomes +// the trigger for escalation; setting it=1 keeps the offline / pre-commit +// fast path. Today the variable changes only the trailing status line so +// operators can verify the contract is observable. +const FAST_MODE = process.env.SF_SECURITY_FAST === "1"; + const PATTERNS = [ { label: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/g }, { @@ -251,4 +261,10 @@ if (findings > 0) { process.exit(1); } -process.stdout.write("secret-scan: no secrets detected ✓\n"); +if (FAST_MODE) { + process.stdout.write( + "secret-scan: fast mode (regex-only, no LLM escalation) ✓\n", + ); +} else { + process.stdout.write("secret-scan: no secrets detected ✓\n"); +} diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh index 1fb53d4a2..4ff252c75 100755 --- a/scripts/secret-scan.sh +++ b/scripts/secret-scan.sh @@ -215,6 +215,11 @@ if [[ $FINDINGS -gt 0 ]]; then echo -e "${RED}Commit blocked. Remove the secrets or add exceptions${NC}" echo -e "${RED}to .secretscanignore if these are false positives.${NC}" echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +elif [[ "${SF_SECURITY_FAST:-0}" == "1" ]]; then + # AC4 contract (sf-mp4w2dij-xm6cwj): SF_SECURITY_FAST=1 is the explicit + # opt-in for the regex-only fast path. When LLM escalation lands, this + # env var becomes the documented opt-out for offline/pre-commit callers. + echo "secret-scan: fast mode (regex-only, no LLM escalation) ✓" else echo "secret-scan: no secrets detected ✓" fi diff --git a/src/tests/secret-scan.test.ts b/src/tests/secret-scan.test.ts index c47bff422..87be06fc8 100644 --- a/src/tests/secret-scan.test.ts +++ b/src/tests/secret-scan.test.ts @@ -18,6 +18,7 @@ const scanScript = join(projectRoot, "scripts", "secret-scan.sh"); function scanContent( content: string, filename = "test-file.ts", + extraEnv: Record = {}, ): { status: number; stdout: string; stderr: string } { const dir = mkdtempSync(join(tmpdir(), "secret-scan-test-")); try { @@ -38,7 +39,7 @@ function scanContent( const result = spawnSync("bash", [scanScript], { cwd: dir, encoding: "utf-8", - env: { ...process.env, TERM: "dumb" }, + env: { ...process.env, TERM: "dumb", ...extraEnv }, }); return { @@ -51,6 +52,32 @@ function scanContent( } } +// ── Fast-mode contract (sf-mp4w2dij-xm6cwj AC4) ───────────────────── + +test( + "SF_SECURITY_FAST=1 prints fast-mode status on clean scan", + { skip: isWindows }, + () => { + const result = scanContent("const x = 1;", "clean.ts", { + SF_SECURITY_FAST: "1", + }); + assert.equal(result.status, 0, `should pass: ${result.stdout}`); + assert.match(result.stdout, /fast mode \(regex-only, no LLM escalation\)/); + }, +); + +test( + "SF_SECURITY_FAST unset prints default clean status", + { skip: isWindows }, + () => { + const result = scanContent("const x = 1;", "clean.ts"); + assert.equal(result.status, 0, `should pass: ${result.stdout}`); + assert.match(result.stdout, /no secrets detected/); + // Must NOT carry the fast-mode marker when unset + assert.doesNotMatch(result.stdout, /fast mode/); + }, +); + // ── Detection tests ────────────────────────────────────────────────── test("detects AWS access key", { skip: isWindows }, () => {