fix(redact): close the remaining #1946 fail-opens — detection coverage + one-time consent

Two of #1946's reported gaps were still open after the v1.64 fail-closed
work (the git-error and oversized-diff paths in bin/gstack-redact-prepush
are already strict, chunked, and pinned by tests):

1. Detection fail-open: env.kv required an UPPERCASE name with an '='
   assignment, so 'api_key=…', 'apiKey: "…"', and 'password: …' — the
   most common real config shapes — produced NO finding at all. The pattern
   is now case-insensitive, accepts ':' (YAML/JSON) as well as '='
   assignment, and handles quoted JSON keys. It stays MEDIUM and
   entropy-gated per the calibration rule (a generic net that cries wolf
   gets bypassed), with pinned cases for each closed shape plus the
   placeholder/entropy negatives.

2. Install fail-open: nothing ever offered the guard, so a plain 'git
   push' scanned nothing and users believing themselves protected weren't.
   setup now asks ONCE for consent on a real interactive terminal
   (maintainer decision 6): an explicit answer is recorded to the existing
   redact_prepush_hook key and never re-asked; a timeout or non-interactive
   run changes nothing and keeps the hint-only posture. Default stays
   FALSE, and setup still never installs the hook itself — /ship owns the
   per-repo install (the wrong-repo invariant is pinned by the existing
   'setup carries the hint only' test).

Tests: per-shape pattern cases, prompt gating statics (key-absence + TTY +
timed default-N read), timeout-persists-nothing, non-interactive stays
hint-only with no key write, and recorded-answer-is-silent behavior runs.

Contributes to #1946 (the pre-push guard's fail-closed scan paths landed
in earlier releases; this closes the coverage and consent gaps it names).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 09:49:31 -07:00
co-authored by Claude Fable 5
parent 9af589bb73
commit 9c0de5fed1
4 changed files with 148 additions and 12 deletions
+23
View File
@@ -166,6 +166,29 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv");
});
// #1946 gap 3: the uppercase-`=`-only shape made lowercase and YAML/JSON
// colon assignments invisible — the exact config shapes people actually
// push. Each closed detection fail-open gets a pinned case.
test("env.kv fires on lowercase = assignment (#1946)", () => {
expect(ids("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
});
test("env.kv fires on YAML colon assignment (#1946)", () => {
expect(ids("password: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
});
test("env.kv fires on quoted JSON key colon assignment (#1946)", () => {
expect(ids('"apiKey": "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv");
});
test("env.kv colon/lowercase forms stay entropy-gated and placeholder-safe", () => {
expect(ids("password: changeme")).not.toContain("env.kv");
expect(ids("apiKey: YOUR_API_KEY_HERE")).not.toContain("env.kv");
expect(ids("api_key=${MY_VAR}")).not.toContain("env.kv");
});
test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => {
const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" })
.findings.find((x) => x.id === "env.kv");
expect(f?.tier).toBe("MEDIUM");
});
// #1946 — Bearer is the most FP-prone shape in the wave: docs and examples
// are full of "Authorization: Bearer <token>". MEDIUM + header proximity +
// the env.kv entropy recipe keep it calibrated.
+76
View File
@@ -221,6 +221,82 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
expect(tmpl).toContain(".redact-prepush-prompted");
expect(tmpl).toContain("redact_prepush_hook");
});
// #1946 / maintainer decision 6: setup asks ONCE for consent on a real TTY,
// records the answer to the existing redact_prepush_hook key, and keeps the
// hint-only posture everywhere else. Default stays FALSE; setup never
// installs the hook itself (the assertion above pins that).
describe("one-time consent prompt in setup (#1946, decision 6)", () => {
const setup = fs.readFileSync(path.join(ROOT, "setup"), "utf8");
const block = setup.slice(setup.indexOf("# ─── Redact pre-push guard consent"));
test("prompt is gated on key ABSENCE and a real TTY, with a timed default-N read", () => {
expect(block).toContain("grep -q '^redact_prepush_hook:'");
expect(block).toContain('[ -t 0 ] && [ -t 1 ]');
expect(block).toContain("[y/N]");
expect(block).toContain('read -t "$_REDACT_PROMPT_TIMEOUT"');
});
test("an explicit answer persists true/false; timeout persists NOTHING", () => {
expect(block).toContain("set redact_prepush_hook true");
expect(block).toContain("set redact_prepush_hook false");
// The timeout branch must not write the key (a silent decline would
// permanently suppress the ask without the user ever seeing it). The
// branch's hint TEXT mentions the command; the executable invocation is
// the quoted "$GSTACK_CONFIG" form.
const timeoutBranch = block.slice(block.indexOf("*)"), block.indexOf("esac"));
expect(timeoutBranch).not.toContain('"$GSTACK_CONFIG" set redact_prepush_hook');
});
test("non-interactive setup keeps the hint-only posture (no prompt, no key write)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-"));
try {
const script = [
"QUIET=0",
'log() { echo "$@"; }',
`GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`,
block,
].join("\n");
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY
env: { ...process.env, GSTACK_HOME: home },
timeout: 15_000,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("Tip:");
expect(r.stdout).not.toContain("[y/N]");
const cfg = path.join(home, "config.yaml");
const cfgText = fs.existsSync(cfg) ? fs.readFileSync(cfg, "utf8") : "";
expect(cfgText).not.toContain("redact_prepush_hook");
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test("a recorded answer is never re-asked (key present → silent)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-set-"));
try {
fs.writeFileSync(path.join(home, "config.yaml"), "redact_prepush_hook: false\n");
const script = [
"QUIET=0",
'log() { echo "$@"; }',
`GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`,
block,
].join("\n");
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: home },
timeout: 15_000,
});
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe("");
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
});
describe("escape valve", () => {