From 68bd8db9b4c5c5abf5d71f286760f1078814ea54 Mon Sep 17 00:00:00 2001 From: Joy Shi Date: Tue, 7 Apr 2026 12:03:15 -0400 Subject: [PATCH] fix(aiv): harden state manager with memory caps, path validation, and 404s Cap touchedFiles at 500 per session and strategyChanges at 50. Add isFilePath validation to reject URLs, garbage, and overly long strings. Add periodic idle-session sweep (30min TTL, 5min interval). Return 404 for non-existent sessions on GET /aiv/intent/:sessionID. Addresses P0/P1 hardening tasks from review notes. Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/aiv/state.ts | 45 +++++++++++++++++++--- packages/opencode/src/server/routes/aiv.ts | 1 + 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/aiv/state.ts b/packages/opencode/src/aiv/state.ts index 50c2002f7..169470c48 100644 --- a/packages/opencode/src/aiv/state.ts +++ b/packages/opencode/src/aiv/state.ts @@ -11,10 +11,27 @@ import { AivPersistence } from "./persistence" const log = Log.create({ service: "aiv" }) +const MAX_TOUCHED_FILES = 500 +const MAX_STRATEGY_CHANGES = 50 +const IDLE_EVICTION_MS = 30 * 60 * 1000 // 30 minutes +const SWEEP_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes + const intents = new Map() const touchedFiles = new Map>() +function isFilePath(value: string): boolean { + if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//")) return false + if (value.includes("\n") || value.includes("\t")) return false + if (value.length > 500) return false + if (/^[.\/~]/.test(value) || /\/[^/]+\.\w+$/.test(value)) return true + return false +} + export namespace AivState { + export function has(sessionID: SessionID): boolean { + return intents.has(sessionID) + } + export function get(sessionID: SessionID): AivSchema.Intent { return intents.get(sessionID) ?? AivSchema.empty(sessionID) } @@ -55,7 +72,7 @@ export namespace AivState { to: next.workType, timestamp: Date.now(), } - next.strategyChanges = [...prev.strategyChanges, change] + next.strategyChanges = [...prev.strategyChanges, change].slice(-MAX_STRATEGY_CHANGES) Bus.publish(AivEvent.StrategyChanged, { sessionID, change }) AivPersistence.appendEvent({ sessionID, @@ -94,6 +111,11 @@ export namespace AivState { }) } + function addFile(files: Set, path: string) { + if (files.size >= MAX_TOUCHED_FILES) return + if (isFilePath(path)) files.add(path) + } + function handleToolPart(sessionID: SessionID, part: MessageV2.ToolPart) { const files = getOrCreateFiles(sessionID) @@ -101,12 +123,12 @@ export namespace AivState { const input = "input" in part.state ? part.state.input : {} if (input) { const filePath = input.file_path ?? input.path ?? input.filename - if (typeof filePath === "string") files.add(filePath) + if (typeof filePath === "string") addFile(files, filePath) const filePaths = input.files ?? input.paths if (Array.isArray(filePaths)) { for (const f of filePaths) { - if (typeof f === "string") files.add(f) + if (typeof f === "string") addFile(files, f) } } } @@ -144,7 +166,7 @@ export namespace AivState { function handlePatchPart(sessionID: SessionID, files: string[]) { const tracked = getOrCreateFiles(sessionID) - for (const f of files) tracked.add(f) + for (const f of files) addFile(tracked, f) const allPaths = [...tracked] updateIntent(sessionID, { @@ -201,7 +223,7 @@ export namespace AivState { const files = diff.map((d) => d.file) if (files.length > 0) { const tracked = getOrCreateFiles(sessionID) - for (const f of files) tracked.add(f) + for (const f of files) addFile(tracked, f) const allPaths = [...tracked] updateIntent(sessionID, { location: classifyLocationFromPaths(allPaths), @@ -243,9 +265,22 @@ export namespace AivState { }), ) + // Periodic sweep to evict idle sessions + const sweepTimer = setInterval(() => { + const now = Date.now() + for (const [sessionID, intent] of intents) { + if (!intent.active && now - intent.timestamp > IDLE_EVICTION_MS) { + intents.delete(sessionID) + touchedFiles.delete(sessionID) + log.info("evicted idle session", { sessionID }) + } + } + }, SWEEP_INTERVAL_MS) + log.info("AIV state subscriptions initialized") return () => { + clearInterval(sweepTimer) for (const unsub of unsubs) unsub() log.info("AIV state subscriptions stopped") } diff --git a/packages/opencode/src/server/routes/aiv.ts b/packages/opencode/src/server/routes/aiv.ts index 7e2db4034..a16d6cd9e 100644 --- a/packages/opencode/src/server/routes/aiv.ts +++ b/packages/opencode/src/server/routes/aiv.ts @@ -56,6 +56,7 @@ export const AIVRoutes = lazy(() => validator("param", z.object({ sessionID: SessionID.zod })), async (c) => { const { sessionID } = c.req.valid("param") + if (!AivState.has(sessionID)) return c.json({ error: "Session not found" }, 404) return c.json(AivState.get(sessionID)) }, )