Skip to content

Commit 6514dc0

Browse files
committed
fix(appkit): address PR #345 review findings (section 9)
Apply Mario's defensive/correctness fixes from the supervisor API adapter review without touching the public API shape (sections 1-8 will land in a stacked branch). Highlights: High - Route the three SSE error-leak sites in supervisor-api.ts (streamBody catch, mapEvent "error", output_item.done with id="error") through a single emitError helper that returns a stable client-facing code (`Supervisor API error (transport|upstream_failed|upstream_tool| upstream_unknown)`) and logs the verbose detail server-side only. Addresses CWE-209 verbatim-upstream-error-text leak. Medium - Gate the terminal {status:"complete"} emission on lastCompleted.status / .error / .incomplete_details so a `response.completed` with a nested failed status no longer silently succeeds; surface as upstream_failed instead. Regression tests added. - Skip the terminal error in the streamBody catch when signal.aborted — consumer-initiated aborts now end with a clean stop, not a contradictory terminal error event. Regression test added. - Tighten the output_item.done error match: require item.type === "error" (or pair the reserved id="error" with a non-message type) so a stray assistant message with id="error" is not mis-classified. - Add maxLineChars / maxBufferChars caps to readSseEvents with 1 MiB / 8 MiB defaults; throw on overflow. Addresses CWE-770. Tests added. - Docs: add a CWE-1427 callout warning that hosted-tool `description` is a prompt-injection sink — do not derive it from untrusted input. - Redact the no-delta warning log: summariseErrorPayload extracts a short `type: message` line; full payload only via DEBUG. Addresses CWE-532. - Gate the buffer-level CRLF normalize in sse-reader on `\r` presence to skip the regex on LF-only steady state. Low - mapEvent("error") fallback no longer wraps "Unknown error" with literal JSON quotes (uses string branch). - Drop the misleading "we await the factory at module init" comment in dev-playground; the code never awaits. - Fix @example imports in supervisor-api.ts JSDoc to use @databricks/appkit/beta (the actual public re-export). - Replace trimStart() with single-U+0020 strip in sse-reader per the SSE spec; remove the now-dead per-line `\r$` strip after the buffer-level CRLF normalise. - Flag streamPath as @internal in connectors/serving/client.ts noting the CWE-918 SSRF risk if it ever leaks to user-controlled input. - Add JSDoc warning to workspaceClient on SupervisorApiAdapterOptions: passing a per-request OBO client would leak identity across requests (CWE-664). Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
1 parent 19d7450 commit 6514dc0

7 files changed

Lines changed: 430 additions & 49 deletions

File tree

