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>
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { discoverTemplates, includesSkill } from './discover-skills';
|
|
import { externalSkillName, extractNameAndDescription } from './external-skill-names';
|
|
import { getHostConfig } from '../hosts';
|
|
import { CLAUDE_CODE_RUNTIME_FILES } from '../lib/claude-code-migration';
|
|
|
|
const args = process.argv.slice(2);
|
|
const value = (flag: string): string => {
|
|
const index = args.indexOf(flag);
|
|
if (index < 0 || !args[index + 1]) throw new Error(`missing ${flag}`);
|
|
return args[index + 1];
|
|
};
|
|
const exists = (file: string) => fs.lstatSync(file, { throwIfNoEntry: false });
|
|
const inside = (file: string, root: string) => file === root || file.startsWith(`${root}${path.sep}`);
|
|
const refuse = (operation: string, target: string) => {
|
|
throw new Error(`Refusing: Codex ${operation} overlaps source or escapes its namespace: ${target}`);
|
|
};
|
|
const physical = (file: string): string => {
|
|
let ancestor = path.resolve(file);
|
|
const suffix: string[] = [];
|
|
const visited = new Set<string>();
|
|
while (true) {
|
|
if (exists(ancestor)) {
|
|
try { return path.join(fs.realpathSync(ancestor), ...suffix); }
|
|
catch (error) {
|
|
if (!exists(ancestor)?.isSymbolicLink() || visited.has(ancestor)) throw error;
|
|
visited.add(ancestor);
|
|
ancestor = path.resolve(path.dirname(ancestor), fs.readlinkSync(ancestor));
|
|
continue;
|
|
}
|
|
}
|
|
const parent = path.dirname(ancestor);
|
|
if (parent === ancestor) throw new Error(`unresolvable path: ${file}`);
|
|
suffix.unshift(path.basename(ancestor));
|
|
ancestor = parent;
|
|
}
|
|
};
|
|
const source = fs.realpathSync(value('--source'));
|
|
const generation = path.join(source, '.agents/skills');
|
|
const namespace = value('--namespace');
|
|
const selected = value('--selected') === '1';
|
|
const windows = value('--windows') === '1';
|
|
const local = value('--local') === '1';
|
|
const runtime = path.join(namespace, 'gstack');
|
|
const runtimeStat = exists(runtime);
|
|
const migrating = selected && !local && !!runtimeStat && runtimeStat.isDirectory()
|
|
&& !runtimeStat.isSymbolicLink() && physical(runtime) === source;
|
|
const relocated = migrating ? value('--relocation') : source;
|
|
|
|
if (migrating && (exists(relocated) || inside(physical(relocated), source))) refuse('checkout relocation', relocated);
|
|
const generationRoot = physical(generation);
|
|
if (!inside(generationRoot, source) || generationRoot === source) refuse('generation namespace', generation);
|
|
const physicalNamespace = physical(namespace);
|
|
if (selected && inside(physicalNamespace, source) && (!local || physicalNamespace !== generationRoot)) refuse('host namespace', namespace);
|
|
|
|
const checkOutput = (file: string, root: string, operation: string) => {
|
|
if (!inside(physical(file), root)) refuse(operation, file);
|
|
};
|
|
const checkAtomicCopy = (file: string, root: string, operation: string, detachesParent = false) => {
|
|
const parent = path.dirname(file);
|
|
const writeParent = detachesParent && exists(parent)?.isSymbolicLink() ? path.dirname(parent) : parent;
|
|
const resolved = physical(writeParent);
|
|
if (!inside(resolved, root) || (inside(resolved, source) && !inside(resolved, generationRoot))) {
|
|
refuse(operation, file);
|
|
}
|
|
};
|
|
const checkPostRelocationAlias = (file: string) => {
|
|
if (!migrating) return;
|
|
let entry = file;
|
|
while (inside(entry, source) && entry !== source) {
|
|
if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
|
|
refuse(entry === path.join(source, '.agents') || entry === generation
|
|
? 'post-relocation generation namespace' : 'post-relocation generated alias', entry);
|
|
}
|
|
entry = path.dirname(entry);
|
|
}
|
|
};
|
|
const checkReplace = (file: string, operation: string) => {
|
|
const stat = exists(file);
|
|
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return;
|
|
if (inside(source, physical(file))) {
|
|
if ((migrating || local) && file === runtime && physical(file) === source) return;
|
|
refuse(operation, file);
|
|
}
|
|
};
|
|
const userOwnedRoot = (root: string): boolean => {
|
|
const stat = exists(root);
|
|
const skill = path.join(root, 'SKILL.md');
|
|
return !!stat && stat.isDirectory() && !stat.isSymbolicLink()
|
|
&& !!fs.statSync(skill, { throwIfNoEntry: false })?.isFile()
|
|
&& !fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from');
|
|
};
|
|
const linkIsOurs = (file: string): boolean => {
|
|
try { return inside(physical(file), source); }
|
|
catch { return false; }
|
|
};
|
|
const owned = (file: string): boolean => {
|
|
const stat = exists(file);
|
|
if (!stat) return false;
|
|
if (stat.isSymbolicLink()) return linkIsOurs(file);
|
|
if (!stat.isDirectory()) return false;
|
|
if (exists(path.join(file, '.gstack-owned'))) return true;
|
|
const skill = path.join(file, 'SKILL.md');
|
|
if (exists(skill)?.isSymbolicLink() && linkIsOurs(skill)) return true;
|
|
try { return fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from'); }
|
|
catch { return false; }
|
|
};
|
|
|
|
const config = getHostConfig('codex');
|
|
const names = new Set<string>();
|
|
const sourceNames = new Set<string>();
|
|
for (const template of discoverTemplates(source)) {
|
|
const dir = path.dirname(template.tmpl);
|
|
const name = externalSkillName(dir, extractNameAndDescription(fs.readFileSync(path.join(source, template.tmpl), 'utf8')).name);
|
|
sourceNames.add(name);
|
|
if (!includesSkill(config, dir)) continue;
|
|
names.add(name);
|
|
const skill = path.join(generation, name, 'SKILL.md');
|
|
let loop = false;
|
|
try {
|
|
loop = fs.realpathSync(path.join(source, template.output)) === path.join(fs.realpathSync(path.dirname(skill)), 'SKILL.md');
|
|
} catch {}
|
|
if (loop) continue;
|
|
checkPostRelocationAlias(skill);
|
|
checkOutput(skill, generationRoot, 'generated skill write');
|
|
if (config.generation.generateMetadata) {
|
|
const metadata = path.join(generation, name, 'agents/openai.yaml');
|
|
checkPostRelocationAlias(metadata);
|
|
checkOutput(metadata, generationRoot, 'generated metadata write');
|
|
}
|
|
}
|
|
|
|
const generatedEntries = exists(generation)?.isDirectory() ? fs.readdirSync(generation) : [];
|
|
for (const name of generatedEntries.filter(name => name.startsWith('gstack-') && !names.has(name))) {
|
|
const entry = path.join(generation, name);
|
|
if (exists(entry)?.isDirectory() && !exists(entry)?.isSymbolicLink()) checkReplace(entry, 'stale render pruning');
|
|
}
|
|
|
|
const hostEntries = exists(namespace)?.isDirectory() ? fs.readdirSync(namespace) : [];
|
|
for (const name of hostEntries.filter(name => name.startsWith('gstack-') && !sourceNames.has(name))) {
|
|
const entry = path.join(namespace, name);
|
|
const stat = exists(entry);
|
|
if (!stat || stat.isSymbolicLink()) continue;
|
|
if (stat.isDirectory()) {
|
|
const skill = path.join(entry, 'SKILL.md');
|
|
if (!fs.existsSync(skill) || !fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from')) continue;
|
|
if (!exists(skill)?.isSymbolicLink() && inside(physical(skill), source) && !inside(physical(skill), generationRoot)) {
|
|
refuse('stale host cleanup', skill);
|
|
}
|
|
} else if (inside(physical(entry), source) && !inside(physical(entry), generationRoot)) {
|
|
refuse('stale host cleanup', entry);
|
|
}
|
|
}
|
|
|
|
const old = path.join(namespace, 'gstack-claude');
|
|
const nextSkill = path.join(namespace, 'gstack-claude-code');
|
|
const runtimeSkill = path.join(runtime, 'SKILL.md');
|
|
const runtimeBanner = !fs.existsSync(runtimeSkill) || fs.readFileSync(runtimeSkill, 'utf8').includes('<!-- AUTO-GENERATED from');
|
|
const rename = owned(old) && (!exists(nextSkill) || owned(nextSkill))
|
|
&& (!exists(runtime)?.isSymbolicLink() || linkIsOurs(runtime))
|
|
&& runtimeBanner && CLAUDE_CODE_RUNTIME_FILES.every(rel => {
|
|
const parent = path.join(runtime, path.dirname(rel));
|
|
return exists(path.join(source, rel)) && (!exists(parent)?.isSymbolicLink() || linkIsOurs(parent));
|
|
});
|
|
if (rename) {
|
|
const next = nextSkill;
|
|
checkReplace(runtime, 'rename runtime replacement');
|
|
checkReplace(next, 'rename replacement');
|
|
if (exists(next)?.isDirectory() && !exists(next)?.isSymbolicLink()) {
|
|
for (const rel of ['SKILL.md', 'agents/openai.yaml']) checkAtomicCopy(path.join(next, rel), physicalNamespace, 'rename skill write', true);
|
|
}
|
|
if (exists(runtime) && physical(runtime) !== source) {
|
|
for (const rel of CLAUDE_CODE_RUNTIME_FILES) {
|
|
const dest = path.join(runtime, rel);
|
|
if (exists(dest) && physical(dest) === physical(path.join(source, rel))) continue;
|
|
checkAtomicCopy(dest, physicalNamespace, 'rename runtime write');
|
|
}
|
|
}
|
|
for (const name of names) {
|
|
if (name === 'gstack' || name === 'gstack-claude' || name === 'gstack-claude-code') continue;
|
|
const installed = path.join(namespace, name);
|
|
if (owned(installed) && !exists(installed)?.isSymbolicLink()) {
|
|
checkAtomicCopy(path.join(installed, 'SKILL.md'), physicalNamespace, 'rename workflow write', true);
|
|
if (config.generation.generateMetadata) checkAtomicCopy(path.join(installed, 'agents/openai.yaml'), physicalNamespace, 'rename metadata write', true);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selected) {
|
|
checkReplace(runtime, 'runtime replacement');
|
|
if (!local && !migrating && userOwnedRoot(runtime)) {
|
|
throw new Error(`Refusing: global Codex runtime ${runtime} is a real user-owned skill with a handwritten SKILL.md. Move it aside or choose another CODEX_HOME; setup will not replace it.`);
|
|
}
|
|
if (windows) for (const name of names) {
|
|
if (name !== 'gstack' && !(local && name === 'gstack-claude')) checkReplace(path.join(namespace, name), 'skill copy replacement');
|
|
}
|
|
|
|
const sidecar = path.join(generation, 'gstack');
|
|
if (!userOwnedRoot(sidecar)) {
|
|
checkOutput(sidecar, generationRoot, 'sidecar root write');
|
|
for (const rel of ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md']) {
|
|
const src = path.join(source, rel), dest = path.join(sidecar, rel);
|
|
if (!exists(src) || (!windows && exists(dest) && !exists(dest)?.isSymbolicLink())) continue;
|
|
if (exists(dest)?.isSymbolicLink()) checkOutput(path.dirname(dest), generationRoot, 'sidecar asset write');
|
|
else {
|
|
checkReplace(dest, 'sidecar asset replacement');
|
|
checkOutput(dest, generationRoot, 'sidecar asset write');
|
|
}
|
|
}
|
|
const configFile = path.join(source, 'supabase/config.sh');
|
|
if (exists(configFile)) {
|
|
const dest = path.join(sidecar, 'supabase/config.sh');
|
|
if (exists(dest)?.isSymbolicLink()) checkOutput(path.dirname(dest), generationRoot, 'sidecar config write');
|
|
else checkOutput(dest, generationRoot, 'sidecar config write');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (migrating) {
|
|
for (const entry of [path.join(source, '.agents'), generation]) {
|
|
if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
|
|
refuse('post-relocation generation namespace', entry);
|
|
}
|
|
}
|
|
for (const name of generatedEntries.filter(name => name.startsWith('gstack'))) {
|
|
const entry = path.join(generation, name);
|
|
if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
|
|
refuse('post-relocation generated alias', entry);
|
|
}
|
|
}
|
|
}
|