From 1cb1020b06db5cba3738b6c01708d11008c18c85 Mon Sep 17 00:00:00 2001 From: maxpetrusenkoagent Date: Wed, 10 Jun 2026 15:23:17 -0400 Subject: [PATCH] fix: install redact pre-push guard safely --- bin/gstack-config | 30 ++++++-- bin/gstack-redact-prepush | 40 ++++++++-- lib/redact-patterns.ts | 49 +++++++++++- setup | 77 +++++++++++++++++++ test/gstack-config-redact-keys.test.ts | 12 ++- test/redact-engine.test.ts | 26 +++++++ test/redact-prepush-hook.test.ts | 19 +++++ ...tup-plan-tune-hooks-noninteractive.test.ts | 23 ++++++ 8 files changed, 258 insertions(+), 18 deletions(-) diff --git a/bin/gstack-config b/bin/gstack-config index 01defcef8..e6d7c6806 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -75,7 +75,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Set to true once the privacy gate has asked the user. # # Flip back to false to be re-prompted. # -# ─── Plan-tune hooks ───────────────────────────────────────────────── +# ─── Redaction + Plan-tune hooks ─────────────────────────────────────── +# redact_prepush_hook: prompt # prompt | yes | no — ask to install the local +# # gstack-redact pre-push guard on a real TTY, +# # skip otherwise. Override per-run with +# # ./setup --redact-prepush-hook / +# # --no-redact-prepush-hook, or env +# # GSTACK_REDACT_PREPUSH_HOOK. +# # plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune # # Claude Code hooks (PostToolUse capture + # # PreToolUse preference enforcement). @@ -123,7 +130,7 @@ lookup_default() { plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection - redact_prepush_hook) echo "false" ;; + redact_prepush_hook) echo "prompt" ;; # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, @@ -294,9 +301,16 @@ case "${1:-}" in echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2 VALUE="unknown" fi - if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then - echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2 - VALUE="false" + if [ "$KEY" = "redact_prepush_hook" ]; then + case "$VALUE" in + true|yes|y|1|on|install) VALUE="yes" ;; + false|no|n|0|off|skip) VALUE="no" ;; + prompt) VALUE="prompt" ;; + *) + echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 + VALUE="prompt" + ;; + esac fi if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 @@ -331,7 +345,8 @@ case "${1:-}" in for KEY in proactive routing_declined telemetry auto_upgrade update_check \ 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; do + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ + redact_prepush_hook; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -347,7 +362,8 @@ case "${1:-}" in for KEY in proactive routing_declined telemetry auto_upgrade update_check \ 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; do + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ + redact_prepush_hook; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 25fc8c1d4..3d6b93248 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -35,17 +35,29 @@ const ZERO = /^0+$/; // The canonical empty-tree object; diffing against it yields all content as added. const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; -function git(args: string[]): string { +class GitScanError extends Error { + constructor( + readonly args: string[], + readonly status: number | null, + readonly stderr: string, + ) { + super(`git ${args.join(" ")} failed`); + } +} + +function git(args: string[], opts: { allowFailure?: boolean } = {}): string { const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); - return r.status === 0 ? (r.stdout ?? "") : ""; + if (r.status === 0) return r.stdout ?? ""; + if (opts.allowFailure) return ""; + throw new GitScanError(args, r.status, r.stderr ?? ""); } function defaultRemoteBranch(): string { // origin/HEAD → origin/main, fall back to main/master. - const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim(); + const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }).trim(); if (sym) return sym.replace("refs/remotes/", ""); for (const b of ["origin/main", "origin/master"]) { - if (git(["rev-parse", "--verify", b]).trim()) return b; + if (git(["rev-parse", "--verify", b], { allowFailure: true }).trim()) return b; } return "origin/main"; } @@ -57,7 +69,7 @@ function addedLinesFor(localSha: string, remoteSha: string): string { // New branch: prefer what's unique to localSha vs the remote default branch. // With no merge-base (e.g. no remote yet), diff against the empty tree so ALL // branch content is scanned as added — fail-safe (scans more, never less). - const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim(); + const base = git(["merge-base", localSha, defaultRemoteBranch()], { allowFailure: true }).trim(); range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`; } else { // Existing branch (incl. force-push): net new content remote..local. @@ -108,7 +120,23 @@ function main() { for (const [, localSha, , remoteSha] of refs) { if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed - const added = addedLinesFor(localSha, remoteSha || "0"); + let added = ""; + try { + added = addedLinesFor(localSha, remoteSha || "0"); + } catch (e) { + if (e instanceof GitScanError) { + process.stderr.write( + "gstack-redact-prepush: could not scan pushed diff safely — blocking fail-closed.\n" + + ` git ${e.args.join(" ")} exited ${e.status ?? "unknown"}\n`, + ); + if (e.stderr.trim()) process.stderr.write(` ${e.stderr.trim()}\n`); + } else { + process.stderr.write( + "gstack-redact-prepush: could not scan pushed diff safely — blocking fail-closed.\n", + ); + } + process.exit(1); + } if (!added.trim()) continue; // Visibility doesn't change HIGH behavior; pass private so nothing is treated // as public-strict (HIGH blocks regardless either way). diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index a10f78e17..cfd784ac1 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -108,6 +108,10 @@ export function shannonEntropy(s: string): number { return h; } +function looksHighEntropySecret(span: string): boolean { + return span.length >= 20 && /[A-Za-z]/.test(span) && /\d/.test(span) && shannonEntropy(span) >= 3.0; +} + /** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */ export function isPublicIPv4(ip: string): boolean { const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); @@ -271,6 +275,49 @@ export const PATTERNS: RedactPattern[] = [ description: "Discord webhook URL", regex: /(https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_\-]{60,})/, }, + { + id: "gitlab.token", + tier: "HIGH", + category: "secret", + description: "GitLab personal/project/group access token (glpat…)", + regex: /\b(glpat-[A-Za-z0-9_\-]{20,})\b/, + }, + { + id: "huggingface.token", + tier: "HIGH", + category: "secret", + description: "Hugging Face access token (hf_…)", + regex: /\b(hf_[A-Za-z0-9]{30,})\b/, + }, + { + id: "npm.token", + tier: "HIGH", + category: "secret", + description: "npm access token (npm_…)", + regex: /\b(npm_[A-Za-z0-9_\-]{30,})\b/, + }, + { + id: "digitalocean.token", + tier: "HIGH", + category: "secret", + description: "DigitalOcean API token (dop_v1_…)", + regex: /\b(dop_v1_[A-Za-z0-9]{64,})\b/, + }, + { + id: "auth.bearer", + tier: "HIGH", + category: "secret", + description: "High-entropy Bearer authorization token", + regex: /\bBearer[ \t]+([A-Za-z0-9_\-.=]{24,})\b/i, + validate: (span) => looksHighEntropySecret(span), + }, + { + id: "gcp.service_account_json", + tier: "HIGH", + category: "secret", + description: "Google Cloud service-account JSON with embedded private key", + regex: /(\{[\s\S]{0,20000}"type"\s*:\s*"service_account"[\s\S]{0,20000}"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----[\s\S]{0,20000}\})/, + }, { id: "twilio.auth_token", tier: "HIGH", @@ -340,7 +387,7 @@ export const PATTERNS: RedactPattern[] = [ tier: "MEDIUM", category: "secret", description: "Env-style SECRET assignment with high-entropy value", - regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/, + regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[A-Z0-9_]*[ \t]*(?:=|:)[ \t]*['"]?([^\s'"]{8,})['"]?/i, // Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs. validate: (span) => !isPlaceholderSpan(span) && diff --git a/setup b/setup index 37991eda7..b05180b74 100755 --- a/setup +++ b/setup @@ -83,6 +83,7 @@ SKILL_PREFIX_FLAG=0 TEAM_MODE=0 NO_TEAM_MODE=0 PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit +REDACT_PREPUSH_HOOK_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit while [ $# -gt 0 ]; do case "$1" in --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; @@ -95,6 +96,9 @@ while [ $# -gt 0 ]; do --plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;; --no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;; --plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;; + --redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="yes"; shift ;; + --no-redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="no"; shift ;; + --redact-prepush-hook=*) REDACT_PREPUSH_HOOK_MODE="${1#--redact-prepush-hook=}"; shift ;; -q|--quiet) QUIET=1; shift ;; *) shift ;; esac @@ -1448,6 +1452,79 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \ fi fi + +# 11b. gstack-redact pre-push hook install. +# +# The hook catches accidental HIGH credential pushes before they leave the local +# repo. It is consent-based because it mutates .git/hooks/pre-push, but setup now +# asks on a real TTY instead of hiding the guard behind a config key nobody sets. +REDACT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-redact" +REDACT_PREPUSH_INSTALL_MARKER="$HOME/.gstack/.redact-prepush-hook-prompted" + +if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$REDACT_BIN" ] && ( cd "$SOURCE_GSTACK_DIR" && git rev-parse --git-dir >/dev/null 2>&1 ); then + RP_DECISION="$REDACT_PREPUSH_HOOK_MODE" + [ -z "$RP_DECISION" ] && RP_DECISION="${GSTACK_REDACT_PREPUSH_HOOK:-}" + [ -z "$RP_DECISION" ] && RP_DECISION="$($GSTACK_CONFIG get redact_prepush_hook 2>/dev/null || true)" + RP_DECISION=$(printf '%s' "$RP_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') + case "$RP_DECISION" in + y|yes|true|install|on|1) RP_DECISION="yes" ;; + n|no|false|skip|off|0) RP_DECISION="no" ;; + *) RP_DECISION="prompt" ;; + esac + + _install_redact_prepush_hook() { + ( cd "$SOURCE_GSTACK_DIR" && bun "$REDACT_BIN" install-prepush-hook ) + } + + if [ "$RP_DECISION" = "yes" ]; then + _install_redact_prepush_hook + "$GSTACK_CONFIG" set redact_prepush_hook yes 2>/dev/null || true + touch "$REDACT_PREPUSH_INSTALL_MARKER" + elif [ "$RP_DECISION" = "no" ]; then + log "" + log "gstack-redact pre-push hook not installed (opted out)." + log "Install later with: ./setup --redact-prepush-hook" + "$GSTACK_CONFIG" set redact_prepush_hook no 2>/dev/null || true + touch "$REDACT_PREPUSH_INSTALL_MARKER" + elif [ -f "$REDACT_PREPUSH_INSTALL_MARKER" ]; then + : + elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then + _REDACT_PROMPT_TIMEOUT=10 + log "" + log "──────────────────────────────────────────────────────────" + log "gstack-redact: install local pre-push secret guard?" + log "──────────────────────────────────────────────────────────" + log "" + log "This scans pushed diff lines for HIGH credentials and blocks accidental leaks." + log "Existing pre-push hooks are preserved as pre-push.local and chained." + log "Bypass remains explicit: git push --no-verify or GSTACK_REDACT_PREPUSH=skip." + log "" + printf "Install gstack-redact pre-push hook now? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT" + read -t "$_REDACT_PROMPT_TIMEOUT" -r REDACT_PREPUSH_INSTALL_REPLY /dev/null || REDACT_PREPUSH_INSTALL_REPLY="" + case "$REDACT_PREPUSH_INSTALL_REPLY" in + y|Y) + _install_redact_prepush_hook + "$GSTACK_CONFIG" set redact_prepush_hook yes 2>/dev/null || true + touch "$REDACT_PREPUSH_INSTALL_MARKER" + ;; + n|N) + log "" + log "Skipped. Re-run ./setup --redact-prepush-hook to install later." + "$GSTACK_CONFIG" set redact_prepush_hook no 2>/dev/null || true + touch "$REDACT_PREPUSH_INSTALL_MARKER" + ;; + *) + log "" + log "No opt-in — skipped for now. Re-run ./setup --redact-prepush-hook to install." + ;; + esac + else + log "" + log "gstack-redact pre-push hook not installed (non-interactive setup)." + log "Install with: ./setup --redact-prepush-hook" + fi +fi + # Also tear down plan-tune hooks on --no-team (matches the existing pattern). if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true diff --git a/test/gstack-config-redact-keys.test.ts b/test/gstack-config-redact-keys.test.ts index 9290d478d..5a9167983 100644 --- a/test/gstack-config-redact-keys.test.ts +++ b/test/gstack-config-redact-keys.test.ts @@ -31,8 +31,8 @@ describe("redact config keys", () => { test("redact_repo_visibility default is empty (falls through to detection)", () => { expect(cfg(["get", "redact_repo_visibility"]).out).toBe(""); }); - test("redact_prepush_hook default is false", () => { - expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false"); + test("redact_prepush_hook default is prompt", () => { + expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt"); }); test("set + get round-trips a valid visibility", () => { cfg(["set", "redact_repo_visibility", "private"]); @@ -43,9 +43,13 @@ describe("redact config keys", () => { expect(r.err).toContain("not recognized"); expect(cfg(["get", "redact_repo_visibility"]).out).toBe("unknown"); }); - test("invalid prepush flag is rejected to false", () => { + test("invalid prepush flag is rejected to prompt", () => { cfg(["set", "redact_prepush_hook", "maybe"]); - expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false"); + expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt"); + }); + test("redact_prepush_hook appears in list/defaults", () => { + expect(cfg(["list"]).out).toContain("redact_prepush_hook:"); + expect(cfg(["defaults"]).out).toContain("redact_prepush_hook:"); }); test("no block_private key (HIGH blocks both visibilities unconditionally)", () => { // The default for an unknown key is empty string — there is no such key. diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index 52c119a19..dcf96feb9 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -41,6 +41,19 @@ describe("HIGH credential patterns", () => { ["slack.token", "xox" + "b-1234567890-abcdefghijklmnop"], ["slack.webhook", "https://hooks.slack.com/services/T00000000/B11111111/" + "a".repeat(24)], ["discord.webhook", "https://discord.com/api/webhooks/123456789012345678/" + "a".repeat(60)], + ["gitlab.token", "glpat-" + "a".repeat(20)], + ["huggingface.token", "hf_" + "a".repeat(34)], + ["npm.token", "npm_" + "a".repeat(36)], + ["digitalocean.token", "dop_v1_" + "a".repeat(64)], + [ + "gcp.service_account_json", + `{ + "type": "service_account", + "private_key_id": "${"a".repeat(40)}", + "private_key": "-----BEGIN PRIVATE KEY-----\\n${"A".repeat(64)}\\n-----END PRIVATE KEY-----\\n", + "client_email": "svc@project.iam.gserviceaccount.com" +}`, + ], ["pem.private_key", "-----BEGIN RSA PRIVATE KEY-----"], ]; for (const [id, text] of cases) { @@ -57,6 +70,16 @@ describe("HIGH credential patterns", () => { expect(ids(`random ${tok} here`)).not.toContain("twilio.auth_token"); }); + test("generic Bearer token needs authorization context and enough entropy", () => { + const token = "Ab3_xY9.KlmN0pQr-StUvWxYz456789"; + const lowercaseToken = "abc123def456abc123def456abc123def456"; + expect(ids("Authorization: Bearer " + token)).toContain("auth.bearer"); + expect(ids("Bearer " + token)).toContain("auth.bearer"); + expect(ids("Authorization: Bearer " + lowercaseToken)).toContain("auth.bearer"); + expect(ids("docs say Bearer here")).not.toContain("auth.bearer"); + expect(ids("Authorization: Bearer " + "a".repeat(32))).not.toContain("auth.bearer"); + }); + test("db.url_with_password flags real password, skips placeholder/env-var", () => { expect(ids("postgres://user:s3cretP@ss@db.example.com/app")).toContain("db.url_with_password"); expect(ids("postgres://user:${DB_PASSWORD}@host/app")).not.toContain("db.url_with_password"); @@ -88,7 +111,10 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => { }); test("env.kv fires on high-entropy, skips placeholder", () => { expect(ids("API_TOKEN=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + expect(ids("api_key: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + expect(ids('apiToken: "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv"); expect(ids("API_KEY=changeme")).not.toContain("env.kv"); + expect(ids("api_key: changeme")).not.toContain("env.kv"); expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv"); }); }); diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 8447cf6d5..295b6c037 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -82,6 +82,25 @@ describe("pre-push hook gating", () => { expect(code).toBe(0); expect(stderr).toContain("MEDIUM"); }); + + test("common token prefixes in pushed diff block", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("tokens.txt", "gitlab glpat-" + "a".repeat(20) + "\n", "add gitlab token"); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(1); + expect(stderr).toContain("gitlab.token"); + }); +}); + +describe("fail-closed git failures", () => { + test("invalid local sha blocks instead of silently allowing an unscanned push", () => { + const base = git(["rev-parse", "HEAD"]); + const { code, stderr } = runHook( + `refs/heads/main deadbeefdeadbeefdeadbeefdeadbeefdeadbeef refs/heads/main ${base}\n`, + ); + expect(code).toBe(1); + expect(stderr).toContain("could not scan pushed diff safely"); + }); }); describe("diff direction + special refs", () => { diff --git a/test/setup-plan-tune-hooks-noninteractive.test.ts b/test/setup-plan-tune-hooks-noninteractive.test.ts index 9a0f03ded..bbff5746f 100644 --- a/test/setup-plan-tune-hooks-noninteractive.test.ts +++ b/test/setup-plan-tune-hooks-noninteractive.test.ts @@ -62,6 +62,29 @@ describe('setup: plan-tune hooks are non-interactive-safe', () => { }); }); +describe('setup: redaction pre-push hook install is consent-based and non-interactive-safe', () => { + test('exposes --redact-prepush-hook / --no-redact-prepush-hook / =value flags', () => { + expect(setupSrc).toContain('--redact-prepush-hook)'); + expect(setupSrc).toContain('--no-redact-prepush-hook)'); + expect(setupSrc).toContain('--redact-prepush-hook=*)'); + }); + + test('redaction hook decision falls through env then saved config', () => { + expect(setupSrc).toContain('GSTACK_REDACT_PREPUSH_HOOK'); + expect(setupSrc).toContain('get redact_prepush_hook'); + }); + + test('redaction hook prompt is time-bounded and TTY-gated', () => { + expect(setupSrc).toMatch(/read -t (?:\d+|"?\$\{?\w+\}?"?) -r REDACT_PREPUSH_INSTALL_REPLY <\/dev\/tty/); + expect(setupSrc).toMatch(/\[ "\$QUIET" -ne 1 \] && \[ -t 0 \] && \[ -t 1 \]/); + }); + + test('redaction hook git check and install target the source gstack repo', () => { + expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && git rev-parse --git-dir'); + expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && bun "$REDACT_BIN" install-prepush-hook )'); + }); +}); + describe('dev-setup: never silently mutates global settings.json', () => { const DEV_SETUP = path.join(ROOT, 'bin', 'dev-setup'); const devSetupSrc = fs.readFileSync(DEV_SETUP, 'utf-8');