mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 06:58:59 +02:00
Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup
Second overlapping-wave merge; resolutions compose intent: - TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test; ours additionally has ios-qa/scripts + browser-skills). package.json 'test' keeps routing through the canonical strict runner. - gbrainAvailable: main fixed the same load-flake with a strictly better mechanism (memoized stat-based PATH scan, no subprocess at all) — theirs supersedes this branch's memoized-exec probe. Main also made the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS). - Model defaults: adopted main's lib/eval-model.ts abstraction (one resolution point, env-overridable per kind) and applied decision D1a inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow. - Parent watchdog: main's rewrite (named parameterized tick, driven deterministically by its test via __testInternals__, plus handoff suppression semantics from session persistence) supersedes this branch's env-tunable interval; adopted their server + test wholesale. - windows-free-tests: ours (curated bun run test:windows) — main's hand-list grew by one more file, which the curated runner subsumes automatically; that drift is the reason for D11. - context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept this branch's comment (carries the receipts). - .gitignore: main's superset (also ignores Package.resolved — their never-commit call; untracked the copy this branch had committed). Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5, actionlint clean, eval:select works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch Chrome with CDP (remote debugging) enabled.
|
||||
# Usage: chrome-cdp [port]
|
||||
#
|
||||
# Chrome refuses --remote-debugging-port on its default data directory.
|
||||
# We create a separate data dir with a symlink to the user's real profile,
|
||||
# so Chrome thinks it's non-default but uses the same cookies/extensions.
|
||||
|
||||
PORT="${1:-9222}"
|
||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
|
||||
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"
|
||||
|
||||
if ! [ -f "$CHROME" ]; then
|
||||
echo "Chrome not found at $CHROME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Chrome is running
|
||||
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
|
||||
echo "Chrome is still running. Quitting..."
|
||||
osascript -e 'tell application "Google Chrome" to quit' 2>/dev/null
|
||||
|
||||
# Wait for it to fully exit
|
||||
for i in $(seq 1 20); do
|
||||
pgrep -f "Google Chrome" >/dev/null 2>&1 || break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
|
||||
echo "Chrome won't quit. Force-killing..." >&2
|
||||
pkill -f "Google Chrome"
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up CDP data dir with symlinked profile
|
||||
# Chrome requires a "non-default" data dir for --remote-debugging-port.
|
||||
# We symlink the real Default profile so cookies/extensions carry over.
|
||||
mkdir -p "$CDP_DATA_DIR"
|
||||
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
|
||||
ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
|
||||
echo "Linked real Chrome profile into CDP data dir"
|
||||
fi
|
||||
# Also link Local State (contains crypto keys for cookie decryption, etc.)
|
||||
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
|
||||
ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
|
||||
fi
|
||||
|
||||
echo "Launching Chrome with CDP on port $PORT..."
|
||||
"$CHROME" \
|
||||
--remote-debugging-port="$PORT" \
|
||||
--remote-debugging-address=127.0.0.1 \
|
||||
--remote-allow-origins="http://127.0.0.1:$PORT" \
|
||||
--user-data-dir="$CDP_DATA_DIR" \
|
||||
--restore-last-session &
|
||||
disown
|
||||
|
||||
# Wait for CDP to be available
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
|
||||
echo "CDP ready on port $PORT"
|
||||
echo "Run: \$B connect chrome"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "CDP not available after 30s." >&2
|
||||
exit 1
|
||||
@@ -34,9 +34,9 @@
|
||||
* gstack-brain-context-load --quiet
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
|
||||
import { join, dirname, basename, resolve } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs";
|
||||
import { join, dirname, basename, resolve, delimiter } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
|
||||
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
|
||||
@@ -68,7 +68,9 @@ interface QueryResult {
|
||||
|
||||
const HOME = homedir();
|
||||
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
|
||||
const MCP_TIMEOUT_MS = 500;
|
||||
// 500ms hard cap per Section 1C; overridable for slow/loaded environments
|
||||
// (test harnesses under CI load, cold CLI starts).
|
||||
const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500);
|
||||
const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
@@ -190,26 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null {
|
||||
|
||||
// ── Dispatchers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Memoized: availability can't change mid-invocation, and the per-query
|
||||
// re-probe was both wasteful (N probes per run) and load-flaky — a cold
|
||||
// `gbrain --version` on a saturated box can exceed the 500ms budget, branding
|
||||
// gbrain "missing" for one query while its siblings succeed (observed under
|
||||
// the parallel free-suite runner: SKIP at dur=505ms with two OKs after it).
|
||||
let _gbrainAvailable: boolean | null = null;
|
||||
let gbrainOnPath: boolean | null = null;
|
||||
|
||||
function gbrainAvailable(): boolean {
|
||||
if (_gbrainAvailable !== null) return _gbrainAvailable;
|
||||
try {
|
||||
execFileSync("gbrain", ["--version"], {
|
||||
stdio: "ignore",
|
||||
// Generous first-probe budget: this runs ONCE, and a slow-to-start CLI
|
||||
// is not a missing CLI. Query calls keep the tight MCP_TIMEOUT_MS.
|
||||
timeout: 5_000,
|
||||
});
|
||||
_gbrainAvailable = true;
|
||||
} catch {
|
||||
_gbrainAvailable = false;
|
||||
}
|
||||
return _gbrainAvailable;
|
||||
// Stat-based PATH scan, memoized. Spawning `gbrain --version` under the
|
||||
// 500ms budget misreported gbrain as missing whenever a cold process spawn
|
||||
// exceeded the timeout (loaded machine, node-based CLI cold start), and
|
||||
// re-probing per query burned 3x the budget before any real work.
|
||||
if (gbrainOnPath !== null) return gbrainOnPath;
|
||||
const exts = process.platform === "win32"
|
||||
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
|
||||
: [""];
|
||||
gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) =>
|
||||
dir !== "" && exts.some((ext) => {
|
||||
try {
|
||||
accessSync(join(dir, `gbrain${ext}`), constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
);
|
||||
return gbrainOnPath;
|
||||
}
|
||||
|
||||
function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult {
|
||||
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* gstack-code-intelligence — pick a code-intelligence provider and use it to
|
||||
* index and search this repo. OPTIONAL: with nothing selected, gstack works
|
||||
* fine and callers use grep / the file-only decision store.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* Usage:
|
||||
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
|
||||
* gstack-code-intelligence options # list providers (GBrain first) + availability
|
||||
* gstack-code-intelligence status # current selection + availability
|
||||
* gstack-code-intelligence consent [repo-path] <yes|no> # record per-repo indexing consent (value REQUIRED)
|
||||
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
|
||||
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
|
||||
* gstack-code-intelligence search <query...> # search via the selected provider
|
||||
*
|
||||
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
|
||||
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
|
||||
* nothing leaves the machine, so no consent is needed. Graphify is never
|
||||
* auto-installed.
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { realpathSync } from "fs";
|
||||
import { hostname } from "os";
|
||||
import { basename, resolve } from "path";
|
||||
import {
|
||||
CodeProviderError,
|
||||
RECOMMENDED_ORDER,
|
||||
detectAvailable,
|
||||
getRoot,
|
||||
hasConsent,
|
||||
providerById,
|
||||
readSelection,
|
||||
resolveSelectedProvider,
|
||||
setConsent,
|
||||
setProvider,
|
||||
setRoot,
|
||||
shouldOfferIndexing,
|
||||
type CodeProviderId,
|
||||
} from "../lib/code-intelligence";
|
||||
|
||||
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
|
||||
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
|
||||
const NOTE: Record<CodeProviderId, string> = {
|
||||
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
|
||||
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
|
||||
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
|
||||
};
|
||||
|
||||
function out(s: string): void {
|
||||
process.stdout.write(`${s}\n`);
|
||||
}
|
||||
function fail(s: string): never {
|
||||
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdOptions(): Promise<void> {
|
||||
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
|
||||
const avail = await detectAvailable();
|
||||
const byId = new Map(avail.map((a) => [a.id, a]));
|
||||
for (const id of RECOMMENDED_ORDER) {
|
||||
const a = byId.get(id);
|
||||
const mark = a?.available ? "available" : "not available";
|
||||
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
|
||||
if (a?.detail) out(` ${a.detail}`);
|
||||
}
|
||||
out("\nSelect one with: gstack-code-intelligence select <provider>");
|
||||
}
|
||||
|
||||
async function cmdStatus(): Promise<void> {
|
||||
const sel = readSelection();
|
||||
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
|
||||
const avail = await detectAvailable();
|
||||
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
|
||||
* agent should ask the user about indexing this repo, and when it should, the
|
||||
* provider options with their reasons so the question is self-contained.
|
||||
*/
|
||||
async function cmdSuggest(rest: string[]): Promise<void> {
|
||||
const json = rest.includes("--json");
|
||||
const pathArg = rest.find((a) => !a.startsWith("--"));
|
||||
const repoPath = resolve(pathArg ?? process.cwd());
|
||||
const suggestion = shouldOfferIndexing(repoPath);
|
||||
if (!suggestion.offer) {
|
||||
if (json) {
|
||||
out(JSON.stringify({ ...suggestion, repoPath }));
|
||||
} else {
|
||||
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const avail = await detectAvailable();
|
||||
if (json) {
|
||||
out(JSON.stringify({
|
||||
...suggestion,
|
||||
repoPath,
|
||||
options: avail.map((a) => ({
|
||||
id: a.id,
|
||||
label: LABEL[a.id],
|
||||
reason: NOTE[a.id],
|
||||
local: providerById(a.id).local,
|
||||
available: a.available,
|
||||
detail: a.detail,
|
||||
})),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
|
||||
await cmdOptions();
|
||||
}
|
||||
|
||||
function cmdSelect(arg: string | undefined): void {
|
||||
if (arg === "none") {
|
||||
setProvider(null);
|
||||
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
|
||||
return;
|
||||
}
|
||||
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
|
||||
fail("Usage: select <gbrain|sourcebot|graphify|none>");
|
||||
}
|
||||
const id = arg as CodeProviderId;
|
||||
setProvider(id);
|
||||
out(`selected ${LABEL[id]}.`);
|
||||
const provider = providerById(id);
|
||||
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record per-repo indexing consent: `consent [repo-path] <yes|no>`.
|
||||
*
|
||||
* The yes|no value is REQUIRED (true/false also accepted). It is never
|
||||
* defaulted: an agent recording a user's "no" must persist consent DENIED,
|
||||
* and a missing/unknown value must record NOTHING — a consent gate that
|
||||
* assumes "yes" is a consent gate that lies.
|
||||
*/
|
||||
function cmdConsent(rest: string[]): void {
|
||||
const positional = rest.filter((a) => !a.startsWith("--"));
|
||||
const CONSENT_USAGE = "Usage: consent [repo-path] <yes|no> — the yes/no value is required; consent is never assumed";
|
||||
if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE);
|
||||
const value = positional[positional.length - 1].toLowerCase();
|
||||
let consented: boolean;
|
||||
if (value === "yes" || value === "true") consented = true;
|
||||
else if (value === "no" || value === "false") consented = false;
|
||||
else fail(CONSENT_USAGE);
|
||||
const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd());
|
||||
setConsent(repoPath, consented);
|
||||
out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host+path-hashed source id for GBrain/Sourcebot — the same approach as
|
||||
* deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides:
|
||||
* two repos both named "api" (or the same repo on two machines against a
|
||||
* federated brain) would silently share one source. Suffix = first 8 hex of
|
||||
* sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id
|
||||
* charset (lowercase alnum + interior hyphens) and capped so the whole id
|
||||
* stays within gbrain's 32-char limit.
|
||||
*/
|
||||
function hashedSourceId(repoPath: string): string {
|
||||
let real = repoPath;
|
||||
try {
|
||||
real = realpathSync(repoPath);
|
||||
} catch {
|
||||
// path may not exist yet at id-derivation time — hash the resolved form
|
||||
}
|
||||
const host = process.env.GSTACK_HOSTNAME || hostname();
|
||||
const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8);
|
||||
const base =
|
||||
basename(real)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 23)
|
||||
.replace(/-+$/, "") || "repo";
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
async function cmdIndex(pathArg: string | undefined): Promise<void> {
|
||||
const provider = resolveSelectedProvider();
|
||||
if (!provider) fail("no provider selected; run `select <provider>` first");
|
||||
const repoPath = resolve(pathArg ?? process.cwd());
|
||||
// Indexing is write-class: hasConsent's default op class applies, so a
|
||||
// `deny` OR `read-only` repo trust policy vetoes it (code indexing writes
|
||||
// pages — same semantics as gstack-gbrain-sync's runCodeImport).
|
||||
const consented = hasConsent(repoPath);
|
||||
if (!provider!.local && !consented) {
|
||||
const recorded = readSelection().consents[repoPath] === true;
|
||||
fail(recorded
|
||||
? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set <origin-url> read-write`
|
||||
: `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`);
|
||||
}
|
||||
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short
|
||||
// host+path-hashed id (bare basenames collide across same-named repos).
|
||||
const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath);
|
||||
const repo = { id: sourceId, path: repoPath };
|
||||
try {
|
||||
const registered = await provider!.registerSource(repo, { consented });
|
||||
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
|
||||
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
|
||||
// Remember which repo this provider indexed so `search` reads the same graph.
|
||||
setRoot(provider!.id, repoPath);
|
||||
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
|
||||
} catch (err) {
|
||||
handleProviderError(err, provider!.label);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSearch(terms: string[]): Promise<void> {
|
||||
const query = terms.join(" ").trim();
|
||||
if (!query) fail("Usage: search <query...>");
|
||||
const provider = resolveSelectedProvider();
|
||||
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
|
||||
// Search is read-class: a read-only repo trust policy still allows it
|
||||
// (mirrors gstack-gbrain-sync: search allowed, page writes never), but a
|
||||
// deny tier — or no recorded consent at all — still refuses for non-local
|
||||
// providers, because the query text itself is repo-derived content. The
|
||||
// consent repo is the one this provider indexed (search reads that graph);
|
||||
// loopback providers need no consent, so their path is unchanged.
|
||||
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
|
||||
const consented = hasConsent(searchRoot, undefined, "read");
|
||||
// Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate
|
||||
// (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or
|
||||
// receipt exist), but the CLI names WHY — missing consent vs a deny repo
|
||||
// trust policy — instead of surfacing a generic provider error.
|
||||
if (!provider!.local && !consented) {
|
||||
const recorded = readSelection().consents[searchRoot] === true;
|
||||
fail(recorded
|
||||
? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set <origin-url> read-only (search allowed) or read-write`
|
||||
: `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`);
|
||||
}
|
||||
try {
|
||||
const hits = await provider!.search(query, { limit: 10, consented });
|
||||
if (!hits.length) {
|
||||
out("(no results)");
|
||||
return;
|
||||
}
|
||||
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
|
||||
} catch (err) {
|
||||
handleProviderError(err, provider!.label);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderError(err: unknown, label: string): never {
|
||||
if (err instanceof CodeProviderError) {
|
||||
if (err.code === "PROVIDER_UNAVAILABLE") {
|
||||
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
|
||||
}
|
||||
if (err.code === "PROVIDER_NOT_CONSENTED") {
|
||||
fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent <repo-path> yes\` first (a deny repo trust policy overrides recorded consent).`);
|
||||
}
|
||||
fail(`${label} ${err.code}: ${err.message}`);
|
||||
}
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [action, ...rest] = process.argv.slice(2);
|
||||
switch (action) {
|
||||
case "suggest":
|
||||
return cmdSuggest(rest);
|
||||
case "options":
|
||||
return cmdOptions();
|
||||
case "status":
|
||||
return cmdStatus();
|
||||
case "select":
|
||||
return cmdSelect(rest[0]);
|
||||
case "consent":
|
||||
return cmdConsent(rest);
|
||||
case "index":
|
||||
return cmdIndex(rest[0]);
|
||||
case "search":
|
||||
return cmdSearch(rest);
|
||||
default:
|
||||
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] <yes|no> | index [path] | search <query...>");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||
@@ -133,6 +133,8 @@ lookup_default() {
|
||||
|
||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||
redact_prepush_hook) echo "false" ;;
|
||||
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
|
||||
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
|
||||
# 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,
|
||||
@@ -319,6 +321,14 @@ case "${1:-}" in
|
||||
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
||||
VALUE="false"
|
||||
fi
|
||||
if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||
echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2
|
||||
VALUE="off"
|
||||
fi
|
||||
if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
|
||||
echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2
|
||||
VALUE="true"
|
||||
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
|
||||
VALUE="prompt"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// gstack-context-bill — token bill-of-materials for an installed skills tree.
|
||||
// All behavior lives in lib/context-bill.ts; this is the CLI shim.
|
||||
|
||||
import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor
|
||||
import { contextBillMain } from '../lib/context-bill';
|
||||
|
||||
process.exit(await contextBillMain(process.argv.slice(2)));
|
||||
|
||||
+42
-17
@@ -51,39 +51,64 @@ if (args.includes("--compact")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The payload is identified by its leading `{`, not by "first non-flag arg" — a
|
||||
// `--supersede <id> '{...}'` call would otherwise mistake the target id for the payload.
|
||||
const jsonArg = args.find((a) => a.trimStart().startsWith("{"));
|
||||
|
||||
/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */
|
||||
function validPayload(raw: string): DecisionEvent {
|
||||
let obj: Partial<DecisionEvent>;
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
process.stderr.write("gstack-decision-log: invalid JSON\n");
|
||||
process.exit(1);
|
||||
}
|
||||
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
|
||||
const res = validateDecide(obj);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return res.event;
|
||||
}
|
||||
|
||||
const supersedeId = flagValue(args, "--supersede");
|
||||
const redactId = flagValue(args, "--redact");
|
||||
if (supersedeId || redactId) {
|
||||
const kind = supersedeId ? "supersede" : "redact";
|
||||
const targetId = (supersedeId || redactId) as string;
|
||||
if (targetId.trimStart().startsWith("{")) {
|
||||
process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (kind === "redact" && jsonArg) {
|
||||
process.stderr.write(
|
||||
"gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Validate the replacement BEFORE anything is written, then append it FIRST and
|
||||
// retire the old one SECOND. Appends are individually atomic, so the only visible
|
||||
// interleaving is "both active" (recoverable); the reverse order could retire the
|
||||
// old decision and lose the replacement the user was recording.
|
||||
const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined;
|
||||
if (replacement) appendEvent(paths, replacement);
|
||||
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
|
||||
rebuildSnapshot(paths);
|
||||
enqueue();
|
||||
console.log(`${kind}: ${targetId}`);
|
||||
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonArg = args.find((a) => !a.startsWith("--"));
|
||||
if (!jsonArg) {
|
||||
process.stderr.write(
|
||||
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let obj: Partial<DecisionEvent>;
|
||||
try {
|
||||
obj = JSON.parse(jsonArg);
|
||||
} catch {
|
||||
process.stderr.write("gstack-decision-log: invalid JSON\n");
|
||||
process.exit(1);
|
||||
}
|
||||
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
|
||||
const res = validateDecide(obj);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
appendEvent(paths, res.event);
|
||||
const event = validPayload(jsonArg);
|
||||
appendEvent(paths, event);
|
||||
rebuildSnapshot(paths);
|
||||
enqueue();
|
||||
console.log(res.event.id);
|
||||
console.log(event.id);
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${1:-main}"
|
||||
# Detect the repo's default branch when no arg is given (#703-class
|
||||
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
|
||||
_default_base() {
|
||||
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
|
||||
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
|
||||
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
|
||||
echo "main"
|
||||
}
|
||||
BASE="${1:-$(_default_base)}"
|
||||
|
||||
# Get changed file list
|
||||
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")
|
||||
|
||||
@@ -197,8 +197,13 @@ RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
|
||||
const INPUT_PER_TOKEN = 1e-6;
|
||||
const OUTPUT_PER_TOKEN = 5e-6;
|
||||
|
||||
// Host-neutral model resolution (mirrors lib/eval-model.ts — this inline
|
||||
// bun -e script cannot import repo-relative libs from an arbitrary cwd).
|
||||
const distillModel = process.env.GSTACK_EVAL_MODEL_DISTILL
|
||||
|| process.env.GSTACK_EVAL_MODEL
|
||||
|| "claude-haiku-4-5-20251001";
|
||||
const resp = await client.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
model: distillModel,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
|
||||
import { writeReceipt } from "../lib/egress-receipt";
|
||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
|
||||
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -778,6 +779,40 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through
|
||||
* the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and
|
||||
* schema migration — do not reimplement either here).
|
||||
*
|
||||
* The tier was previously enforced only in /sync-gbrain skill prose, so a
|
||||
* direct or cron invocation of this script ingested repo code regardless of
|
||||
* a `deny`/`read-only` setting — and the egress receipt below cited this
|
||||
* chokepoint as consent before it existed (#2140 sync path). This check
|
||||
* closes both gaps.
|
||||
*
|
||||
* Fail-open ONLY when no policy store exists (nothing was ever set — same
|
||||
* behavior as before for every non-policy user, and skips the subprocess).
|
||||
* Fail-closed ("error") when a store exists but can't be read: a policy the
|
||||
* user set must not be silently bypassed by a broken store or missing jq.
|
||||
*
|
||||
* Reads through the shared lib/gbrain-repo-policy-client.ts (same client as
|
||||
* the code-intelligence consent veto — the two gates can never drift, and
|
||||
* win32 gets the invoke-via-bash path). A spawn failure is still fail-closed
|
||||
* but says so, instead of the misleading "store could not be read".
|
||||
*/
|
||||
export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" {
|
||||
const res = sharedRepoPolicyTier(url, process.env);
|
||||
if (res.error === "spawn-failed") {
|
||||
process.stderr.write(
|
||||
"[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " +
|
||||
"refusing ingest rather than bypassing a possibly-set policy\n",
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
if (res.error) return "error";
|
||||
return res.tier === "none" ? "unset" : res.tier;
|
||||
}
|
||||
|
||||
async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||
const t0 = Date.now();
|
||||
const root = repoRoot();
|
||||
@@ -787,6 +822,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||
|
||||
const sourceId = deriveCodeSourceId(root);
|
||||
|
||||
// Per-repo trust tier — checked BEFORE the dry-run branch so previews report
|
||||
// the refusal honestly instead of claiming they would sync.
|
||||
const policyUrl = originUrl();
|
||||
const tier = repoPolicyTier(policyUrl);
|
||||
if (tier === "read-only") {
|
||||
// Honoring an explicit user setting (search allowed, page writes never) is
|
||||
// a clean skip, not a stage failure — code ingest writes pages.
|
||||
return {
|
||||
name: "code",
|
||||
ran: false,
|
||||
ok: true,
|
||||
duration_ms: Date.now() - t0,
|
||||
summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`,
|
||||
detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" },
|
||||
};
|
||||
}
|
||||
if (tier === "deny" || tier === "error") {
|
||||
const why = tier === "deny"
|
||||
? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`
|
||||
: "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy";
|
||||
return {
|
||||
name: "code",
|
||||
ran: true,
|
||||
ok: false,
|
||||
duration_ms: Date.now() - t0,
|
||||
summary: `refused: ${why}`,
|
||||
detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" },
|
||||
};
|
||||
}
|
||||
|
||||
// dry-run preview always shows the would-do steps, regardless of local
|
||||
// engine state. Useful for "what would /sync-gbrain do" without probing
|
||||
// the engine.
|
||||
|
||||
@@ -52,8 +52,9 @@ import {
|
||||
readSync,
|
||||
closeSync,
|
||||
rmSync,
|
||||
realpathSync,
|
||||
} from "fs";
|
||||
import { join, basename, dirname } from "path";
|
||||
import { join, basename, dirname, delimiter } from "path";
|
||||
import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process";
|
||||
import { homedir } from "os";
|
||||
import { createHash } from "crypto";
|
||||
@@ -1458,13 +1459,40 @@ function runGbrainImportOnce(
|
||||
// still reporting `written: N` from the staged count. Silent data loss
|
||||
// on every run. A working run logs `import.collect_files done ... files=N`
|
||||
// with N > 0 and takes minutes, not seconds.
|
||||
const child = spawnGbrainAsync([
|
||||
"import",
|
||||
stagingDir,
|
||||
"--no-embed",
|
||||
...(includeGitignored ? ["--include-gitignored"] : []),
|
||||
"--json",
|
||||
]);
|
||||
//
|
||||
// GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense:
|
||||
// it stops git's upward repo discovery at the staging dir's parent, so a
|
||||
// git-enumerating collector fails cleanly out of the git fast path and
|
||||
// falls back to its plain FS walk even on gbrain builds whose flag
|
||||
// semantics drift. The ceiling must be the REAL path — git compares
|
||||
// canonicalized directories during discovery, and a staging dir reached
|
||||
// through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME)
|
||||
// otherwise never matches the ceiling entry. Scoped to this one child;
|
||||
// no on-disk state, staging-guard/resume contracts untouched.
|
||||
let ceiling: string;
|
||||
try {
|
||||
ceiling = realpathSync(dirname(stagingDir));
|
||||
} catch {
|
||||
ceiling = dirname(stagingDir); // staging parent vanished mid-run; spawn will fail loudly anyway
|
||||
}
|
||||
const baseEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// path.delimiter, not ':' — git splits this on ';' on Windows, and
|
||||
// drive-letter paths contain ':' themselves.
|
||||
GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES
|
||||
? `${ceiling}${delimiter}${process.env.GIT_CEILING_DIRECTORIES}`
|
||||
: ceiling,
|
||||
};
|
||||
const child = spawnGbrainAsync(
|
||||
[
|
||||
"import",
|
||||
stagingDir,
|
||||
"--no-embed",
|
||||
...(includeGitignored ? ["--include-gitignored"] : []),
|
||||
"--json",
|
||||
],
|
||||
{ baseEnv },
|
||||
);
|
||||
_activeImportChild = child;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -1941,6 +1969,18 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
// Silent-zero pathology detector (#2144's other half): pages were staged
|
||||
// but NOTHING imported or skipped-as-unchanged. That shape hid the dead
|
||||
// ingest for months — it must be loud even under --quiet, because a run
|
||||
// that indexes nothing is otherwise indistinguishable from a healthy one.
|
||||
const importedCount = (importJson.imported ?? 0) + (importJson.skipped ?? 0);
|
||||
if (prep.prepared.length > 0 && importedCount === 0 && (importJson.errors ?? 0) === 0) {
|
||||
console.error(
|
||||
`[memory-ingest] WARNING: ${prep.prepared.length} page(s) staged but gbrain collected ZERO ` +
|
||||
`(no imports, no unchanged-skips, no errors). This is the #2144 silent-zero shape — ` +
|
||||
`check gbrain's import.collect_files log line and your gbrain version.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
|
||||
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that
|
||||
|
||||
+26
-1
@@ -405,7 +405,32 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
|
||||
else if (a === "-h" || a === "--help") help = true;
|
||||
}
|
||||
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
|
||||
if (!base) base = "main";
|
||||
if (!base) {
|
||||
// Detect the default branch instead of assuming main (local-only repos
|
||||
// on trunk/master work like GitHub repos on main). Same probe order as
|
||||
// the canonical chain in bin/gstack-diff-scope and {{BASE_BRANCH_DETECT}}
|
||||
// (scripts/resolvers/utility.ts): origin/HEAD -> origin/main ->
|
||||
// origin/master -> literal "main". origin/HEAD is unset on plain clones
|
||||
// that never ran `git remote set-head`, so the rev-parse probes matter.
|
||||
try {
|
||||
const head = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
base = head.replace("refs/remotes/origin/", "");
|
||||
} catch {
|
||||
// fall through to the rev-parse probes
|
||||
}
|
||||
if (!base) {
|
||||
for (const candidate of ["main", "master"]) {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", "-q", `origin/${candidate}`], { stdio: ["ignore", "ignore", "ignore"] });
|
||||
base = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// probe failed; try the next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!base) base = "main";
|
||||
}
|
||||
if (!bump) {
|
||||
console.error("Error: --bump is required (major|minor|patch|micro)");
|
||||
process.exit(2);
|
||||
|
||||
@@ -61,6 +61,12 @@ elif [ -n "${TMP:-}" ]; then
|
||||
else
|
||||
_tmp_root=".gstack/tmp"
|
||||
fi
|
||||
# macOS exports TMPDIR with a trailing slash; mktemp templates built as
|
||||
# "$TMP_ROOT/name-XXXXXX" would then carry "//", and any consumer comparing
|
||||
# paths gets a spurious mismatch. Strip it (never strips a bare "/"). #2091
|
||||
case "$_tmp_root" in
|
||||
*/) [ "$_tmp_root" != "/" ] && _tmp_root="${_tmp_root%/}" ;;
|
||||
esac
|
||||
|
||||
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
|
||||
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
|
||||
|
||||
@@ -210,6 +210,13 @@ fi
|
||||
STEP_FIELD="null"
|
||||
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
|
||||
|
||||
# Integrity first: a non-numeric duration would splice raw text into the
|
||||
# JSON line ("duration_s":%s) and corrupt the whole JSONL stream — the range
|
||||
# caps below silently no-op on non-integers because both test(1) comparisons
|
||||
# fail. Reject anything that isn't a plain integer.
|
||||
case "$DURATION" in
|
||||
''|*[!0-9]*) DURATION="" ;;
|
||||
esac
|
||||
# Cap unreasonable durations
|
||||
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
|
||||
DURATION="" # null if > 24h
|
||||
|
||||
@@ -81,15 +81,27 @@ while IFS= read -r LINE; do
|
||||
[ -z "$LINE" ] && continue
|
||||
echo "$LINE" | grep -q '^{' || continue
|
||||
|
||||
# Strip local-only fields (keep v, ts, sessions as-is for edge function)
|
||||
CLEAN="$(echo "$LINE" | sed \
|
||||
-e 's/,"_repo_slug":"[^"]*"//g' \
|
||||
-e 's/,"_branch":"[^"]*"//g' \
|
||||
-e 's/,"repo":"[^"]*"//g')"
|
||||
|
||||
# If anonymous tier, strip installation_id
|
||||
if [ "$TIER" = "anonymous" ]; then
|
||||
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
|
||||
# Strip local-only fields (keep v, ts, sessions as-is for edge function).
|
||||
# jq del() is structural — a value containing an escaped quote (repo names,
|
||||
# branch names) can't smuggle the field past a regex or corrupt the strip.
|
||||
# The sed path stays only as a jq-less fallback.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ "$TIER" = "anonymous" ]; then
|
||||
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN=""
|
||||
else
|
||||
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo)' 2>/dev/null)" || CLEAN=""
|
||||
fi
|
||||
# A line jq can't parse is malformed telemetry — drop it rather than
|
||||
# forwarding bytes the strip never touched.
|
||||
[ -z "$CLEAN" ] && continue
|
||||
else
|
||||
CLEAN="$(echo "$LINE" | sed \
|
||||
-e 's/,"_repo_slug":"[^"]*"//g' \
|
||||
-e 's/,"_branch":"[^"]*"//g' \
|
||||
-e 's/,"repo":"[^"]*"//g')"
|
||||
if [ "$TIER" = "anonymous" ]; then
|
||||
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FIRST" = "true" ]; then
|
||||
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-verify-gate — Stop hook. Blocks the turn from ending until the
|
||||
# project's declared verification command passes.
|
||||
#
|
||||
# Declare the command on one line in the project's CLAUDE.md:
|
||||
# <!-- gstack:verify: bun test -->
|
||||
#
|
||||
# Read-or-ask: gstack never invents this command. No declaration, no gate.
|
||||
# Fails open on every absence (no CLAUDE.md, no declaration, empty value).
|
||||
#
|
||||
# Trust boundary: hooks bypass the permission system, so a declared command
|
||||
# NEVER runs until the user records it in the per-repo trust store:
|
||||
# gstack-verify-gate --trust (run from inside the repo)
|
||||
# The store maps realpath(repo root) -> sha256(command) at
|
||||
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash",
|
||||
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
|
||||
# until --trust is run again. Untrusted commands never block the turn.
|
||||
#
|
||||
# Exit 0 = allow the turn to end, one-line reason on stdout.
|
||||
# Exit 2 = block, Claude Code feeds stderr back to the agent.
|
||||
#
|
||||
# Remove with: gstack-settings-hook remove-source --source verify-gate
|
||||
set -uo pipefail
|
||||
|
||||
TAB="$(printf '\t')"
|
||||
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust"
|
||||
|
||||
_sha256() {
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
||||
else
|
||||
printf '%s' "$1" | openssl dgst -sha256 | awk '{print $NF}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the project root: CLAUDE_PROJECT_DIR, else walk up from $PWD to
|
||||
# the first directory containing CLAUDE.md. Sets ROOT (may lack CLAUDE.md).
|
||||
_resolve_root() {
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do
|
||||
ROOT="$(dirname "$ROOT")"
|
||||
done
|
||||
}
|
||||
|
||||
# Extract the declared command from $ROOT/CLAUDE.md into CMD (may be empty).
|
||||
# Accepts both `<!-- gstack:verify: cmd -->` and bare `gstack:verify: cmd`.
|
||||
_extract_cmd() {
|
||||
CMD="$(sed -n 's/^[[:space:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
|
||||
CMD="${CMD%%-->*}"
|
||||
CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')"
|
||||
}
|
||||
|
||||
# Symlink-stable store key for the root.
|
||||
_trust_key() {
|
||||
(cd "$ROOT" 2>/dev/null && pwd -P) || printf '%s' "$ROOT"
|
||||
}
|
||||
|
||||
# Print the stored hash for key $1, or return 1 when absent.
|
||||
_trusted_hash() {
|
||||
[ -f "$STORE" ] || return 1
|
||||
local p h
|
||||
while IFS="$TAB" read -r p h; do
|
||||
if [ "$p" = "$1" ]; then
|
||||
printf '%s' "$h"
|
||||
return 0
|
||||
fi
|
||||
done <"$STORE"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Record key $1 -> hash $2, replacing any prior entry. Atomic, 0600.
|
||||
_record_trust() {
|
||||
local store_dir tmp p h
|
||||
store_dir="$(dirname "$STORE")"
|
||||
mkdir -p "$store_dir"
|
||||
tmp="$STORE.tmp.$$"
|
||||
: >"$tmp"
|
||||
chmod 600 "$tmp"
|
||||
if [ -f "$STORE" ]; then
|
||||
while IFS="$TAB" read -r p h; do
|
||||
[ "$p" = "$1" ] || printf '%s\t%s\n' "$p" "$h" >>"$tmp"
|
||||
done <"$STORE"
|
||||
fi
|
||||
printf '%s\t%s\n' "$1" "$2" >>"$tmp"
|
||||
mv -f "$tmp" "$STORE"
|
||||
}
|
||||
|
||||
# Minimal JSON string escaping (backslash + double quote). CMD and paths are
|
||||
# single-line by construction, so control characters never appear.
|
||||
_json_escape() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
# Forensic audit of trust grants. --trust stays agent-runnable (guardrail
|
||||
# posture: catch accidents, not determined actors — same as the redaction
|
||||
# guard), but a grant is never invisible: append {ts, root, cmd_sha256,
|
||||
# cmd verbatim, tty} to a 0600 JSONL under GSTACK_HOME/security/.
|
||||
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
|
||||
_log_trust_grant() {
|
||||
local sec_dir log tty ts
|
||||
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security"
|
||||
log="$sec_dir/verify-gate-trust-grants.jsonl"
|
||||
tty=false
|
||||
[ -t 0 ] && tty=true
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
mkdir -p "$sec_dir"
|
||||
[ -f "$log" ] || : >"$log"
|
||||
chmod 600 "$log" 2>/dev/null || true
|
||||
printf '{"ts":"%s","root":"%s","cmd_sha256":"%s","cmd":"%s","tty":%s}\n' \
|
||||
"$ts" "$(_json_escape "$1")" "$2" "$(_json_escape "$3")" "$tty" >>"$log"
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--trust" ]; then
|
||||
_resolve_root
|
||||
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
||||
echo "verify-gate: no CLAUDE.md above $PWD, nothing to trust." >&2
|
||||
exit 1
|
||||
fi
|
||||
_extract_cmd
|
||||
if [ -z "$CMD" ]; then
|
||||
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, nothing to trust." >&2
|
||||
exit 1
|
||||
fi
|
||||
KEY="$(_trust_key)"
|
||||
HASH="$(_sha256 "$CMD")"
|
||||
_record_trust "$KEY" "$HASH"
|
||||
_log_trust_grant "$KEY" "$HASH" "$CMD"
|
||||
echo "verify-gate: trusted '$CMD' for $ROOT."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
INPUT=""
|
||||
[ -t 0 ] || INPUT="$(cat)"
|
||||
|
||||
# Claude Code re-runs Stop hooks after a block (stop_hook_active=true). A
|
||||
# re-entry is NOT a free pass: the gate re-runs the trusted check so an agent
|
||||
# can't clear a red verification by simply stopping again. Re-entry blocks are
|
||||
# bounded per episode (MAX_REENTRY_BLOCKS) so a stuck check can't loop forever;
|
||||
# at the bound the gate allows with a loud warning.
|
||||
REENTRY=0
|
||||
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
|
||||
REENTRY=1
|
||||
fi
|
||||
|
||||
_resolve_root
|
||||
|
||||
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
||||
echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_extract_cmd
|
||||
|
||||
if [ -z "$CMD" ]; then
|
||||
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Trust gate: never execute a declared command the user has not recorded.
|
||||
# Applies on re-entry too — untrusted commands keep the exit-0-with-hint path.
|
||||
if [ "$(_trusted_hash "$(_trust_key)" || true)" != "$(_sha256 "$CMD")" ]; then
|
||||
echo "verify-gate: found '$CMD' in $ROOT/CLAUDE.md but it is not trusted yet, skipping; enable with: cd $ROOT && $0 --trust" >&2
|
||||
echo "verify-gate: declared command not trusted, allowing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Episode-scoped re-entry attempt counter. Keyed by the hook-input session_id
|
||||
# when present, else ppid+root — stale entries are fine to overwrite.
|
||||
MAX_REENTRY_BLOCKS=3
|
||||
_session_key() {
|
||||
local sid
|
||||
sid="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
|
||||
[ -n "$sid" ] || sid="ppid-$PPID"
|
||||
_sha256 "$sid|$(_trust_key)"
|
||||
}
|
||||
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts"
|
||||
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
|
||||
|
||||
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
|
||||
if [ "$REENTRY" -eq 0 ]; then
|
||||
rm -f "$COUNTER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)"
|
||||
STATUS=$?
|
||||
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
rm -f "$COUNTER" 2>/dev/null || true
|
||||
echo "verify-gate: declared check passed ($CMD)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$REENTRY" -eq 1 ]; then
|
||||
COUNT="$(cat "$COUNTER" 2>/dev/null || echo 0)"
|
||||
case "$COUNT" in
|
||||
''|*[!0-9]*) COUNT=0 ;;
|
||||
esac
|
||||
if [ "$COUNT" -ge "$MAX_REENTRY_BLOCKS" ]; then
|
||||
rm -f "$COUNTER" 2>/dev/null || true
|
||||
WARN="verify-gate: WARNING — allowing after $MAX_REENTRY_BLOCKS blocked re-entries but the declared check is still FAILING ($CMD). Verification is RED; do not treat this turn as verified."
|
||||
echo "$WARN"
|
||||
echo "$WARN" >&2
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$ATTEMPTS_DIR"
|
||||
echo $((COUNT + 1)) >"$COUNTER"
|
||||
fi
|
||||
|
||||
echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2
|
||||
printf '%s\n' "$OUT" | tail -20 >&2
|
||||
echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2
|
||||
exit 2
|
||||
Reference in New Issue
Block a user