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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@
"dompurify": "^3.3.1",
"three": "^0.182.0",
"zod": "^3.25.76"
},
"overrides": {
"devalue": ">=5.8.1"
}
}
5 changes: 4 additions & 1 deletion src/lib/components/MissionBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
if (!iso) return '-';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const ms = d.getTime() - Date.now();
const localTime = d.toLocaleString(undefined, {
weekday: 'short',
Expand Down Expand Up @@ -285,7 +286,9 @@
function scheduleCell(rec: ScheduleRecord): string {
const pattern = humanizeCron(rec.cron);
if (!rec.nextFireAt) return pattern;
const ms = new Date(rec.nextFireAt).getTime() - Date.now();
const nextMs = new Date(rec.nextFireAt).getTime();
if (Number.isNaN(nextMs)) return pattern;
const ms = nextMs - Date.now();
if (ms <= 0) return `${pattern} · due now`;
const s = Math.floor(ms / 1000);
let rel: string;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/server/access-execution-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ export function resolveSparkCliBinary(): string {
return matches.find((line) => line.toLowerCase().endsWith('.cmd')) || matches[0] || 'spark';
}
return matches[0] || 'spark';
} catch {
} catch (err) {
console.warn('[access-execution-actions] spark binary lookup failed:', err);
return 'spark';
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/lib/server/brief-enricher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ import { resolveCliBinary } from './cli-resolver';
// Aggressive timeout: enrichment is a nice-to-have. If claude can't
// respond fast, use deterministic assumptions/questions so the user's
// request doesn't stall the bot. Override via BRIEF_ENRICH_TIMEOUT_MS.
const ENRICH_TIMEOUT_MS = Number(process.env.BRIEF_ENRICH_TIMEOUT_MS || 12_000);
const ENRICH_MIN_LENGTH = Number(process.env.BRIEF_ENRICH_MIN_LENGTH || 600);
const ENRICH_MIN_KEYWORDS = Number(process.env.BRIEF_ENRICH_MIN_KEYWORDS || 8);
function positiveNumericEnv(raw: string | undefined, fallback: number): number {
const trimmed = (raw || '').trim();
if (!/^\d+(?:\.\d+)?$/.test(trimmed)) return fallback;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
const ENRICH_TIMEOUT_MS = positiveNumericEnv(process.env.BRIEF_ENRICH_TIMEOUT_MS, 12_000);
const ENRICH_MIN_LENGTH = positiveNumericEnv(process.env.BRIEF_ENRICH_MIN_LENGTH, 600);
const ENRICH_MIN_KEYWORDS = positiveNumericEnv(process.env.BRIEF_ENRICH_MIN_KEYWORDS, 8);
const ENRICH_PROVIDER = (process.env.SPAWNER_BRIEF_ENRICH_PROVIDER || 'deterministic').trim().toLowerCase();

export interface EnrichmentResult {
Expand Down
22 changes: 20 additions & 2 deletions src/lib/server/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { externalProjectPathsAllowed, resolveContainedPath, sparkWorkspaceRoot }

export const MAX_OUTPUT_LENGTH = 5000;
export const COMMAND_TIMEOUT_MS = commandTimeoutMs();
const SIGTERM_GRACE_MS = 5000;

export interface CommandResult {
exitCode: number;
Expand Down Expand Up @@ -159,6 +160,19 @@ export function runCommand(
windowsHide: true
});

// Node sends SIGTERM when the spawn `timeout` fires. If the child ignores
// SIGTERM (npm/test shells with their own signal handlers, hung worker
// pools), `close` never fires and the API caller waits indefinitely.
// Schedule a SIGKILL escalation that fires only if the child is still
// running after the SIGTERM grace window.
const sigkillTimer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// Child may already have exited after SIGTERM.
}
}, timeoutMs + SIGTERM_GRACE_MS);

child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
Expand All @@ -168,6 +182,7 @@ export function runCommand(
});

child.on('close', (code) => {
clearTimeout(sigkillTimer);
if (!resolved) {
resolved = true;
res({
Expand All @@ -180,6 +195,7 @@ export function runCommand(
});

child.on('error', (err) => {
clearTimeout(sigkillTimer);
if (!resolved) {
resolved = true;
res({
Expand Down Expand Up @@ -222,7 +238,8 @@ export async function detectTypecheckCommand(projectPath: string): Promise<{ com
if (deps['typescript']) {
return { command: 'npx', args: ['tsc', '--noEmit'] };
}
} catch {
} catch (err) {
console.warn('[command-runner] dependency check failed:', err);
// fall through
}
}
Expand All @@ -238,7 +255,8 @@ export async function hasTestScript(projectPath: string): Promise<boolean> {
try {
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
return !!(pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1');
} catch {
} catch (err) {
console.warn('[command-runner] package.json parse failed:', err);
return false;
}
}
Expand Down
59 changes: 58 additions & 1 deletion src/lib/server/creator-mission.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { env } from '$env/dynamic/private';
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
Expand Down Expand Up @@ -368,6 +369,14 @@ function creatorMissionDir(stateDir = spawnerStateDir()): string {
return path.join(stateDir, 'creator-missions');
}

/**
* Validates that a missionId contains only safe characters and cannot be used for path traversal.
* Mission IDs should be alphanumeric with hyphens/underscores only.
*/
function validateMissionId(missionId: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(missionId);
}

function pendingLoadPath(stateDir = spawnerStateDir()): string {
return path.join(stateDir, 'pending-load.json');
}
Expand Down Expand Up @@ -1632,7 +1641,7 @@ export async function saveCreatorMissionTrace(trace: CreatorMissionTrace, stateD
const dir = creatorMissionDir(stateDir);
await mkdir(dir, { recursive: true });
const filePath = creatorMissionPath(trace.mission_id, stateDir);
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
const tempPath = `${filePath}.${randomUUID()}.tmp`;
await writeFile(tempPath, JSON.stringify(trace, null, 2), 'utf-8');
await rename(tempPath, filePath);
}
Expand All @@ -1651,6 +1660,10 @@ export async function readCreatorMissionTrace(
): Promise<CreatorMissionTrace | null> {
const missionId = input.missionId?.trim();
if (missionId) {
if (!validateMissionId(missionId)) {
console.error(`Invalid missionId format: ${missionId}`);
return null;
}
const filePath = creatorMissionPath(missionId, stateDir);
if (!existsSync(filePath)) return null;
return parseCreatorMissionTraceFile(await readFile(filePath, 'utf-8'));
Expand Down Expand Up @@ -1727,6 +1740,32 @@ export async function executeCreatorMission(

const VALIDATION_EXECUTABLE_ALLOWLIST = new Set(['node', 'python', 'python3', 'py', 'npm', 'npx', 'pnpm', 'spark-intelligence']);

// Interpreters that accept flags to execute arbitrary code snippets.
const INTERPRETER_EXECUTABLES = new Set(['node', 'python', 'python3', 'py']);
// Flags that cause interpreters to evaluate an arbitrary code string from the argument
// line (or read a program from stdin).
// A command like `python -c "os.system('rm -rf /')"` would pass the allowlist
// because only the executable name is checked. These inline-eval flags must be rejected.
// Covers node (-e/--eval, -p/--print), python (-c/--command) and the bare `-`
// stdin-program form. This list must include every interpreter flag that evaluates an
// argument string or stdin program rather than naming a script/module on disk.
//
// NOTE: `-m`/`--module` is intentionally NOT listed. `python -m <module>` runs a named,
// importable module (e.g. `python -m pytest tests`) — it executes installed code on disk,
// exactly like running a script file, and never evaluates an arbitrary inline string. It
// is a first-class validation pattern here (the default manifest validation command is
// `python -m pytest tests`, and the planner shells out `python -m spark_intelligence.cli`),
// so blocking it would break legitimate runs.
const DANGEROUS_INTERPRETER_FLAGS = new Set([
'-c',
'--command',
'-e',
'--eval',
'-p',
'--print',
'-'
]);

function splitCommandLine(command: string): string[] {
const parts: string[] = [];
let current = '';
Expand Down Expand Up @@ -1966,6 +2005,24 @@ async function runCreatorValidationCommand(
error: `Validation executable is not allowlisted: ${executable}`
};
}
// Reject interpreters invoked with flags that execute arbitrary code snippets.
if (INTERPRETER_EXECUTABLES.has(executable)) {
const hasDangerousFlag = args.some((arg) => DANGEROUS_INTERPRETER_FLAGS.has(arg));
if (hasDangerousFlag) {
return {
artifact_id: manifest.artifact_id,
artifact_type: manifest.artifact_type,
repo: manifest.repo,
command,
cwd,
status: 'skipped',
exit_code: null,
stdout_tail: '',
stderr_tail: '',
error: `Interpreter executable '${executable}' is not permitted with code-execution flags. Use a script file instead.`
};
}
}
const runner = options.commandRunner || activeCreatorValidationCommandRunner || defaultCreatorValidationCommandRunner;
const resolved = options.commandRunner ? { executable, args } : resolveValidationCommand(executable, args);
const result = await runner(resolved.executable, resolved.args, { cwd, timeoutMs: options.timeoutMs });
Expand Down
7 changes: 5 additions & 2 deletions src/lib/server/hosted-ui-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,12 @@ describe('hosted UI auth', () => {
expect(hostedUiCredentialsAreValid('my-private-spawner', 'wrong', env)).toBe(false);
});

it('uses forwarded IP as the hosted auth rate-limit key', () => {
it('uses the trusted-proxy (last) forwarded-for hop as the hosted auth rate-limit key', () => {
// The leftmost x-forwarded-for entry is client-controllable and can be spoofed to
// dodge the rate limiter. The hardened key derivation trusts only the last hop,
// which is appended by our reverse proxy.
const request = new Request('https://x.test/', { headers: { 'x-forwarded-for': '203.0.113.10, 10.0.0.1' } });
expect(hostedUiAuthClientKey(request)).toBe('203.0.113.10');
expect(hostedUiAuthClientKey(request)).toBe('10.0.0.1');
});

it('rate-limits repeated hosted auth failures within the window', () => {
Expand Down
20 changes: 17 additions & 3 deletions src/lib/server/hosted-ui-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ const hostedUiSessions = new Map<
function constantTimeEquals(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
// Use the longer length to prevent timing leak on length comparison
const maxLen = Math.max(leftBuffer.length, rightBuffer.length);
const leftPadded = Buffer.alloc(maxLen, 0);
const rightPadded = Buffer.alloc(maxLen, 0);
leftBuffer.copy(leftPadded);
rightBuffer.copy(rightPadded);
return timingSafeEqual(leftPadded, rightPadded) && leftBuffer.length === rightBuffer.length;
}

function hashSessionId(sessionId: string): string {
Expand Down Expand Up @@ -217,8 +223,16 @@ export function hostedUiShouldBypassLocalOperatorAuth(
}

export function hostedUiAuthClientKey(request: Request): string {
const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
return forwardedFor || request.headers.get('x-real-ip')?.trim() || 'unknown';
// Prefer x-real-ip (set by trusted reverse proxy) over x-forwarded-for.
// x-forwarded-for first entry is client-controllable; use last entry (set by trusted proxy).
const realIp = request.headers.get('x-real-ip')?.trim();
if (realIp) return realIp;
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
const parts = forwardedFor.split(',').map(p => p.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
return 'unknown';
}

export function hostedUiAuthRateLimitStatus(clientKey: string, now = Date.now()): {
Expand Down
13 changes: 7 additions & 6 deletions src/lib/server/mcp-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ const rateLimitBuckets = new Map<string, number[]>();
function constantTimeEquals(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);

if (leftBuffer.length !== rightBuffer.length) {
return false;
}

return timingSafeEqual(leftBuffer, rightBuffer);
// Use the longer length to prevent timing leak on length comparison
const maxLen = Math.max(leftBuffer.length, rightBuffer.length);
const leftPadded = Buffer.alloc(maxLen, 0);
const rightPadded = Buffer.alloc(maxLen, 0);
leftBuffer.copy(leftPadded);
rightBuffer.copy(rightPadded);
return timingSafeEqual(leftPadded, rightPadded) && leftBuffer.length === rightBuffer.length;
}

interface ApiKeyExtractionOptions {
Expand Down
58 changes: 55 additions & 3 deletions src/lib/server/mission-control-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ function getMissionControlPersistenceInfo(): MissionControlRelaySnapshot['persis
}
}

function isMissionControlMissionId(value: unknown): value is string {
export function isMissionControlMissionId(value: unknown): value is string {
return typeof value === 'string' && /^(spark|mission)-[A-Za-z0-9_-]+$/.test(value.trim());
}

Expand Down Expand Up @@ -234,9 +234,49 @@ const relayState: {
recent: []
};

// Lifecycle-state maps are server-process-lifetime; without a cap, every
// mission/task ID processed since the last restart accumulates an entry
// and the maps grow unbounded. Cap each map and evict the oldest
// insertion when the cap is reached. Map iteration order preserves
// insertion order, so the first key reached is the oldest.
const MAX_LIFECYCLE_ENTRIES = 5000;
const missionLifecycleStates = new Map<string, string>();
const taskLifecycleStates = new Map<string, string>();

function setBoundedLifecycle(map: Map<string, string>, key: string, value: string): void {
if (!map.has(key) && map.size >= MAX_LIFECYCLE_ENTRIES) {
const oldest = map.keys().next().value;
if (oldest !== undefined) map.delete(oldest);
}
map.set(key, value);
}

// Seed lifecycle dedup maps from the persisted recent entries so that, after a
// server restart, replays of an already-recorded terminal lifecycle event are
// skipped instead of re-broadcast to Spark ingest and webhooks.
function seedLifecycleStatesFromRecent(): void {
// recent is newest-first (unshift); walk oldest-first so the latest status wins.
// recent is capped (MAX_RECENT_EVENTS), so this seeds far fewer than
// MAX_LIFECYCLE_ENTRIES entries; route through setBoundedLifecycle to keep a
// single insertion path and preserve the cap invariant.
for (let i = relayState.recent.length - 1; i >= 0; i -= 1) {
const entry = relayState.recent[i];
const taskStatus = lifecycleTaskStatusForEvent(entry.eventType);
if (taskStatus) {
const identity = taskIdentityForStatusEntry(entry);
if (identity) {
setBoundedLifecycle(taskLifecycleStates, `${entry.missionId}:${identity}`, taskStatus);
}
continue;
}
const missionStatus = lifecycleMissionStatusForEvent(entry.eventType);
if (missionStatus) {
setBoundedLifecycle(missionLifecycleStates, entry.missionId, missionStatus);
}
}
}
seedLifecycleStatesFromRecent();

function normalizeMissionId(event: MissionControlBridgeEvent): string {
return typeof event.missionId === 'string' && event.missionId.trim().length > 0 ? event.missionId : 'unknown-mission';
}
Expand Down Expand Up @@ -433,6 +473,18 @@ function shouldRecordMissionControlEvent(event: MissionControlBridgeEvent): bool
function recordRelayEvent(event: MissionControlBridgeEvent): void {
const entry = toStatusEntry(event);
if (!isMissionControlMissionId(entry.missionId)) {
// Surface the drop so non-conformant emitters notice their events never reach
// /kanban, /trace, or any board view. The event was accepted by /api/events
// (and is still broadcast via SSE) — only the board persistence is filtered.
// Suppress the warning for events with no missionId at all (those are never
// intended to be mission-scoped).
if (entry.missionId) {
console.warn(
`[mission-control-relay] mission event "${entry.eventType}" for missionId ` +
`"${entry.missionId}" was accepted but will not appear on the board: ` +
`missionId must match /^(spark|mission)-[A-Za-z0-9_-]+$/.`
);
}
return;
}
relayState.totalRelayed += 1;
Expand Down Expand Up @@ -722,7 +774,7 @@ function shouldRecordLifecycleTransition(event: MissionControlBridgeEvent): bool
if (taskLifecycleStates.get(key) === taskStatus) {
return false;
}
taskLifecycleStates.set(key, taskStatus);
setBoundedLifecycle(taskLifecycleStates, key, taskStatus);
return true;
}

Expand All @@ -731,7 +783,7 @@ function shouldRecordLifecycleTransition(event: MissionControlBridgeEvent): bool
if (missionLifecycleStates.get(missionId) === missionStatus) {
return false;
}
missionLifecycleStates.set(missionId, missionStatus);
setBoundedLifecycle(missionLifecycleStates, missionId, missionStatus);
return true;
}

Expand Down
Loading