mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a cwd bunfig.toml preload before the program starts. gstack runs inside the repo it audits, so a committed .env could steer state roots, the claude binary or spend caps, and a bunfig preload ran arbitrary code in every bin and hook. Every bun shebang, shell and skill caller, TypeScript spawn and compiled build now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(), --no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture regressions. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: name the directly executed scripts in the isolation ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: plan-mode render pattern accepts the isolation flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
161 lines
6.0 KiB
TypeScript
Executable File
161 lines
6.0 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-decision-log — append a durable decision (or supersede/redact/compact it).
|
|
*
|
|
* Usage:
|
|
* gstack-decision-log --help
|
|
* gstack-decision-log '{"decision":"...","rationale":"...","scope":"repo","source":"user"}'
|
|
* gstack-decision-log --supersede <decision-id>
|
|
* gstack-decision-log --redact <decision-id>
|
|
* gstack-decision-log --compact
|
|
*
|
|
* Event-sourced (lib/gstack-decision): every call appends an event and refreshes the
|
|
* bounded active snapshot. NON-INTERACTIVE — never prompts (agents/skills call this;
|
|
* a prompt would hang them). Validation + injection + HIGH-secret rejection happen in
|
|
* validateDecide; a rejected decision exits 1 with a message, nothing persisted.
|
|
*/
|
|
|
|
import { dirname } from "path";
|
|
import { mkdirpSync } from "../lib/fs-utils";
|
|
import { spawnSync } from "child_process";
|
|
import {
|
|
DECISION_SCOPES,
|
|
DECISION_SOURCES,
|
|
decisionPaths,
|
|
validateDecide,
|
|
makeRefEvent,
|
|
appendEvent,
|
|
rebuildSnapshot,
|
|
compact,
|
|
type DecisionEvent,
|
|
} from "../lib/gstack-decision";
|
|
import { resolveSlug, gitBranch, flagValue } from "../lib/bin-context";
|
|
|
|
const HERE = import.meta.dir;
|
|
|
|
const args = process.argv.slice(2);
|
|
const usage = `Usage:
|
|
gstack-decision-log '<JSON>'
|
|
gstack-decision-log --supersede <decision-id> ['<replacement JSON>']
|
|
gstack-decision-log --redact <decision-id>
|
|
gstack-decision-log --compact
|
|
gstack-decision-log --help
|
|
|
|
Decision JSON:
|
|
decision: nonempty string (required)
|
|
scope: ${DECISION_SCOPES.join("|")} (default: repo)
|
|
source: ${DECISION_SOURCES.join("|")} (default: agent)
|
|
rationale, alternatives_considered: optional explanatory text
|
|
branch: branch name; inferred from git for scope=branch when omitted
|
|
issue: issue identifier for issue-scoped retrieval
|
|
confidence: optional integer 1-10
|
|
id, date, session: optional record metadata; id/date are generated when omitted
|
|
Only these payload fields are stored; skill and tags are not record fields.
|
|
|
|
Pass JSON as one argument, not stdin. A quoted heredoc preserves apostrophes:
|
|
payload=$(cat <<'JSON'
|
|
{"decision":"Keep the caller's acceptance rule","rationale":"The application owns the threshold","scope":"repo","source":"user"}
|
|
JSON
|
|
)
|
|
gstack-decision-log "$payload"
|
|
|
|
Successful writes print the decision id. Invalid payloads exit 1 without a record.
|
|
Supersede retires the old decision and optionally records a validated replacement.
|
|
Redact accepts no replacement; compact removes redacted history and archives superseded records.
|
|
Help prints this schema and exits without resolving the project or writing state.
|
|
`;
|
|
|
|
// Help and empty-input usage must not create slug caches, stores or sync work.
|
|
if (args.includes("--help")) {
|
|
process.stdout.write(usage);
|
|
process.exit(0);
|
|
}
|
|
if (args.every((arg) => !arg.trim())) {
|
|
process.stderr.write(usage);
|
|
process.exit(1);
|
|
}
|
|
|
|
const slug = resolveSlug(`${HERE}/gstack-slug`);
|
|
const paths = decisionPaths(slug);
|
|
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
|
|
// recursive mkdir on an existing dir (#2635), and this runs on every log call.
|
|
mkdirpSync(dirname(paths.log));
|
|
|
|
function enqueue(): void {
|
|
// Fire-and-forget cross-machine sync (no-op when artifacts_sync is off).
|
|
spawnSync(`${HERE}/gstack-brain-enqueue`, [`projects/${slug}/decisions.jsonl`], { stdio: "ignore" });
|
|
}
|
|
|
|
if (args.includes("--compact")) {
|
|
const r = compact(paths);
|
|
if (r.skipped) {
|
|
console.log("compact skipped: a concurrent write/compact is in progress; log left intact — re-run");
|
|
process.exit(0);
|
|
}
|
|
console.log(`compacted: ${r.activeCount} active, ${r.archivedCount} archived, ${r.expungedCount} expunged`);
|
|
enqueue();
|
|
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(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!jsonArg) {
|
|
process.stderr.write(
|
|
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const event = validPayload(jsonArg);
|
|
appendEvent(paths, event);
|
|
rebuildSnapshot(paths);
|
|
enqueue();
|
|
console.log(event.id);
|