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>
203 lines
9.6 KiB
TypeScript
203 lines
9.6 KiB
TypeScript
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/** Validate all host renders and the freshness of repository-owned outputs.
|
|
* Optional installed host caches are never read or changed. */
|
|
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
import { validateSkill, externalHostPathLeaks } from '../test/helpers/skill-parser';
|
|
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
|
import {
|
|
runGeneration,
|
|
type GeneratedArtifact,
|
|
} from './gen-skill-docs';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
export interface CheckDiagnostic {
|
|
kind: 'error' | 'invalid' | 'stale' | 'untracked';
|
|
relativePath?: string;
|
|
message: string;
|
|
}
|
|
|
|
/** Match the strict YAML contract used by hosts, rather than accepting fields
|
|
* that merely look present to the generator's extraction regexes. */
|
|
export function validateSkillFrontmatter(content: string): string[] {
|
|
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
if (!block) return ['frontmatter must have opening and closing delimiters'];
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = Bun.YAML.parse(block[1]);
|
|
} catch (error) {
|
|
return [`frontmatter must be valid YAML: ${error instanceof Error ? error.message : String(error)}`];
|
|
}
|
|
const fields = parsed as Record<string, unknown> | null;
|
|
return ['name', 'description'].flatMap(field =>
|
|
typeof fields?.[field] === 'string' && (fields[field] as string).trim()
|
|
? [] : [`frontmatter ${field} must be a nonempty string`]);
|
|
}
|
|
|
|
/** Reuse the command/snapshot validator and host smoke-test content rules. */
|
|
export function validateGeneratedArtifact(renderRoot: string, artifact: GeneratedArtifact): CheckDiagnostic[] {
|
|
const diagnostics: CheckDiagnostic[] = [];
|
|
const invalid = (message: string) => diagnostics.push({ kind: 'invalid', relativePath: artifact.relativePath, message });
|
|
const file = path.join(renderRoot, artifact.relativePath);
|
|
try {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
if (!content.trim()) invalid('generated file is empty');
|
|
if (artifact.kind === 'skill') {
|
|
for (const message of validateSkillFrontmatter(content)) invalid(message);
|
|
}
|
|
if (artifact.kind === 'skill' || artifact.kind === 'section') {
|
|
const validation = validateSkill(file);
|
|
for (const command of validation.invalid) invalid(`line ${command.line}: unknown command '${command.command}'`);
|
|
for (const error of validation.snapshotFlagErrors) invalid(`line ${error.command.line}: ${error.error}`);
|
|
if (artifact.host && artifact.host !== 'claude') {
|
|
// Host smoke tests permit legitimate fallback paths in bash examples.
|
|
if (externalHostPathLeaks(content).length) invalid('contains .claude/skills reference outside a bash block');
|
|
}
|
|
}
|
|
if (artifact.kind === 'metadata') {
|
|
for (const field of ['display_name:', 'short_description:', 'default_prompt:', 'allow_implicit_invocation: true']) {
|
|
if (!content.includes(field)) invalid(`metadata is missing ${field}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
diagnostics.push({ kind: 'error', relativePath: artifact.relativePath, message: (error as Error).message });
|
|
}
|
|
return diagnostics;
|
|
}
|
|
|
|
function git(root: string, args: string[], input?: string, accepted = [0]): string {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf-8', input, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
if (result.error || result.status === null || !accepted.includes(result.status)) {
|
|
throw new Error(`git ${args.join(' ')} failed: ${result.error?.message || result.stderr.trim() || `exit ${result.status}`}`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
/** Git refuses to check paths beneath an installed symlink. Evaluate the
|
|
* expected generated tree in an empty worktree view, retaining the repository's
|
|
* Git excludes and applicable source .gitignore rules. Never follow cache links
|
|
* while finding those rules, and never change the render or source checkout. */
|
|
function ignoredGeneratedPaths(repoRoot: string, paths: string[]): Set<string> {
|
|
if (!paths.length) return new Set();
|
|
const view = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ignore-check-'));
|
|
try {
|
|
const gitDir = git(repoRoot, ['rev-parse', '--absolute-git-dir']).trim();
|
|
const excludesFile = git(repoRoot, ['config', '--path', '--get', 'core.excludesFile'], undefined, [0, 1]).trim();
|
|
const directories = new Map<string, boolean>([['', true]]);
|
|
for (const file of paths) {
|
|
let dir = '';
|
|
for (const segment of file.split('/').slice(0, -1)) {
|
|
dir = dir ? `${dir}/${segment}` : segment;
|
|
if (!directories.has(dir)) {
|
|
try {
|
|
directories.set(dir, fs.lstatSync(path.join(repoRoot, dir)).isDirectory());
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
directories.set(dir, false);
|
|
}
|
|
}
|
|
if (!directories.get(dir)) break;
|
|
}
|
|
}
|
|
for (const [dir, reachable] of directories) {
|
|
if (!reachable) continue;
|
|
const source = path.join(repoRoot, dir, '.gitignore');
|
|
try {
|
|
// Git also ignores symlinked .gitignore files.
|
|
if (!fs.lstatSync(source).isFile()) continue;
|
|
const destination = path.join(view, dir, '.gitignore');
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
fs.copyFileSync(source, destination);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
return new Set(git(view, ['--git-dir', gitDir, '--work-tree', view,
|
|
...(excludesFile ? ['-c', `core.excludesFile=${path.resolve(repoRoot, excludesFile)}`] : []),
|
|
'check-ignore', '--no-index', '-z', '--stdin'], `${paths.join('\0')}\0`, [0, 1]).split('\0').filter(Boolean));
|
|
} finally {
|
|
fs.rmSync(view, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Compare only generated targets: unrelated dirty files cannot fail this check.
|
|
* An expected nonignored output must also be tracked, catching new skills whose
|
|
* generated file was never committed. Tracked files win over ignore rules. */
|
|
export function checkGeneratedFreshness(repoRoot: string, renderRoot: string, artifacts: GeneratedArtifact[]): { checked: number; diagnostics: CheckDiagnostic[] } {
|
|
const diagnostics: CheckDiagnostic[] = [];
|
|
let checked = 0;
|
|
try {
|
|
const tracked = new Set(git(repoRoot, ['ls-files', '-z']).split('\0').filter(Boolean));
|
|
const untracked = artifacts.map(a => a.relativePath).filter(file => !tracked.has(file));
|
|
const ignored = ignoredGeneratedPaths(repoRoot, untracked);
|
|
for (const artifact of artifacts) {
|
|
const { relativePath } = artifact;
|
|
if (!tracked.has(relativePath)) {
|
|
if (!ignored.has(relativePath)) {
|
|
diagnostics.push({ kind: 'untracked', relativePath, message: 'generated output is not tracked; regenerate and add it to git' });
|
|
}
|
|
continue;
|
|
}
|
|
checked++;
|
|
try {
|
|
const generated = fs.readFileSync(path.join(renderRoot, relativePath));
|
|
const existing = fs.readFileSync(path.join(repoRoot, relativePath));
|
|
if (!generated.equals(existing)) diagnostics.push({ kind: 'stale', relativePath, message: 'generated output differs from the tracked file' });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
diagnostics.push({ kind: 'stale', relativePath, message: 'generated or tracked output is missing' });
|
|
} else {
|
|
diagnostics.push({ kind: 'error', relativePath, message: (error as Error).message });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
diagnostics.push({ kind: 'error', message: (error as Error).message });
|
|
}
|
|
return { checked, diagnostics };
|
|
}
|
|
|
|
export async function main(): Promise<number> {
|
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-check-'));
|
|
try {
|
|
console.log('Generating all hosts into scratch with canonical settings...');
|
|
const result = await runGeneration({ host: 'all', outputRoot: scratch, contentLinkRoot: null });
|
|
for (const diagnostic of result.diagnostics) {
|
|
console.log(`${diagnostic.kind.toUpperCase()}: ${diagnostic.host ? `${diagnostic.host}: ` : ''}${diagnostic.message}`);
|
|
}
|
|
if (result.exitCode !== 0) {
|
|
console.error('Generation failed; freshness and content checks require a complete render.');
|
|
return 1;
|
|
}
|
|
|
|
const diagnostics = result.artifacts.flatMap(artifact => validateGeneratedArtifact(scratch, artifact));
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const skills = result.artifacts.filter(a => a.host === host.name && a.kind === 'skill').length;
|
|
console.log(` ${host.displayName}: ${skills} generated skills checked`);
|
|
}
|
|
const freshness = checkGeneratedFreshness(ROOT, scratch, result.artifacts);
|
|
diagnostics.push(...freshness.diagnostics);
|
|
for (const diagnostic of diagnostics) {
|
|
console.error(`${diagnostic.kind.toUpperCase()}: ${diagnostic.relativePath ? `${diagnostic.relativePath}: ` : ''}${diagnostic.message}`);
|
|
}
|
|
console.log(`\nChecked ${result.artifacts.length} generated artifacts; ${freshness.checked} tracked outputs compared.`);
|
|
if (diagnostics.some(d => d.kind === 'stale' || d.kind === 'untracked')) {
|
|
console.error('Run: bun run gen:skill-docs --host all, then commit the generated outputs.');
|
|
}
|
|
return diagnostics.length ? 1 : 0;
|
|
} finally {
|
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
void main().then(code => { process.exitCode = code; }).catch(error => {
|
|
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|