mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 23:21:53 +02:00
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed --scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a page rendered from it. gitleaks' assignment rules don't match across a JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page shows as KEY="v" was imported unflagged. And the gate skipped a file only on scanner "gitleaks" with findings, so a scan that errored (non-zero exit, 16MB maxBuffer overflow on a file with many findings, unparseable report) or could not run (gitleaks missing, slow-probe cooldown) imported the file unscanned. Scan the rendered page body, the exact bytes writeStaged() writes, via a new secretScanText() helper, and skip the file whenever the scan did not complete. Skipped files stay out of the state file, so the next run retries them. Reword the helper warnings and setup-gbrain/memory.md, which described the fail-open as intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(test): reconcile Bun failure markers and footer counts * fix(sync-gbrain): verify source-scoped reads without mutation * fix(test): recognize grounded TTHW target choices structurally * fix(aside): make the readiness probe work under zsh and report why it failed The probe built its deadline into `_T` and expanded it unquoted, so `$_T aside repl …` only worked in a shell that word-splits. zsh does not: it looked for a command literally named "gtimeout 30", the probe answered ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill fell back to the bundled Chromium in silence. zsh is the macOS default and Aside is macOS-only, so on a stock Mac the probe could never report READY. The deadline becomes a function, `_gs_d`. It receives the command as "$@", already split, so sh, bash and zsh all behave the same, and the gtimeout → timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded when none of the three is present, which is what the empty `_T` did before. Not `eval`: it re-parses the string, so the parens and `;` of the perl arm become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm that actually runs. On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:, the shape gstack-render already uses: the first line that starts with a capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its loader frame. "Not running" covers states with different fixes — no window open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare verdict sent all of them to "open the Aside app". The BROWSER SETUP prose quotes that reason before asking the user to open the app. The text pin asserted the broken invocation verbatim, so it now pins the function and asserts neither `$_T aside repl` nor an eval form comes back. A second test executes the rendered probe in sh, bash and zsh on each of the four deadline arms with stubbed binaries on a narrowed PATH, plus two failing CLIs: one that prints its own sentence, one that crashes like Node with the useful line below the frame. The deadline function costs zero bytes against the lines it replaces; the reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP line gives 9 back). That moves four guards by the measured amount: plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured 1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504). Fixes #2842, #2941. * Clarify engineering review startup and decision flow * Fix Windows readiness fixture PATH and command shim * fix(test): recognize grounded TTHW target choices structurally * Clarify engineering review startup and decision flow * fix(test): restrict QA-only fixture tools to its no-Edit contract * v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata * fix(browse): validate canonical upload targets * fix(gbrain): classify structured PGLite busy response * fix(browse): preserve native extension runtime APIs * Fix displayless browser handoff ownership * Accept unique installed autoplan methodology aliases * fix(skills): preserve positional literals during installation * fix(browse): checksum installer contents through stdin * fix(test): normalize Windows checksum fixture paths * test: emulate unavailable shasum in Windows checksum fixture * fix(investigate): preserve owned freeze lifecycle * fix(review): preserve N+1 retry and Red Team completion * fix: bound Aside readiness and preserve safe fallback * test: exercise setup and Chromium on native ARM * fix: preserve install ownership and ARM browser selection * Fix gbrain ingest scan boundaries and seed observation * Refresh managed ship hooks and supervise expanded paid census * Reject resumed gbrain pages excluded by current policy * Recover zombie agent locks safely and enable CI Python venv * Repair paid actor declarations and Aside pitch assertions * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Reconcile new main planning flow and paid judge census * fix: reconcile rebased planning and source-bound validation * test: pin cookie workflow judge to scored Sonnet model * fix: keep terminal agent boot out of module imports * fix: preserve pending-question uncertainty in engineering review * fix: stabilize Windows reliability-wave fixtures * fix: clarify design consultation research workflow * fix: preserve independent design consultation inputs * fix: resolve design taste scope and browser research guidance * fix: make consultation opt-in preflight unambiguous * test: await native Edge owner readiness or terminal result --------- Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
340 lines
13 KiB
TypeScript
Executable File
340 lines
13 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* gstack-redact — scan text for secrets/PII/legal content via the shared engine.
|
|
*
|
|
* Skill-facing CLI over lib/redact-engine.ts. Reads from stdin (default) or
|
|
* --from-file, scans, and prints findings as JSON (--json) or a human table.
|
|
*
|
|
* Exit codes (consumed by skill bash to gate dispatch/file/edit/commit):
|
|
* 0 clean (no HIGH, no MEDIUM)
|
|
* 2 MEDIUM present (no HIGH) — skill runs the per-finding AskUserQuestion
|
|
* 3 HIGH present — skill blocks
|
|
*
|
|
* WARN findings (tool-fence-degraded credentials) never change the exit code.
|
|
*
|
|
* Flags:
|
|
* --json Emit JSON {findings, counts, repoVisibility, oversize}
|
|
* --repo-visibility V public | private | unknown (default unknown=public-strict wording)
|
|
* --from-file PATH Read input from PATH instead of stdin
|
|
* --allowlist PATH Newline-delimited exact spans to suppress
|
|
* --self-email EMAIL Suppress this email (the invoking user's own)
|
|
* --repo-public-emails PATH Newline-delimited repo-public emails to suppress
|
|
* --auto-redact IDS Comma-separated finding ids to auto-redact;
|
|
* prints the redacted body to stdout + diff to stderr.
|
|
* --max-bytes N Override the fail-closed size cap (default 1 MiB).
|
|
*
|
|
* Security note: this is a GUARDRAIL, not airtight enforcement. A determined
|
|
* user can always bypass it (direct gh/git). It catches accidents.
|
|
*/
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { spawnSync } from "child_process";
|
|
import {
|
|
scan,
|
|
applyRedactions,
|
|
exitCodeFor,
|
|
type RepoVisibility,
|
|
type ScanOptions,
|
|
type Finding,
|
|
} from "../lib/redact-engine";
|
|
import { mkdirpSync } from "../lib/fs-utils";
|
|
|
|
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
|
|
|
// ── pre-push hook install/uninstall (chains any existing hook) ────────────────
|
|
|
|
const MANAGED_MARKER = "# gstack-redact pre-push (managed)";
|
|
|
|
function hooksPath(): string {
|
|
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" });
|
|
if (r.status !== 0) {
|
|
process.stderr.write("gstack-redact: not in a git repo\n");
|
|
process.exit(1);
|
|
}
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
function installPrepushHook(): void {
|
|
const dir = hooksPath();
|
|
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
|
|
// recursive mkdir when .git/hooks already exists (#2635).
|
|
mkdirpSync(dir);
|
|
const hookPath = path.join(dir, "pre-push");
|
|
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
|
|
|
// stdin is single-consume: capture it once, feed both the chained hook and ours.
|
|
// The `printf x` sentinel preserves the trailing newline that `$(cat)` strips.
|
|
// Without it, a chained shell pre-push.local built on `while read` silently
|
|
// drops the final (often only) ref line and exits 0 — the guard reports
|
|
// success having scanned nothing, i.e. it fails OPEN.
|
|
const wrapper = `#!/usr/bin/env bash
|
|
${MANAGED_MARKER}
|
|
set -euo pipefail
|
|
_input="$(cat; printf x)"
|
|
_input="\${_input%x}"
|
|
_local="$(git rev-parse --git-path hooks/pre-push.local)"
|
|
if [ -x "$_local" ]; then
|
|
printf '%s' "$_input" | "$_local" "$@" || exit $?
|
|
fi
|
|
printf '%s' "$_input" | bun "${prepushBin}" "$@"
|
|
`;
|
|
|
|
// If a non-managed hook exists, preserve it as pre-push.local and chain it.
|
|
if (fs.existsSync(hookPath)) {
|
|
const existing = fs.readFileSync(hookPath, "utf8");
|
|
if (existing.split("\n").includes(MANAGED_MARKER)) {
|
|
// A hook we already own. Returning here unconditionally froze every
|
|
// existing install on whatever wrapper it first received: the `printf x`
|
|
// fail-open fix landed in v1.64.0.0 and still had not reached a single
|
|
// repo that got the hook before it, because the only writer is gated on
|
|
// this branch. A wrapper naming a gstack that has since been moved or
|
|
// removed stays pointed at that dead path for the same reason.
|
|
//
|
|
// Rewrite when the body has drifted from what this version generates;
|
|
// stay a no-op when it has not, so the command is still idempotent. The
|
|
// chained pre-push.local is never touched on either path — it is the
|
|
// user's, not ours.
|
|
if (existing === wrapper) {
|
|
process.stdout.write("gstack-redact: pre-push hook already installed.\n");
|
|
return;
|
|
}
|
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
|
fs.chmodSync(hookPath, 0o755);
|
|
process.stdout.write(
|
|
`gstack-redact: refreshed stale managed pre-push hook at ${hookPath}\n`,
|
|
);
|
|
return;
|
|
}
|
|
const localPath = path.join(dir, "pre-push.local");
|
|
fs.renameSync(hookPath, localPath);
|
|
fs.chmodSync(localPath, 0o755);
|
|
process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n");
|
|
}
|
|
|
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
|
fs.chmodSync(hookPath, 0o755);
|
|
process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`);
|
|
}
|
|
|
|
function uninstallPrepushHook(): void {
|
|
const dir = hooksPath();
|
|
const hookPath = path.join(dir, "pre-push");
|
|
const localPath = path.join(dir, "pre-push.local");
|
|
if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) {
|
|
process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n");
|
|
return;
|
|
}
|
|
if (fs.existsSync(localPath)) {
|
|
fs.renameSync(localPath, hookPath); // restore the chained original
|
|
process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n");
|
|
} else {
|
|
fs.unlinkSync(hookPath);
|
|
process.stdout.write("gstack-redact: removed managed pre-push hook.\n");
|
|
}
|
|
}
|
|
|
|
function arg(name: string): string | undefined {
|
|
const i = process.argv.indexOf(name);
|
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
}
|
|
function flag(name: string): boolean {
|
|
return process.argv.includes(name);
|
|
}
|
|
|
|
function readInput(): string {
|
|
const file = arg("--from-file");
|
|
// An explicitly-passed EMPTY path must error, not silently fall through to
|
|
// stdin: skill blocks pass "$FILE" from a $(mktemp) that may have failed,
|
|
// and the stdin fallback then scans nothing while looking green (#2679).
|
|
if (file === "") {
|
|
process.stderr.write(
|
|
"gstack-redact: --from-file requires a non-empty path (did mktemp fail?)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (file) {
|
|
const st = fs.statSync(file);
|
|
if (st.size > MAX_STDIN_BYTES) {
|
|
// Don't even read it — fail closed at the CLI boundary.
|
|
process.stderr.write(`gstack-redact: input file too large (${st.size} bytes)\n`);
|
|
process.exit(3);
|
|
}
|
|
return fs.readFileSync(file, "utf8");
|
|
}
|
|
// stdin
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
const fd = 0;
|
|
const buf = Buffer.alloc(65536);
|
|
while (true) {
|
|
let n = 0;
|
|
try {
|
|
n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
} catch (e: any) {
|
|
if (e.code === "EAGAIN") continue;
|
|
if (e.code === "EOF") break;
|
|
throw e;
|
|
}
|
|
if (n === 0) break;
|
|
total += n;
|
|
if (total > MAX_STDIN_BYTES) {
|
|
process.stderr.write("gstack-redact: stdin too large\n");
|
|
process.exit(3);
|
|
}
|
|
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
function readLines(path: string | undefined): string[] | undefined {
|
|
if (!path || !fs.existsSync(path)) return undefined;
|
|
return fs
|
|
.readFileSync(path, "utf8")
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildOpts(): ScanOptions {
|
|
const vis = (arg("--repo-visibility") as RepoVisibility) || "unknown";
|
|
const maxBytes = arg("--max-bytes");
|
|
// #1824: validate the RAW string, not the parse result. parseInt("123abc")
|
|
// is 123 and parseInt("foo") is NaN — both silently corrupt the fail-closed
|
|
// oversize guard. Require a clean positive integer or reject before scanning.
|
|
let maxBytesOpt: number | undefined;
|
|
if (maxBytes !== undefined) {
|
|
if (!/^\d+$/.test(maxBytes) || Number(maxBytes) <= 0) {
|
|
process.stderr.write(
|
|
`gstack-redact: --max-bytes must be a positive integer (got "${maxBytes}")\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
maxBytesOpt = Number(maxBytes);
|
|
}
|
|
return {
|
|
repoVisibility: ["public", "private", "unknown"].includes(vis) ? vis : "unknown",
|
|
allowlist: readLines(arg("--allowlist")),
|
|
selfEmail: arg("--self-email"),
|
|
repoPublicEmails: readLines(arg("--repo-public-emails")),
|
|
...(maxBytesOpt !== undefined ? { maxBytes: maxBytesOpt } : {}),
|
|
};
|
|
}
|
|
|
|
function humanTable(findings: Finding[]): string {
|
|
if (!findings.length) return " (no findings)";
|
|
const rows = findings.map(
|
|
(f) =>
|
|
` ${f.severity.padEnd(6)} ${f.id.padEnd(24)} ${String(f.line).padStart(4)}:${String(
|
|
f.col,
|
|
).padEnd(3)} ${f.preview}`,
|
|
);
|
|
return rows.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Usage. Exits 0 when asked for (--help), 1 when the invocation was wrong.
|
|
*
|
|
* Deliberately NOT 2 or 3: those mean MEDIUM and HIGH findings, and callers
|
|
* gate dispatch on them (see the exit-code table at the top). A usage error
|
|
* that exited 2 would be read as "medium findings — prompt the user".
|
|
*/
|
|
function printUsage(code: number): never {
|
|
const out = code === 0 ? process.stdout : process.stderr;
|
|
out.write(
|
|
"gstack-redact — scan text for secrets/PII/legal content.\n" +
|
|
"\n" +
|
|
"Reads the text to scan from STDIN, or from --from-file PATH. It is a\n" +
|
|
"filter: with nothing piped in it has nothing to scan.\n" +
|
|
"\n" +
|
|
" git diff | gstack-redact --repo-visibility private\n" +
|
|
" gstack-redact --from-file notes.md --json\n" +
|
|
"\n" +
|
|
"Subcommands:\n" +
|
|
" install-prepush-hook install the managed git pre-push credential guard\n" +
|
|
" uninstall-prepush-hook remove it\n" +
|
|
"\n" +
|
|
"Flags: --json --repo-visibility V --from-file PATH --allowlist PATH\n" +
|
|
" --self-email EMAIL --repo-public-emails PATH --auto-redact IDS\n" +
|
|
" --max-bytes N\n" +
|
|
"\n" +
|
|
"Exit: 0 clean · 1 usage error · 2 MEDIUM present · 3 HIGH present\n",
|
|
);
|
|
process.exit(code);
|
|
}
|
|
|
|
function main() {
|
|
// Subcommands (positional, not flags).
|
|
const sub = process.argv[2];
|
|
if (sub === "install-prepush-hook") return installPrepushHook();
|
|
if (sub === "uninstall-prepush-hook") return uninstallPrepushHook();
|
|
if (sub === "--help" || sub === "-h" || sub === "help") return printUsage(0);
|
|
|
|
// An unrecognized POSITIONAL is a typo, not input. This used to fall through
|
|
// to the stdin scan, which on empty stdin prints "(no findings)" and exits 0
|
|
// — so `install-prepush-hooks` (plural) installed nothing and still looked
|
|
// like success, leaving the credential guard absent while the operator
|
|
// believed it was armed. A guard that no-ops must never exit 0.
|
|
//
|
|
// "scan" is exempt: the human output header reads "gstack-redact scan —
|
|
// repo …", so people reasonably type it. It stays an alias for the default.
|
|
// Flags start with "-" and are parsed further down, so only bare words land
|
|
// here.
|
|
if (sub !== undefined && sub !== "scan" && !sub.startsWith("-")) {
|
|
process.stderr.write(`gstack-redact: unknown subcommand "${sub}"\n\n`);
|
|
return printUsage(1);
|
|
}
|
|
|
|
const opts = buildOpts();
|
|
|
|
// Nothing piped in and no --from-file: readInput() below blocks on
|
|
// readSync(fd 0) until EOF, which on an interactive terminal never comes.
|
|
// That prints nothing at all and is indistinguishable from a crash or a
|
|
// slow scan. Show usage instead of hanging silently.
|
|
if (!arg("--from-file") && process.stdin.isTTY) return printUsage(1);
|
|
|
|
const input = readInput();
|
|
|
|
// Auto-redact mode: print redacted body to stdout, diff to stderr, exit 0.
|
|
const autoIds = arg("--auto-redact");
|
|
if (autoIds) {
|
|
const { body, diff, skipped } = applyRedactions(input, autoIds.split(","), opts);
|
|
process.stdout.write(body);
|
|
if (diff) process.stderr.write(diff + "\n");
|
|
if (skipped.length) {
|
|
process.stderr.write(
|
|
`\ngstack-redact: ${skipped.length} finding(s) could not be auto-redacted (structural) — edit manually:\n` +
|
|
skipped.map((f) => ` ${f.id} @ ${f.line}:${f.col}`).join("\n") +
|
|
"\n",
|
|
);
|
|
}
|
|
// Same truncation class as the report path below: the redacted BODY can
|
|
// be arbitrarily large; let stdout drain instead of process.exit(0).
|
|
return;
|
|
}
|
|
|
|
const result = scan(input, opts);
|
|
const code = exitCodeFor(result);
|
|
|
|
if (flag("--json")) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
} else {
|
|
const vis = result.repoVisibility.toUpperCase();
|
|
process.stdout.write(`gstack-redact scan — repo ${vis}\n`);
|
|
if (result.oversize) {
|
|
process.stdout.write(" BLOCKED — input too large to scan safely (fail-closed)\n");
|
|
} else {
|
|
process.stdout.write(humanTable(result.findings) + "\n");
|
|
const { HIGH, MEDIUM, LOW, WARN } = result.counts;
|
|
process.stdout.write(` HIGH=${HIGH} MEDIUM=${MEDIUM} LOW=${LOW} WARN=${WARN}\n`);
|
|
}
|
|
}
|
|
// process.exit() discards stdout still buffered in the pipe: a report past
|
|
// ~145 KB read by a slow consumer (node's gate-secret-scan.mjs collector)
|
|
// arrived truncated, JSON.parse failed, and the CI quality gate failed
|
|
// CLOSED on a clean scan. Set the exit code and let the runtime drain
|
|
// stdout instead — same contract, no truncation.
|
|
process.exitCode = code;
|
|
}
|
|
|
|
main();
|