diff --git a/bin/gstack-config b/bin/gstack-config index 2a6e9ff68..361704b32 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -108,6 +108,8 @@ lookup_default() { cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt artifacts_sync_mode) echo "off" ;; artifacts_sync_mode_prompted) echo "false" ;; + redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection + redact_prepush_hook) echo "false" ;; *) echo "" ;; esac } @@ -143,6 +145,17 @@ case "${1:-}" in echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2 VALUE="off" fi + # redact_repo_visibility: a LOCAL override for repos gh/glab can't read (e.g. + # self-hosted GitLab). It lives in ~/.gstack/config.yaml (never committed), so + # it can't be used to weaken the gate repo-wide for other contributors. + if [ "$KEY" = "redact_repo_visibility" ] && [ "$VALUE" != "public" ] && [ "$VALUE" != "private" ] && [ "$VALUE" != "unknown" ]; then + 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" + fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then diff --git a/test/gstack-config-redact-keys.test.ts b/test/gstack-config-redact-keys.test.ts new file mode 100644 index 000000000..9290d478d --- /dev/null +++ b/test/gstack-config-redact-keys.test.ts @@ -0,0 +1,54 @@ +/** + * Config keys for redaction (T12). Verifies gstack-config knows the two new + * keys, validates their value domains, and does NOT expose a block_private key + * (HIGH blocks both visibilities unconditionally — locked decision). + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; + +const CONFIG = path.resolve(import.meta.dir, "..", "bin", "gstack-config"); +let home: string; + +function cfg(args: string[]): { code: number; out: string; err: string } { + const r = spawnSync(CONFIG, args, { + encoding: "utf8", + env: { ...process.env, GSTACK_HOME: home }, + }); + return { code: r.status ?? 0, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "cfg-")); +}); +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +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("set + get round-trips a valid visibility", () => { + cfg(["set", "redact_repo_visibility", "private"]); + expect(cfg(["get", "redact_repo_visibility"]).out).toBe("private"); + }); + test("invalid visibility is rejected to unknown with a warning", () => { + const r = cfg(["set", "redact_repo_visibility", "bogus"]); + expect(r.err).toContain("not recognized"); + expect(cfg(["get", "redact_repo_visibility"]).out).toBe("unknown"); + }); + test("invalid prepush flag is rejected to false", () => { + cfg(["set", "redact_prepush_hook", "maybe"]); + expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false"); + }); + test("no block_private key (HIGH blocks both visibilities unconditionally)", () => { + // The default for an unknown key is empty string — there is no such key. + expect(cfg(["get", "redact_prepush_hook_block_private"]).out).toBe(""); + }); +});