mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 08:29:04 +02:00
fix: pre-landing review fixes (27 specialist findings, 3 critical)
Specialist army findings, all quote-verified before fixing:
Security: careful force-push guard now catches git's plus-refspec force
syntax (git push origin +main carried force with no flag — silently allowed
before) and refspec-form targets (HEAD:main); default-branch matching is
tokenized FIXED-STRING comparison on the full branch path (slashed defaults
like release/2.0 work; no ERE interpolation), glob-safe via noglob. HIGH rm
tier is tokenized too: trailing long options (--no-preserve-root) and /* are
root-class. Stored evidence fingerprints are 40-hex re-validated before
reaching git argv. normalizeForDetection sweeps ALL Unicode format chars
(\p{Cf}: soft hyphens, bidi marks, tag chars) instead of five enumerated
zero-widths. The wiring scanner gains flagless gh pr/issue view patterns. The
release-body banner tripwire diffs against the fetched original so a hostile
pre-existing banner string can't permanently DoS doc updates. Ship/land
evidence checks now pass --expect-cmd (a green `echo ok` recorded under the
label can never mint FRESH); package.json stays allow-listed with the
residual documented.
Performance: gstack-wtree seeds its temp index by COPYING the real index
(stat cache preserved — measured 40x faster than read-tree seeding, identical
hash) with read-tree fallback; evidence uses findLast and one gstack-slug
spawn; the stream pump honors backpressure via drain; careful's pattern block
short-circuits before slug resolution when no pattern file exists.
Testing: the gh-failure envelope test was VACUOUS (killing PATH killed the
bun shebang before the code under test ran) — replaced with a PATH gh shim
that exercises the real branch, plus shimmed happy paths (issue/pr-body/
unparseable JSON); evidence check --all + empty ledger + non-numeric
--max-age (now a usage error, was silent fail-open) covered; HIGH-tier
variants pinned; hook analytics respect GSTACK_HOME so tests stop writing the
operator's real skill-usage.jsonl.
Maintainability: dead exit ternary removed; flagValue deduped into
bin-context; sentinel defusal derived from the banner constants (no invisible
literals — \u escapes only); scratch-repo git fixture extracted to
test/helpers/scratch-repo.ts (one hermetic incantation, three consumers);
shared gstack_hook_log_fire in hook-extract.sh; the dashboard/land diff-scoped
row lists are aligned (codex-review) and drift-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2398fb7295
commit
a171029e6b
+28
-10
@@ -38,7 +38,6 @@ import { mkdirSync, openSync, writeSync, closeSync, readdirSync, statSync, unlin
|
||||
import { join, dirname } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
|
||||
import { resolveSlug } from "../lib/bin-context";
|
||||
import { scan, applyRedactions } from "../lib/redact-engine";
|
||||
|
||||
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
|
||||
@@ -94,10 +93,12 @@ function currentWtree(): string | undefined {
|
||||
|
||||
function ledgerPath(): { dir: string; file: string; logsDir: string } {
|
||||
const home = process.env.GSTACK_HOME || join(process.env.HOME || "~", ".gstack");
|
||||
const slug = resolveSlug(join(BIN_DIR, "gstack-slug"));
|
||||
// Same branch→filename sanitization as reviews.jsonl (gstack-slug's BRANCH).
|
||||
// ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines
|
||||
// (same branch→filename sanitization as reviews.jsonl).
|
||||
const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" });
|
||||
const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m);
|
||||
const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m);
|
||||
const slug = sm ? sm[1].trim() : "unknown";
|
||||
const branch = bm ? bm[1].trim() : "no-branch";
|
||||
const dir = join(home, "projects", slug);
|
||||
return { dir, file: join(dir, `${branch}-evidence.jsonl`), logsDir: join(dir, "logs") };
|
||||
@@ -212,7 +213,10 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined, out: NodeJS.WriteStream) => {
|
||||
if (!stream) return;
|
||||
for await (const chunk of stream) {
|
||||
out.write(chunk);
|
||||
// Honor backpressure: when the console consumer is slower than the child
|
||||
// (piped into a pager/log collector), wait for drain instead of queueing
|
||||
// unbounded chunks in the WriteStream buffer.
|
||||
if (!out.write(chunk)) await new Promise((r) => out.once("drain", r));
|
||||
teeToLog(chunk);
|
||||
}
|
||||
};
|
||||
@@ -298,7 +302,15 @@ function cmdCheck(argv: string[]): number {
|
||||
}
|
||||
wanted[wanted.length - 1].expectCmd = argv[++i] ?? "";
|
||||
} else if (a === "--all") all = true;
|
||||
else if (a === "--max-age") maxAgeHours = Number(argv[++i]);
|
||||
else if (a === "--max-age") {
|
||||
maxAgeHours = Number(argv[++i]);
|
||||
if (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0) {
|
||||
// A typo must never silently drop the age gate (fail open) on a
|
||||
// freshness checker: it is a usage error.
|
||||
console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(argv[i])}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
else if (a === "--allow-paths") allowPaths = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (!all && wanted.length === 0) {
|
||||
@@ -324,7 +336,7 @@ function cmdCheck(argv: string[]): number {
|
||||
const wtreeNow = currentWtree();
|
||||
let allFresh = true;
|
||||
for (const { label, expectCmd } of labels) {
|
||||
const latest = [...records].reverse().find((r) => r.label === label);
|
||||
const latest = records.findLast((r) => r.label === label);
|
||||
if (!latest) {
|
||||
console.log(`EVIDENCE: MISSING label=${label}`);
|
||||
allFresh = false;
|
||||
@@ -336,7 +348,7 @@ function cmdCheck(argv: string[]): number {
|
||||
if (latest.exit !== 0) {
|
||||
verdict = "STALE";
|
||||
reason = "recorded run failed";
|
||||
} else if (maxAgeHours !== undefined && Number.isFinite(maxAgeHours)) {
|
||||
} else if (maxAgeHours !== undefined) {
|
||||
const ageMs = Date.now() - Date.parse(latest.ts);
|
||||
if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) {
|
||||
verdict = "STALE";
|
||||
@@ -351,9 +363,15 @@ function cmdCheck(argv: string[]): number {
|
||||
// Content binding: identical working-tree fingerprint, or a diff confined
|
||||
// to the allow-list. Any git failure (gc'd tree, not a repo) → STALE —
|
||||
// never an error into the calling flow.
|
||||
if (!latest.wtree || !wtreeNow) {
|
||||
if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) {
|
||||
// Stored fingerprints are re-validated before reaching git argv — a
|
||||
// forged/corrupt ledger line must degrade, never inject options.
|
||||
verdict = "STALE";
|
||||
reason = !latest.wtree ? "record has no content fingerprint" : "current fingerprint unavailable";
|
||||
reason = !latest.wtree
|
||||
? "record has no content fingerprint"
|
||||
: !/^[0-9a-f]{40}$/.test(latest.wtree)
|
||||
? "record has malformed fingerprint"
|
||||
: "current fingerprint unavailable";
|
||||
} else if (latest.wtree !== wtreeNow) {
|
||||
const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]);
|
||||
if (diff === undefined) {
|
||||
@@ -392,5 +410,5 @@ try {
|
||||
// that breaks a skill flow: `run` propagates the child's code from inside
|
||||
// cmdRun; reaching here means bookkeeping blew up outside it.
|
||||
warn(`unexpected error: ${e?.message ?? e}`);
|
||||
process.exit(sub === "check" ? 1 : 1);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { wrapUntrustedTrackerContent } from "../lib/tracker-guard";
|
||||
import { flagValue } from "../lib/bin-context";
|
||||
|
||||
function gh(args: string[]): { ok: boolean; out: string; err: string } {
|
||||
try {
|
||||
@@ -36,11 +37,6 @@ function fail(msg: string): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function flagValue(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
const [, , mode, ...rest] = process.argv;
|
||||
|
||||
if (mode === "--stdin") {
|
||||
|
||||
+28
-7
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-wtree — print a working-tree CONTENT fingerprint (a git tree hash).
|
||||
#
|
||||
# Builds a temp index seeded from HEAD, stages the full working tree into it
|
||||
# (`git add -A`, so .gitignore'd scratch stays out and UNTRACKED source is
|
||||
# included), and prints `git write-tree` of that index. Properties that make
|
||||
# this the right staleness fingerprint, vs `git rev-parse HEAD^{tree}`:
|
||||
# Builds a temp index, stages the full working tree into it (`git add -A`, so
|
||||
# .gitignore'd scratch stays out and UNTRACKED source is included), and prints
|
||||
# `git write-tree` of that index. Properties that make this the right
|
||||
# staleness fingerprint, vs `git rev-parse HEAD^{tree}`:
|
||||
#
|
||||
# - Committing identical content does NOT change the fingerprint, so a
|
||||
# record made on a dirty tree stays valid after the exact same content is
|
||||
@@ -13,15 +13,36 @@
|
||||
# can't stay FRESH after a new file appears.
|
||||
# - Rebase/amend/squash that preserve content do not change it.
|
||||
#
|
||||
# Performance: the temp index is seeded by COPYING the real index (git writes
|
||||
# it atomically via rename, so the copy is a consistent snapshot). That
|
||||
# preserves the stat cache, so `git add -A` only re-hashes files whose stat
|
||||
# changed — measured 40x faster than a `read-tree HEAD` seed, which zeroes
|
||||
# stat data and forces a full re-hash of every tracked file. Both seeds
|
||||
# produce the identical write-tree hash. Fallback: `read-tree HEAD` when the
|
||||
# index copy is unavailable (fresh repo, exotic index).
|
||||
#
|
||||
# The real repo index is never touched. Staged blobs land in the object store
|
||||
# as unreachable objects and get gc'd like stash churn. Exit 1 outside a git
|
||||
# repo or in a repo with no commits — callers treat that as "no fingerprint".
|
||||
# as unreachable objects and get gc'd like stash churn (note: this means the
|
||||
# CONTENT of untracked, non-ignored files enters .git/objects until gc — the
|
||||
# same property `git stash -u` has). Exit 1 outside a git repo or in a repo
|
||||
# with no commits — callers treat that as "no fingerprint".
|
||||
set -euo pipefail
|
||||
|
||||
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
|
||||
TMPIDX=$(mktemp "${TMPDIR:-/tmp}/gstack-wtree-XXXXXX")
|
||||
trap 'rm -f "$TMPIDX"' EXIT
|
||||
export GIT_INDEX_FILE="$TMPIDX"
|
||||
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
|
||||
|
||||
REAL_INDEX=$(git -C "$TOP" rev-parse --git-path index 2>/dev/null || true)
|
||||
# Resolve relative --git-path output against the repo root.
|
||||
case "$REAL_INDEX" in
|
||||
""|/*) ;;
|
||||
*) REAL_INDEX="$TOP/$REAL_INDEX" ;;
|
||||
esac
|
||||
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>/dev/null; then
|
||||
: # stat-cache-preserving seed
|
||||
else
|
||||
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
|
||||
fi
|
||||
git -C "$TOP" add -A 2>/dev/null || exit 1
|
||||
git -C "$TOP" write-tree 2>/dev/null
|
||||
|
||||
Reference in New Issue
Block a user