This commit is contained in:
maxpetrusenkoagent 2026-07-14 19:16:50 -07:00 committed by GitHub
commit dc3462ffa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 145 additions and 23 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).
@ -132,7 +139,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@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
@ -306,9 +313,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
@ -350,7 +364,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
@ -366,7 +381,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,14 +35,26 @@ const ZERO = /^0+$/;
// The canonical empty-tree object; diffing against it yields all content as added.
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
class GitScanError extends Error {
constructor(
readonly args: string[],
readonly status: number | null,
readonly stderr: string,
) {
super(`git ${args.join(" ")} failed`);
}
}
/**
* Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse,
* merge-base) where a non-zero exit is normal control flow. The DIFF call
* must NOT use this — see gitStrict (#1946 fail-closed).
*/
function git(args: string[]): string {
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 ?? "");
}
/**
@ -72,10 +84,10 @@ function objectExists(sha: string): boolean {
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";
}
@ -87,14 +99,14 @@ 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 if (!objectExists(remoteSha)) {
// Remote tip object absent locally (shallow clone, force-push without a
// prior fetch, CI checkout): remote..local can't resolve. Fall back to
// the merge-base/empty-tree path — scans MORE, never less — instead of
// hard-blocking a legitimate push (adversarial review finding 8).
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.
@ -154,10 +166,16 @@ function main() {
// Fail CLOSED (#1946): if we can't compute the pushed diff we can't
// scan it, and unscanned-but-allowed is the failure mode this hook
// exists to prevent.
const detail =
err instanceof GitScanError
? `git ${err.args.join(" ")} exited ${err.status ?? "unknown"}${err.stderr.trim() ? `: ${err.stderr.trim()}` : ""}`
: err instanceof Error
? err.message.split("\n")[0]
: String(err);
process.stderr.write(
"\n⛔ gstack-redact-prepush BLOCKED the push — could not compute the pushed diff, " +
"so it cannot be scanned for credentials.\n" +
` (${err instanceof Error ? err.message.split("\n")[0] : String(err)})\n` +
"\n⛔ gstack-redact-prepush BLOCKED the push — could not scan pushed diff safely: " +
"could not compute the pushed diff, so it cannot be scanned for credentials.\n" +
` (${detail.slice(0, 500)})\n` +
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
);
process.exit(1);

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})$/);
@ -243,14 +247,14 @@ export const PATTERNS: RedactPattern[] = [
tier: "HIGH",
category: "secret",
description: "npm granular access token",
regex: /\b(npm_[A-Za-z0-9]{36})\b/,
regex: /\b(npm_[A-Za-z0-9_\-]{30,})\b/,
},
{
id: "digitalocean.token",
tier: "HIGH",
category: "secret",
description: "DigitalOcean personal access token",
regex: /\b(dop_v1_[a-f0-9]{64})\b/,
regex: /\b(dop_v1_[A-Za-z0-9]{64,})\b/,
},
{
id: "gcp.service_account",
@ -318,6 +322,13 @@ 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: "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",
@ -387,7 +398,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) &&

5
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
@ -1508,6 +1512,7 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
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

@ -42,6 +42,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-----"],
// #1946 coverage-gap additions
["gitlab.token", "remote: glpat-" + "Ab12Cd34Ef56Gh78Ij90"],
@ -99,6 +112,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)).not.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");
@ -130,7 +153,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');