mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
harden runtime packaging and verification
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||
installManagedRuntime,
|
||||
runtimeNativePackagePaths,
|
||||
} from '../../runtime/install.js';
|
||||
import { atomicWriteJson } from '../../runtime/storage.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '../..');
|
||||
const FORBIDDEN_COMPONENT = /browserbase|browserless|huggingface|onnxruntime|claude-agent-sdk/i;
|
||||
|
||||
export interface RuntimeBundleAudit {
|
||||
schemaVersion: 1;
|
||||
platform: string;
|
||||
arch: string;
|
||||
sourceBundleVersion: string;
|
||||
generatedAt: string;
|
||||
sourceGitCommit: string;
|
||||
sourceGitDirty: boolean;
|
||||
components: number;
|
||||
files: number;
|
||||
bytes: number;
|
||||
capabilityLaunchers: number;
|
||||
nativeComponents: string[];
|
||||
forbiddenComponents: string[];
|
||||
bundleManifestSha256: string;
|
||||
reproductionCommand: string;
|
||||
}
|
||||
|
||||
export function summarizeRuntimeBundle(
|
||||
manifest: Record<string, unknown> & {
|
||||
version: string;
|
||||
components: string[];
|
||||
files: Array<{ path: string; size: number; mode: number; sha256: string }>;
|
||||
},
|
||||
): RuntimeBundleAudit {
|
||||
const nativeComponents = runtimeNativePackagePaths();
|
||||
const forbiddenComponents = manifest.components.filter((component) => FORBIDDEN_COMPONENT.test(component));
|
||||
const digestInput = JSON.stringify(manifest);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
sourceBundleVersion: manifest.version,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceGitCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(),
|
||||
sourceGitDirty: execFileSync(
|
||||
'git',
|
||||
['status', '--porcelain', '--untracked-files=all'],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf8' },
|
||||
).trim().length > 0,
|
||||
components: manifest.components.length,
|
||||
files: manifest.files.length,
|
||||
bytes: manifest.files.reduce((total, file) => total + file.size, 0),
|
||||
capabilityLaunchers: Object.keys(DEFAULT_CAPABILITY_LAUNCHERS).length,
|
||||
nativeComponents: [...nativeComponents],
|
||||
forbiddenComponents,
|
||||
bundleManifestSha256: createHash('sha256').update(digestInput).digest('hex'),
|
||||
reproductionCommand: `bun run scripts/gstack2/audit-runtime-bundle.ts --output evals/runtime-bundle/${process.platform}-${process.arch}.json`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const outputIndex = argv.indexOf('--output');
|
||||
if (argv.length !== 0 && (outputIndex !== 0 || argv.length !== 2 || !argv[1])) {
|
||||
throw new TypeError('Usage: audit-runtime-bundle.ts [--output <path>]');
|
||||
}
|
||||
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'gstack2-runtime-bundle-audit-'));
|
||||
try {
|
||||
const result = await installManagedRuntime({
|
||||
sourceDir: REPO_ROOT,
|
||||
home: path.join(scratch, 'home'),
|
||||
buildMissing: false,
|
||||
});
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(result.path, '.gstack-bundle.json'), 'utf8'));
|
||||
const audit = summarizeRuntimeBundle(manifest);
|
||||
if (audit.forbiddenComponents.length > 0) {
|
||||
throw new Error(`Forbidden production components: ${audit.forbiddenComponents.join(', ')}`);
|
||||
}
|
||||
if (outputIndex === 0) {
|
||||
const output = path.resolve(REPO_ROOT, argv[1]);
|
||||
await atomicWriteJson(output, audit, { mode: 0o644 });
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(audit, null, 2)}\n`);
|
||||
} finally {
|
||||
await fs.rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
if (invokedPath === import.meta.url) await main();
|
||||
@@ -1,5 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SOURCE="$(cd "${1:-$PWD}" && pwd -P)"
|
||||
ROOT="$(mktemp -d /tmp/gstack2-devcontainer-gate.XXXXXX)"
|
||||
ROOT="$(cd "$ROOT" && pwd -P)"
|
||||
REPO="$ROOT/source"
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "$ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
mkdir -p "$REPO"
|
||||
tar -C "$SOURCE" \
|
||||
--exclude='./.git' \
|
||||
--exclude='./node_modules' \
|
||||
-cf - . \
|
||||
| tar -C "$REPO" -xf -
|
||||
|
||||
# The parity and generated-file checks need the checkout's Git history. Point
|
||||
# the disposable worktree at the read-only source metadata, while leaving all
|
||||
# Git commands free to discover fixture repositories normally.
|
||||
REAL_GIT="${GSTACK_GATE_BASE_GIT:-$(command -v git)}"
|
||||
if SOURCE_GIT_DIR="$("$REAL_GIT" -c safe.directory="$SOURCE" -C "$SOURCE" rev-parse --absolute-git-dir 2>/dev/null)"; then
|
||||
printf 'gitdir: %s\n' "$SOURCE_GIT_DIR" > "$REPO/.git"
|
||||
GIT_WRAPPER_DIR="$ROOT/bin"
|
||||
mkdir -p "$GIT_WRAPPER_DIR"
|
||||
export GSTACK_GATE_BASE_GIT="$REAL_GIT"
|
||||
export GSTACK_GATE_SOURCE="$SOURCE"
|
||||
export GSTACK_GATE_WORK_TREE="$REPO"
|
||||
export GIT_OPTIONAL_LOCKS=0
|
||||
export PATH="$GIT_WRAPPER_DIR:$PATH"
|
||||
cat > "$GIT_WRAPPER_DIR/git" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$GSTACK_GATE_BASE_GIT" \
|
||||
-c safe.directory="$GSTACK_GATE_SOURCE" \
|
||||
-c safe.directory="$GSTACK_GATE_WORK_TREE" \
|
||||
"$@"
|
||||
EOF
|
||||
chmod 700 "$GIT_WRAPPER_DIR/git"
|
||||
fi
|
||||
|
||||
cd "$REPO"
|
||||
bun install --frozen-lockfile
|
||||
bun run test:gstack2
|
||||
|
||||
@@ -541,10 +541,6 @@ interface RenderedModuleRecord {
|
||||
disposition: string;
|
||||
}
|
||||
|
||||
function referencedModules(content: string): string[] {
|
||||
return [...new Set([...content.matchAll(/references\/legacy\/([a-z0-9-]+)\.md/g)].map((match) => match[1]))].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the complete module graph for each independently installable public
|
||||
* skill. Owner modules are roots because compatibility aliases may select any
|
||||
@@ -570,7 +566,11 @@ function packageModuleClosure(rendered: Map<string, RenderedModuleRecord>): Map<
|
||||
for (const source of [...sources]) {
|
||||
const module = rendered.get(source);
|
||||
if (!module) throw new Error(`${tree} references unknown preserved module ${source}`);
|
||||
for (const dependency of referencedModules(module.content)) {
|
||||
const dependencies = new Set(
|
||||
[...module.content.matchAll(/references\/legacy\/([a-z0-9-]+)\.md/g)]
|
||||
.map((match) => match[1]),
|
||||
);
|
||||
for (const dependency of [...dependencies].sort()) {
|
||||
if (!rendered.has(dependency)) throw new Error(`${source} references unknown preserved module ${dependency}`);
|
||||
if (!sources.has(dependency)) {
|
||||
sources.add(dependency);
|
||||
|
||||
@@ -645,10 +645,13 @@ export function validateStructuredResult(value: unknown): value is StructuredHos
|
||||
|
||||
function parseJsonCandidate(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
try { return JSON.parse(trimmed); } catch { /* try a fenced payload */ }
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1];
|
||||
if (fenced) return JSON.parse(fenced);
|
||||
throw new Error('final agent message was not JSON');
|
||||
try {
|
||||
return JSON.parse(fenced ?? trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error('final agent message was not JSON');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStructuredFinal(messages: string[]): {
|
||||
@@ -842,7 +845,9 @@ function stageAuthentication(codexHome: string): void {
|
||||
if (!fs.existsSync(source)) return;
|
||||
const destination = path.join(codexHome, 'auth.json');
|
||||
fs.copyFileSync(source, destination);
|
||||
try { fs.chmodSync(destination, 0o600); } catch { /* Windows and restrictive filesystems may ignore chmod. */ }
|
||||
// Windows has no POSIX owner-only mode. On POSIX, failing to secure copied
|
||||
// credentials is fatal rather than silently continuing with broad access.
|
||||
if (os.platform() !== 'win32') fs.chmodSync(destination, 0o600);
|
||||
}
|
||||
|
||||
async function runFixture(options: {
|
||||
|
||||
@@ -221,9 +221,7 @@ function policyUnitTranscript() {
|
||||
});
|
||||
}
|
||||
|
||||
function containsSensitiveMaterial(value: string): boolean {
|
||||
return /(?:sk-[A-Za-z0-9_-]{12,}|AKIA[0-9A-Z]{16}|gh[opusr]_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/.test(value);
|
||||
}
|
||||
const SENSITIVE_MATERIAL = /(?:sk-[A-Za-z0-9_-]{12,}|AKIA[0-9A-Z]{16}|gh[opusr]_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/;
|
||||
|
||||
function sanitizeLivePrompt(value: string): string {
|
||||
return value
|
||||
@@ -237,7 +235,7 @@ const LIVE_OUTPUT_SCHEMA = `Return one JSON object and no prose with exactly the
|
||||
const LIVE_JUDGE_SCHEMA = `Return one JSON object and no prose with fields verdict and dimensions. verdict must be EQUIVALENT, INTENTIONAL_IMPROVEMENT, or REGRESSION. dimensions must be an object with exactly these keys: ${SEMANTIC_DIMENSIONS.join(', ')}. Each dimension value must be an object with classification (one of the same three values) and a concise reason. Treat any loss of pressure, gates, evidence, mutation restraint, recommendation, or voice as REGRESSION. Do not call tools.`;
|
||||
|
||||
async function runClaude(prompt: string, model: string, maxBudgetUsd: number): Promise<{ raw: string; parsed: Record<string, string> }> {
|
||||
if (containsSensitiveMaterial(prompt)) throw new Error('Refusing live semantic eval: prompt matched a credential pattern');
|
||||
if (SENSITIVE_MATERIAL.test(prompt)) throw new Error('Refusing live semantic eval: prompt matched a credential pattern');
|
||||
const proc = Bun.spawn([
|
||||
'claude', '-p', '--bare', '--no-session-persistence', '--disable-slash-commands', '--no-chrome',
|
||||
'--model', model, '--max-turns', '1', '--max-budget-usd', maxBudgetUsd.toFixed(2),
|
||||
|
||||
@@ -344,11 +344,14 @@ function listInstalledSkills(root: string): string[] {
|
||||
}
|
||||
|
||||
export function stripTerminalControls(value: string): string {
|
||||
return value
|
||||
.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
|
||||
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r(?=[^\n])/g, '')
|
||||
.trim();
|
||||
const terminalControls = [
|
||||
/\x1B\][^\x07]*(?:\x07|\x1B\\)/g,
|
||||
/\x1B\[[0-?]*[ -/]*[@-~]/g,
|
||||
/\r(?=[^\n])/g,
|
||||
];
|
||||
let clean = value;
|
||||
for (const control of terminalControls) clean = clean.replace(control, '');
|
||||
return clean.trim();
|
||||
}
|
||||
|
||||
function trimEvidenceOutput(value: string, maxCharacters = 16_000): string {
|
||||
@@ -555,9 +558,7 @@ function runRemoval(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function runFastChecks(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspection {
|
||||
return inspectRepository(repoRoot);
|
||||
}
|
||||
export const runFastChecks = inspectRepository;
|
||||
|
||||
export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence {
|
||||
if (!options.outputPath) throw new Error('Full install matrix requires a caller-supplied outputPath');
|
||||
|
||||
+12
-1
@@ -18,13 +18,24 @@ import * as path from "path";
|
||||
|
||||
const base = process.argv[2] || "main";
|
||||
|
||||
// `skills/` is a committed, deterministic projection of the preserved source
|
||||
// corpus. Its support files are intentionally copied into self-contained Agent
|
||||
// Skills packages, so duplicate-signature findings there describe the package
|
||||
// format rather than new handwritten implementation. Scan the generator and
|
||||
// original source files instead; freshness/parity separately prove the output.
|
||||
function isGeneratedCanonicalOutput(file: string): boolean {
|
||||
return file.startsWith("skills/");
|
||||
}
|
||||
|
||||
// 1. Find changed files
|
||||
const diffResult = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
const changedFiles = new Set(
|
||||
(diffResult.stdout || "").trim().split("\n").filter(Boolean),
|
||||
(diffResult.stdout || "").trim().split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((file) => !isGeneratedCanonicalOutput(file)),
|
||||
);
|
||||
if (changedFiles.size === 0) {
|
||||
console.log("No files changed vs", base, "— nothing to check.");
|
||||
|
||||
+24
-17
@@ -119,10 +119,11 @@ export const DEFAULT_SHARD_COUNT = 20;
|
||||
export const DEFAULT_MAX_FILES_PER_SHARD = 20;
|
||||
export const FREE_TEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
const SCHEDULED_EXIT_ZERO = /\b(?:setTimeout|setInterval|setImmediate|queueMicrotask)\s*\(\s*(?:(?:async\s*)?(?:\([^)]*\)|[$\w]+)\s*=>|function(?:\s+[$\w]+)?\s*\([^)]*\)\s*\{)[\s\S]{0,256}?\bprocess\.exit\s*\(\s*0\s*\)/;
|
||||
const SCHEDULED_CALLBACK_START = /\b(?:setTimeout|setInterval|setImmediate|queueMicrotask)\s*\(\s*(?:(?:async\s*)?(?:\([^)]*\)|[$\w]+)\s*=>|function(?:\s+[$\w]+)?\s*\([^)]*\)\s*\{)/g;
|
||||
const PROCESS_EXIT_ZERO = /\bprocess\.exit\s*\(\s*0\s*\)/;
|
||||
// Deliberately require column zero. That identifies conventional module-scope
|
||||
// setup while avoiding process.env changes indented inside hooks and tests.
|
||||
const TOP_LEVEL_PROCESS_ENV_MUTATION = /^(?:process\.env\.[A-Za-z_][A-Za-z0-9_]*[ \t]*=(?!=)|delete[ \t]+process\.env\.[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*;)?[ \t]*(?:\/\/.*)?$)/m;
|
||||
const TOP_LEVEL_PROCESS_ENV_MUTATION = /^(?:process\.env\.[A-Za-z_][A-Za-z0-9_]*[ \t]*=(?!=)|delete[ \t]+process\.env\.[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*;)?[ \t]*(?:\/\/.*)?$)/;
|
||||
|
||||
export function normalizeRelativePath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
@@ -233,27 +234,29 @@ export function assignFilesToShards(files: string[], shardCount: number): string
|
||||
}
|
||||
|
||||
export function containsScheduledProcessExitZero(source: string): boolean {
|
||||
return SCHEDULED_EXIT_ZERO.test(source);
|
||||
for (const callback of source.matchAll(SCHEDULED_CALLBACK_START)) {
|
||||
const bodyStart = (callback.index ?? 0) + callback[0].length;
|
||||
const exit = PROCESS_EXIT_ZERO.exec(source.slice(bodyStart, bodyStart + 320));
|
||||
if (exit !== null && exit.index <= 256) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasScheduledProcessExitZero(absolutePath: string): boolean {
|
||||
try {
|
||||
return containsScheduledProcessExitZero(fs.readFileSync(absolutePath, 'utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
return containsScheduledProcessExitZero(source);
|
||||
}
|
||||
|
||||
export function containsTopLevelProcessEnvMutation(source: string): boolean {
|
||||
return TOP_LEVEL_PROCESS_ENV_MUTATION.test(source);
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
if (TOP_LEVEL_PROCESS_ENV_MUTATION.test(line)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasTopLevelProcessEnvMutation(absolutePath: string): boolean {
|
||||
try {
|
||||
return containsTopLevelProcessEnvMutation(fs.readFileSync(absolutePath, 'utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
return containsTopLevelProcessEnvMutation(source);
|
||||
}
|
||||
|
||||
export interface BoundedShardOptions {
|
||||
@@ -276,13 +279,17 @@ export function planBoundedFreeTestShards(
|
||||
throw new Error(`Maximum files per shard must be a positive integer. Received: ${maxFilesPerShard}`);
|
||||
}
|
||||
|
||||
const orderedFiles = [...new Set(files)].sort();
|
||||
if (maxFilesPerShard === 1) return orderedFiles.map((file) => [file]);
|
||||
|
||||
const normal: string[] = [];
|
||||
const isolated: string[] = [];
|
||||
for (const file of [...new Set(files)].sort()) {
|
||||
for (const file of orderedFiles) {
|
||||
const absolutePath = path.join(rootDir, file);
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
if (
|
||||
hasScheduledProcessExitZero(absolutePath)
|
||||
|| hasTopLevelProcessEnvMutation(absolutePath)
|
||||
containsScheduledProcessExitZero(source)
|
||||
|| containsTopLevelProcessEnvMutation(source)
|
||||
) isolated.push(file);
|
||||
else normal.push(file);
|
||||
}
|
||||
|
||||
@@ -53,17 +53,6 @@ const DEFAULT_TERMINATION_TIMER: TerminationTimerApi = {
|
||||
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
};
|
||||
|
||||
function killWithoutThrowing(
|
||||
child: Pick<ChildProcess, 'kill'>,
|
||||
signal: NodeJS.Signals,
|
||||
): void {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have exited between close detection and signal delivery.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one active child to the parent's termination lifecycle. SIGINT and
|
||||
* SIGTERM get a grace period so Bun can clean up; a repeated signal, timeout,
|
||||
@@ -82,19 +71,19 @@ export function installChildSignalForwarding(
|
||||
const forward = (signal: ForwardedTerminationSignal): void => {
|
||||
if (disposed) return;
|
||||
if (receivedSignal !== null) {
|
||||
killWithoutThrowing(child, 'SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
receivedSignal = signal;
|
||||
killWithoutThrowing(child, signal);
|
||||
child.kill(signal);
|
||||
forceTimer = timer.schedule(() => {
|
||||
forceTimer = null;
|
||||
killWithoutThrowing(child, 'SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}, graceMs);
|
||||
};
|
||||
const onSigint = () => forward('SIGINT');
|
||||
const onSigterm = () => forward('SIGTERM');
|
||||
const onExit = () => killWithoutThrowing(child, 'SIGKILL');
|
||||
const onExit = () => { child.kill('SIGKILL'); };
|
||||
|
||||
source.on('SIGINT', onSigint);
|
||||
source.on('SIGTERM', onSigterm);
|
||||
@@ -283,7 +272,7 @@ export async function runStrictTestShard(files: string[]): Promise<number> {
|
||||
const forwarding = installChildSignalForwarding(child);
|
||||
|
||||
if (!child.stdout || !child.stderr) {
|
||||
killWithoutThrowing(child, 'SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
forwarding.dispose();
|
||||
throw new Error('Bun test output pipes were not created');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user