Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/core/harness/config-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { DEFAULT_LOOP_BOUNDS, LOOP_HARD_CAP } from './goal-loop.js';
import { DEFAULT_CRITICAL_STAGE_IDS } from './checkpoints.js';
import { getCanonicalStage } from './stages.js';
import { rstackStateDir } from './runs.js';
// #452: the sandbox timeout hard cap lives with the executor it bounds, so the
// config validator and the resolver clamp/name against ONE value.
import { MAX_TIMEOUT_MS } from './sandbox.js';
// #136 (BLE-6.2): context-pressure thresholds live in rstack.config.json under
// `context_pressure`; validated field-by-field like every other block.
import { validateContextPressureConfig } from './context-pressure.js';
Expand Down Expand Up @@ -119,6 +122,78 @@ export function validateRstackConfig(parsed) {
if (parsed.enabled_packs != null) {
issues.push(...validateEnabledPacksConfig(parsed.enabled_packs));
}
// #452: transient-sandbox execution settings (The Scientist). A typo'd
// per_stage key or command is the silent-failure mode here — the command
// never runs and execution silently stays unverified — so each is named.
if (parsed.sandbox != null) {
issues.push(...validateSandboxConfig(parsed.sandbox));
}
return issues;
}

const SANDBOX_KNOWN_KEYS = ['enabled', 'image', 'command', 'network', 'timeout_ms', 'timeoutMs', 'per_stage', 'perStage', 'limits'];

export function validateSandboxConfig(sandbox) {
const issues = [];
if (!isPlainObject(sandbox)) {
issues.push({ field: 'sandbox', problem: 'must be an object of sandbox execution settings (e.g. { "command": "npm test", "image": "node:20-alpine" })' });
return issues;
}
for (const key of Object.keys(sandbox)) {
if (!SANDBOX_KNOWN_KEYS.includes(key)) {
issues.push({ field: `sandbox.${key}`, problem: 'unknown sandbox key — this setting is ignored' });
}
}
if (sandbox.enabled != null && typeof sandbox.enabled !== 'boolean') {
issues.push({ field: 'sandbox.enabled', problem: `must be a boolean, got ${JSON.stringify(sandbox.enabled)} — the default (true) applies` });
}
if (sandbox.network != null && typeof sandbox.network !== 'boolean') {
issues.push({ field: 'sandbox.network', problem: `must be a boolean (default false = no network), got ${JSON.stringify(sandbox.network)}` });
}
if (sandbox.image != null && (typeof sandbox.image !== 'string' || !sandbox.image.trim())) {
issues.push({ field: 'sandbox.image', problem: 'must be a non-empty container image reference (e.g. "node:20-alpine") — the default (alpine:3.20) runs shell only' });
}
if (sandbox.command != null && (typeof sandbox.command !== 'string' || !sandbox.command.trim())) {
issues.push({ field: 'sandbox.command', problem: 'must be a non-empty shell command string (e.g. "npm test") — execution stays unverified without one' });
}
const timeout = sandbox.timeout_ms ?? sandbox.timeoutMs;
if (timeout != null && !isNonNegativeNumber(timeout)) {
issues.push({ field: 'sandbox.timeout_ms', problem: `must be a non-negative number of milliseconds, got ${JSON.stringify(timeout)} — the default applies` });
} else if (timeout != null && Number(timeout) > MAX_TIMEOUT_MS) {
issues.push({ field: 'sandbox.timeout_ms', problem: `exceeds the hard cap of ${MAX_TIMEOUT_MS}ms — it will be clamped to ${MAX_TIMEOUT_MS}ms` });
}
// limits sub-fields: resolveSandboxConfig silently drops bad-typed values, so
// name them here (the whole point of this validator) instead of leaving a
// typo like { pids: "many" } to pass clean and be ignored downstream.
if (sandbox.limits != null) {
if (!isPlainObject(sandbox.limits)) {
issues.push({ field: 'sandbox.limits', problem: 'must be an object of { memory, pids, cpus }' });
} else {
if (sandbox.limits.memory != null && (typeof sandbox.limits.memory !== 'string' || !sandbox.limits.memory.trim())) {
issues.push({ field: 'sandbox.limits.memory', problem: `must be a non-empty string (e.g. "512m"), got ${JSON.stringify(sandbox.limits.memory)} — the default applies` });
}
for (const key of ['pids', 'cpus']) {
if (sandbox.limits[key] != null && !isNonNegativeNumber(sandbox.limits[key])) {
issues.push({ field: `sandbox.limits.${key}`, problem: `must be a non-negative number, got ${JSON.stringify(sandbox.limits[key])} — the default applies` });
}
}
}
}
const perStage = sandbox.per_stage ?? sandbox.perStage;
if (perStage != null) {
if (!isPlainObject(perStage)) {
issues.push({ field: 'sandbox.per_stage', problem: 'must be an object of canonical-stage-id -> { command } (e.g. "07-code": { "command": "npm test" })' });
} else {
for (const [stageId, entry] of Object.entries(perStage)) {
if (!getCanonicalStage(stageId)) {
issues.push({ field: `sandbox.per_stage.${stageId}`, problem: 'unknown canonical stage id — this per-stage command will NEVER run; use a canonical 00-14 stage id like "07-code"' });
}
if (!isPlainObject(entry) || typeof entry.command !== 'string' || !entry.command.trim()) {
issues.push({ field: `sandbox.per_stage.${stageId}`, problem: 'must be an object with a non-empty "command" string' });
}
}
}
}
return issues;
}

