mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 01:15:29 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/retention-cohorts
# Conflicts: # CHANGELOG.md # VERSION # package.json
This commit is contained in:
@@ -31,20 +31,52 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then
|
||||
fi
|
||||
|
||||
# ─── Fetch aggregated stats from edge function ────────────────
|
||||
DATA="$(curl -sf --max-time 15 \
|
||||
# HTTP status captured (#1947): a backend failure must read as "unknown",
|
||||
# never as a healthy "Weekly active installs: 0".
|
||||
TMPBODY="$(mktemp)"
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||
-H "apikey: ${ANON_KEY}" \
|
||||
2>/dev/null || echo "{}")"
|
||||
2>/dev/null || true)"
|
||||
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
|
||||
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
|
||||
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"
|
||||
[ -n "$HTTP_CODE" ] || HTTP_CODE="000"
|
||||
DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")"
|
||||
|
||||
echo "gstack community dashboard"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ] || ! printf '%s' "$DATA" | grep -q '"weekly_active"'; then
|
||||
echo "Community stats: unknown — backend error (HTTP ${HTTP_CODE})"
|
||||
echo ""
|
||||
echo "For local analytics: gstack-analytics"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Weekly active installs ──────────────────────────────────
|
||||
WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")"
|
||||
CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")"
|
||||
|
||||
echo "Weekly active installs: ${WEEKLY}"
|
||||
# Marker check: jq when available (whitespace/reserialization-proof); the
|
||||
# grep fallback tolerates optional whitespace around the colon.
|
||||
_STALE="false"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
_MARKER="$(printf '%s' "$DATA" | jq -r '.status // empty' 2>/dev/null)"
|
||||
_STALE="$(printf '%s' "$DATA" | jq -r '.stale // false' 2>/dev/null)"
|
||||
else
|
||||
_MARKER="$(printf '%s' "$DATA" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"' && echo ok || true)"
|
||||
fi
|
||||
if [ "$_MARKER" != "ok" ]; then
|
||||
echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)"
|
||||
elif [ "$_STALE" = "true" ]; then
|
||||
# Backend serves its last good snapshot when recompute fails — real but
|
||||
# frozen figures must not read as current (matches security-dashboard).
|
||||
echo " (stale snapshot — backend recompute failing; figures may be out of date)"
|
||||
fi
|
||||
if [ "$CHANGE" -gt 0 ] 2>/dev/null; then
|
||||
echo " Change: +${CHANGE}%"
|
||||
elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then
|
||||
|
||||
+4
-2
@@ -411,8 +411,10 @@ case "${1:-}" in
|
||||
fi
|
||||
|
||||
case "$STATUS" in
|
||||
ok)
|
||||
echo "Detected gbrain v$VERSION."
|
||||
ok|timeout)
|
||||
# "timeout" = slow-but-healthy engine (#1964) — same treatment as
|
||||
# "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs.
|
||||
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
|
||||
# Render brain-aware blocks INTO the global install so EVERY project's
|
||||
# Claude sessions get them (other projects read SKILL.md + sections from
|
||||
# ~/.claude/skills/gstack via absolute paths baked at gen time). Guards
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
|
||||
* "gstack_brain_git": true|false,
|
||||
* "gstack_artifacts_remote": "https://..." | "",
|
||||
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db",
|
||||
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout",
|
||||
* "gbrain_pooler_mode": "transaction"|"session"|null
|
||||
* }
|
||||
*
|
||||
@@ -48,7 +48,13 @@ import { isTransactionModePooler } from "../lib/gbrain-exec";
|
||||
const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config");
|
||||
const GBRAIN_CONFIG = join(userHome(), ".gbrain", "config.json");
|
||||
// Honors GBRAIN_HOME — must stay consistent with lib/gbrain-local-status's
|
||||
// config resolution, or the detect JSON reports gbrain_local_status "ok"
|
||||
// alongside gbrain_config_exists false for relocated-home users.
|
||||
const GBRAIN_CONFIG = join(
|
||||
process.env.GBRAIN_HOME || join(userHome(), ".gbrain"),
|
||||
"config.json",
|
||||
);
|
||||
const CLAUDE_JSON = join(userHome(), ".claude.json");
|
||||
|
||||
function userHome(): string {
|
||||
@@ -234,14 +240,17 @@ function main(): void {
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"), 1
|
||||
// otherwise. Runs detection live (never reads the possibly-stale
|
||||
// gbrain-detection.json), so callers — setup, bin/dev-setup, and
|
||||
// `gstack-config gbrain-refresh` — can decide whether to render the gbrain
|
||||
// :user variant without duplicating the JSON grep. Prints nothing on stdout.
|
||||
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok", or
|
||||
// "timeout" — a slow-but-healthy engine, #1964 — slow must not silently
|
||||
// suppress brain features), 1 otherwise. Runs detection live (never reads
|
||||
// the possibly-stale gbrain-detection.json), so callers — setup,
|
||||
// bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to
|
||||
// render the gbrain :user variant without duplicating the JSON grep.
|
||||
// Prints nothing on stdout.
|
||||
if (process.argv.includes("--is-ok")) {
|
||||
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
|
||||
process.exit(localEngineStatus({ noCache }) === "ok" ? 0 : 1);
|
||||
const status = localEngineStatus({ noCache });
|
||||
process.exit(status === "ok" || status === "timeout" ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -717,6 +717,8 @@ function dreamMarkerPid(): number | null {
|
||||
* missing-config → "no local engine; run /setup-gbrain to add local PGLite"
|
||||
* broken-config → "config file at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5"
|
||||
* broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5"
|
||||
* timeout → kept for Record totality; stages PROCEED on timeout (#1964)
|
||||
* via the gate's warnProbeTimeout path, never this skip.
|
||||
*/
|
||||
function skipStageForLocalStatus(
|
||||
stage: "code" | "memory" | "dream",
|
||||
@@ -731,6 +733,8 @@ function skipStageForLocalStatus(
|
||||
"config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5",
|
||||
"broken-db":
|
||||
"config points at unreachable DB; see /setup-gbrain Step 1.5",
|
||||
"timeout":
|
||||
"engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow",
|
||||
};
|
||||
const reason = reasons[status as Exclude<LocalEngineStatus, "ok">];
|
||||
return {
|
||||
@@ -742,6 +746,20 @@ function skipStageForLocalStatus(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "timeout" means the probe hit its deadline with no recognized error — the
|
||||
* engine is most likely healthy but slow (#1964: cold pooler connections
|
||||
* measured at 6.9-10.7s). Stages proceed; a genuinely-dead engine surfaces
|
||||
* its REAL error at the first actual operation instead of a false
|
||||
* "config malformed" skip.
|
||||
*/
|
||||
function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
|
||||
process.stderr.write(
|
||||
`[gstack-gbrain-sync] ${stage}: engine probe timed out — proceeding anyway; ` +
|
||||
`raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow\n`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||
const t0 = Date.now();
|
||||
@@ -773,7 +791,9 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||
// when the local DB is dead. Skipped on --dry-run (above) since dry-run
|
||||
// never actually probes anything.
|
||||
const localStatus = localEngineStatus({ noCache: false });
|
||||
if (localStatus !== "ok") {
|
||||
if (localStatus === "timeout") {
|
||||
warnProbeTimeout("code"); // #1964: slow-but-healthy — proceed
|
||||
} else if (localStatus !== "ok") {
|
||||
return skipStageForLocalStatus("code", localStatus, t0);
|
||||
}
|
||||
|
||||
@@ -1031,7 +1051,9 @@ function runMemoryIngest(args: CliArgs): StageResult {
|
||||
// not ok, SKIP cleanly so brain-sync (the only stage that doesn't depend
|
||||
// on local engine) still runs.
|
||||
const localStatus = localEngineStatus({ noCache: false });
|
||||
if (localStatus !== "ok") {
|
||||
if (localStatus === "timeout") {
|
||||
warnProbeTimeout("memory"); // #1964: slow-but-healthy — proceed
|
||||
} else if (localStatus !== "ok") {
|
||||
return skipStageForLocalStatus("memory", localStatus, t0);
|
||||
}
|
||||
|
||||
@@ -1193,7 +1215,9 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
|
||||
}
|
||||
|
||||
const localStatus = localEngineStatus({ noCache: false });
|
||||
if (localStatus !== "ok") {
|
||||
if (localStatus === "timeout") {
|
||||
warnProbeTimeout("dream"); // #1964: slow-but-healthy — proceed
|
||||
} else if (localStatus !== "ok") {
|
||||
return skipStageForLocalStatus("dream", localStatus, t0);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,24 @@
|
||||
# by gstack-learnings-search ("latest winner" per key+type).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun
|
||||
# on Windows cannot resolve as an ES module specifier in the import below.
|
||||
# cygpath -m converts to C:/Users/... which Bun accepts.
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||
esac
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
INPUT="$1"
|
||||
|
||||
# Validate and sanitize input
|
||||
# Validate and sanitize input. Errors surface (#1950): stderr is captured and
|
||||
# printed on failure instead of swallowed — a silent exit 1 here cost Windows
|
||||
# users every AI-logged learning.
|
||||
TMPERR=$(mktemp)
|
||||
trap 'rm -f "$TMPERR"' EXIT
|
||||
set +e
|
||||
VALIDATED=$(printf '%s' "$INPUT" | bun -e "
|
||||
import { hasInjection } from '$SCRIPT_DIR/../lib/jsonl-store.ts';
|
||||
const raw = await Bun.stdin.text();
|
||||
@@ -63,9 +74,14 @@ if (!j.ts) j.ts = new Date().toISOString();
|
||||
j.trusted = j.source === 'user-stated';
|
||||
|
||||
console.log(JSON.stringify(j));
|
||||
" 2>/dev/null)
|
||||
" 2>"$TMPERR")
|
||||
VALIDATE_RC=$?
|
||||
set -e
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$VALIDATED" ]; then
|
||||
if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then
|
||||
if [ -s "$TMPERR" ]; then
|
||||
cat "$TMPERR" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+13
-17
@@ -27,6 +27,12 @@
|
||||
# Append-only JSONL. Dedup is at read time in gstack-question-sensitivity --read-log.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun
|
||||
# on Windows cannot resolve as an ES module specifier in bun -e imports.
|
||||
# cygpath -m converts to C:/Users/... which Bun accepts.
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||
esac
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
||||
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||
@@ -39,6 +45,7 @@ TMPERR=$(mktemp)
|
||||
trap 'rm -f "$TMPERR"' EXIT
|
||||
set +e
|
||||
VALIDATED=$(printf '%s' "$INPUT" | bun -e "
|
||||
import { hasInjection } from '$SCRIPT_DIR/../lib/jsonl-store.ts';
|
||||
const path = require('path');
|
||||
const raw = await Bun.stdin.text();
|
||||
let j;
|
||||
@@ -104,23 +111,12 @@ if (j.question_summary.includes('\n')) {
|
||||
j.question_summary = j.question_summary.replace(/\n+/g, ' ');
|
||||
}
|
||||
|
||||
// Injection defense on the summary — same patterns as learnings-log.
|
||||
const INJECTION_PATTERNS = [
|
||||
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
|
||||
/you\s+are\s+now\s+/i,
|
||||
/always\s+output\s+no\s+findings/i,
|
||||
/skip\s+(all\s+)?(security|review|checks)/i,
|
||||
/override[:\s]/i,
|
||||
/\bsystem\s*:/i,
|
||||
/\bassistant\s*:/i,
|
||||
/\buser\s*:/i,
|
||||
/do\s+not\s+(report|flag|mention)/i,
|
||||
];
|
||||
for (const pat of INJECTION_PATTERNS) {
|
||||
if (pat.test(j.question_summary)) {
|
||||
process.stderr.write('gstack-question-log: question_summary contains suspicious instruction-like content, rejected\n');
|
||||
process.exit(1);
|
||||
}
|
||||
// Injection defense on the summary — shared audited list (lib/jsonl-store.ts),
|
||||
// same source of truth as learnings-log and decision-log. The previous local
|
||||
// duplicate drifted (#1934): pattern fixes to the lib never propagated here.
|
||||
if (hasInjection(j.question_summary)) {
|
||||
process.stderr.write('gstack-question-log: question_summary contains suspicious instruction-like content, rejected\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Registry lookup for category + door_type enrichment.
|
||||
|
||||
@@ -35,11 +35,41 @@ const ZERO = /^0+$/;
|
||||
// The canonical empty-tree object; diffing against it yields all content as added.
|
||||
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
return r.status === 0 ? (r.stdout ?? "") : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed git for the diff that decides whether the push is scanned
|
||||
* (#1946). status !== 0 covers repo errors; status === null covers a killed
|
||||
* process AND maxBuffer overflow — the oversized-diff case is exactly where
|
||||
* a large secret-bearing blob is most likely, so "couldn't read the diff"
|
||||
* must block, not silently allow.
|
||||
*/
|
||||
function gitStrict(args: string[]): string {
|
||||
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
// status !== 0 covers BOTH a non-zero exit AND null (process killed by a
|
||||
// signal or maxBuffer overflow — null !== 0 is true).
|
||||
if (r.status !== 0) {
|
||||
throw new Error(
|
||||
`git ${args[0]} failed (status=${r.status ?? "killed/overflow"}): ${(r.stderr ?? "").slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return r.stdout ?? "";
|
||||
}
|
||||
|
||||
/** True when the object exists in the local odb (cat-file -e signals via exit code). */
|
||||
function objectExists(sha: string): boolean {
|
||||
const r = spawnSync("git", ["cat-file", "-e", sha], { encoding: "utf8" });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function defaultRemoteBranch(): string {
|
||||
// origin/HEAD → origin/main, fall back to main/master.
|
||||
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
|
||||
@@ -59,13 +89,22 @@ function addedLinesFor(localSha: string, remoteSha: string): string {
|
||||
// branch content is scanned as added — fail-safe (scans more, never less).
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).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();
|
||||
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||
} else {
|
||||
// Existing branch (incl. force-push): net new content remote..local.
|
||||
range = `${remoteSha}..${localSha}`;
|
||||
}
|
||||
// -U0: only changed lines; we keep lines starting with '+' (added), drop the
|
||||
// +++ file header. Unified diff added lines start with a single '+'.
|
||||
const diff = git(["diff", "--unified=0", "--no-color", range]);
|
||||
// Strict (#1946): a failed diff used to return "" and the push sailed
|
||||
// through unscanned — fail open on the exact path the guard exists for.
|
||||
const diff = gitStrict(["diff", "--unified=0", "--no-color", range]);
|
||||
const added: string[] = [];
|
||||
for (const line of diff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
@@ -108,7 +147,21 @@ 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: string;
|
||||
try {
|
||||
added = addedLinesFor(localSha, remoteSha || "0");
|
||||
} catch (err) {
|
||||
// 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.
|
||||
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` +
|
||||
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\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).
|
||||
|
||||
@@ -41,28 +41,52 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DATA="$(curl -sf --max-time 15 \
|
||||
# Fetch with the HTTP status captured (#1947). A backend failure must read
|
||||
# as "unknown", never as a healthy "0 attacks" — fake zeros on a security
|
||||
# surface are indistinguishable from good news.
|
||||
TMPBODY="$(mktemp)"
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||
-H "apikey: ${ANON_KEY}" \
|
||||
2>/dev/null || echo "{}")"
|
||||
2>/dev/null || true)"
|
||||
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
|
||||
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
|
||||
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"
|
||||
[ -n "$HTTP_CODE" ] || HTTP_CODE="000"
|
||||
DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")"
|
||||
|
||||
# Extract the security section. Prefer jq for brace-balanced parsing of
|
||||
# nested arrays/objects (top_attack_domains etc.). Fall back to regex if
|
||||
# jq isn't installed — the regex is lossy but the dashboard degrades
|
||||
# gracefully to "0 attacks" rather than misreporting numbers.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
SEC_SECTION="$(echo "$DATA" | jq -rc '.security // empty | "\"security\":\(.)"' 2>/dev/null || echo "")"
|
||||
else
|
||||
SEC_SECTION="$(echo "$DATA" | grep -o '"security":{[^}]*}' 2>/dev/null || echo "")"
|
||||
# Classify the response:
|
||||
# ok — 200 from the new backend (carries "status":"ok"); figures authoritative
|
||||
# legacy — 200 with a security section but no marker (pre-#1947 backend);
|
||||
# figures shown but flagged unverified (old backend masked errors as zeros)
|
||||
# unknown — non-200 / network failure / error body / missing section / no jq
|
||||
STATE="ok"
|
||||
REASON=""
|
||||
if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ]; then
|
||||
STATE="unknown"; REASON="backend_error"
|
||||
elif ! command -v jq >/dev/null 2>&1; then
|
||||
# No lossy-grep fallback: the old regex broke on nested arrays and
|
||||
# under-reported attacks as zero. Without jq the honest answer is unknown.
|
||||
STATE="unknown"; REASON="jq_missing"
|
||||
elif ! echo "$DATA" | jq -e '.security' >/dev/null 2>&1; then
|
||||
STATE="unknown"; REASON="backend_error"
|
||||
elif [ "$(echo "$DATA" | jq -r '.status // empty' 2>/dev/null)" != "ok" ]; then
|
||||
STATE="legacy"
|
||||
fi
|
||||
|
||||
if [ "$JSON_MODE" = "1" ]; then
|
||||
# Machine-readable — echo the whole security section (or empty object)
|
||||
if [ -n "$SEC_SECTION" ]; then
|
||||
echo "{${SEC_SECTION}}"
|
||||
else
|
||||
echo '{"security":{"attacks_last_7_days":0,"top_attack_domains":[],"top_attack_layers":[],"verdict_distribution":[]}}'
|
||||
fi
|
||||
case "$STATE" in
|
||||
unknown)
|
||||
echo "{\"security\":null,\"status\":\"unknown\",\"reason\":\"${REASON}\"}"
|
||||
;;
|
||||
legacy)
|
||||
echo "$DATA" | jq -c '{security: .security, status: "legacy_unverified"}'
|
||||
;;
|
||||
ok)
|
||||
echo "$DATA" | jq -c '{security: .security, status: "ok", stale: (.stale // false)}'
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -71,47 +95,64 @@ echo "gstack security dashboard"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
TOTAL="$(echo "$DATA" | grep -o '"attacks_last_7_days":[0-9]*' | grep -o '[0-9]*' | head -1 || echo "0")"
|
||||
if [ "$STATE" = "unknown" ]; then
|
||||
if [ "$REASON" = "jq_missing" ]; then
|
||||
echo "Attacks detected last 7 days: unknown — install jq for exact figures"
|
||||
else
|
||||
echo "Attacks detected last 7 days: unknown — backend error (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
echo ""
|
||||
echo "Your local log: ~/.gstack/security/attempts.jsonl"
|
||||
echo "Your telemetry mode: $(${GSTACK_DIR}/bin/gstack-config get telemetry 2>/dev/null || echo unknown)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# jq is guaranteed here (jq-missing classified as unknown above). The old
|
||||
# grep chain matched the digit 7 inside "attacks_last_7_days" itself and
|
||||
# misreported every count as 7.
|
||||
TOTAL="$(echo "$DATA" | jq -r '.security.attacks_last_7_days // 0' 2>/dev/null || echo "0")"
|
||||
echo "Attacks detected last 7 days: ${TOTAL}"
|
||||
if [ "$TOTAL" = "0" ]; then
|
||||
if [ "$STATE" = "legacy" ]; then
|
||||
echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)"
|
||||
elif [ "$(echo "$DATA" | jq -r '.stale // false' 2>/dev/null)" = "true" ]; then
|
||||
# The backend serves its last good snapshot when recompute fails — figures
|
||||
# are real but frozen. Don't present them as current.
|
||||
echo " (stale snapshot — backend recompute failing; figures may be out of date)"
|
||||
elif [ "$TOTAL" = "0" ]; then
|
||||
echo " (No attack attempts reported by the community yet. Good news.)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Top attacked domains — parse objects inside top_attack_domains array
|
||||
DOMAINS="$(echo "$DATA" | sed -n 's/.*"top_attack_domains":\(\[[^]]*\]\).*/\1/p' | head -1)"
|
||||
if [ -n "$DOMAINS" ] && [ "$DOMAINS" != "[]" ]; then
|
||||
# Array sections — jq is guaranteed past the state gate; the old sed/grep
|
||||
# parsing truncated at the first ']' and dropped entries on any nesting
|
||||
# (the same bug class as the "every count is 7" TOTAL grep).
|
||||
DOMAINS="$(echo "$DATA" | jq -r '.security.top_attack_domains[]? | "\(.domain)\t\(.count)"' 2>/dev/null)"
|
||||
if [ -n "$DOMAINS" ]; then
|
||||
echo "Top attacked domains"
|
||||
echo "────────────────────"
|
||||
echo "$DOMAINS" | grep -o '{[^}]*}' | head -10 | while read -r OBJ; do
|
||||
DOMAIN="$(echo "$OBJ" | grep -o '"domain":"[^"]*"' | awk -F'"' '{print $4}')"
|
||||
COUNT="$(echo "$OBJ" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')"
|
||||
printf '%s\n' "$DOMAINS" | head -10 | while IFS="$(printf '\t')" read -r DOMAIN COUNT; do
|
||||
[ -n "$DOMAIN" ] && [ -n "$COUNT" ] && printf " %-40s %s attempts\n" "$DOMAIN" "$COUNT"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Which layer catches attacks
|
||||
LAYERS="$(echo "$DATA" | sed -n 's/.*"top_attack_layers":\(\[[^]]*\]\).*/\1/p' | head -1)"
|
||||
if [ -n "$LAYERS" ] && [ "$LAYERS" != "[]" ]; then
|
||||
LAYERS="$(echo "$DATA" | jq -r '.security.top_attack_layers[]? | "\(.layer)\t\(.count)"' 2>/dev/null)"
|
||||
if [ -n "$LAYERS" ]; then
|
||||
echo "Top detection layers"
|
||||
echo "────────────────────"
|
||||
echo "$LAYERS" | grep -o '{[^}]*}' | while read -r OBJ; do
|
||||
LAYER="$(echo "$OBJ" | grep -o '"layer":"[^"]*"' | awk -F'"' '{print $4}')"
|
||||
COUNT="$(echo "$OBJ" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')"
|
||||
printf '%s\n' "$LAYERS" | while IFS="$(printf '\t')" read -r LAYER COUNT; do
|
||||
[ -n "$LAYER" ] && [ -n "$COUNT" ] && printf " %-28s %s\n" "$LAYER" "$COUNT"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Verdict distribution
|
||||
VERDICTS="$(echo "$DATA" | sed -n 's/.*"verdict_distribution":\(\[[^]]*\]\).*/\1/p' | head -1)"
|
||||
if [ -n "$VERDICTS" ] && [ "$VERDICTS" != "[]" ]; then
|
||||
VERDICTS="$(echo "$DATA" | jq -r '.security.verdict_distribution[]? | "\(.verdict)\t\(.count)"' 2>/dev/null)"
|
||||
if [ -n "$VERDICTS" ]; then
|
||||
echo "Verdict distribution"
|
||||
echo "────────────────────"
|
||||
echo "$VERDICTS" | grep -o '{[^}]*}' | while read -r OBJ; do
|
||||
VERDICT="$(echo "$OBJ" | grep -o '"verdict":"[^"]*"' | awk -F'"' '{print $4}')"
|
||||
COUNT="$(echo "$OBJ" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')"
|
||||
printf '%s\n' "$VERDICTS" | while IFS="$(printf '\t')" read -r VERDICT COUNT; do
|
||||
[ -n "$VERDICT" ] && [ -n "$COUNT" ] && printf " %-14s %s\n" "$VERDICT" "$COUNT"
|
||||
done
|
||||
echo ""
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
set -uo pipefail
|
||||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
SCRIPT_DIR="$GSTACK_DIR/bin"
|
||||
# Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun
|
||||
# on Windows cannot resolve as an ES module specifier in bun -e imports.
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||
esac
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
ANALYTICS_DIR="$STATE_DIR/analytics"
|
||||
JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl"
|
||||
@@ -177,8 +183,29 @@ BRANCH="$(json_safe "$BRANCH")"
|
||||
ERR_FIELD="null"
|
||||
[ -n "$ERROR_CLASS" ] && ERR_FIELD="\"$(json_safe "$ERROR_CLASS")\""
|
||||
|
||||
# error_message goes through the redaction engine before it touches disk
|
||||
# (#1947): stack traces and failed-API errors can embed credentials, paths,
|
||||
# and hostnames. Every finding span becomes <REDACTED-{id}>; the rest of the
|
||||
# message survives for crash triage. The bun snippet emits a JSON-encoded
|
||||
# string (quotes included) ready to drop into the printf below. FAIL CLOSED:
|
||||
# if bun / the engine is unavailable, the scan errors, or the output doesn't
|
||||
# look like a JSON string, the whole message becomes null — never raw.
|
||||
ERR_MSG_FIELD="null"
|
||||
[ -n "$ERROR_MESSAGE" ] && ERR_MSG_FIELD="\"$(printf '%s' "$ERROR_MESSAGE" | head -c 200 | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n\r' ' ')\""
|
||||
if [ -n "$ERROR_MESSAGE" ]; then
|
||||
ERR_MSG_FIELD="$(printf '%s' "$ERROR_MESSAGE" | bun -e "
|
||||
import { redactFindingSpans } from '$SCRIPT_DIR/../lib/redact-engine.ts';
|
||||
const input = await Bun.stdin.text();
|
||||
const out = redactFindingSpans(input, { repoVisibility: 'private' });
|
||||
if (out === null) process.exit(1);
|
||||
console.log(JSON.stringify(out.slice(0, 200)));
|
||||
" 2>/dev/null)" || ERR_MSG_FIELD="null"
|
||||
case "$ERR_MSG_FIELD" in
|
||||
*"
|
||||
"*) ERR_MSG_FIELD="null" ;; # embedded newline would corrupt the JSONL record
|
||||
\"*\") ;; # single-line JSON string — safe to embed
|
||||
*) ERR_MSG_FIELD="null" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
STEP_FIELD="null"
|
||||
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
|
||||
|
||||
Reference in New Issue
Block a user