Skip to content

Commit efe3d12

Browse files
committed
Merge pull request #197 from EyJunge1/fix/structured-output-tool-isolation
fix(security): isolate structured-output sessions from agent tools
2 parents 1e40183 + dd953b9 commit efe3d12

6 files changed

Lines changed: 401 additions & 44 deletions

File tree

src/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import { getLanguageName } from "./services/language-detector.js";
2121
import type { MemoryScope } from "./services/client.js";
2222
import { getHostClientConfig } from "./services/ai/opencode-host-config.js";
2323
import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js";
24+
import {
25+
isInternalStructuredSession,
26+
STRUCTURED_OUTPUT_AGENT,
27+
STRUCTURED_OUTPUT_TOOLS,
28+
} from "./services/ai/opencode-provider.js";
2429

2530
import {
2631
INTERNAL_CAPTURE_SESSION_TITLE,
@@ -89,6 +94,25 @@ async function isInternalCaptureSession(client: unknown, sessionID: string): Pro
8994
return false;
9095
}
9196

97+
/** Least-privilege agent used only by internal structured-output sessions (issue #189). */
98+
export function applyStructuredOutputAgentConfig(cfg: { agent?: Record<string, unknown> }): void {
99+
cfg.agent = {
100+
...cfg.agent,
101+
[STRUCTURED_OUTPUT_AGENT]: {
102+
description: "Internal least-privilege agent for opencode-mem structured output",
103+
mode: "subagent",
104+
// OpenCode reads `steps` at runtime; SDK AgentConfig also documents maxSteps.
105+
steps: 2,
106+
maxSteps: 2,
107+
tools: STRUCTURED_OUTPUT_TOOLS,
108+
permission: {
109+
"*": "deny",
110+
StructuredOutput: "allow",
111+
},
112+
},
113+
};
114+
}
115+
92116
export async function configureOpencodeHostTransport(ctx: {
93117
readonly client: unknown;
94118
readonly serverUrl?: string | URL;
@@ -313,6 +337,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
313337
});
314338