Expand Down
148 changes: 146 additions & 2 deletions src/core/harness/sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@

import { spawn, spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';

const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_TIMEOUT_MS = 600_000;
export const MAX_TIMEOUT_MS = 600_000;
const OUTPUT_TAIL_BYTES = 8 * 1024; // bounded like goal-check's maxBuffer precedent
const RUNTIMES = ['docker', 'podman'];

Expand Down Expand Up @@ -176,3 +178,145 @@ export function runInSandbox(runDir, { taskId, command, network = false, timeout
child.on('close', (code) => finish(code));
});
}

// ---------------------------------------------------------------------------
// #452 PR2 — wiring the Scientist into the validate gate.
//
// Sandbox execution config lives in .rstack/rstack.config.json under a `sandbox`
// block, sibling to `guardrails` (read the same way loadProjectGuardrails does).
// ---------------------------------------------------------------------------

export const DEFAULT_SANDBOX_CONFIG = Object.freeze({
enabled: true, // auto-run whenever a runtime + authoritative command exist
image: 'alpine:3.20', // shell-only default; set node:/python: images to run real suites
network: false, // OFF by default (exfiltration + malicious-install guard)
timeoutMs: DEFAULT_TIMEOUT_MS,
command: null, // project-global authoritative test command (trusted)
perStage: {}, // canonical-stage-id -> { command, image?, network?, timeoutMs? }
limits: { memory: '512m', pids: 256, cpus: 1 },
});

// Merge + validate a raw `sandbox` block into an effective config. Unknown keys
// are ignored; bad-typed values fall back to the default (never silently weaken
// the network/isolation posture).
export function resolveSandboxConfig(raw = {}) {
const cfg = {
...DEFAULT_SANDBOX_CONFIG,
limits: { ...DEFAULT_SANDBOX_CONFIG.limits },
perStage: {},
};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cfg;
if (typeof raw.enabled === 'boolean') cfg.enabled = raw.enabled;
if (typeof raw.image === 'string' && raw.image.trim()) cfg.image = raw.image.trim();
if (typeof raw.network === 'boolean') cfg.network = raw.network;
const timeout = Number(raw.timeout_ms ?? raw.timeoutMs);
if (Number.isFinite(timeout) && timeout > 0) cfg.timeoutMs = Math.min(timeout, MAX_TIMEOUT_MS);
if (typeof raw.command === 'string' && raw.command.trim()) cfg.command = raw.command.trim();
if (raw.limits && typeof raw.limits === 'object' && !Array.isArray(raw.limits)) {
if (typeof raw.limits.memory === 'string' && raw.limits.memory.trim()) cfg.limits.memory = raw.limits.memory.trim();
if (Number.isFinite(Number(raw.limits.pids))) cfg.limits.pids = Number(raw.limits.pids);
if (Number.isFinite(Number(raw.limits.cpus))) cfg.limits.cpus = Number(raw.limits.cpus);
}
const per = raw.per_stage ?? raw.perStage;
if (per && typeof per === 'object' && !Array.isArray(per)) {
for (const [stageId, entry] of Object.entries(per)) {
if (entry && typeof entry === 'object' && typeof entry.command === 'string' && entry.command.trim()) {
const stageTimeout = Number(entry.timeout_ms ?? entry.timeoutMs);
cfg.perStage[stageId] = {
command: entry.command.trim(),
image: typeof entry.image === 'string' && entry.image.trim() ? entry.image.trim() : undefined,
network: typeof entry.network === 'boolean' ? entry.network : undefined,
timeoutMs: Number.isFinite(stageTimeout) && stageTimeout > 0 ? Math.min(stageTimeout, MAX_TIMEOUT_MS) : undefined,
};
}
}
}
return cfg;
}

