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>
133 lines
9.4 KiB
TypeScript
Executable File
133 lines
9.4 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-regen — the generated-file registry `.gstack/generated.json` (plan C4).
|
|
*
|
|
* gstack-regen check [--allow-repo-commands] [--base <ref>] [--registry-from base|head] [--only <id,..>] [--touched <path,..>] [--json]
|
|
* gstack-regen write [--allow-repo-commands] [--base <ref>] [--registry-from base|head] [--only <id,..>] [--json]
|
|
* gstack-regen list | validate | init [--explain] [--force]
|
|
*
|
|
* `check` regenerates every selected entry in a scratch `git worktree` seeded
|
|
* from the working tree (node_modules linked, never copied) and reports stale
|
|
* outputs without touching the tree; `write` regenerates in place. Commands
|
|
* are read from the committed copy on origin/<base>; they run only with
|
|
* --allow-repo-commands (what /ship passes). Standalone, `check`/`write` list
|
|
* the commands and exit 3 (REPO_COMMANDS_NOT_ALLOWED). Workflow commands end
|
|
* with the GSTACK_RESULT line. Logic: lib/regen.ts.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
|
|
import {
|
|
GENERATED_PATH, exampleRegistryJson, isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, renderEntry, runRegen,
|
|
type RegistryLoad,
|
|
} from '../lib/regen';
|
|
import { RESULT_CODES, resultLine } from '../lib/result-codes';
|
|
import { policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
|
|
|
const HELP = `gstack-regen — generated files regenerate, never hand-merge (docs/pregate.md#generated)
|
|
|
|
check regenerate in a scratch git worktree; report stale entries (exit 1) without touching the tree
|
|
write regenerate in place (the restamp and the Step 3 conflict rule)
|
|
list print every entry: id, command, outputs, inputs
|
|
validate schema and containment check of the working-tree ${GENERATED_PATH}
|
|
init [--explain] [--force] write an example registry
|
|
|
|
--allow-repo-commands run the registry's commands (what /ship passes); without it, check/write list them and exit 3
|
|
--registry-from base|head base (default, reviewed: origin/<base>) or head (unreviewed; needs --allow-repo-commands)
|
|
--base <ref> base branch (default: origin/HEAD, else main)
|
|
--only <id,...> restrict to these entries
|
|
--touched <path,...> restrict to entries whose inputs or outputs intersect these paths
|
|
--json JSON only on stdout
|
|
|
|
Prints one \`regen <id>: fresh|stale|failed|listed|written\` line per entry and
|
|
GSTACK_RESULT: skill=regen status=complete|incomplete|refused run=<wtree>.
|
|
${exitTableHelp()}`;
|
|
|
|
function flag(args: string[], name: string): string | undefined {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
function list(v: string | undefined): string[] | undefined {
|
|
return v === undefined ? undefined : v.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
function repoRoot(): string {
|
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
|
|
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd();
|
|
}
|
|
|
|
function load(root: string, rest: string[], defaultFrom: 'base' | 'head'): RegistryLoad | number {
|
|
const from = flag(rest, '--registry-from') ?? defaultFrom;
|
|
if (from !== 'base' && from !== 'head') { process.stderr.write(`gstack-regen: --registry-from needs base or head\n`); return EXIT.usage; }
|
|
if (from === 'head') {
|
|
if (defaultFrom === 'base' && !rest.includes('--allow-repo-commands')) { process.stderr.write('gstack-regen: --registry-from head needs --allow-repo-commands (the working-tree registry is unreviewed)\n'); return EXIT.refused; }
|
|
return loadRegistryFromHead(root);
|
|
}
|
|
const base = resolveBaseBranch(root, flag(rest, '--base'));
|
|
const loaded = loadRegistryFromBase(root, base);
|
|
if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; }
|
|
return loaded;
|
|
}
|
|
|
|
function main(argv: string[]): number {
|
|
const [cmd, ...rest] = argv;
|
|
const json = rest.includes('--json');
|
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
|
const root = repoRoot();
|
|
|
|
if (cmd === 'init') {
|
|
if (rest.includes('--explain')) {
|
|
process.stdout.write('entries[].id: stable id ([a-z0-9][a-z0-9_.-]*), used by --only and the pre-gate\nentries[].command: the one command that regenerates the outputs; null lists outputs regenerated by hand\nentries[].outputs: repo-relative globs the command writes (non-empty)\nentries[].inputs: repo-relative globs that feed it; empty or absent means always regenerate\nentries[].note: free text shown in list and --explain\n');
|
|
if (rest.length === 1) return EXIT.ok;
|
|
}
|
|
const file = path.join(root, GENERATED_PATH);
|
|
if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(resultLine('gstack-regen init', 'POLICY_EXISTS', file) + '\n'); return EXIT.refused; }
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, exampleRegistryJson());
|
|
process.stdout.write(`REGISTRY_WRITTEN: ${GENERATED_PATH} (edit it, then gstack-regen validate; commit it on the base branch before /ship reads it)\n`);
|
|
return EXIT.ok;
|
|
}
|
|
|
|
if (cmd === 'validate' || cmd === 'list') {
|
|
const fromBase = cmd === 'list' && flag(rest, '--registry-from') === 'base';
|
|
const loaded = fromBase ? loadRegistryFromBase(root, resolveBaseBranch(root, flag(rest, '--base'))) : loadRegistryFromHead(root);
|
|
if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', loaded.base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; }
|
|
for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH}`) + '\n');
|
|
if (json) { process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, entries: loaded.registry.entries, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; }
|
|
if (cmd === 'validate') { process.stdout.write(loaded.errors.length ? `REGISTRY_INVALID: ${loaded.errors.length} error(s)\n` : `REGISTRY_VALID: ${loaded.present ? GENERATED_PATH : `no ${GENERATED_PATH} (nothing registered)`}\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; }
|
|
process.stdout.write(`registry: ${loaded.source.label}\n`);
|
|
for (const e of loaded.registry.entries) process.stdout.write(`${e.id}: command=${e.command === null ? 'null (by hand)' : JSON.stringify(e.command)} outputs=${e.outputs.join(',')} inputs=${e.inputs.join(',') || '(always)'}${e.note ? ` note=${JSON.stringify(e.note)}` : ''}\n`);
|
|
return loaded.errors.length ? EXIT.fail : EXIT.ok;
|
|
}
|
|
|
|
if (cmd !== 'check' && cmd !== 'write') { process.stderr.write(`gstack-regen: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; }
|
|
const loaded = load(root, rest, 'base');
|
|
if (typeof loaded === 'number') return loaded;
|
|
if (isRegistryUnavailable(loaded)) return EXIT.refused;
|
|
if (loaded.errors.length) {
|
|
for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH} (${loaded.source.label})`) + '\n');
|
|
process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n');
|
|
return EXIT.refused;
|
|
}
|
|
const commands = loaded.registry.entries.filter(e => e.command);
|
|
if (commands.length && !rest.includes('--allow-repo-commands')) {
|
|
process.stderr.write(`gstack-regen ${cmd}: ${RESULT_CODES.REPO_COMMANDS_NOT_ALLOWED.summary.replace('the policy declares commands', `${GENERATED_PATH} declares ${commands.length} command(s)`)}; fix: pass --allow-repo-commands (what /ship does) (REPO_COMMANDS_NOT_ALLOWED)\n`);
|
|
for (const e of commands) process.stdout.write(`COMMAND: ${e.id}=${JSON.stringify(e.command)} outputs=${e.outputs.join(',')}\n`);
|
|
process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n');
|
|
return EXIT.refused;
|
|
}
|
|
let outcome;
|
|
try { outcome = runRegen({ repoRoot: root, registry: loaded.registry, mode: cmd, only: list(flag(rest, '--only')), touched: list(flag(rest, '--touched')) }); }
|
|
catch (e: any) { process.stderr.write(resultLine(`gstack-regen ${cmd}`, 'PREGATE_INCOMPLETE', e.message) + '\n'); process.stdout.write(formatResult('regen', 'incomplete', loaded.source.label) + '\n'); return EXIT.fail; }
|
|
const bad = outcome.results.filter(r => r.status === 'stale' || r.status === 'failed' || r.status === 'deps-missing');
|
|
const status = outcome.results.some(r => r.status === 'failed' || r.status === 'deps-missing') ? 'incomplete' : 'complete';
|
|
if (json) { process.stdout.write(JSON.stringify({ mode: cmd, registry: loaded.source.label, wtree: outcome.wtree, results: outcome.results, ok: bad.length === 0 }) + '\n'); return bad.length ? EXIT.fail : EXIT.ok; }
|
|
process.stdout.write(`registry: ${loaded.source.label}\n`);
|
|
for (const r of outcome.results) process.stdout.write(renderEntry(r) + '\n');
|
|
process.stdout.write(`REGEN: ${cmd} ${bad.length ? `${bad.length} stale or failed` : 'fresh'} (${outcome.results.length} entries)\n`);
|
|
process.stdout.write(formatResult('regen', status, outcome.wtree ?? loaded.source.label) + '\n');
|
|
return bad.length ? EXIT.fail : EXIT.ok;
|
|
}
|
|
|
|
process.exitCode = main(process.argv.slice(2));
|