315339
return {
340+
config: async (cfg) => {
341+
applyStructuredOutputAgentConfig(cfg);
342+
},
343+
316344
"chat.message": async (input, output) => {
317345
if (!isConfigured() || !CONFIG.chatMessage.enabled) return;
318346

@@ -325,7 +353,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
325353
const userMessage = textParts.map((p) => p.text).join("\n");
326354
if (!userMessage.trim()) return;
327355

328-
if (isStructuredSummaryPromptMessage(userMessage)) {
356+
if (
357+
isStructuredSummaryPromptMessage(userMessage) ||
358+
isInternalStructuredSession(input.sessionID)
359+
) {
329360
return;
330361
}
331362

src/services/ai/opencode-provider.ts

Lines changed: 160 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* then delete the session so it does not pollute the user's TUI session
1212
* list.
1313
*
14+
* Internal capture sessions are least-privilege (issue #189): ordinary
15+
* agent tools are denied, only StructuredOutput is allowed, a dedicated
16+
* agent caps steps, and a hard timeout fails closed.
17+
*
1418
* The primary transport is the authenticated v2 SDK client initialized from
1519
* the plugin host's client configuration. A raw fetch fallback remains for
1620
* older SDK builds that do not expose the v2 session methods.
@@ -34,6 +38,38 @@ import {
3438
} from "./internal-capture-sessions.js";
3539
import { createLazyV2Client, type HostTransport } from "./opencode-sdk-client.js";
3640

41+
/** Dedicated agent registered via the plugin config hook (step-capped). */
42+
export const STRUCTURED_OUTPUT_AGENT = "opencode-mem-structured";
43+
44+
/** Hard ceiling for a single internal structured-output prompt. */
45+
export const STRUCTURED_OUTPUT_TIMEOUT_MS = 90_000;
46+
47+
let _structuredOutputTimeoutMs = STRUCTURED_OUTPUT_TIMEOUT_MS;
48+
49+
/** Test helper: override the structured-output prompt timeout. Pass undefined to reset. */
50+
export function setStructuredOutputTimeoutMsForTests(ms: number | undefined): void {
51+
_structuredOutputTimeoutMs = ms ?? STRUCTURED_OUTPUT_TIMEOUT_MS;
52+
}
53+
54+
export const STRUCTURED_OUTPUT_PERMISSIONS = [
55+
{ permission: "*", pattern: "*", action: "deny" as const },
56+
{ permission: "StructuredOutput", pattern: "*", action: "allow" as const },
57+
];
58+
59+
export const STRUCTURED_OUTPUT_TOOLS: Record<string, boolean> = {
60+
"*": false,
61+
StructuredOutput: true,
62+
};
63+
64+
export const STRUCTURED_OUTPUT_METADATA = {
65+
"opencode-mem": {
66+
internal: true,
67+
purpose: "structured-output",
68+
},
69+
};
70+
71+
const _internalSessions = new Set<string>();
72+
3773
let _connectedProviders: Set<string> = new Set();
3874
let _v2Client: OpencodeClient | undefined;
3975
let _v2BaseUrl: string | undefined;
@@ -72,6 +108,57 @@ export function createV2Client(serverUrl: URL | string, transport?: HostTranspor
72108
return createLazyV2Client(baseUrl, activeTransport);
73109
}
74110

111+
/** True while an internal structured-output session is live (create → delete). */
112+
export function isInternalStructuredSession(sessionID: string): boolean {
113+
return _internalSessions.has(sessionID);
114+
}
115+
116+
/** Test helper: clear tracked internal session IDs. */
117+
export function resetInternalStructuredSessions(): void {
118+
_internalSessions.clear();
119+
}
120+
121+
function markInternalSession(sessionID: string): void {
122+
_internalSessions.add(sessionID);
123+
}
124+
125+
function unmarkInternalSession(sessionID: string): void {
126+
_internalSessions.delete(sessionID);
127+
}
128+
129+
function sessionCreateBody(): Record<string, unknown> {
130+
return {
131+
title: INTERNAL_CAPTURE_SESSION_TITLE,
132+
permission: STRUCTURED_OUTPUT_PERMISSIONS,
133+
metadata: STRUCTURED_OUTPUT_METADATA,
134+
};
135+
}
136+
137+
function sessionPromptFields(args: {
138+
providerID: string;
139+
modelID: string;
140+
systemPrompt: string;
141+
userPrompt: string;
142+
jsonSchema: Record<string, unknown>;
143+
retryCount?: number;
144+
}): Record<string, unknown> {
145+
return {
146+
model: { providerID: args.providerID, modelID: args.modelID },
147+
agent: STRUCTURED_OUTPUT_AGENT,
148+
system: args.systemPrompt,
149+
parts: [{ type: "text", text: args.userPrompt }],
150+
tools: STRUCTURED_OUTPUT_TOOLS,
151+
// `noReply` suppresses assistant generation in current OpenCode builds,
152+
// which also suppresses `info.structured_output`; structured capture needs
153+
// the assistant run even though the temporary session is deleted afterward.
154+
format: {
155+
type: "json_schema",
156+
schema: args.jsonSchema,
157+
...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}),
158+
},
159+
};
160+
}
161+
75162
export interface StructuredOutputOptions<T> {
76163
client: OpencodeClient;
77164
providerID: string;
@@ -138,7 +225,7 @@ function readRecentOpencodeModel(
138225
* Generate one structured-output completion via opencode's HTTP API.
139226
* Throws on: session.create failure, prompt failure, AssistantMessage.error
140227
* (StructuredOutputError / ApiError / ...), missing `info.structured`,
141-
* or final Zod validation failure.
228+
* timeout, or final Zod validation failure.
142229
*/
143230
export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<T>): Promise<T> {
144231
const resolved = resolveOpencodeModelRef({
@@ -177,17 +264,22 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
177264
const base = stripTrailingSlash(baseUrl);
178265

179266
const sessionID = await createSession(base, directory);
267+
markInternalSession(sessionID);
180268
try {
181-
const info = await promptSession(base, {
182-
sessionID,
183-
directory,
184-
providerID,
185-
modelID,
186-
systemPrompt,
187-
userPrompt,
188-
jsonSchema,
189-
retryCount,
190-
});
269+
const info = await withStructuredOutputTimeout(
270+
() =>
271+
promptSession(base, {
272+
sessionID,
273+
directory,
274+
providerID,
275+
modelID,
276+
systemPrompt,
277+
userPrompt,
278+
jsonSchema,
279+
retryCount,
280+
}),
281+
() => abortSession(base, sessionID, directory)
282+
);
191283

192284
if (info.error) {
193285
throw new Error(
@@ -204,6 +296,7 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
204296

205297
return schema.parse(structuredOutput);
206298
} finally {
299+
unmarkInternalSession(sessionID);
207300
// Best-effort: leaving a transient session behind is cosmetic, not
208301
// worth failing a successful capture if cleanup itself errors.
209302
try {
@@ -221,6 +314,7 @@ type V2SessionClient = {
221314
create(parameters?: Record<string, unknown>): Promise<unknown>;
222315
prompt(parameters: Record<string, unknown>): Promise<unknown>;
223316
delete(parameters: Record<string, unknown>): Promise<unknown>;
317+
abort?(parameters: Record<string, unknown>): Promise<unknown>;
224318
};
225319
};
226320

@@ -251,7 +345,7 @@ async function generateViaSdkClient<T>(
251345
args: SdkStructuredOutputArgs<T>
252346
): Promise<T> {
253347
const createdResponse = await client.session.create({
254-
title: INTERNAL_CAPTURE_SESSION_TITLE,
348+
...sessionCreateBody(),
255349
...(args.directory ? { directory: args.directory } : {}),
256350
});
257351
const created = readSdkData<{ id?: string }>(createdResponse, "POST /session");
@@ -263,19 +357,21 @@ async function generateViaSdkClient<T>(
263357

264358
const sessionID = created.id;
265359
trackInternalCaptureSession(sessionID);
360+
markInternalSession(sessionID);
266361
try {
267-
const promptResponse = await client.session.prompt({
268-
sessionID,
269-
...(args.directory ? { directory: args.directory } : {}),
270-
model: { providerID: args.providerID, modelID: args.modelID },
271-
system: args.systemPrompt,
272-
parts: [{ type: "text", text: args.userPrompt }],
273-
format: {
274-
type: "json_schema",
275-
schema: args.jsonSchema,
276-
...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}),
277-
},
278-
});
362+
const promptResponse = await withStructuredOutputTimeout(
363+
() =>
364+
client.session.prompt({
365+
sessionID,
366+
...(args.directory ? { directory: args.directory } : {}),
367+
...sessionPromptFields(args),
368+
}),
369+
() =>
370+
client.session.abort?.({
371+
sessionID,
372+
...(args.directory ? { directory: args.directory } : {}),
373+
})
374+
);
279375
const data = readSdkData<MessageV2WithParts>(promptResponse, "POST /session/{id}/message");
280376
if (!data.info) {
281377
throw new Error("opencode-mem: prompt response missing `info`");
@@ -294,6 +390,7 @@ async function generateViaSdkClient<T>(
294390
}
295391
return args.schema.parse(structuredOutput);
296392
} finally {
393+
unmarkInternalSession(sessionID);
297394
try {
298395
await client.session.delete({
299396
sessionID,
@@ -307,6 +404,34 @@ async function generateViaSdkClient<T>(
307404
}
308405
}
309406

407+
async function withStructuredOutputTimeout<T>(
408+
run: () => Promise<T>,
409+
onTimeout: () => unknown
410+
): Promise<T> {
411+
let timer: ReturnType<typeof setTimeout> | undefined;
412+
const timeoutMs = _structuredOutputTimeoutMs;
413+
const timeoutPromise = new Promise<never>((_, reject) => {
414+
timer = setTimeout(() => {
415+
reject(new Error(`opencode-mem: structured-output timed out after ${timeoutMs}ms`));
416+
}, timeoutMs);
417+
});
418+
419+
try {
420+
return await Promise.race([run(), timeoutPromise]);
421+
} catch (error) {
422+
if (error instanceof Error && error.message.includes("structured-output timed out after")) {
423+
try {
424+
await onTimeout();
425+
} catch {
426+
// best-effort abort
427+
}
428+
}
429+
throw error;
430+
} finally {
431+
if (timer !== undefined) clearTimeout(timer);
432+
}
433+
}
434+
310435
function readSdkData<T>(response: unknown, label: string): T {
311436
const result = response as
312437
{ data?: T; error?: unknown; request?: Request; response?: Response } | undefined;
@@ -354,7 +479,7 @@ async function createSession(base: string, directory?: string): Promise<string>
354479
{
355480
method: "POST",
356481
headers: { "Content-Type": "application/json" },
357-
body: JSON.stringify({ title: INTERNAL_CAPTURE_SESSION_TITLE }),
482+
body: JSON.stringify(sessionCreateBody()),
358483
}
359484
);
360485
if (!body.id) {
@@ -415,19 +540,7 @@ interface MessageV2WithParts {
415540

416541
async function promptSession(base: string, args: PromptSessionArgs): Promise<AssistantInfo> {
417542
const url = `${base}/session/${encodeURIComponent(args.sessionID)}/message${buildQuery(args.directory)}`;
418-
const body: Record<string, unknown> = {
419-
model: { providerID: args.providerID, modelID: args.modelID },
420-
system: args.systemPrompt,
421-
parts: [{ type: "text", text: args.userPrompt }],
422-
// `noReply` suppresses assistant generation in current OpenCode builds,
423-
// which also suppresses `info.structured_output`; structured capture needs
424-
// the assistant run even though the temporary session is deleted afterward.
425-
format: {
426-
type: "json_schema",
427-
schema: args.jsonSchema,
428-
...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}),
429-
},
430-
};
543+
const body = sessionPromptFields(args);
431544
const data = await fetchJson<MessageV2WithParts>(
432545
{ label: "POST /session/{id}/message", url },
433546
{
@@ -442,6 +555,15 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise<Ass
442555
return data.info;
443556
}
444557

558+
async function abortSession(base: string, sessionID: string, directory?: string): Promise<void> {
559+
const url = `${base}/session/${encodeURIComponent(sessionID)}/abort${buildQuery(directory)}`;
560+
try {
561+
await activeFetch()(new Request(url, { method: "POST" }));
562+
} catch {
563+
// best-effort
564+
}
565+
}
566+
445567
async function deleteSession(base: string, sessionID: string, directory?: string): Promise<void> {
446568
const url = `${base}/session/${encodeURIComponent(sessionID)}${buildQuery(directory)}`;
447569
let res: Response;

src/services/ai/opencode-sdk-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export function createLazyV2Client(baseUrl: string, transport?: HostTransport):
5050
const client = await getSdkClient();
5151
return client.session.delete(...args);
5252
},
53+
abort: async (...args: Parameters<OpencodeClient["session"]["abort"]>) => {
54+
const client = await getSdkClient();
55+
return client.session.abort(...args);
56+
},
5357
},
5458
} as OpencodeClient;
5559
}

0 commit comments

Comments
 (0)