// Load the effective sandbox config from .rstack/rstack.config.json. A malformed
// file yields the (safe) defaults with a stderr note — mirrors
// loadProjectGuardrails so the two never disagree on config-read semantics.
export async function loadSandboxConfig(projectRoot) {
const configPath = join(projectRoot, '.rstack', 'rstack.config.json');
if (!existsSync(configPath)) return resolveSandboxConfig();
try {
const parsed = JSON.parse(await readFile(configPath, 'utf8'));
return resolveSandboxConfig(parsed?.sandbox || {});
} catch (error) {
if (error instanceof SyntaxError) {
console.error(`[rstack] Ignoring malformed ${configPath} for sandbox config: ${error.message}. Sandbox defaults apply.`);
return resolveSandboxConfig();
}
throw error;
}
}

// AUTHORITATIVE command resolution. The command executed for the gate MUST come
// from a trusted source — a plan-time task field or project config — NEVER from
// the untrusted builder's self-reported `tests_run`. If the builder chose the
// command it would just declare `tests_run: ["true"]` and mint itself a green,
// defeating the whole point of running the code. Returns { command, image,
// network, timeoutMs, limits } or null when nothing authoritative is configured
// (→ honest `observed` degrade at the gate, never a false PASS).
export function resolveSandboxCommand({ config = DEFAULT_SANDBOX_CONFIG, stageIds = [], task = {} } = {}) {
// 1. Plan-time task command (written by the planner, not the builder).
const taskCommand = typeof task?.test_command === 'string' && task.test_command.trim() ? task.test_command.trim() : null;
if (taskCommand) {
return { command: taskCommand, image: config.image, network: config.network, timeoutMs: config.timeoutMs, limits: config.limits };
}
// 2. Per-stage configured command (first matching canonical stage).
for (const stageId of stageIds) {
const entry = config.perStage?.[stageId];
if (entry?.command) {
return {
command: entry.command,
image: entry.image ?? config.image,
network: entry.network ?? config.network,
timeoutMs: entry.timeoutMs ?? config.timeoutMs,
limits: config.limits,
};
}
}
// 3. Project-global configured command.
if (config.command) {
return { command: config.command, image: config.image, network: config.network, timeoutMs: config.timeoutMs, limits: config.limits };
}
return null;
}

// Map a runInSandbox evidence record → a validation.json check entry, shaped so
// priorCritiqueBlock (#451) can surface the REAL captured output to the next
// builder attempt. On FAIL the evidence IS the actual logs (per the posture:
// "the Evidence must be the actual log output, not a summary"), never a
// paraphrase.
export function executionCheck(record, command) {
if (!record || record.tier === 'unverified' || record.status === 'observed') {
return {
name: 'sandbox_execution',
status: 'WARN',
evidence: `${record?.evidence ?? 'execution unverified'} — gate fell back to contract validation (command: ${command ?? 'n/a'})`,
};
}
if (record.status === 'PASS') {
return {
name: 'sandbox_execution',
status: 'PASS',
evidence: `sandboxed ${record.tier} run passed: ${record.evidence} — \`${command}\``,
};
}
const logs = [record.stderr_tail, record.stdout_tail]
.map((section) => String(section ?? '').trim())
.filter(Boolean)
.join('\n');
const failure = record.exit_code === null ? 'timed out' : `exit ${record.exit_code}`;
return {
name: 'sandbox_execution',
status: 'FAIL',
root_cause: `sandboxed ${record.tier} run ${failure}`,
evidence: logs
? `Command \`${command}\` ${failure} in the ${record.tier} sandbox. Actual output:\n${logs}`
: `Command \`${command}\` ${failure} in the ${record.tier} sandbox (no output captured).`,
remediation: 'Fix the failing command shown above — these are the errors from a real sandboxed run, not a self-report.',
};
}
Loading
Loading