fix: install redact pre-push guard safely

This commit is contained in:
maxpetrusenkoagent 2026-06-10 15:23:17 -04:00
parent cab774cced
commit 1cb1020b06
8 changed files with 258 additions and 18 deletions

View File

@ -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@<hash> — 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
;;

View File

@ -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).

View File

@ -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) &&

77
setup
View File

@ -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/tty 2>/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

View File

@ -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.

View File

@ -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 <token> 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");
});
});

View File

@ -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", () => {

View File

@ -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');