apps/dev-playground/server/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ const helper = createAgent({
7878
// Uncomment a `supervisorTools.*` entry (and import 'supervisorTools' from
7979
// '@databricks/appkit/beta') to give the model real powers.
8080
//
81-
// We `await` the factory at module init so a misconfigured workspace
82-
// (missing host, bad credentials) fails fast with a clear error here
83-
// instead of as an unhandled rejection. Top-level await is fine in this
84-
// ESM module.
81+
// `createAgent({ model })` accepts an adapter promise, so the factory's
82+
// host/credential resolution is awaited lazily on first dispatch (via
83+
// `resolveAdapter` in the agents plugin). A misconfigured workspace will
84+
// surface at first chat request, not at module init.
8585
const supervisor = createAgent({
8686
instructions:
8787
"You are an assistant powered by the Databricks Supervisor API.",

docs/docs/plugins/agents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ const model = fromSupervisorApi({
269269

270270
`description` is **required and non-empty** — the LLM uses it to route between tools, so two Genie spaces both labelled "Genie space" will be indistinguishable.
271271

272+
:::warning Hosted-tool descriptions are trusted application configuration (CWE-1427)
273+
A hosted tool's `description` is read by the LLM to decide when to route to that tool. **Do not derive it from untrusted input** — user messages, request bodies, freeform fields from external systems, or any value an attacker could influence. Treat `description` (and `id`/`name`) as application-controlled, alongside the agent's `instructions`. Allowing a user-controlled string here is a prompt-injection sink: a hostile description can convince the model to route to (or away from) a tool for any future request handled by the agent.
274+
275+
The same caution applies to MCP `description`s and to any other field the model reads at routing time.
276+
:::
277+
272278
| Factory | Tool kind | Identifier |
273279
|---|---|---|
274280
| `supervisorTools.genieSpace(id, description)` | Genie space | space id |

packages/appkit/src/agents/supervisor-api.ts

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,59 @@ import { readSseEvents } from "../stream";
1212

1313
const logger = createLogger("agents:supervisor-api");
1414

15+
/**
16+
* Stable client-facing error codes. We never surface raw upstream error
17+
* strings to the client (CWE-209) — the helper logs the verbose detail
18+
* server-side and returns one of these codes in the {@link AgentEvent}.
19+
*/
20+
type SupervisorErrorCode =
21+
| "transport"
22+
| "upstream_failed"
23+
| "upstream_tool"
24+
| "upstream_unknown";
25+
26+
/**
27+
* Single sink for all error events emitted by the adapter. Logs the verbose
28+
* detail (stack, upstream payload, etc.) at `warn` level and returns a
29+
* sanitised {@link AgentEvent} carrying only a stable code so the client
30+
* never sees raw upstream text.
31+
*/
32+
function emitError(code: SupervisorErrorCode, detail: unknown): AgentEvent {
33+
logger.warn("supervisor-api error code=%s detail=%O", code, detail);
34+
return {
35+
type: "status",
36+
status: "error",
37+
error: `Supervisor API error (${code})`,
38+
};
39+
}
40+
41+
/**
42+
* Renders an upstream error / incomplete_details payload as a short
43+
* single-line string for log lines. Avoids dumping the full JSON tree
44+
* (CWE-532): we keep the discriminator (`type`/`code`) plus a trimmed
45+
* message, and that's it. Full payloads are still available via
46+
* `DEBUG=appkit:agents:supervisor-api`.
47+
*/
48+
function summariseErrorPayload(payload: unknown): string {
49+
if (payload == null) return "<none>";
50+
if (typeof payload === "string") {
51+
return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
52+
}
53+
if (typeof payload !== "object") return String(payload);
54+
const obj = payload as Record<string, unknown>;
55+
const kind =
56+
(typeof obj.type === "string" && obj.type) ||
57+
(typeof obj.code === "string" && obj.code) ||
58+
(typeof obj.reason === "string" && obj.reason) ||
59+
"object";
60+
const message =
61+
(typeof obj.message === "string" && obj.message) ||
62+
(typeof obj.detail === "string" && obj.detail) ||
63+
"";
64+
const trimmed = message.length > 80 ? `${message.slice(0, 80)}…` : message;
65+
return trimmed ? `${kind}: ${trimmed}` : kind;
66+
}
67+
1568
/**
1669
* Transport shim: given a request body, returns the raw SSE byte stream from
1770
* the Supervisor API endpoint. Injected at construction time so callers can
@@ -135,6 +188,12 @@ export interface SupervisorApiAdapterOptions {
135188
* and per-request authentication. When omitted, a `WorkspaceClient({})`
136189
* is created internally using the default SDK credential chain
137190
* (`DATABRICKS_HOST`, OAuth, PAT, etc.).
191+
*
192+
* ⚠ The `workspaceClient` is captured at construction and reused across
193+
* every request. Passing a per-request OBO (On-Behalf-Of) client here
194+
* would silently leak the first request's identity into all subsequent
195+
* requests served by this adapter instance. Use the default credential
196+
* chain or pass a service-principal client. (CWE-664)
138197
*/
139198
workspaceClient?: WorkspaceClientLike;
140199
}
@@ -168,11 +227,12 @@ export interface SupervisorApiAdapterCtorOptions {
168227
*
169228
* @example
170229
* ```ts
171-
* import { createApp, createAgent, agents } from "@databricks/appkit";
230+
* import { createApp, createAgent } from "@databricks/appkit";
172231
* import {
232+
* agents,
173233
* fromSupervisorApi,
174234
* supervisorTools,
175-
* } from "@databricks/appkit/agents/supervisor-api";
235+
* } from "@databricks/appkit/beta";
176236
*
177237
* const adapter = await fromSupervisorApi({
178238
* model: "databricks-claude-sonnet-4",
@@ -254,13 +314,11 @@ export class SupervisorApiAdapter implements AgentAdapter {
254314
try {
255315
stream = await this.streamBody(body, signal);
256316
} catch (err) {
257-
const message = err instanceof Error ? err.message : String(err);
258-
logger.warn("Supervisor API request failed: %s", message);
259-
yield {
260-
type: "status",
261-
status: "error",
262-
error: `Supervisor API error: ${message}`,
263-
};
317+
// Aborts surface as exceptions thrown by `fetch`/SDK transports when
318+
// the consumer cancels mid-request. Treat as a clean stop so consumers
319+
// don't see a contradictory terminal `error` after their own abort.
320+
if (signal?.aborted) return;
321+
yield emitError("transport", err);
264322
return;
265323
}
266324

@@ -360,26 +418,41 @@ export class SupervisorApiAdapter implements AgentAdapter {
360418
}
361419

362420
if (eventCounts.has("response.completed")) {
421+
// SA sometimes signals a failed turn via `response.completed` with a
422+
// nested `status: "failed"` (or a populated `error`/`incomplete_details`)
423+
// rather than emitting `response.failed`. Without this gate the
424+
// adapter would silently yield `complete` on a server-side failure.
425+
if (
426+
lastCompleted?.status === "failed" ||
427+
lastCompleted?.error != null ||
428+
lastCompleted?.incomplete_details != null
429+
) {
430+
yield emitError("upstream_failed", {
431+
status: lastCompleted?.status,
432+
error: lastCompleted?.error,
433+
incomplete_details: lastCompleted?.incomplete_details,
434+
});
435+
return;
436+
}
363437
yield { type: "status", status: "complete" };
364438
}
365439

366440
if (!receivedAnyDelta) {
367441
const histogram = [...eventCounts.entries()]
368442
.map(([t, n]) => `${t}=${n}`)
369443
.join(", ");
370-
const completedError = lastCompleted?.error
371-
? JSON.stringify(lastCompleted.error)
372-
: undefined;
373-
const completedIncomplete = lastCompleted?.incomplete_details
374-
? JSON.stringify(lastCompleted.incomplete_details)
375-
: undefined;
376444
logger.warn(
377445
"Supervisor API stream completed without any output_text deltas. " +
378446
"events={%s} completed.status=%s completed.error=%s completed.incomplete=%s",
379447
histogram,
380448
lastCompleted?.status ?? "<none>",
381-
completedError ?? "<none>",
382-
completedIncomplete ?? "<none>",
449+
summariseErrorPayload(lastCompleted?.error),
450+
summariseErrorPayload(lastCompleted?.incomplete_details),
451+
);
452+
logger.debug(
453+
"Supervisor API no-delta full payload: error=%O incomplete=%O",
454+
lastCompleted?.error,
455+
lastCompleted?.incomplete_details,
383456
);
384457
}
385458
}
@@ -476,14 +549,20 @@ function mapEvent(
476549
// the stream produced none, then emits `{status:"complete"}` itself.
477550

478551
case "response.failed":
479-
return { type: "status", status: "error", error: "Response failed" };
552+
return emitError("upstream_failed", data);
480553

481554
case "error": {
482-
const errMsg =
555+
// Branch detail extraction so a missing `error` field doesn't surface
556+
// the JSON-stringified literal `'"Unknown error"'` (with quotes) in
557+
// server logs. The client never sees this string — `emitError`
558+
// sanitises it to a stable code.
559+
const detail =
483560
typeof data.error === "string"
484561
? data.error
485-
: JSON.stringify(data.error ?? "Unknown error");
486-
return { type: "status", status: "error", error: errMsg };
562+
: data.error == null
563+
? "Unknown error"
564+
: data.error;
565+
return emitError("upstream_unknown", detail);
487566
}
488567

489568
case "response.output_item.done": {
@@ -495,9 +574,15 @@ function mapEvent(
495574
}
496575
| undefined;
497576

498-
if (item?.id === "error") {
499-
const errText = item.content?.[0]?.text ?? "Unknown tool error from SA";
500-
return { type: "status", status: "error", error: errText };
577+
// SA's contract reserves `item.id === "error"` for tool failures, but
578+
// a 5-char identifier collision is too small a margin. Require either
579+
// an explicit `type === "error"` or pair the reserved id with a
580+
// non-message type (a normal assistant message uses `type: "message"`).
581+
if (
582+
item?.type === "error" ||
583+
(item?.id === "error" && item?.type !== "message")
584+
) {
585+
return emitError("upstream_tool", item);
501586
}
502587

503588
// Fallback: when SA produces a tool-driven response (e.g. Genie space),
@@ -541,7 +626,7 @@ function mapEvent(
541626
* import {
542627
* fromSupervisorApi,
543628
* supervisorTools,
544-
* } from "@databricks/appkit/agents/supervisor-api";
629+
* } from "@databricks/appkit/beta";
545630
*
546631
* const adapter = await fromSupervisorApi({
547632
* model: "databricks-claude-sonnet-4",
@@ -553,6 +638,12 @@ function mapEvent(
553638
* ],
554639
* });
555640
* ```
641+
*
642+
* @remarks
643+
* ⚠ When passing your own `workspaceClient`, see the warning on
644+
* {@link SupervisorApiAdapterOptions.workspaceClient} — the client is
645+
* captured once and reused, so per-request OBO clients would leak
646+
* identity across requests.
556647
*/
557648
export async function fromSupervisorApi(
558649
options: SupervisorApiAdapterOptions,

0 commit comments

Comments
 (0)