From 913ec20aab19e8872182e29f64f8787e29e5cd5a Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 20:33:29 +0000 Subject: [PATCH 01/26] fix(ai): bind terminal safety-stop authority to adapter provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (snowykr P1 at 9573248886): errorKind: provider_safety_stop traveled as plain message data, so any provider or custom stream payload could self-label a refusal, terminalize it, and suppress the user's configured fallback chain — a compromised provider could force refusal by naming the field, and terminal policy suppressed alternate-model fallback on its word alone. Authority now lives off the data channel. applyProviderSafetyStop mints a module-scoped WeakSet mark only when first-party adapter code supplies a structured refusal signal it actually parsed (Anthropic stop_reason refusal/sensitive, OpenAI content_filter, Google prompt/candidate block reasons); unrecognized signals fail closed with no kind and no authority. The loop strips unauthenticated labels at the single stream exit before any retry/discard gate or the managed snapshot shell reads them, the discard gate re-verifies identity, and transferProviderSafetyStop carries the mark across the shell rebuild without ever making it mintable from message data. Clones, JSON round-trips, and persisted/reloaded messages are unauthenticated by construction: transport and persistence preserve the field for display but never upgrade an unauthenticated payload into a terminal stop. Adversarial coverage: a forged field with transport facts (400 and 429) cannot suppress fallback — the chain advances to the alternate and no typed stop is committed; authenticated envelopes (no-transport, 400, 429, retryable 5xx) remain single-dispatch terminal with the manual-switch hint; the provenance unit pins the vocabulary, the fail-closed unknown signal, and that clones, JSON, and fresh copies are never authenticated. Issue: #4777 Reviewed-by: snowykr (P1 from CHANGES_REQUESTED at 9573248886) Confidence: high Scope-risk: medium Reversibility: simple revert Tested: 346 focused tests across loop/e2e/persistence/provenance/adapter suites (0 fail), agent package suite 807 pass, adapter safety-stop suites 52 pass, ai/agent/coding-agent package checks Not-tested: live provider content-filter responses; in-process adapter code retains code-level authority by construction Directive: unauthenticated provider metadata must remain non-terminal and permit normal fallback --- packages/agent/CHANGELOG.md | 1 + packages/agent/src/agent-loop.ts | 48 ++++++++- .../test/managed-attempt-transaction.test.ts | 102 +++++++++++++++++- packages/ai/CHANGELOG.md | 2 + packages/ai/src/index.ts | 1 + packages/ai/src/providers/anthropic.ts | 10 +- .../ai/src/providers/google-gemini-cli.ts | 8 +- packages/ai/src/providers/google-shared.ts | 10 +- .../ai/src/providers/openai-completions.ts | 11 +- packages/ai/src/utils/provider-safety-stop.ts | 89 +++++++++++++++ packages/ai/test/provider-safety-stop.test.ts | 63 +++++++++++ packages/coding-agent/CHANGELOG.md | 1 + .../agent-session-resilient-retry.test.ts | 13 ++- .../provider-safety-stop-hint.e2e.test.ts | 59 +++++++++- 14 files changed, 402 insertions(+), 16 deletions(-) create mode 100644 packages/ai/src/utils/provider-safety-stop.ts create mode 100644 packages/ai/test/provider-safety-stop.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a3d5facd2b..c63d628d96 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777). +- Terminal safety-stop authority is now provenance-bound instead of data-bound: a provider or custom stream payload that self-labels `errorKind: "provider_safety_stop"` without the adapter-minted mark is stripped at the stream exit before any retry/discard gate or the managed snapshot shell reads it, so a compromised provider can no longer force refusal by naming the field (#4777 review). Authenticated first-party envelopes (structured refusal signals parsed by the anthropic, openai-completions, and google adapters) keep terminal treatment, and the mark transfers across the shell rebuild without ever being mintable from message data — clones, JSON/persistence round-trips, and re-emitted payloads are all unauthenticated. - A foreign error that self-declares a local failure kind no longer gets one either (#4618). `errorKind` and the structured `bufferOverflow` shape now come from a single identity-checked extractor (`managedLocalErrorDiagnostic`) used by both terminal-message producers — `managedFailureMessage` and the `Agent` run catch. Previously the shape was identity-gated but the label was not, so a provider or custom-stream failure carrying `errorKind: "local_buffer_overflow"` reached the parent receipt preview as `Local staging-buffer overflow; structured diagnostic unavailable.` and pointed whoever read it at the wrong subsystem. - Local diagnostic authority fields are no longer foreign-settable through the managed snapshot shell (#4618). `managedAssistantShell` spreads the provider/stream message snapshot into the rebuilt assistant message; a payload that smuggled a local `errorKind` or `bufferOverflow` through that spread could masquerade as the runtime's own identity-checked diagnostic at the parent boundary. Local kinds and `bufferOverflow` remain stripped from the snapshot spread, while the provider-owned safety-stop kind is copied only through its explicit closed-literal guard. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 07638696db..25cc7b4d6f 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -12,11 +12,13 @@ import { classifyFallbackTrigger, EMPTY_RESPONSE_PROVIDER_CODE, EventStream, + isProviderSafetyStopAuthenticated, isZodSchema, streamSimple, type ToolChoice, type ToolResultMessage, type TSchema, + transferProviderSafetyStop, transportFailureFacts, type UserMessage, validateToolArguments, @@ -360,6 +362,18 @@ function managedTransportFailure(failure: unknown) { function managedRetryableFailure(failure: unknown): boolean { const facts = managedTransportFailure(failure); if (!facts) return false; + // A typed provider safety stop is terminal evidence ahead of any transport + // class, but only with adapter-minted provenance: unauthenticated labels + // are stripped at the stream exit (`sanitizeProviderSafetyStopProvenance`) + // and must fall through to ordinary transport classification so the chain + // can still advance (#4777). + if ( + managedProperty(failure, "stopReason") === "error" && + managedProperty(failure, "errorKind") === "provider_safety_stop" && + isProviderSafetyStopAuthenticated(failure) + ) { + return false; + } const trigger = classifyFallbackTrigger(facts); // A plain `forbidden` is terminal: retrying it just re-sends a request the // caller is not authorized to make, and the credential-mutating consumers @@ -384,6 +398,26 @@ function promoteTypedEmptyResponseStop(message: AssistantMessage): void { message.stopReason = "error"; message.errorMessage = "Provider returned an empty response with zero token usage"; } +/** + * Terminal safety-stop authority is provenance-bound, not data-bound: a + * provider or custom stream payload that self-labels + * `errorKind: "provider_safety_stop"` without the adapter-minted mark must not + * terminalize the failure, because terminal treatment suppresses the user's + * configured fallback chain (#4777 review follow-up). Strip the unauthenticated + * field from the live final message at the single stream-exit point, before + * the managed snapshot shell clones it and before any retry/discard policy + * reads it, so a forged label degrades to an ordinary (fallback-eligible) + * error everywhere downstream — loop gates, session policy, and persistence. + */ +function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): void { + if ( + message.stopReason === "error" && + message.errorKind === "provider_safety_stop" && + !isProviderSafetyStopAuthenticated(message) + ) { + delete message.errorKind; + } +} /** * Neutralize leaked reserved control tokens in-place across the outgoing @@ -1218,7 +1252,7 @@ function managedAssistantShell( // runtime failure in the executor's parent-facing summary (#4618). delete safeMetadata.errorKind; delete safeMetadata.bufferOverflow; - return { + const rebuilt: AssistantMessage = { ...safeMetadata, role: "assistant", content, @@ -1233,6 +1267,12 @@ function managedAssistantShell( ...(errorKind ? { errorKind } : {}), ...(typeof errorStatus === "number" && Number.isFinite(errorStatus) ? { errorStatus } : {}), }; + // The closed-literal copy above is fed by the stream-exit provenance + // sanitize, so an unauthenticated label never reaches here; carry the + // adapter-minted authority across the rebuild so downstream gates (the + // discard decision and session policy) can re-verify identity (#4777). + if (errorKind) transferProviderSafetyStop(value, rebuilt); + return rebuilt; } function managedContentBlock(block: unknown): AssistantMessage["content"] { @@ -3494,9 +3534,11 @@ async function streamAssistantResponse( case "done": case "error": { + const finished = await finishResponse(); + sanitizeProviderSafetyStopProvenance(finished); const finalMessage = config.fallbackManaged - ? managedAssistantShell(await finishResponse(), config.model, managedDegradedFieldDiagnostics) - : await finishResponse(); + ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) + : finished; promoteTypedEmptyResponseStop(finalMessage); if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 004d0c0ae3..0f3f51aa9b 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -9,7 +9,12 @@ import { sanitizedDetachedClone, } from "@gajae-code/agent-core/agent-loop"; import type { AgentContext, AgentEvent, AgentLoopConfig } from "@gajae-code/agent-core/types"; -import type { AssistantMessage, AssistantMessageEvent, Message } from "@gajae-code/ai"; +import { + type AssistantMessage, + type AssistantMessageEvent, + applyProviderSafetyStop, + type Message, +} from "@gajae-code/ai"; import { createMockModel } from "@gajae-code/ai/providers/mock"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; @@ -105,18 +110,33 @@ describe("managed attempt transaction", () => { // These values are deliberately supplied by the provider envelope. The // runtime-authored local kinds tested below and in the local snapshot/ // overflow cases must not be confused with this untrusted input surface. + // The provider-owned safety-stop kind survives only when the envelope + // carries adapter-minted provenance; a wire-assignable field alone is + // stripped (#4777 review follow-up). const cases = [ { errorKind: "provider_safety_stop" as const, stopReason: "error" as const, + authenticated: true, expected: "provider_safety_stop" as const, }, - { errorKind: "provider_safety_stop" as const, stopReason: "stop" as const, expected: undefined }, + { + errorKind: "provider_safety_stop" as const, + stopReason: "error" as const, + authenticated: false, + expected: undefined, + }, + { + errorKind: "provider_safety_stop" as const, + stopReason: "stop" as const, + authenticated: true, + expected: undefined, + }, { errorKind: "local_buffer_overflow" as const, stopReason: "error" as const, expected: undefined }, { errorKind: undefined, stopReason: "error" as const, expected: undefined }, ]; - for (const { errorKind, stopReason, expected } of cases) { + for (const { errorKind, stopReason, authenticated, expected } of cases) { const mock = createMockModel(); const streamFn = () => { const stream = new AssistantMessageEventStream(); @@ -126,6 +146,7 @@ describe("managed attempt transaction", () => { errorMessage: "provider response", ...(errorKind ? { errorKind } : {}), }; + if (authenticated) applyProviderSafetyStop(message, "refusal"); queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: "stop", message }); @@ -145,6 +166,81 @@ describe("managed attempt transaction", () => { } }); + it("keeps an authenticated transport-fact-carrying provider safety stop terminal instead of discarding it", async () => { + // A first-party adapter that reports its safety stop on an HTTP envelope + // (OpenAI content_filter with status + transportFailure) keeps terminal + // authority even when the facts classify as retryable (5xx): the + // attempt is committed for the parent, not discarded into a retryable + // managed failure outcome that would advance the chain (#4777). + const mock = createMockModel(); + let dispatches = 0; + const streamFn = () => { + dispatches += 1; + const stream = new AssistantMessageEventStream(); + const message: AssistantMessage = { + ...assistantMessage(mock.model), + stopReason: "error", + errorMessage: "The response was filtered by the content management policy", + errorStatus: 500, + transportFailure: { kind: "transport", status: 500 }, + }; + applyProviderSafetyStop(message, "content_filter"); + queueMicrotask(() => { + stream.push({ type: "start", partial: message }); + stream.push({ type: "error", reason: "error", error: message }); + }); + return stream; + }; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn, + }); + + await agent.prompt("run", { fallbackManaged: true }); + + expect(dispatches).toBe(1); + const terminal = agent.state.messages.at(-1); + expect(terminal?.role).toBe("assistant"); + expect(terminal).toMatchObject({ + stopReason: "error", + errorKind: "provider_safety_stop", + }); + }); + + it("discards an unauthenticated transport-fact-carrying safety-stop label for retry", async () => { + // The forged counterpart: the same HTTP-envelope shape without + // adapter-minted provenance is ordinary retryable transport data. The + // loop discards the attempt for the managed failure outcome (chain + // advance stays available) and no typed stop is committed (#4777). + const mock = createMockModel(); + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + const message: AssistantMessage = { + ...assistantMessage(mock.model), + stopReason: "error", + errorKind: "provider_safety_stop", + errorMessage: "The response was filtered by the content management policy", + errorStatus: 500, + transportFailure: { kind: "transport", status: 500 }, + }; + queueMicrotask(() => { + stream.push({ type: "start", partial: message }); + stream.push({ type: "error", reason: "error", error: message }); + }); + return stream; + }; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn, + }); + + await agent.prompt("run", { fallbackManaged: true }); + + const terminal = agent.state.messages.at(-1); + expect(terminal?.role).not.toBe("assistant"); + expect(terminal && "errorKind" in terminal ? terminal.errorKind : undefined).toBeUndefined(); + }); + it("commits a detached accepted message when a managed partial is not structured-cloneable", async () => { const mock = createMockModel(); let liveMessage: AssistantMessage | undefined; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 99c070cf47..f66d365cda 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,8 @@ - OpenAI-family idle watchdog floors now key on the Grok model as well as the provider: Grok model ids served through any OpenAI-compatible host (openrouter `x-ai/grok-*`, kilo, litellm, zenmux, venice …) get the same 300-second idle window as native xAI and Grok Build, so a long Grok reasoning gap no longer surfaces as `OpenAI completions stream stalled while waiting for the next event` under the 120-second shared default (#4797). Env overrides still win; non-Grok models are unchanged. - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. +- Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: `applyProviderSafetyStop` marks a message only when first-party adapter code supplies a structured refusal signal it actually parsed (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), `isProviderSafetyStopAuthenticated` verifies the mark by identity, and `transferProviderSafetyStop` carries it across a boundary rebuild (#4777). Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON round-trips, and reloaded messages are never authenticated. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through this path instead of assigning `errorKind` directly. +- GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. - OpenAI-family streams now give xAI Grok and the Grok Build (`grok-cli-responses`) wrapper the same 300-second default idle window as Anthropic, so long Grok reasoning gaps no longer surface as `OpenAI responses stream stalled while waiting for the next event` under the 120-second OpenAI default. Env overrides still win. The observed stall was `grok-build/grok-4.6` on `openai-responses`; keying only `xai` would have left that path on 120s because `streamGrokCli` keeps `model.provider === "grok-build"`. diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index bd78eebf4d..38504ff819 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -54,6 +54,7 @@ export type { OAuthProviderInfo, } from "./utils/oauth/types"; export * from "./utils/overflow"; +export * from "./utils/provider-safety-stop"; export * from "./utils/retry"; export * from "./utils/schema"; export * from "./utils/tool-choice-capability"; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index c37016a406..efbe269fdf 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -83,6 +83,7 @@ import { import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { GLM_ZCODE_ANTHROPIC_BASE_URL } from "../utils/oauth/glm-zcode"; import { notifyProviderResponse } from "../utils/provider-response"; +import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { isCopilotTransientModelError } from "../utils/retry"; import { getRetryAfterMsFromHeaders } from "../utils/retry-after"; import { resolveRetryBudget } from "../utils/retry-budget"; @@ -2464,7 +2465,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( sawProviderSafetyStop = true; sawTerminalEnvelope = true; output.stopReason = "error"; - output.errorKind = "provider_safety_stop"; + // Mint the terminal kind with adapter provenance: the + // structured refusal signal was parsed from the stream + // delta, so the mark (not the wire field) carries the + // authority (#4777). + applyProviderSafetyStop( + output, + stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), + ); if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); const category = stopDetails.category; diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 1584a54a12..99180dbd29 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -22,6 +22,7 @@ import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { transportFailureFacts } from "../utils/fallback-transport"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; +import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { resolveRetryBudget } from "../utils/retry-budget"; // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted); // the stream provider trusts the access token threaded through `options.apiKey`. @@ -566,7 +567,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (candidate?.finishReason) { if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) { hasContent = true; - output.errorKind = PROVIDER_SAFETY_STOP; + // Adapter-minted terminal authority from the parsed + // structured finish reason (#4777). + applyProviderSafetyStop(output, candidate.finishReason); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReasonString(candidate.finishReason); @@ -580,7 +583,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (blockReason) { hasContent = true; if (isGooglePromptSafetyStopReason(blockReason)) { - output.errorKind = PROVIDER_SAFETY_STOP; + // Prompt-level block reason: adapter-minted authority (#4777). + applyProviderSafetyStop(output, blockReason); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 5934c0eb02..3944af5e15 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -22,6 +22,7 @@ import { normalizeSystemPrompts, sanitizeJsonStrings } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { transportFailureFacts } from "../utils/fallback-transport"; import { finalizeErrorMessage, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; +import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema"; import { isForcedToolChoiceUnsupportedError, @@ -658,7 +659,10 @@ export async function consumeGoogleStream(args: { if (candidate?.finishReason) { if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) { - output.errorKind = PROVIDER_SAFETY_STOP; + // Terminal authority is minted by the adapter after parsing the + // structured candidate finish reason; a wire-assignable field + // alone never carries it (#4777). + applyProviderSafetyStop(output, candidate.finishReason); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReason(candidate.finishReason); @@ -671,7 +675,9 @@ export async function consumeGoogleStream(args: { const blockReason = getGooglePromptBlockReason(chunk.promptFeedback); if (blockReason) { if (isGooglePromptSafetyStopReason(blockReason)) { - output.errorKind = PROVIDER_SAFETY_STOP; + // Prompt-level block reasons carry the same adapter-minted + // authority as candidate finish reasons (#4777). + applyProviderSafetyStop(output, blockReason); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 85f2c77f8a..0c4ce46bd0 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -58,6 +58,7 @@ import { findUnnecessaryUnicodeEscape, isCompleteJson, parseStreamingJson } from import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { notifyProviderResponse } from "../utils/provider-response"; +import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { callWithCopilotModelRetry } from "../utils/retry"; import { resolveRetryBudget } from "../utils/retry-budget"; import { adaptSchemaForStrict, flattenToolRootCombinators, NO_STRICT, toolWireSchema } from "../utils/schema"; @@ -829,8 +830,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( let providerSafetyStop = false; const markProviderSafetyStop = (errorMessage?: string): void => { providerSafetyStop = true; - output.errorKind = "provider_safety_stop"; output.stopReason = "error"; + // Terminal authority comes from the adapter mark, not the wire + // field: this call site parsed the structured content_filter + // finish reason from the provider's own response (#4777). + applyProviderSafetyStop(output, "content_filter"); if (errorMessage) output.errorMessage = errorMessage; }; @@ -1073,7 +1077,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; output.errorMessage = rewriteCopilotError(output.errorMessage, normalizedError, model.provider); if (hasContentFilterSafetyCode(capturedErrorResponse)) { - output.errorKind = "provider_safety_stop"; + // The structured content_filter code was parsed from the captured + // HTTP response body; mint adapter provenance for the terminal + // kind instead of trusting a wire-assignable field (#4777). + applyProviderSafetyStop(output, "content_filter"); } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; diff --git a/packages/ai/src/utils/provider-safety-stop.ts b/packages/ai/src/utils/provider-safety-stop.ts new file mode 100644 index 0000000000..3da911cc20 --- /dev/null +++ b/packages/ai/src/utils/provider-safety-stop.ts @@ -0,0 +1,89 @@ +import type { AssistantMessage } from "../types"; + +/** + * Terminal provider safety-stop authority (issue #4777 review follow-up). + * + * `errorKind: "provider_safety_stop"` makes a failure terminal: retry policy + * suppresses it and managed fallback never advances the chain to another + * model. When that authority traveled as plain message data, any provider or + * custom stream payload could self-label a refusal and deny the user their + * configured fallback — a compromised endpoint could force refusal by naming + * the typed kind. + * + * Authority therefore never travels on the data channel. It lives in this + * module-scoped {@link WeakSet}, minted only by {@link applyProviderSafetyStop} + * when first-party adapter code calls it with a structured refusal signal it + * actually parsed from the provider's response (Anthropic `stop_reason` + * refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block + * reasons). Every re-entry boundary re-checks identity: structured clones, + * JSON round-trips, persisted-and-reloaded messages, and re-emitted stream + * payloads are new objects and carry no authority, so transport and + * persistence can preserve the label for display but can never upgrade an + * unauthenticated payload into a terminal stop. + */ +const authenticatedProviderSafetyStops = new WeakSet(); + +/** + * Structured refusal vocabulary per first-party adapter. The google entries + * mirror the closed lists in `google-shared.ts` + * (`isGoogleCandidateSafetyStopReason` / `isGooglePromptSafetyStopReason`); + * keep them in sync. + */ +const STRUCTURED_REFUSAL_SIGNALS: ReadonlySet = new Set([ + // anthropic-messages: stop_reason / stop_details.type + "refusal", + "sensitive", + // openai-completions: finish_reason / error.code + "content_filter", + // google-generative-ai: candidate finishReason + "SAFETY", + "IMAGE_SAFETY", + "PROHIBITED_CONTENT", + "IMAGE_PROHIBITED_CONTENT", + "SPII", + "BLOCKLIST", + "RECITATION", + "IMAGE_RECITATION", + "MODEL_ARMOR", + // google-generative-ai: promptFeedback.blockReason + "JAILBREAK", +]); + +/** + * Mint terminal safety-stop authority for one message object. Adapter-side + * use only: call it at the parse site, with the structured refusal signal that + * was actually validated against the provider's response. An unrecognized + * signal fails closed — the message keeps whatever it had and gains no + * authority — so an adapter bug degrades to ordinary fallback, never to a + * forced refusal. + * + * Returns whether authority was minted. + */ +export function applyProviderSafetyStop(message: AssistantMessage, signal: string): boolean { + if (!STRUCTURED_REFUSAL_SIGNALS.has(signal)) return false; + authenticatedProviderSafetyStops.add(message); + message.errorKind = "provider_safety_stop"; + return true; +} + +/** + * Identity check for terminal safety-stop authority. True only for the exact + * object a first-party adapter marked in this process. Copies, clones, + * JSON/persistence round-trips, and fresh objects carrying the field are all + * unauthenticated — data alone is never terminal. + */ +export function isProviderSafetyStopAuthenticated(message: unknown): boolean { + return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); +} +/** + * Transfer terminal safety-stop authority from a live marked message onto the + * rebuilt message a boundary constructed from it. Callers can never mint + * authority: the target is marked only when the source already was. Used by + * the managed snapshot shell so the rebuilt assistant message keeps provenance + * across the clone boundary (#4777). + */ +export function transferProviderSafetyStop(from: unknown, to: AssistantMessage): void { + if (typeof from === "object" && from !== null && authenticatedProviderSafetyStops.has(from)) { + authenticatedProviderSafetyStops.add(to); + } +} diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts new file mode 100644 index 0000000000..11a280b146 --- /dev/null +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import type { AssistantMessage } from "../src/types"; +import { applyProviderSafetyStop, isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; + +function message(): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: "Refusal (safety): Policy violation", + timestamp: 1, + }; +} + +describe("provider safety-stop provenance authority", () => { + test("mints the typed kind only for structured first-party refusal signals", () => { + for (const signal of ["refusal", "sensitive", "content_filter", "SAFETY", "JAILBREAK", "RECITATION"]) { + const marked = message(); + expect(applyProviderSafetyStop(marked, signal)).toBe(true); + expect(marked.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(marked)).toBe(true); + } + }); + + test("fails closed on an unrecognized signal: no kind, no authority", () => { + const unmarked = message(); + unmarked.errorKind = "provider_safety_stop"; + expect(applyProviderSafetyStop(unmarked, "totally-not-a-refusal")).toBe(false); + // The pre-existing wire-assignable field stays exactly as unauthenticated + // as it was; the adapter bug degraded to ordinary fallback, not a mint. + expect(isProviderSafetyStopAuthenticated(unmarked)).toBe(false); + }); + + test("data alone is never authenticated: clones, JSON round-trips, and fresh copies lose authority", () => { + const marked = message(); + expect(applyProviderSafetyStop(marked, "refusal")).toBe(true); + + const cloned = structuredClone(marked); + expect(cloned.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(cloned)).toBe(false); + + const persisted = JSON.parse(JSON.stringify(marked)) as AssistantMessage; + expect(persisted.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(persisted)).toBe(false); + + const fresh = message(); + fresh.errorKind = "provider_safety_stop"; + expect(isProviderSafetyStopAuthenticated(fresh)).toBe(false); + expect(isProviderSafetyStopAuthenticated(undefined)).toBe(false); + expect(isProviderSafetyStopAuthenticated("provider_safety_stop")).toBe(false); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8475d4cf84..bf64ed8538 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ - Queued SDK prompts now retain their dispatch-time ownership across selection fences instead of reclassifying from a contradictory later streaming snapshot. Fresh promotion, earlier follow-up ordering, and terminal-abort cancellation are reachable again, while `/btw` test fixtures now model the user-drainable queue count required by the current empty-submit contract. - Coordinator stop and idle-reap now initialize canonical namespace state before reading durable deletion recovery. Fresh or upgraded projection-only sessions were misreported as `state_corrupt` before the broker close was attempted, and completed deletion receipts were excluded from the idempotent missing-session lookup; both paths now preserve strict malformed-state rejection while allowing safe cleanup and replay. - Fixed provider safety-stop classification being lost before session persistence (#4777). The managed provider-envelope boundary now preserves only the allowlisted `provider_safety_stop` kind, typed safety stops remain terminal even with transport facts on a multi-model fallback chain, and the regression e2e test is selected both by the focused affected-path route and exactly one normal coding-agent shard. +- A forged `errorKind: "provider_safety_stop"` label can no longer suppress model fallback (#4777 review). Terminal safety-stop authority is now adapter-minted (module-scoped provenance set by the first-party anthropic/openai/google adapters when they parse a structured refusal) rather than carried on message data: the loop strips unauthenticated labels at the stream exit, the managed discard gate re-verifies identity, and transport facts never upgrade provenance, so a compromised provider that names the typed kind on an error envelope degrades to an ordinary fallback-eligible failure on every status (400 and 429 both keep advancing the chain). Authenticated envelopes remain single-dispatch terminal with the manual-switch hint intact, and persisted/reloaded messages keep the field for display only. - Retained-publication acquisition diagnostics now name the stage that actually refused instead of always claiming a failed open, and the native errno vocabulary is closed over the actionable `open(2)` refusal set (#4764). `nativeRetainedObstruction()` rendered every unmatched reason as "could not be opened", so a `broker.json` FIFO — which opens fine `O_RDWR | O_NOFOLLOW | O_NONBLOCK` and then refuses `read_to_end` with `WouldBlock` — was reported as an open failure that had in fact succeeded, `clone`/`metadata` refusals of already-open descriptors were reported as opens, and `unsupported-platform` read as an open error. The structured native reason now drives the prose: `errno-`/`io-` render as open refusals, `read-` as a read failure, `clone-`/`metadata[-kind]` as inspection failures of an already-open object, `unsupported-platform` as unimplemented authority, and any future reason falls back to a stable "withheld publication authority (reason)" sentence instead of misnaming the stage; the structured reason is preferred whenever the native message parses, so the observed-state fallback is never entered for a stage-named refusal. The Rust `errno_name()` vocabulary adds `EAGAIN`, `EMFILE`, `ENAMETOOLONG`, `ENFILE`, `ENOMEM`, `EOVERFLOW`, and `EPERM` alongside the existing six — descriptor exhaustion and LSM/seccomp denial no longer collapse to `UNKNOWN` — while every unlisted errno still renders as `UNKNOWN` and no errno number or OS string ever leaves the native layer. - The `errno_name()` closed-vocabulary fixtures introduced with the retained-publication diagnostic (#4764) are now gated `#[cfg(all(test, unix))]` like every sibling test module in `path_identity.rs`. The module was file-level `#[cfg(test)]` while importing `super::publication::errno_name`, an item that only exists in the unix `mod publication`; on Windows the `#[cfg(not(unix))]` sibling has no such item, so `cargo test -p pi-natives` failed to compile on a Windows dev machine (CI was unaffected — its Windows jobs build without `--tests`). Unix coverage is unchanged: both fixtures still run. - Conventional MCP autoload now reads the user scope from the agent directory instead of a home-relative `/.gjc/agent` path (#4767). Every writer and denylist reader already resolved user scope through `getMCPConfigPath("user")` (the agent directory), while native discovery derived it from the load context's home, so the two disagreed the moment an agent-directory profile was in play: `GJC_CODING_AGENT_DIR= gjc mcp add ` wrote `/mcp.json` and reported the server as loaded by ordinary sessions at startup, but startup read `~/.gjc/agent/mcp.json` — the profile's own registrations never loaded and the default profile's servers loaded into the profile instead. `loadAllMCPConfigs` accepts an `agentDir`, sessions created with their own `agentDir` pass it, and the `disabledServers` denylist follows the same scope, so discovery, `gjc mcp add`, the `/mcp` wizard, and `gjc customize doctor` all name one file. This also restores isolation for the MCP autoload suites, which established their temp user scope with `setAgentDir()`: after the trusted-home provenance rework their `os.homedir()` mock no longer reached discovery, so on a developer machine the red-team suite read the real `~/.gjc/agent/mcp.json` and in CI it found nothing. diff --git a/packages/coding-agent/test/agent-session-resilient-retry.test.ts b/packages/coding-agent/test/agent-session-resilient-retry.test.ts index 29d6bf5832..54980fc6a6 100644 --- a/packages/coding-agent/test/agent-session-resilient-retry.test.ts +++ b/packages/coding-agent/test/agent-session-resilient-retry.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent, type AgentTool, type StreamFn } from "@gajae-code/agent-core"; -import { type AssistantMessage, getBundledModel, type Model, type ToolCall } from "@gajae-code/ai"; +import { + type AssistantMessage, + applyProviderSafetyStop, + getBundledModel, + type Model, + type ToolCall, +} from "@gajae-code/ai"; import { createMockModel } from "@gajae-code/ai/providers/mock"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; @@ -189,6 +195,11 @@ describe("AgentSession resilient retry", () => { : { transportFailure: callFailure?.transportFailure ?? options.transportFailure }), timestamp: Date.now(), }; + // The typed safety stop is adapter-minted: this helper simulates a + // first-party provider envelope, so the structured refusal signal + // carries the terminal authority rather than the wire field + // alone (#4777). + if (options.errorKind === "provider_safety_stop") applyProviderSafetyStop(message, "refusal"); stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); }); diff --git a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts index c0b4522ea4..1aa259bd16 100644 --- a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts +++ b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent, type AgentOptions } from "@gajae-code/agent-core"; -import { type AssistantMessage, getBundledModel, type Model } from "@gajae-code/ai"; +import { type AssistantMessage, applyProviderSafetyStop, getBundledModel, type Model } from "@gajae-code/ai"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; import { Settings } from "@gajae-code/coding-agent/config/settings"; @@ -32,6 +32,7 @@ function safetyStopStream( model: Model, refusal: string, transportFacts?: { status: number }, + options?: { authenticated?: boolean }, ): AssistantMessageEventStream { const stream = new AssistantMessageEventStream(); queueMicrotask(() => { @@ -52,7 +53,6 @@ function safetyStopStream( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "error", - errorKind: "provider_safety_stop", errorMessage: refusal, ...(transportFacts ? { @@ -62,6 +62,12 @@ function safetyStopStream( : {}), timestamp: Date.now(), }; + // Simulate the first-party adapter envelope: a structured refusal signal + // parsed from the provider's own response mints the terminal authority. + // Omitting the mark simulates a wire/custom-stream payload that only + // carries the forged field. + if (options?.authenticated !== false) applyProviderSafetyStop(message, "refusal"); + else message.errorKind = "provider_safety_stop"; stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); }); @@ -215,6 +221,55 @@ describe("provider safety stop hint e2e (#4650)", () => { session = undefined; } }); + it("does not let a forged safety-stop field suppress fallback (#4777 review)", async () => { + // A wire/custom-stream payload that self-labels the typed kind without + // adapter-minted provenance must never terminalize: unauthenticated + // provider metadata stays fallback-eligible so a compromised provider + // cannot force refusal by naming the field. Transport facts do not + // upgrade provenance — retryable and non-retryable statuses both keep + // the chain advancing. + for (const status of [400, 429] as const) { + const primary = getBundledModel("anthropic", "claude-sonnet-4-5"); + const alternate = getBundledModel("openai", "gpt-4o-mini"); + if (!primary || !alternate) throw new Error("Expected bundled test models"); + const calls: string[] = []; + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { model: primary, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: ((model, _context, _options) => { + calls.push(selector(model)); + return safetyStopStream( + model, + "Upstream returned an unclassified error envelope", + { status }, + { + authenticated: false, + }, + ); + }) satisfies AgentOptions["streamFn"], + }); + const settings = Settings.isolated({ "compaction.enabled": false, "retry.baseDelayMs": 1 }); + settings.set("modelRoles", { default: [selector(primary), selector(alternate)] }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry: new ModelRegistry(authStorage), + }); + + await session.prompt("trigger forged safety-stop label"); + await session.waitForIdle(); + + // The forged label never terminalized: the chain advanced past the + // primary to the configured alternate. + expect(calls).toContain(selector(alternate)); + const last = [...session.state.messages].reverse().find(message => message.role === "assistant"); + expect((last as AssistantMessage).errorKind).toBeUndefined(); + expect(resolveProviderSafetyStopHint(last as AssistantMessage, session)).toBeUndefined(); + await session.dispose(); + session = undefined; + } + }); it("falls back to bounded static guidance when no alternate is configured", async () => { const primary = getBundledModel("anthropic", "claude-sonnet-4-5"); From 01996565ceba78011392d82049eaa6aeb6d6eb42 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 21:36:29 +0000 Subject: [PATCH 02/26] fix(ai): close provider safety-stop minting boundary Keep terminal safety-stop authority exclusive to first-party adapter parse sites and sanitize the trailing completion path before managed-shell rebuilding. Public AI consumers can verify or transfer existing provenance but cannot mint it from message data or structural signals. Fixes the two P1 review findings on #4777. Lore-id: 4777-p1-provenance Constraint: custom provider streams must remain fallback-eligible unless adapter provenance is authenticated Constraint: user cancellation and non-error completion must never become provider safety stops Rejected: public mint helper | arbitrary consumers could forge allowlisted terminal authority Confidence: high Scope-risk: regression-risk Reversibility: revert-commit Tested: focused provider, managed-attempt, session-retry, and provider-safety e2e suites Not-tested: full CI after sibling #4784 merges --- packages/agent/CHANGELOG.md | 1 + packages/agent/src/agent-loop.ts | 6 +- .../test/managed-attempt-transaction.test.ts | 67 +++++++++++-- packages/ai/CHANGELOG.md | 2 +- .../adapter-internals/provider-safety-stop.ts | 75 ++++++++++++++ packages/ai/src/providers/anthropic.ts | 8 +- .../ai/src/providers/google-gemini-cli.ts | 9 +- packages/ai/src/providers/google-shared.ts | 9 +- .../ai/src/providers/openai-completions.ts | 9 +- packages/ai/src/utils/provider-safety-stop.ts | 97 ++----------------- packages/ai/test/provider-safety-stop.test.ts | 30 +++++- packages/coding-agent/CHANGELOG.md | 3 +- .../agent-session-resilient-retry.test.ts | 16 +-- .../provider-safety-stop-hint.e2e.test.ts | 11 ++- 14 files changed, 217 insertions(+), 126 deletions(-) create mode 100644 packages/ai/src/adapter-internals/provider-safety-stop.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index c63d628d96..6442eecaa7 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777). - Terminal safety-stop authority is now provenance-bound instead of data-bound: a provider or custom stream payload that self-labels `errorKind: "provider_safety_stop"` without the adapter-minted mark is stripped at the stream exit before any retry/discard gate or the managed snapshot shell reads it, so a compromised provider can no longer force refusal by naming the field (#4777 review). Authenticated first-party envelopes (structured refusal signals parsed by the anthropic, openai-completions, and google adapters) keep terminal treatment, and the mark transfers across the shell rebuild without ever being mintable from message data — clones, JSON/persistence round-trips, and re-emitted payloads are all unauthenticated. +- Safety-stop minting is now limited to the package-private adapter capability, so public AI imports and structural message fields cannot create terminal authority. The trailing stream-completion path also sanitizes provenance before rebuilding managed assistant messages, covering streams that end without a `done` or `error` event and keeping forged labels fallback-eligible (#4777 review). - A foreign error that self-declares a local failure kind no longer gets one either (#4618). `errorKind` and the structured `bufferOverflow` shape now come from a single identity-checked extractor (`managedLocalErrorDiagnostic`) used by both terminal-message producers — `managedFailureMessage` and the `Agent` run catch. Previously the shape was identity-gated but the label was not, so a provider or custom-stream failure carrying `errorKind: "local_buffer_overflow"` reached the parent receipt preview as `Local staging-buffer overflow; structured diagnostic unavailable.` and pointed whoever read it at the wrong subsystem. - Local diagnostic authority fields are no longer foreign-settable through the managed snapshot shell (#4618). `managedAssistantShell` spreads the provider/stream message snapshot into the rebuilt assistant message; a payload that smuggled a local `errorKind` or `bufferOverflow` through that spread could masquerade as the runtime's own identity-checked diagnostic at the parent boundary. Local kinds and `bufferOverflow` remain stripped from the snapshot spread, while the provider-owned safety-stop kind is copied only through its explicit closed-literal guard. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 25cc7b4d6f..7f1f8d0ad6 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -3559,9 +3559,11 @@ async function streamAssistantResponse( closeIterator(); } + const finished = await finishResponse(); + sanitizeProviderSafetyStopProvenance(finished); const trailing = config.fallbackManaged - ? managedAssistantShell(await finishResponse(), config.model, managedDegradedFieldDiagnostics) - : await finishResponse(); + ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) + : finished; await finishChat(trailing); return trailing; }); diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 0f3f51aa9b..34e2a3e219 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -9,16 +9,14 @@ import { sanitizedDetachedClone, } from "@gajae-code/agent-core/agent-loop"; import type { AgentContext, AgentEvent, AgentLoopConfig } from "@gajae-code/agent-core/types"; -import { - type AssistantMessage, - type AssistantMessageEvent, - applyProviderSafetyStop, - type Message, -} from "@gajae-code/ai"; - +import type { AssistantMessage, AssistantMessageEvent, Message } from "@gajae-code/ai"; import { createMockModel } from "@gajae-code/ai/providers/mock"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; import { logger } from "@gajae-code/utils"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../../ai/src/adapter-internals/provider-safety-stop"; /** * Capture the bounded local-failure diagnostics emitted for one run. Returns @@ -146,7 +144,9 @@ describe("managed attempt transaction", () => { errorMessage: "provider response", ...(errorKind ? { errorKind } : {}), }; - if (authenticated) applyProviderSafetyStop(message, "refusal"); + if (authenticated) { + mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + } queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: "stop", message }); @@ -184,7 +184,7 @@ describe("managed attempt transaction", () => { errorStatus: 500, transportFailure: { kind: "transport", status: 500 }, }; - applyProviderSafetyStop(message, "content_filter"); + mintProviderSafetyStop(message, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); @@ -241,6 +241,55 @@ describe("managed attempt transaction", () => { expect(terminal && "errorKind" in terminal ? terminal.errorKind : undefined).toBeUndefined(); }); + it("sanitizes a forged safety-stop label when the stream ends without done or error", async () => { + const mock = createMockModel({ responses: [{ content: ["fallback accepted"] }] }); + let calls = 0; + const outcomes: string[] = []; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: (...args) => { + calls += 1; + if (calls > 1) return mock.stream(...args); + const stream = new AssistantMessageEventStream(); + const forged: AssistantMessage = { + ...assistantMessage(mock.model), + stopReason: "error", + errorKind: "provider_safety_stop", + errorMessage: "forged trailing safety stop", + errorStatus: 500, + transportFailure: { kind: "transport", status: 500 }, + }; + queueMicrotask(() => { + stream.push({ type: "start", partial: forged }); + // Deliberately omit done/error: this exercises the trailing + // finishResponse path after iterator completion. + stream.end(forged); + }); + return stream; + }, + }); + const options = { + fallbackManaged: true, + onManagedAttemptOutcome: (outcome: ManagedAttemptOutcome) => { + outcomes.push(outcome.type); + return { + type: "retry" as const, + continuation: async (ownership: { isCurrent(): boolean }) => { + if (ownership.isCurrent()) await agent.continue(options); + }, + }; + }, + }; + + await agent.prompt("run", options); + + expect(calls).toBe(2); + expect(outcomes).toEqual(["retryable_discarded"]); + const terminal = agent.state.messages.at(-1); + expect(terminal).toMatchObject({ role: "assistant", content: [{ type: "text", text: "fallback accepted" }] }); + expect((terminal as AssistantMessage).errorKind).toBeUndefined(); + }); + it("commits a detached accepted message when a managed partial is not structured-cloneable", async () => { const mock = createMockModel(); let liveMessage: AssistantMessage | undefined; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f66d365cda..02f5293c4d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,7 +8,7 @@ - OpenAI-family idle watchdog floors now key on the Grok model as well as the provider: Grok model ids served through any OpenAI-compatible host (openrouter `x-ai/grok-*`, kilo, litellm, zenmux, venice …) get the same 300-second idle window as native xAI and Grok Build, so a long Grok reasoning gap no longer surfaces as `OpenAI completions stream stalled while waiting for the next event` under the 120-second shared default (#4797). Env overrides still win; non-Grok models are unchanged. - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. -- Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: `applyProviderSafetyStop` marks a message only when first-party adapter code supplies a structured refusal signal it actually parsed (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), `isProviderSafetyStopAuthenticated` verifies the mark by identity, and `transferProviderSafetyStop` carries it across a boundary rebuild (#4777). Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON round-trips, and reloaded messages are never authenticated. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through this path instead of assigning `errorKind` directly. +- Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` and `transferProviderSafetyStop` (#4777). Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts new file mode 100644 index 0000000000..f2a5e10a96 --- /dev/null +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -0,0 +1,75 @@ +import type { AssistantMessage } from "../types"; + +/** + * This module is intentionally outside the package export map. Only the + * first-party provider adapters import its minting capability. The public + * provider-safety-stop utility exposes verification and transfer only. + */ +const PROVIDER_SAFETY_STOP_ADAPTER_BRAND = Symbol("provider-safety-stop-adapter-brand"); + +export type ProviderSafetyStopAdapterCapability = { + readonly [PROVIDER_SAFETY_STOP_ADAPTER_BRAND]: true; +}; + +/** The one unforgeable capability shared by first-party adapter parse sites. */ +export const PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY = Object.freeze({ + [PROVIDER_SAFETY_STOP_ADAPTER_BRAND]: true, +}) as ProviderSafetyStopAdapterCapability; + +const authenticatedProviderSafetyStops = new WeakSet(); + +/** + * Structured refusal vocabulary per first-party adapter. The Google entries + * mirror the closed lists in `google-shared.ts`. + */ +const STRUCTURED_REFUSAL_SIGNALS: ReadonlySet = new Set([ + // anthropic-messages: stop_reason / stop_details.type + "refusal", + "sensitive", + // openai-completions: finish_reason / error.code + "content_filter", + // google-generative-ai: candidate finishReason + "SAFETY", + "IMAGE_SAFETY", + "PROHIBITED_CONTENT", + "IMAGE_PROHIBITED_CONTENT", + "SPII", + "BLOCKLIST", + "RECITATION", + "IMAGE_RECITATION", + "MODEL_ARMOR", + // google-generative-ai: promptFeedback.blockReason + "JAILBREAK", +]); + +/** + * Mint terminal authority only from a first-party adapter parse site. The + * capability is branded by a module-private symbol and is not available from + * the public `@gajae-code/ai` surface. An unrecognized structured signal fails + * closed, so adapter mistakes remain fallback-eligible. + */ +export function mintProviderSafetyStop( + message: AssistantMessage, + signal: string, + capability: ProviderSafetyStopAdapterCapability, +): boolean { + if (capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY || !STRUCTURED_REFUSAL_SIGNALS.has(signal)) return false; + authenticatedProviderSafetyStops.add(message); + message.errorKind = "provider_safety_stop"; + return true; +} + +/** Identity check for terminal provider safety-stop authority. */ +export function isProviderSafetyStopAuthenticated(message: unknown): boolean { + return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); +} + +/** + * Carry existing authority across a runtime-owned message rebuild. This does + * not mint authority from message data and is safe to expose publicly. + */ +export function transferProviderSafetyStop(from: unknown, to: AssistantMessage): void { + if (typeof from === "object" && from !== null && authenticatedProviderSafetyStops.has(from)) { + authenticatedProviderSafetyStops.add(to); + } +} diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index efbe269fdf..ccbfc9c40c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -20,6 +20,10 @@ import { logger, readSseEvents, } from "@gajae-code/utils"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../adapter-internals/provider-safety-stop"; import { hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort, @@ -83,7 +87,6 @@ import { import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { GLM_ZCODE_ANTHROPIC_BASE_URL } from "../utils/oauth/glm-zcode"; import { notifyProviderResponse } from "../utils/provider-response"; -import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { isCopilotTransientModelError } from "../utils/retry"; import { getRetryAfterMsFromHeaders } from "../utils/retry-after"; import { resolveRetryBudget } from "../utils/retry-budget"; @@ -2469,9 +2472,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( // structured refusal signal was parsed from the stream // delta, so the mark (not the wire field) carries the // authority (#4777). - applyProviderSafetyStop( + mintProviderSafetyStop( output, stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, ); if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 99180dbd29..d4b73c6b07 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -6,6 +6,10 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { scheduler } from "node:timers/promises"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@gajae-code/utils"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../adapter-internals/provider-safety-stop"; import { calculateCost } from "../models"; import type { Api, @@ -22,7 +26,6 @@ import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { transportFailureFacts } from "../utils/fallback-transport"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; -import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { resolveRetryBudget } from "../utils/retry-budget"; // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted); // the stream provider trusts the access token threaded through `options.apiKey`. @@ -569,7 +572,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( hasContent = true; // Adapter-minted terminal authority from the parsed // structured finish reason (#4777). - applyProviderSafetyStop(output, candidate.finishReason); + mintProviderSafetyStop(output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReasonString(candidate.finishReason); @@ -584,7 +587,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( hasContent = true; if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reason: adapter-minted authority (#4777). - applyProviderSafetyStop(output, blockReason); + mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 3944af5e15..aeeec9657f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -3,6 +3,10 @@ */ import { extractHttpStatusFromError, readJsonl, readSseJson } from "@gajae-code/utils"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../adapter-internals/provider-safety-stop"; import { calculateCost } from "../models"; import type { Api, @@ -22,7 +26,6 @@ import { normalizeSystemPrompts, sanitizeJsonStrings } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { transportFailureFacts } from "../utils/fallback-transport"; import { finalizeErrorMessage, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; -import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema"; import { isForcedToolChoiceUnsupportedError, @@ -662,7 +665,7 @@ export async function consumeGoogleStream(args: { // Terminal authority is minted by the adapter after parsing the // structured candidate finish reason; a wire-assignable field // alone never carries it (#4777). - applyProviderSafetyStop(output, candidate.finishReason); + mintProviderSafetyStop(output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReason(candidate.finishReason); @@ -677,7 +680,7 @@ export async function consumeGoogleStream(args: { if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). - applyProviderSafetyStop(output, blockReason); + mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 0c4ce46bd0..a26efc408b 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -10,6 +10,10 @@ import type { ChatCompletionToolMessageParam, } from "openai/resources/chat/completions"; import packageJson from "../../package.json" with { type: "json" }; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../adapter-internals/provider-safety-stop"; import { type Effort, getSupportedEfforts } from "../model-thinking"; import { calculateCost } from "../models"; import { getEnvApiKey } from "../stream"; @@ -58,7 +62,6 @@ import { findUnnecessaryUnicodeEscape, isCompleteJson, parseStreamingJson } from import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { notifyProviderResponse } from "../utils/provider-response"; -import { applyProviderSafetyStop } from "../utils/provider-safety-stop"; import { callWithCopilotModelRetry } from "../utils/retry"; import { resolveRetryBudget } from "../utils/retry-budget"; import { adaptSchemaForStrict, flattenToolRootCombinators, NO_STRICT, toolWireSchema } from "../utils/schema"; @@ -834,7 +837,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // Terminal authority comes from the adapter mark, not the wire // field: this call site parsed the structured content_filter // finish reason from the provider's own response (#4777). - applyProviderSafetyStop(output, "content_filter"); + mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); if (errorMessage) output.errorMessage = errorMessage; }; @@ -1080,7 +1083,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // The structured content_filter code was parsed from the captured // HTTP response body; mint adapter provenance for the terminal // kind instead of trusting a wire-assignable field (#4777). - applyProviderSafetyStop(output, "content_filter"); + mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; diff --git a/packages/ai/src/utils/provider-safety-stop.ts b/packages/ai/src/utils/provider-safety-stop.ts index 3da911cc20..df6908e397 100644 --- a/packages/ai/src/utils/provider-safety-stop.ts +++ b/packages/ai/src/utils/provider-safety-stop.ts @@ -1,89 +1,12 @@ -import type { AssistantMessage } from "../types"; - /** - * Terminal provider safety-stop authority (issue #4777 review follow-up). + * Public provider safety-stop surface (issue #4777 review follow-up). * - * `errorKind: "provider_safety_stop"` makes a failure terminal: retry policy - * suppresses it and managed fallback never advances the chain to another - * model. When that authority traveled as plain message data, any provider or - * custom stream payload could self-label a refusal and deny the user their - * configured fallback — a compromised endpoint could force refusal by naming - * the typed kind. - * - * Authority therefore never travels on the data channel. It lives in this - * module-scoped {@link WeakSet}, minted only by {@link applyProviderSafetyStop} - * when first-party adapter code calls it with a structured refusal signal it - * actually parsed from the provider's response (Anthropic `stop_reason` - * refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block - * reasons). Every re-entry boundary re-checks identity: structured clones, - * JSON round-trips, persisted-and-reloaded messages, and re-emitted stream - * payloads are new objects and carry no authority, so transport and - * persistence can preserve the label for display but can never upgrade an - * unauthenticated payload into a terminal stop. - */ -const authenticatedProviderSafetyStops = new WeakSet(); - -/** - * Structured refusal vocabulary per first-party adapter. The google entries - * mirror the closed lists in `google-shared.ts` - * (`isGoogleCandidateSafetyStopReason` / `isGooglePromptSafetyStopReason`); - * keep them in sync. - */ -const STRUCTURED_REFUSAL_SIGNALS: ReadonlySet = new Set([ - // anthropic-messages: stop_reason / stop_details.type - "refusal", - "sensitive", - // openai-completions: finish_reason / error.code - "content_filter", - // google-generative-ai: candidate finishReason - "SAFETY", - "IMAGE_SAFETY", - "PROHIBITED_CONTENT", - "IMAGE_PROHIBITED_CONTENT", - "SPII", - "BLOCKLIST", - "RECITATION", - "IMAGE_RECITATION", - "MODEL_ARMOR", - // google-generative-ai: promptFeedback.blockReason - "JAILBREAK", -]); - -/** - * Mint terminal safety-stop authority for one message object. Adapter-side - * use only: call it at the parse site, with the structured refusal signal that - * was actually validated against the provider's response. An unrecognized - * signal fails closed — the message keeps whatever it had and gains no - * authority — so an adapter bug degrades to ordinary fallback, never to a - * forced refusal. - * - * Returns whether authority was minted. - */ -export function applyProviderSafetyStop(message: AssistantMessage, signal: string): boolean { - if (!STRUCTURED_REFUSAL_SIGNALS.has(signal)) return false; - authenticatedProviderSafetyStops.add(message); - message.errorKind = "provider_safety_stop"; - return true; -} - -/** - * Identity check for terminal safety-stop authority. True only for the exact - * object a first-party adapter marked in this process. Copies, clones, - * JSON/persistence round-trips, and fresh objects carrying the field are all - * unauthenticated — data alone is never terminal. - */ -export function isProviderSafetyStopAuthenticated(message: unknown): boolean { - return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); -} -/** - * Transfer terminal safety-stop authority from a live marked message onto the - * rebuilt message a boundary constructed from it. Callers can never mint - * authority: the target is marked only when the source already was. Used by - * the managed snapshot shell so the rebuilt assistant message keeps provenance - * across the clone boundary (#4777). - */ -export function transferProviderSafetyStop(from: unknown, to: AssistantMessage): void { - if (typeof from === "object" && from !== null && authenticatedProviderSafetyStops.has(from)) { - authenticatedProviderSafetyStops.add(to); - } -} + * First-party adapters mint terminal authority through the package-private + * adapter-internals module. Public consumers may only verify existing + * authority or transfer it across a runtime-owned rebuild; message fields and + * structured refusal text never mint authority. + */ +export { + isProviderSafetyStopAuthenticated, + transferProviderSafetyStop, +} from "../adapter-internals/provider-safety-stop"; diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 11a280b146..9c44a21c81 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../src/adapter-internals/provider-safety-stop"; +import * as publicAi from "../src/index"; import type { AssistantMessage } from "../src/types"; -import { applyProviderSafetyStop, isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; +import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; function message(): AssistantMessage { return { @@ -24,10 +29,17 @@ function message(): AssistantMessage { } describe("provider safety-stop provenance authority", () => { + test("public AI exports expose verification only, never the minting operation", () => { + const publicSurface = publicAi as unknown as Record; + expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); + expect(typeof publicSurface.isProviderSafetyStopAuthenticated).toBe("function"); + expect(typeof publicSurface.transferProviderSafetyStop).toBe("function"); + }); + test("mints the typed kind only for structured first-party refusal signals", () => { for (const signal of ["refusal", "sensitive", "content_filter", "SAFETY", "JAILBREAK", "RECITATION"]) { const marked = message(); - expect(applyProviderSafetyStop(marked, signal)).toBe(true); + expect(mintProviderSafetyStop(marked, signal, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); expect(marked.errorKind).toBe("provider_safety_stop"); expect(isProviderSafetyStopAuthenticated(marked)).toBe(true); } @@ -36,15 +48,25 @@ describe("provider safety-stop provenance authority", () => { test("fails closed on an unrecognized signal: no kind, no authority", () => { const unmarked = message(); unmarked.errorKind = "provider_safety_stop"; - expect(applyProviderSafetyStop(unmarked, "totally-not-a-refusal")).toBe(false); + expect(mintProviderSafetyStop(unmarked, "totally-not-a-refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe( + false, + ); // The pre-existing wire-assignable field stays exactly as unauthenticated // as it was; the adapter bug degraded to ordinary fallback, not a mint. expect(isProviderSafetyStopAuthenticated(unmarked)).toBe(false); }); + test("a structurally forged capability cannot mint authority", () => { + const forged = message(); + const forgedCapability = {} as Parameters[2]; + expect(mintProviderSafetyStop(forged, "refusal", forgedCapability)).toBe(false); + expect(isProviderSafetyStopAuthenticated(forged)).toBe(false); + expect(forged.errorKind).toBeUndefined(); + }); + test("data alone is never authenticated: clones, JSON round-trips, and fresh copies lose authority", () => { const marked = message(); - expect(applyProviderSafetyStop(marked, "refusal")).toBe(true); + expect(mintProviderSafetyStop(marked, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); const cloned = structuredClone(marked); expect(cloned.errorKind).toBe("provider_safety_stop"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bf64ed8538..7c5f460601 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,7 +20,8 @@ - Queued SDK prompts now retain their dispatch-time ownership across selection fences instead of reclassifying from a contradictory later streaming snapshot. Fresh promotion, earlier follow-up ordering, and terminal-abort cancellation are reachable again, while `/btw` test fixtures now model the user-drainable queue count required by the current empty-submit contract. - Coordinator stop and idle-reap now initialize canonical namespace state before reading durable deletion recovery. Fresh or upgraded projection-only sessions were misreported as `state_corrupt` before the broker close was attempted, and completed deletion receipts were excluded from the idempotent missing-session lookup; both paths now preserve strict malformed-state rejection while allowing safe cleanup and replay. - Fixed provider safety-stop classification being lost before session persistence (#4777). The managed provider-envelope boundary now preserves only the allowlisted `provider_safety_stop` kind, typed safety stops remain terminal even with transport facts on a multi-model fallback chain, and the regression e2e test is selected both by the focused affected-path route and exactly one normal coding-agent shard. -- A forged `errorKind: "provider_safety_stop"` label can no longer suppress model fallback (#4777 review). Terminal safety-stop authority is now adapter-minted (module-scoped provenance set by the first-party anthropic/openai/google adapters when they parse a structured refusal) rather than carried on message data: the loop strips unauthenticated labels at the stream exit, the managed discard gate re-verifies identity, and transport facts never upgrade provenance, so a compromised provider that names the typed kind on an error envelope degrades to an ordinary fallback-eligible failure on every status (400 and 429 both keep advancing the chain). Authenticated envelopes remain single-dispatch terminal with the manual-switch hint intact, and persisted/reloaded messages keep the field for display only. +- A forged `errorKind: "provider_safety_stop"` label can no longer suppress model fallback (#4777 review). Terminal safety-stop authority is now adapter-minted (module-scoped provenance set by the first-party anthropic/openai/google adapters when they parse a structured refusal) rather than carried on message data: the loop strips unauthenticated labels at the stream exit, the managed discard gate re-verifies identity, and transport facts never upgrade provenance, so a compromised provider that names the typed kind on an error envelope degrades to an ordinary fallback-eligible failure on every status (400 and 429 both keep advancing the chain). Authenticated envelopes remain single-dispatch terminal with the manual-switch hint intact, and persisted/reloaded … +- The provider safety-stop minting path is now package-private and capability-branded; public AI imports and structural custom-stream payloads cannot forge terminal authority. Trailing stream completion now sanitizes the final message before managed-shell rebuilding when no `done` or `error` event was emitted, preserving fallback for forged labels (#4777 review). - Retained-publication acquisition diagnostics now name the stage that actually refused instead of always claiming a failed open, and the native errno vocabulary is closed over the actionable `open(2)` refusal set (#4764). `nativeRetainedObstruction()` rendered every unmatched reason as "could not be opened", so a `broker.json` FIFO — which opens fine `O_RDWR | O_NOFOLLOW | O_NONBLOCK` and then refuses `read_to_end` with `WouldBlock` — was reported as an open failure that had in fact succeeded, `clone`/`metadata` refusals of already-open descriptors were reported as opens, and `unsupported-platform` read as an open error. The structured native reason now drives the prose: `errno-`/`io-` render as open refusals, `read-` as a read failure, `clone-`/`metadata[-kind]` as inspection failures of an already-open object, `unsupported-platform` as unimplemented authority, and any future reason falls back to a stable "withheld publication authority (reason)" sentence instead of misnaming the stage; the structured reason is preferred whenever the native message parses, so the observed-state fallback is never entered for a stage-named refusal. The Rust `errno_name()` vocabulary adds `EAGAIN`, `EMFILE`, `ENAMETOOLONG`, `ENFILE`, `ENOMEM`, `EOVERFLOW`, and `EPERM` alongside the existing six — descriptor exhaustion and LSM/seccomp denial no longer collapse to `UNKNOWN` — while every unlisted errno still renders as `UNKNOWN` and no errno number or OS string ever leaves the native layer. - The `errno_name()` closed-vocabulary fixtures introduced with the retained-publication diagnostic (#4764) are now gated `#[cfg(all(test, unix))]` like every sibling test module in `path_identity.rs`. The module was file-level `#[cfg(test)]` while importing `super::publication::errno_name`, an item that only exists in the unix `mod publication`; on Windows the `#[cfg(not(unix))]` sibling has no such item, so `cargo test -p pi-natives` failed to compile on a Windows dev machine (CI was unaffected — its Windows jobs build without `--tests`). Unix coverage is unchanged: both fixtures still run. - Conventional MCP autoload now reads the user scope from the agent directory instead of a home-relative `/.gjc/agent` path (#4767). Every writer and denylist reader already resolved user scope through `getMCPConfigPath("user")` (the agent directory), while native discovery derived it from the load context's home, so the two disagreed the moment an agent-directory profile was in play: `GJC_CODING_AGENT_DIR= gjc mcp add ` wrote `/mcp.json` and reported the server as loaded by ordinary sessions at startup, but startup read `~/.gjc/agent/mcp.json` — the profile's own registrations never loaded and the default profile's servers loaded into the profile instead. `loadAllMCPConfigs` accepts an `agentDir`, sessions created with their own `agentDir` pass it, and the `disabledServers` denylist follows the same scope, so discovery, `gjc mcp add`, the `/mcp` wizard, and `gjc customize doctor` all name one file. This also restores isolation for the MCP autoload suites, which established their temp user scope with `setAgentDir()`: after the trusted-home provenance rework their `os.homedir()` mock no longer reached discovery, so on a developer machine the red-team suite read the real `~/.gjc/agent/mcp.json` and in CI it found nothing. diff --git a/packages/coding-agent/test/agent-session-resilient-retry.test.ts b/packages/coding-agent/test/agent-session-resilient-retry.test.ts index 54980fc6a6..6e105c125b 100644 --- a/packages/coding-agent/test/agent-session-resilient-retry.test.ts +++ b/packages/coding-agent/test/agent-session-resilient-retry.test.ts @@ -2,13 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent, type AgentTool, type StreamFn } from "@gajae-code/agent-core"; -import { - type AssistantMessage, - applyProviderSafetyStop, - getBundledModel, - type Model, - type ToolCall, -} from "@gajae-code/ai"; +import { type AssistantMessage, getBundledModel, type Model, type ToolCall } from "@gajae-code/ai"; import { createMockModel } from "@gajae-code/ai/providers/mock"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; @@ -21,6 +15,10 @@ import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import { TempDir } from "@gajae-code/utils"; import * as z from "zod/v4"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../../ai/src/adapter-internals/provider-safety-stop"; /** * Anthropic's statusless capacity-overload envelope exactly as observed in a @@ -199,7 +197,9 @@ describe("AgentSession resilient retry", () => { // first-party provider envelope, so the structured refusal signal // carries the terminal authority rather than the wire field // alone (#4777). - if (options.errorKind === "provider_safety_stop") applyProviderSafetyStop(message, "refusal"); + if (options.errorKind === "provider_safety_stop") { + mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + } stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); }); diff --git a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts index 1aa259bd16..9559aeecbb 100644 --- a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts +++ b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent, type AgentOptions } from "@gajae-code/agent-core"; -import { type AssistantMessage, applyProviderSafetyStop, getBundledModel, type Model } from "@gajae-code/ai"; +import { type AssistantMessage, getBundledModel, type Model } from "@gajae-code/ai"; import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; import { Settings } from "@gajae-code/coding-agent/config/settings"; @@ -22,6 +22,10 @@ import { resolveProviderSafetyStopHint, } from "@gajae-code/coding-agent/session/provider-safety-stop-hint"; import { TempDir } from "@gajae-code/utils"; +import { + mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, +} from "../../ai/src/adapter-internals/provider-safety-stop"; import { AgentSession, type AgentSessionEvent } from "../src/session/agent-session"; import { AuthStorage } from "../src/session/auth-storage"; import { SessionManager } from "../src/session/session-manager"; @@ -66,8 +70,9 @@ function safetyStopStream( // parsed from the provider's own response mints the terminal authority. // Omitting the mark simulates a wire/custom-stream payload that only // carries the forged field. - if (options?.authenticated !== false) applyProviderSafetyStop(message, "refusal"); - else message.errorKind = "provider_safety_stop"; + if (options?.authenticated !== false) { + mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + } else message.errorKind = "provider_safety_stop"; stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); }); From b04dd0c2980dd84159e8930758173173055e89a1 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 23:20:04 +0000 Subject: [PATCH 03/26] fix(agent): keep safety provenance runtime-private Remove public transfer authority from the AI package. The agent loop now records provenance only for its own managed rebuild destinations in a module-private WeakSet, so public consumers cannot copy terminal authority from a genuine adapter-marked source. Closes the remaining P1 trust-boundary finding on #4782. Lore-id: 4777-p1-private-transfer Constraint: public consumers must not mint or transfer provider safety-stop authority Constraint: legitimate managed assistant rebuilds remain terminal Rejected: public transfer helper | arbitrary destinations could become terminal Confidence: high Scope-risk: regression-risk Reversibility: revert-commit Tested: provider, managed-attempt, retry, e2e, and package checks Not-tested: full CI after final push --- packages/agent/CHANGELOG.md | 4 +-- packages/agent/src/agent-loop.ts | 27 ++++++++++++++----- packages/ai/CHANGELOG.md | 2 +- .../adapter-internals/provider-safety-stop.ts | 16 +---------- packages/ai/src/utils/provider-safety-stop.ts | 8 ++---- packages/ai/test/provider-safety-stop.test.ts | 12 ++++++++- 6 files changed, 37 insertions(+), 32 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6442eecaa7..56709575c9 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,8 +4,8 @@ ### Fixed - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777). -- Terminal safety-stop authority is now provenance-bound instead of data-bound: a provider or custom stream payload that self-labels `errorKind: "provider_safety_stop"` without the adapter-minted mark is stripped at the stream exit before any retry/discard gate or the managed snapshot shell reads it, so a compromised provider can no longer force refusal by naming the field (#4777 review). Authenticated first-party envelopes (structured refusal signals parsed by the anthropic, openai-completions, and google adapters) keep terminal treatment, and the mark transfers across the shell rebuild without ever being mintable from message data — clones, JSON/persistence round-trips, and re-emitted payloads are all unauthenticated. -- Safety-stop minting is now limited to the package-private adapter capability, so public AI imports and structural message fields cannot create terminal authority. The trailing stream-completion path also sanitizes provenance before rebuilding managed assistant messages, covering streams that end without a `done` or `error` event and keeping forged labels fallback-eligible (#4777 review). +- Terminal safety-stop authority is now provenance-bound instead of data-bound: a provider or custom stream payload that self-labels `errorKind: "provider_safety_stop"` without the adapter-minted mark is stripped at the stream exit before any retry/discard gate or the managed snapshot shell reads it, so a compromised provider can no longer force refusal by naming the field (#4777 review). Authenticated first-party envelopes (structured refusal signals parsed by the anthropic, openai-completions, and google adapters) keep terminal treatment, and only the agent loop's module-private rebuild set carries that authority onto its own destination — clones, JSON/persistence round-trips, and re-emitted payloads are all unauthenticated. +- Safety-stop minting is now limited to the package-private adapter capability, and public AI consumers cannot transfer authority from a genuine marked source to an arbitrary destination. The trailing stream-completion path also sanitizes provenance before rebuilding managed assistant messages, covering streams that end without a `done` or `error` event and keeping forged labels fallback-eligible (#4777 review). - A foreign error that self-declares a local failure kind no longer gets one either (#4618). `errorKind` and the structured `bufferOverflow` shape now come from a single identity-checked extractor (`managedLocalErrorDiagnostic`) used by both terminal-message producers — `managedFailureMessage` and the `Agent` run catch. Previously the shape was identity-gated but the label was not, so a provider or custom-stream failure carrying `errorKind: "local_buffer_overflow"` reached the parent receipt preview as `Local staging-buffer overflow; structured diagnostic unavailable.` and pointed whoever read it at the wrong subsystem. - Local diagnostic authority fields are no longer foreign-settable through the managed snapshot shell (#4618). `managedAssistantShell` spreads the provider/stream message snapshot into the rebuilt assistant message; a payload that smuggled a local `errorKind` or `bufferOverflow` through that spread could masquerade as the runtime's own identity-checked diagnostic at the parent boundary. Local kinds and `bufferOverflow` remain stripped from the snapshot spread, while the provider-owned safety-stop kind is copied only through its explicit closed-literal guard. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 7f1f8d0ad6..04a3bb7214 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -18,7 +18,6 @@ import { type ToolChoice, type ToolResultMessage, type TSchema, - transferProviderSafetyStop, transportFailureFacts, type UserMessage, validateToolArguments, @@ -359,6 +358,20 @@ function managedTransportFailure(failure: unknown) { return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined; } +// AI owns provider-originated authority. The agent loop owns authority for +// the rebuilt message objects it creates; this second WeakSet is deliberately +// module-private so a public AI consumer cannot transfer authority to an +// arbitrary destination. A destination is marked only while this managed +// runtime is rebuilding a source that AI authenticated. +const managedProviderSafetyStops = new WeakSet(); + +function isManagedProviderSafetyStopAuthenticated(value: unknown): boolean { + return ( + isProviderSafetyStopAuthenticated(value) || + (typeof value === "object" && value !== null && managedProviderSafetyStops.has(value)) + ); +} + function managedRetryableFailure(failure: unknown): boolean { const facts = managedTransportFailure(failure); if (!facts) return false; @@ -370,7 +383,7 @@ function managedRetryableFailure(failure: unknown): boolean { if ( managedProperty(failure, "stopReason") === "error" && managedProperty(failure, "errorKind") === "provider_safety_stop" && - isProviderSafetyStopAuthenticated(failure) + isManagedProviderSafetyStopAuthenticated(failure) ) { return false; } @@ -413,7 +426,7 @@ function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): void { if ( message.stopReason === "error" && message.errorKind === "provider_safety_stop" && - !isProviderSafetyStopAuthenticated(message) + !isManagedProviderSafetyStopAuthenticated(message) ) { delete message.errorKind; } @@ -1268,10 +1281,10 @@ function managedAssistantShell( ...(typeof errorStatus === "number" && Number.isFinite(errorStatus) ? { errorStatus } : {}), }; // The closed-literal copy above is fed by the stream-exit provenance - // sanitize, so an unauthenticated label never reaches here; carry the - // adapter-minted authority across the rebuild so downstream gates (the - // discard decision and session policy) can re-verify identity (#4777). - if (errorKind) transferProviderSafetyStop(value, rebuilt); + // sanitize, so an unauthenticated label never reaches here. Mark the + // runtime-owned destination only when this source is already authenticated; + // no public AI API can perform this transfer (#4777 review). + if (errorKind && isManagedProviderSafetyStopAuthenticated(value)) managedProviderSafetyStops.add(rebuilt); return rebuilt; } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 02f5293c4d..8bec4c1459 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,7 +8,7 @@ - OpenAI-family idle watchdog floors now key on the Grok model as well as the provider: Grok model ids served through any OpenAI-compatible host (openrouter `x-ai/grok-*`, kilo, litellm, zenmux, venice …) get the same 300-second idle window as native xAI and Grok Build, so a long Grok reasoning gap no longer surfaces as `OpenAI completions stream stalled while waiting for the next event` under the 120-second shared default (#4797). Env overrides still win; non-Grok models are unchanged. - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. -- Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` and `transferProviderSafetyStop` (#4777). Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. +- Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index f2a5e10a96..e30f1bdbf2 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -1,10 +1,6 @@ import type { AssistantMessage } from "../types"; -/** - * This module is intentionally outside the package export map. Only the - * first-party provider adapters import its minting capability. The public - * provider-safety-stop utility exposes verification and transfer only. - */ +/** This module is intentionally outside the package export map. */ const PROVIDER_SAFETY_STOP_ADAPTER_BRAND = Symbol("provider-safety-stop-adapter-brand"); export type ProviderSafetyStopAdapterCapability = { @@ -63,13 +59,3 @@ export function mintProviderSafetyStop( export function isProviderSafetyStopAuthenticated(message: unknown): boolean { return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); } - -/** - * Carry existing authority across a runtime-owned message rebuild. This does - * not mint authority from message data and is safe to expose publicly. - */ -export function transferProviderSafetyStop(from: unknown, to: AssistantMessage): void { - if (typeof from === "object" && from !== null && authenticatedProviderSafetyStops.has(from)) { - authenticatedProviderSafetyStops.add(to); - } -} diff --git a/packages/ai/src/utils/provider-safety-stop.ts b/packages/ai/src/utils/provider-safety-stop.ts index df6908e397..d4c85b3ff2 100644 --- a/packages/ai/src/utils/provider-safety-stop.ts +++ b/packages/ai/src/utils/provider-safety-stop.ts @@ -3,10 +3,6 @@ * * First-party adapters mint terminal authority through the package-private * adapter-internals module. Public consumers may only verify existing - * authority or transfer it across a runtime-owned rebuild; message fields and - * structured refusal text never mint authority. + * authority; message fields and structured refusal text never mint authority. */ -export { - isProviderSafetyStopAuthenticated, - transferProviderSafetyStop, -} from "../adapter-internals/provider-safety-stop"; +export { isProviderSafetyStopAuthenticated } from "../adapter-internals/provider-safety-stop"; diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 9c44a21c81..5f31c6f980 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -33,7 +33,7 @@ describe("provider safety-stop provenance authority", () => { const publicSurface = publicAi as unknown as Record; expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); expect(typeof publicSurface.isProviderSafetyStopAuthenticated).toBe("function"); - expect(typeof publicSurface.transferProviderSafetyStop).toBe("function"); + expect(publicSurface.transferProviderSafetyStop).toBeUndefined(); }); test("mints the typed kind only for structured first-party refusal signals", () => { @@ -64,6 +64,16 @@ describe("provider safety-stop provenance authority", () => { expect(forged.errorKind).toBeUndefined(); }); + test("a public consumer cannot clone authority from a genuine marked source", () => { + const marked = message(); + expect(mintProviderSafetyStop(marked, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); + + const forgedDestination = { ...marked }; + expect(isProviderSafetyStopAuthenticated(marked)).toBe(true); + expect(isProviderSafetyStopAuthenticated(forgedDestination)).toBe(false); + expect((publicAi as unknown as Record).transferProviderSafetyStop).toBeUndefined(); + }); + test("data alone is never authenticated: clones, JSON round-trips, and fresh copies lose authority", () => { const marked = message(); expect(mintProviderSafetyStop(marked, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); From 1b592034a1d303b18725cb3ff6cc26174af125c0 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 14:33:08 +0000 Subject: [PATCH 04/26] fix(ai): block the safety-stop mint module from wildcard package exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "./*": "./src/*.ts" export pattern also matches nested subpaths, so @.gajae-code/ai/adapter-internals/provider-safety-stop stayed deep-importable and any custom StreamFn could reach mintProviderSafetyStop plus the adapter capability — the provenance boundary the adapter-internals move was supposed to create did not exist (exact-head review of #4782 at dcc636b8). Block ./adapter-internals/* with a null export target so resolution fails closed outside first-party relative imports; public consumers keep only the verifier through the barrel and ./utils/provider-safety-stop. Lore-id: 0e61c2f1 Constraint: public surface stays verify-only; the mint must stay unreachable Tested: bun test packages/ai/test/provider-safety-stop.test.ts (7 pass incl. new export-map pin) Confidence: high Scope-risk: narrow Reversibility: trivial --- packages/ai/package.json | 1 + packages/ai/test/provider-safety-stop.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/ai/package.json b/packages/ai/package.json index 3109864cdc..8780144d8a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -67,6 +67,7 @@ "types": "./src/index.ts", "import": "./src/index.ts" }, + "./adapter-internals/*": null, "./*": { "types": "./src/*.ts", "import": "./src/*.ts", diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 5f31c6f980..217b49082a 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -92,4 +92,14 @@ describe("provider safety-stop provenance authority", () => { expect(isProviderSafetyStopAuthenticated(undefined)).toBe(false); expect(isProviderSafetyStopAuthenticated("provider_safety_stop")).toBe(false); }); + + test("the mint module is unreachable through the package export map", async () => { + // Deep imports through the public package name resolve through the + // exports map; `./adapter-internals/*` is null there, so neither the + // mint nor the capability is importable outside first-party relative + // imports (#4777 review follow-up). + await expect(import("@gajae-code/ai/adapter-internals/provider-safety-stop")).rejects.toThrow(); + const manifest = (await import("../package.json", { with: { type: "json" } })).default; + expect(manifest.exports["./adapter-internals/*"]).toBeNull(); + }); }); From cc0e4b32fb0015a8f98f5e7514a5a3ebcb67c930 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 14:38:34 +0000 Subject: [PATCH 05/26] fix(ai): restore safety-stop provenance across the pi-native gateway hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With transport=pi-native the first-party adapter runs inside the auth-gateway process; encodeStream serializes the terminal event to SSE and the client parses a fresh object, so the process-local WeakSet mark never reached the agent loop. The loop's provenance sanitize then stripped a genuine Anthropic/OpenAI/Google safety label as unauthenticated, and under transport facts (429/5xx) the refusal advanced the fallback chain instead of staying terminal — the manual-switch hint was lost (exact-head review of #4782). restoreProviderSafetyStopFromTrustedTransport re-establishes exactly the authority serialization dropped, and only for the typed error pair a first-party mint could have written on the gateway side. The client gates the restore on a loopback gateway URL (the default gjc auth-gateway bind); remote explicitly-configured endpoints stay unauthenticated and degrade to ordinary fallback-eligible errors. Lore-id: b7c40e9d Constraint: restore must never create authority, only re-establish what a gateway-side mint produced Constraint: remote gateway endpoints must stay unauthenticated Tested: bun test packages/ai/test/pi-native-client.test.ts packages/ai/test/provider-safety-stop.test.ts (21 pass) Confidence: high Scope-risk: narrow Reversibility: trivial --- .../adapter-internals/provider-safety-stop.ts | 20 +++++++++ packages/ai/src/providers/pi-native-client.ts | 45 ++++++++++++++++++- packages/ai/test/pi-native-client.test.ts | 44 ++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index e30f1bdbf2..96dd1e860c 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -55,6 +55,26 @@ export function mintProviderSafetyStop( return true; } +/** + * Restore authority for a message that crossed a first-party trusted + * transport boundary (the pi-native auth-gateway loopback channel). The + * gateway serializes an adapter-minted message to SSE and the client parses + * a fresh object, which loses the process-local mark. The typed kind is only + * ever written by a first-party mint on the gateway side, so a terminal + * error pair arriving over that channel re-establishes exactly the authority + * serialization dropped — it never creates authority for a message the + * gateway did not already authenticate (#4777 review follow-up). + */ +export function restoreProviderSafetyStopFromTrustedTransport( + message: AssistantMessage, + capability: ProviderSafetyStopAdapterCapability, +): boolean { + if (capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY) return false; + if (message.stopReason !== "error" || message.errorKind !== "provider_safety_stop") return false; + authenticatedProviderSafetyStops.add(message); + return true; +} + /** Identity check for terminal provider safety-stop authority. */ export function isProviderSafetyStopAuthenticated(message: unknown): boolean { return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index c5fbfc5162..a60a6eedf2 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -14,6 +14,10 @@ * containerized GJC deployments that route every LLM call through a * credential-holding sidecar so the container stays credential-free. */ +import { + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + restoreProviderSafetyStopFromTrustedTransport, +} from "../adapter-internals/provider-safety-stop"; import { readSseJson } from "@gajae-code/utils"; import type { Api, @@ -110,6 +114,32 @@ function resolveStreamUrl(model: Model): string { return `${model.baseUrl.replace(/\/+$/, "")}/v1/pi/stream`; } +/** + * The trusted-transport boundary for restoring provider safety-stop authority: + * the default `gjc auth-gateway` bind is loopback (`127.0.0.1:4000`), so a + * terminal typed stop parsed from a loopback gateway response re-establishes + * the adapter-minted mark that SSE serialization dropped. A non-loopback + * gateway target is an explicitly configured remote endpoint — its payloads + * stay unauthenticated and degrade to ordinary fallback-eligible errors + * (#4777 review follow-up). + */ +function isLoopbackGatewayUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:") return false; + const host = parsed.hostname.toLowerCase(); + return ( + host === "localhost" || + host === "[::1]" || + host === "::1" || + host === "127.0.0.1" || + /^127\.(?:[0-9]{1,3}\.){2}[0-9]{1,3}$/.test(host) + ); + } catch { + return false; + } +} + function buildHeaders(model: Model, apiKey: string | undefined): Record { const headers: Record = { "Content-Type": "application/json", @@ -160,6 +190,7 @@ export function streamPiNative( try { const url = resolveStreamUrl(model as Model); + const trustedTransport = isLoopbackGatewayUrl(url); const fetchImpl = options?.fetch ?? globalThis.fetch; const headers = buildHeaders(model as Model, options?.apiKey); const body = JSON.stringify({ @@ -184,7 +215,19 @@ export function streamPiNative( response.body as ReadableStream, signal, )) { - if (event.type === "done" || event.type === "error") sawTerminal = true; + if (event.type === "done" || event.type === "error") { + sawTerminal = true; + // SSE serialization drops the adapter-minted authority mark; a + // loopback gateway hop is first-party trusted transport, so + // re-establish it there. Remote endpoints stay unauthenticated. + if (trustedTransport) { + if (event.type === "done") { + restoreProviderSafetyStopFromTrustedTransport(event.message, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + } else { + restoreProviderSafetyStopFromTrustedTransport(event.error, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + } + } + } stream.push(event); // `stream.push` resolves `.result()` on `done`/`error`; subsequent // pushes are silently dropped by the base class. We still iterate diff --git a/packages/ai/test/pi-native-client.test.ts b/packages/ai/test/pi-native-client.test.ts index 615f723192..787ef0fc0b 100644 --- a/packages/ai/test/pi-native-client.test.ts +++ b/packages/ai/test/pi-native-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; import { streamPiNative } from "../src/providers/pi-native-client"; import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "../src/types"; @@ -298,3 +299,46 @@ describe("streamPiNative event flow", () => { expect(captured.signal).toBe(controller.signal); }); }); + +describe("streamPiNative provider safety-stop provenance", () => { + const typedStop = (): AssistantMessage => + baseAssistant({ + stopReason: "error", + errorKind: "provider_safety_stop", + errorMessage: "Refusal (safety): policy violation", + }); + + it("restores adapter-minted authority for a typed stop from a loopback gateway", async () => { + const fetchImpl: FetchImpl = (async () => + fakeResponse([{ type: "error", reason: "error", error: typedStop() }])) as FetchImpl; + const model = fakeModel({ baseUrl: "http://127.0.0.1:4000" }); + + const result = await streamPiNative(model, baseContext, { apiKey: "k", fetch: fetchImpl }).result(); + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + }); + + it("does not authenticate a typed stop from a non-loopback gateway endpoint", async () => { + const fetchImpl: FetchImpl = (async () => + fakeResponse([{ type: "error", reason: "error", error: typedStop() }])) as FetchImpl; + + const result = await streamPiNative(fakeModel(), baseContext, { apiKey: "k", fetch: fetchImpl }).result(); + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); + }); + + it("restores authority on a done-carried typed stop and never mints it for other messages", async () => { + const fetchImpl: FetchImpl = (async () => + fakeResponse([{ type: "done", reason: "error", message: typedStop() }])) as FetchImpl; + const model = fakeModel({ baseUrl: "http://localhost:4000" }); + + const result = await streamPiNative(model, baseContext, { apiKey: "k", fetch: fetchImpl }).result(); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + + const plainFetch: FetchImpl = (async () => + fakeResponse([{ type: "done", reason: "stop", message: baseAssistant() }])) as FetchImpl; + const plain = await streamPiNative(model, baseContext, { apiKey: "k", fetch: plainFetch }).result(); + expect(plain.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(plain)).toBe(false); + }); +}); From 83c0bec10f420378ce4e9f74c0308675ff67d58a Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 14:40:04 +0000 Subject: [PATCH 06/26] fix(ai): satisfy type and format contracts in the pi-native restore - done-event reason is stop|length|toolUse; the done-carried typed-stop fixture used a fabricated 'error' reason - the blocked deep import must use a non-literal specifier so tsc cannot resolve the nulled subpath while runtime resolution still fails closed - apply biome formatting to the client wiring Amends 0a9590847. Lore-id: 3d1f8a2c Tested: bun --cwd=packages/ai run check exit 0; bun test pi-native-client + provider-safety-stop (21 pass) Confidence: high --- packages/ai/src/providers/pi-native-client.ts | 13 ++++++++++--- packages/ai/test/pi-native-client.test.ts | 4 ++-- packages/ai/test/provider-safety-stop.test.ts | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index a60a6eedf2..4eb69b1e33 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -14,11 +14,12 @@ * containerized GJC deployments that route every LLM call through a * credential-holding sidecar so the container stays credential-free. */ + +import { readSseJson } from "@gajae-code/utils"; import { PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, restoreProviderSafetyStopFromTrustedTransport, } from "../adapter-internals/provider-safety-stop"; -import { readSseJson } from "@gajae-code/utils"; import type { Api, AssistantMessage, @@ -222,9 +223,15 @@ export function streamPiNative( // re-establish it there. Remote endpoints stay unauthenticated. if (trustedTransport) { if (event.type === "done") { - restoreProviderSafetyStopFromTrustedTransport(event.message, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + restoreProviderSafetyStopFromTrustedTransport( + event.message, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + ); } else { - restoreProviderSafetyStopFromTrustedTransport(event.error, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + restoreProviderSafetyStopFromTrustedTransport( + event.error, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + ); } } } diff --git a/packages/ai/test/pi-native-client.test.ts b/packages/ai/test/pi-native-client.test.ts index 787ef0fc0b..9ccb59b54b 100644 --- a/packages/ai/test/pi-native-client.test.ts +++ b/packages/ai/test/pi-native-client.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; import { streamPiNative } from "../src/providers/pi-native-client"; import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "../src/types"; +import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; function sseBytes(events: AssistantMessageEvent[]): Uint8Array { const encoder = new TextEncoder(); @@ -329,7 +329,7 @@ describe("streamPiNative provider safety-stop provenance", () => { it("restores authority on a done-carried typed stop and never mints it for other messages", async () => { const fetchImpl: FetchImpl = (async () => - fakeResponse([{ type: "done", reason: "error", message: typedStop() }])) as FetchImpl; + fakeResponse([{ type: "done", reason: "stop", message: typedStop() }])) as FetchImpl; const model = fakeModel({ baseUrl: "http://localhost:4000" }); const result = await streamPiNative(model, baseContext, { apiKey: "k", fetch: fetchImpl }).result(); diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 217b49082a..25952b4cda 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -98,7 +98,10 @@ describe("provider safety-stop provenance authority", () => { // exports map; `./adapter-internals/*` is null there, so neither the // mint nor the capability is importable outside first-party relative // imports (#4777 review follow-up). - await expect(import("@gajae-code/ai/adapter-internals/provider-safety-stop")).rejects.toThrow(); + // Non-literal specifier so typecheck cannot resolve the blocked subpath; + // the point is runtime resolution failing closed. + const deepImport = "@gajae-code/ai/adapter-internals/provider-safety-stop"; + await expect(import(deepImport)).rejects.toThrow(); const manifest = (await import("../package.json", { with: { type: "json" } })).default; expect(manifest.exports["./adapter-internals/*"]).toBeNull(); }); From d973c2cf16d5556b43c5092647f4d8d7cbc0b842 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 14:52:49 +0000 Subject: [PATCH 07/26] fix(agent): expire safety-stop authority at dispatch and harden the strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three exact-head review findings on #4782 at dcc636b8: 1. Committed stops could re-enter later stream dispatches with a live authority mark. Once a stop is adjudicated, the committed assistant object is handed to the next — possibly custom — stream through convertToLlm; a live mark would let that stream re-use the authenticated object to forge a terminal failure. Expire both WeakSet marks at every dispatch entry (context messages and any transformed copy). The public surface gains revokeProviderSafetyStop, which can only remove authority, never grant it. 2. A frozen or Proxy-trapped final message turned the provenance strip into a run-aborting TypeError inside the sanitizer. The strip now rebuilds a plain mutable copy on failure, so an unauthenticated label still degrades to fallback instead of killing the run. 3. The strip only ran for stopReason 'error', so a forged errorKind 'provider_safety_stop' on a nominally successful response survived into the committed message and could skip session compaction checks downstream. The strip is now stopReason-independent — the field is reserved for adapter-minted terminal stops. Lore-id: 8e3b1f74 Constraint: no live authority mark may be exposed to any stream dispatch Constraint: public surface additions can only remove authority Tested: bun test packages/agent/test/managed-attempt-transaction.test.ts (74 pass); frozen/stop-reason tests verified failing on the pre-fix loop Tested: bun --cwd=packages/agent run check exit 0 Confidence: high Scope-risk: narrow Reversibility: trivial --- packages/agent/src/agent-loop.ts | 54 ++++-- .../test/managed-attempt-transaction.test.ts | 158 ++++++++++++++++++ .../adapter-internals/provider-safety-stop.ts | 13 ++ packages/ai/src/utils/provider-safety-stop.ts | 2 +- 4 files changed, 216 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 04a3bb7214..52365e8af4 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,6 +14,7 @@ import { EventStream, isProviderSafetyStopAuthenticated, isZodSchema, + revokeProviderSafetyStop, streamSimple, type ToolChoice, type ToolResultMessage, @@ -421,14 +422,41 @@ function promoteTypedEmptyResponseStop(message: AssistantMessage): void { * the managed snapshot shell clones it and before any retry/discard policy * reads it, so a forged label degrades to an ordinary (fallback-eligible) * error everywhere downstream — loop gates, session policy, and persistence. + * + * The label is stripped regardless of stopReason: the field is reserved for + * adapter-minted terminal stops, and downstream consumers (session compaction + * checks among them) read it without re-checking the error state, so a forged + * label on a nominally successful response must not survive either. A frozen + * or Proxy-trapped final message is rebuilt as a plain mutable copy instead of + * letting the strip abort the run. */ -function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): void { - if ( - message.stopReason === "error" && - message.errorKind === "provider_safety_stop" && - !isManagedProviderSafetyStopAuthenticated(message) - ) { +function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): AssistantMessage { + if (message.errorKind !== "provider_safety_stop" || isManagedProviderSafetyStopAuthenticated(message)) { + return message; + } + try { delete message.errorKind; + return message; + } catch { + const rebuilt: AssistantMessage = { ...message }; + delete rebuilt.errorKind; + return rebuilt; + } +} + +/** + * Expire residual terminal safety-stop authority before a dispatch exposes + * committed history to a stream. Once a stop has been adjudicated, its + * committed assistant message may be handed unchanged to a later — possibly + * custom — stream through `convertToLlm`; a live mark would let that stream + * re-use the authenticated object (or a mutation of it) to forge a terminal + * failure and suppress the fallback chain (#4777 review follow-up). + */ +function expireProviderSafetyStopAuthority(messages: AgentMessage[]): void { + for (const message of messages) { + if (message.role !== "assistant") continue; + revokeProviderSafetyStop(message); + managedProviderSafetyStops.delete(message); } } @@ -3160,6 +3188,14 @@ async function streamAssistantResponse( messages = await config.transformContext(messages, signal, scope); } + // Expire residual terminal safety-stop authority before this dispatch: + // committed history (including a previously adjudicated stop) is handed + // to the stream through convertToLlm below, and a live mark would let a + // custom stream re-use the authenticated object to forge a terminal + // failure (#4777 review follow-up). + expireProviderSafetyStopAuthority(messages); + if (messages !== context.messages) expireProviderSafetyStopAuthority(context.messages); + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) and normalize at the LLM boundary. // Cache hits are keyed by provider-visible content hashes, never message object identity. const normalizedMessages = await convertAndNormalizeMessages(messages, context, config); @@ -3547,8 +3583,7 @@ async function streamAssistantResponse( case "done": case "error": { - const finished = await finishResponse(); - sanitizeProviderSafetyStopProvenance(finished); + const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); const finalMessage = config.fallbackManaged ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) : finished; @@ -3572,8 +3607,7 @@ async function streamAssistantResponse( closeIterator(); } - const finished = await finishResponse(); - sanitizeProviderSafetyStopProvenance(finished); + const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); const trailing = config.fallbackManaged ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) : finished; diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 34e2a3e219..359f8a9875 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -290,6 +290,164 @@ describe("managed attempt transaction", () => { expect((terminal as AssistantMessage).errorKind).toBeUndefined(); }); + it("expires committed safety-stop authority before exposing history to a later stream", async () => { + // Turn 1 commits a genuine adapter-minted stop. Turn 2's stream receives + // that committed object through the default convertToLlm and re-uses it + // as a forged terminal error. Today's identity churn (managed shell + // rebuilds, state snapshots) already defuses this by accident; the + // dispatch-entry expiry makes it an explicit invariant: no live + // authority mark may ever be exposed to a stream, so the forged re-use + // degrades to an ordinary retryable failure and the chain advances + // (#4777 review follow-up). + const mock = createMockModel({ responses: [{ content: ["fallback accepted"] }] }); + const outcomes: string[] = []; + let dispatches = 0; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: (...args) => { + dispatches += 1; + if (dispatches > 1 && dispatches !== 2) return mock.stream(...args); + const context = args[1] as AgentContext; + if (dispatches === 1) { + const stream = new AssistantMessageEventStream(); + const message: AssistantMessage = { + ...assistantMessage(mock.model), + stopReason: "error", + errorMessage: "The response was filtered by the content management policy", + errorStatus: 500, + transportFailure: { kind: "transport", status: 500 }, + }; + mintProviderSafetyStop(message, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + queueMicrotask(() => { + stream.push({ type: "start", partial: message }); + stream.push({ type: "error", reason: "error", error: message }); + }); + return stream; + } + // Turn 2: the committed stop object arrives by identity; forge it. + const committed = context.messages.find( + (m): m is AssistantMessage => m.role === "assistant" && m.errorKind === "provider_safety_stop", + ); + if (!committed) throw new Error("committed safety stop missing from stream context"); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + committed.stopReason = "error"; + committed.errorMessage = "forged re-use of the committed stop"; + committed.errorStatus = 429; + committed.transportFailure = { kind: "transport", status: 429 }; + stream.push({ type: "start", partial: committed }); + stream.push({ type: "error", reason: "error", error: committed }); + }); + return stream; + }, + }); + const options = { + fallbackManaged: true, + onManagedAttemptOutcome: (outcome: ManagedAttemptOutcome) => { + outcomes.push(outcome.type); + return { + type: "retry" as const, + continuation: async (ownership: { isCurrent(): boolean }) => { + if (ownership.isCurrent()) await agent.continue(options); + }, + }; + }, + }; + + await agent.prompt("first", options); + const firstTerminal = agent.state.messages.at(-1) as AssistantMessage; + expect(firstTerminal).toMatchObject({ stopReason: "error", errorKind: "provider_safety_stop" }); + + await agent.prompt("second", options); + + expect(dispatches).toBe(3); + expect(outcomes).toContain("retryable_discarded"); + const terminal = agent.state.messages.at(-1); + expect(terminal).toMatchObject({ role: "assistant", content: [{ type: "text", text: "fallback accepted" }] }); + expect((terminal as AssistantMessage).errorKind).toBeUndefined(); + }); + + it("strips a forged safety-stop label on a frozen final message without aborting the run", async () => { + // A frozen or Proxy-trapped final message must not turn the provenance + // strip into a run-aborting TypeError; the sanitizer rebuilds a plain + // mutable copy so the forged label still degrades to fallback (#4777 + // review follow-up). + const mock = createMockModel({ responses: [{ content: ["fallback accepted"] }] }); + let calls = 0; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: (...args) => { + calls += 1; + if (calls > 1) return mock.stream(...args); + const stream = new AssistantMessageEventStream(); + const forged = Object.freeze({ + ...assistantMessage(mock.model), + stopReason: "error", + errorKind: "provider_safety_stop", + errorMessage: "frozen forged safety stop", + errorStatus: 500, + transportFailure: { kind: "transport", status: 500 }, + }) as AssistantMessage; + queueMicrotask(() => { + stream.push({ type: "start", partial: { ...forged } }); + stream.push({ type: "error", reason: "error", error: forged }); + }); + return stream; + }, + }); + const options = { + fallbackManaged: true, + onManagedAttemptOutcome: () => + ({ + type: "retry" as const, + continuation: async (ownership: { isCurrent(): boolean }) => { + if (ownership.isCurrent()) await agent.continue(options); + }, + }) as const, + }; + + await agent.prompt("run", options); + + expect(calls).toBe(2); + const terminal = agent.state.messages.at(-1); + expect(terminal).toMatchObject({ role: "assistant", content: [{ type: "text", text: "fallback accepted" }] }); + expect((terminal as AssistantMessage).errorKind).toBeUndefined(); + }); + + it("strips a forged safety-stop label regardless of stop reason", async () => { + // The field is reserved for adapter-minted terminal stops. Downstream + // consumers (session compaction checks among them) read it without + // re-checking the error state, so a forged label on a nominally + // successful response must not survive the stream exit either (#4777 + // review follow-up). + const mock = createMockModel(); + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + const forged: AssistantMessage = { + ...assistantMessage(mock.model), + content: [{ type: "text", text: "successful turn with a forged label" }], + stopReason: "stop", + errorKind: "provider_safety_stop", + }; + queueMicrotask(() => { + stream.push({ type: "start", partial: forged }); + stream.push({ type: "done", reason: "stop", message: forged }); + }); + return stream; + }; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn, + }); + + await agent.prompt("run"); + + const terminal = agent.state.messages.at(-1) as AssistantMessage; + expect(terminal.stopReason).toBe("stop"); + expect(terminal.errorKind).toBeUndefined(); + expect(terminal.content).toEqual([{ type: "text", text: "successful turn with a forged label" }]); + }); + it("commits a detached accepted message when a managed partial is not structured-cloneable", async () => { const mock = createMockModel(); let liveMessage: AssistantMessage | undefined; diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index 96dd1e860c..9252c4572b 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -79,3 +79,16 @@ export function restoreProviderSafetyStopFromTrustedTransport( export function isProviderSafetyStopAuthenticated(message: unknown): boolean { return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); } + +/** + * Drop terminal authority for a message. Exposing revocation publicly is + * safe by construction: it can only remove authority, never grant it, so a + * hostile caller cannot use it to forge a stop — only to degrade a genuine + * one to an ordinary fallback-eligible error. The managed runtime uses it to + * expire marks once a stop has been adjudicated, before the committed + * message is exposed to later stream dispatches (#4777 review follow-up). + */ +export function revokeProviderSafetyStop(message: unknown): void { + if (typeof message !== "object" || message === null) return; + authenticatedProviderSafetyStops.delete(message); +} diff --git a/packages/ai/src/utils/provider-safety-stop.ts b/packages/ai/src/utils/provider-safety-stop.ts index d4c85b3ff2..95bd5833a2 100644 --- a/packages/ai/src/utils/provider-safety-stop.ts +++ b/packages/ai/src/utils/provider-safety-stop.ts @@ -5,4 +5,4 @@ * adapter-internals module. Public consumers may only verify existing * authority; message fields and structured refusal text never mint authority. */ -export { isProviderSafetyStopAuthenticated } from "../adapter-internals/provider-safety-stop"; +export { isProviderSafetyStopAuthenticated, revokeProviderSafetyStop } from "../adapter-internals/provider-safety-stop"; From 3ed1d98fac779535d2a7ef8d3815911e8c471f2f Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 14:57:22 +0000 Subject: [PATCH 08/26] docs(changelog): record the #4782 fix-forward hardening entries Lore-id: c9a2e11b Confidence: high --- packages/agent/CHANGELOG.md | 1 + packages/ai/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 56709575c9..0ae6be95ad 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777). - Terminal safety-stop authority is now provenance-bound instead of data-bound: a provider or custom stream payload that self-labels `errorKind: "provider_safety_stop"` without the adapter-minted mark is stripped at the stream exit before any retry/discard gate or the managed snapshot shell reads it, so a compromised provider can no longer force refusal by naming the field (#4777 review). Authenticated first-party envelopes (structured refusal signals parsed by the anthropic, openai-completions, and google adapters) keep terminal treatment, and only the agent loop's module-private rebuild set carries that authority onto its own destination — clones, JSON/persistence round-trips, and re-emitted payloads are all unauthenticated. - Safety-stop minting is now limited to the package-private adapter capability, and public AI consumers cannot transfer authority from a genuine marked source to an arbitrary destination. The trailing stream-completion path also sanitizes provenance before rebuilding managed assistant messages, covering streams that end without a `done` or `error` event and keeping forged labels fallback-eligible (#4777 review). +- Safety-stop authority now expires at every stream dispatch entry: committed assistant messages (including a previously adjudicated stop) are handed to the next — possibly custom — stream through `convertToLlm`, and the dispatch-entry expiry guarantees no live authority mark is ever exposed to a stream, so re-use of a committed stop object cannot forge a terminal failure (#4777 review). The provenance strip is also stopReason-independent and rebuilds frozen or Proxy-trapped final messages as plain mutable copies, so a forged label on a nominally successful response cannot survive into the committed message (where it could skip session compaction checks) and cannot abort the run through a rejection trap. - A foreign error that self-declares a local failure kind no longer gets one either (#4618). `errorKind` and the structured `bufferOverflow` shape now come from a single identity-checked extractor (`managedLocalErrorDiagnostic`) used by both terminal-message producers — `managedFailureMessage` and the `Agent` run catch. Previously the shape was identity-gated but the label was not, so a provider or custom-stream failure carrying `errorKind: "local_buffer_overflow"` reached the parent receipt preview as `Local staging-buffer overflow; structured diagnostic unavailable.` and pointed whoever read it at the wrong subsystem. - Local diagnostic authority fields are no longer foreign-settable through the managed snapshot shell (#4618). `managedAssistantShell` spreads the provider/stream message snapshot into the rebuilt assistant message; a payload that smuggled a local `errorKind` or `bufferOverflow` through that spread could masquerade as the runtime's own identity-checked diagnostic at the parent boundary. Local kinds and `bufferOverflow` remain stripped from the snapshot spread, while the provider-owned safety-stop kind is copied only through its explicit closed-literal guard. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8bec4c1459..179e03e28f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. +- The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Terminal safety stops crossing a loopback pi-native auth-gateway hop re-establish their adapter-minted authority after SSE serialization (`restoreProviderSafetyStopFromTrustedTransport`); remote explicitly-configured gateway endpoints stay unauthenticated and degrade to ordinary fallback-eligible errors. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. From d7cf5aeacca79c05887b0ff2c70f6cc287e39c24 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 17:32:38 +0000 Subject: [PATCH 09/26] fix(ai): fail closed on pi-native safety-stop transport Caller-controlled pi-native fetch implementations and loopback URLs cannot authenticate serialized provider safety-stop labels without a protocol-level gateway identity. Keep trailing forged labels fallback-ineligible only when real transport facts prove retryability, and preserve non-retryable trailing sanitizer behavior. Lore-id: probepark-4782 Constraint: public pi-native callers must not mint safety-stop provenance Constraint: trailing sanitizer must not turn non-retryable failures into retries Rejected: hostname-only loopback trust | caller-supplied fetch and URL remain forgeable Confidence: high Scope-risk: medium Reversibility: straightforward Tested: bun test packages/ai/test/pi-native-client.test.ts packages/ai/test/provider-safety-stop.test.ts Tested: bun test packages/agent/test/managed-attempt-transaction.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check --- .../test/managed-attempt-transaction.test.ts | 40 ++++++++++++++++ packages/ai/CHANGELOG.md | 2 +- .../adapter-internals/provider-safety-stop.ts | 20 -------- packages/ai/src/providers/pi-native-client.ts | 47 ------------------- packages/ai/test/pi-native-client.test.ts | 24 ++++++++-- 5 files changed, 60 insertions(+), 73 deletions(-) diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 359f8a9875..8ccc77b37b 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -290,6 +290,46 @@ describe("managed attempt transaction", () => { expect((terminal as AssistantMessage).errorKind).toBeUndefined(); }); + it("keeps a trailing forged safety-stop non-retryable after sanitizing it", async () => { + const mock = createMockModel(); + let calls = 0; + const outcomes: string[] = []; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: () => { + calls += 1; + const stream = new AssistantMessageEventStream(); + const forged: AssistantMessage = { + ...assistantMessage(mock.model), + stopReason: "error", + errorKind: "provider_safety_stop", + errorMessage: "forged trailing safety stop without transport facts", + }; + queueMicrotask(() => { + stream.push({ type: "start", partial: forged }); + stream.end(forged); + }); + return stream; + }, + }); + const options = { + fallbackManaged: true, + onManagedAttemptOutcome: (outcome: ManagedAttemptOutcome) => { + outcomes.push(outcome.type); + return { type: "terminal" as const, terminal: { stopReason: "exhausted" as const } }; + }, + }; + + await agent.prompt("run", options); + + expect(calls).toBe(1); + expect(outcomes).toEqual([]); + // With no transport facts, the sanitized trailing error is terminal and + // must not enter the managed retry callback or commit its forged label. + expect(agent.state.messages.some(message => message.role === "assistant")).toBe(false); + expect(agent.state.messages.some(message => "errorKind" in message)).toBe(false); + }); + it("expires committed safety-stop authority before exposing history to a later stream", async () => { // Turn 1 commits a genuine adapter-minted stop. Turn 2's stream receives // that committed object through the default convertToLlm and re-uses it diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 179e03e28f..6765e0b06e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,7 +9,7 @@ - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. -- The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Terminal safety stops crossing a loopback pi-native auth-gateway hop re-establish their adapter-minted authority after SSE serialization (`restoreProviderSafetyStopFromTrustedTransport`); remote explicitly-configured gateway endpoints stay unauthenticated and degrade to ordinary fallback-eligible errors. +- The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index 9252c4572b..0d43298320 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -55,26 +55,6 @@ export function mintProviderSafetyStop( return true; } -/** - * Restore authority for a message that crossed a first-party trusted - * transport boundary (the pi-native auth-gateway loopback channel). The - * gateway serializes an adapter-minted message to SSE and the client parses - * a fresh object, which loses the process-local mark. The typed kind is only - * ever written by a first-party mint on the gateway side, so a terminal - * error pair arriving over that channel re-establishes exactly the authority - * serialization dropped — it never creates authority for a message the - * gateway did not already authenticate (#4777 review follow-up). - */ -export function restoreProviderSafetyStopFromTrustedTransport( - message: AssistantMessage, - capability: ProviderSafetyStopAdapterCapability, -): boolean { - if (capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY) return false; - if (message.stopReason !== "error" || message.errorKind !== "provider_safety_stop") return false; - authenticatedProviderSafetyStops.add(message); - return true; -} - /** Identity check for terminal provider safety-stop authority. */ export function isProviderSafetyStopAuthenticated(message: unknown): boolean { return typeof message === "object" && message !== null && authenticatedProviderSafetyStops.has(message); diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index 4eb69b1e33..58b890a4bd 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -16,10 +16,6 @@ */ import { readSseJson } from "@gajae-code/utils"; -import { - PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - restoreProviderSafetyStopFromTrustedTransport, -} from "../adapter-internals/provider-safety-stop"; import type { Api, AssistantMessage, @@ -115,32 +111,6 @@ function resolveStreamUrl(model: Model): string { return `${model.baseUrl.replace(/\/+$/, "")}/v1/pi/stream`; } -/** - * The trusted-transport boundary for restoring provider safety-stop authority: - * the default `gjc auth-gateway` bind is loopback (`127.0.0.1:4000`), so a - * terminal typed stop parsed from a loopback gateway response re-establishes - * the adapter-minted mark that SSE serialization dropped. A non-loopback - * gateway target is an explicitly configured remote endpoint — its payloads - * stay unauthenticated and degrade to ordinary fallback-eligible errors - * (#4777 review follow-up). - */ -function isLoopbackGatewayUrl(url: string): boolean { - try { - const parsed = new URL(url); - if (parsed.protocol !== "http:") return false; - const host = parsed.hostname.toLowerCase(); - return ( - host === "localhost" || - host === "[::1]" || - host === "::1" || - host === "127.0.0.1" || - /^127\.(?:[0-9]{1,3}\.){2}[0-9]{1,3}$/.test(host) - ); - } catch { - return false; - } -} - function buildHeaders(model: Model, apiKey: string | undefined): Record { const headers: Record = { "Content-Type": "application/json", @@ -191,7 +161,6 @@ export function streamPiNative( try { const url = resolveStreamUrl(model as Model); - const trustedTransport = isLoopbackGatewayUrl(url); const fetchImpl = options?.fetch ?? globalThis.fetch; const headers = buildHeaders(model as Model, options?.apiKey); const body = JSON.stringify({ @@ -218,22 +187,6 @@ export function streamPiNative( )) { if (event.type === "done" || event.type === "error") { sawTerminal = true; - // SSE serialization drops the adapter-minted authority mark; a - // loopback gateway hop is first-party trusted transport, so - // re-establish it there. Remote endpoints stay unauthenticated. - if (trustedTransport) { - if (event.type === "done") { - restoreProviderSafetyStopFromTrustedTransport( - event.message, - PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - ); - } else { - restoreProviderSafetyStopFromTrustedTransport( - event.error, - PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - ); - } - } } stream.push(event); // `stream.push` resolves `.result()` on `done`/`error`; subsequent diff --git a/packages/ai/test/pi-native-client.test.ts b/packages/ai/test/pi-native-client.test.ts index 9ccb59b54b..2435c5bec0 100644 --- a/packages/ai/test/pi-native-client.test.ts +++ b/packages/ai/test/pi-native-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { streamPiNative } from "../src/providers/pi-native-client"; +import { streamSimple } from "../src/stream"; import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "../src/types"; import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; @@ -308,17 +309,30 @@ describe("streamPiNative provider safety-stop provenance", () => { errorMessage: "Refusal (safety): policy violation", }); - it("restores adapter-minted authority for a typed stop from a loopback gateway", async () => { + it("does not mint authority from a caller-supplied fetch and loopback URL", async () => { const fetchImpl: FetchImpl = (async () => fakeResponse([{ type: "error", reason: "error", error: typedStop() }])) as FetchImpl; const model = fakeModel({ baseUrl: "http://127.0.0.1:4000" }); const result = await streamPiNative(model, baseContext, { apiKey: "k", fetch: fetchImpl }).result(); expect(result.errorKind).toBe("provider_safety_stop"); - expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); }); - it("does not authenticate a typed stop from a non-loopback gateway endpoint", async () => { + it("keeps the public streamSimple pi-native path fail-closed", async () => { + const fetchImpl: FetchImpl = (async () => + fakeResponse([{ type: "done", reason: "stop", message: typedStop() }])) as FetchImpl; + + const result = await streamSimple(fakeModel({ baseUrl: "http://127.0.0.1:4000" }), baseContext, { + apiKey: "k", + fetch: fetchImpl, + }).result(); + + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); + }); + + it("does not authenticate a typed stop from any serialized gateway endpoint", async () => { const fetchImpl: FetchImpl = (async () => fakeResponse([{ type: "error", reason: "error", error: typedStop() }])) as FetchImpl; @@ -327,13 +341,13 @@ describe("streamPiNative provider safety-stop provenance", () => { expect(isProviderSafetyStopAuthenticated(result)).toBe(false); }); - it("restores authority on a done-carried typed stop and never mints it for other messages", async () => { + it("never authenticates a done-carried typed stop after SSE serialization", async () => { const fetchImpl: FetchImpl = (async () => fakeResponse([{ type: "done", reason: "stop", message: typedStop() }])) as FetchImpl; const model = fakeModel({ baseUrl: "http://localhost:4000" }); const result = await streamPiNative(model, baseContext, { apiKey: "k", fetch: fetchImpl }).result(); - expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); const plainFetch: FetchImpl = (async () => fakeResponse([{ type: "done", reason: "stop", message: baseAssistant() }])) as FetchImpl; From d2e9cc081abba20a9cc86736031cdbf31b72ee0d Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 17:44:28 +0000 Subject: [PATCH 10/26] fix(agent): close remaining safety provenance seams Provider adapter fetch seams and managed metadata accessors were still able to bypass the review boundary. Reject caller-supplied fetches before minting adapter authority and rebuild managed metadata through guarded reads so hostile payloads remain contained. Lore-id: probepark-4782-review Constraint: public adapter fetches cannot mint safety-stop authority Constraint: hostile metadata must not abort managed attempts Rejected: trusting custom fetch responses | no provider contact proves no adapter provenance Confidence: high Scope-risk: medium Reversibility: straightforward Tested: bun test packages/ai/test/provider-safety-stop.test.ts packages/ai/test/pi-native-client.test.ts packages/agent/test/managed-attempt-transaction.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check --- packages/agent/CHANGELOG.md | 2 ++ packages/agent/src/agent-loop.ts | 10 +++++-- packages/ai/CHANGELOG.md | 1 + .../adapter-internals/provider-safety-stop.ts | 15 ++++++++-- packages/ai/src/providers/anthropic.ts | 1 + .../ai/src/providers/google-gemini-cli.ts | 14 +++++++-- packages/ai/src/providers/google-shared.ts | 11 +++++-- .../ai/src/providers/openai-completions.ts | 4 +-- packages/ai/test/provider-safety-stop.test.ts | 29 ++++++++++++++++++- 9 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0ae6be95ad..6c98527355 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- Managed assistant reconstruction now copies provider metadata through guarded property reads instead of an unguarded spread, so accessor-trapped metadata degrades without aborting the attempt or creating managed retry authority (#4777 review). + ### Fixed - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777). diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 52365e8af4..b09b1622d5 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1281,9 +1281,13 @@ function managedAssistantShell( stopReason === "error" && managedProperty(source, "errorKind") === "provider_safety_stop" ? ("provider_safety_stop" as const) : undefined; - const safeMetadata: Record = isManagedPlainRecord(detailed.snapshot) - ? { ...detailed.snapshot } - : {}; + const safeMetadata: Record = {}; + if (isManagedPlainRecord(detailed.snapshot)) { + for (const key of Object.keys(detailed.snapshot)) { + const metadata = managedProperty(detailed.snapshot, key); + if (metadata !== undefined) safeMetadata[key] = metadata; + } + } delete safeMetadata.errorMessage; delete safeMetadata.errorStatus; delete safeMetadata.transportFailure; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6765e0b06e..fd1a2e5587 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -28,6 +28,7 @@ ## [0.14.2] - 2026-08-20 ### Fixed +- Public provider adapter calls that inject a custom `fetch` can no longer mint terminal safety-stop authority from fabricated OpenAI, Anthropic, or Google responses; the adapter provenance boundary fails closed until a trusted invocation or authenticated transport exists (#4777 review). - Grok Build now gets the same 300s idle window as other long-turn providers, so turns no longer stall waiting on a shorter default. - `getCachedUsageReport` surfaces cached usage for API-key credentials, not only OAuth accounts (#4686). - The auth gateway accepts explicit `null` fields on openai-chat requests instead of rejecting the payload (#4667). diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index 0d43298320..497dd17937 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -41,15 +41,24 @@ const STRUCTURED_REFUSAL_SIGNALS: ReadonlySet = new Set([ /** * Mint terminal authority only from a first-party adapter parse site. The * capability is branded by a module-private symbol and is not available from - * the public `@gajae-code/ai` surface. An unrecognized structured signal fails - * closed, so adapter mistakes remain fallback-eligible. + * the public `@gajae-code/ai` surface. A caller-supplied fetch is also not a + * trusted adapter invocation: its response can be fabricated without any + * provider contact, so adapter call sites pass that seam explicitly and fail + * closed when it is present. An unrecognized structured signal fails closed, + * so adapter mistakes remain fallback-eligible. */ export function mintProviderSafetyStop( message: AssistantMessage, signal: string, capability: ProviderSafetyStopAdapterCapability, + callerFetch?: unknown, ): boolean { - if (capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY || !STRUCTURED_REFUSAL_SIGNALS.has(signal)) return false; + if ( + capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY || + callerFetch !== undefined || + !STRUCTURED_REFUSAL_SIGNALS.has(signal) + ) + return false; authenticatedProviderSafetyStops.add(message); message.errorKind = "provider_safety_stop"; return true; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ccbfc9c40c..57c776e021 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2476,6 +2476,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( output, stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, ); if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index d4b73c6b07..4369e00dea 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -572,7 +572,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( hasContent = true; // Adapter-minted terminal authority from the parsed // structured finish reason (#4777). - mintProviderSafetyStop(output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + output, + candidate.finishReason, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, + ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReasonString(candidate.finishReason); @@ -587,7 +592,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( hasContent = true; if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reason: adapter-minted authority (#4777). - mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + output, + blockReason, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, + ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index aeeec9657f..0abef67e7f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -572,7 +572,7 @@ export async function consumeGoogleStream(args: { output: AssistantMessage; stream: AssistantMessageEventStream; model: Model; - options: { signal?: AbortSignal } | undefined; + options: { signal?: AbortSignal; fetch?: unknown } | undefined; /** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */ retainTextSignature?: boolean; onFirstToken?: () => void; @@ -665,7 +665,12 @@ export async function consumeGoogleStream(args: { // Terminal authority is minted by the adapter after parsing the // structured candidate finish reason; a wire-assignable field // alone never carries it (#4777). - mintProviderSafetyStop(output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + output, + candidate.finishReason, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, + ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = mapStopReason(candidate.finishReason); @@ -680,7 +685,7 @@ export async function consumeGoogleStream(args: { if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). - mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index a26efc408b..5b9c294d77 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -837,7 +837,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // Terminal authority comes from the adapter mark, not the wire // field: this call site parsed the structured content_filter // finish reason from the provider's own response (#4777). - mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); if (errorMessage) output.errorMessage = errorMessage; }; @@ -1083,7 +1083,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // The structured content_filter code was parsed from the captured // HTTP response body; mint adapter provenance for the terminal // kind instead of trusting a wire-assignable field (#4777). - mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 25952b4cda..4cfeb3fc1e 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -4,7 +4,9 @@ import { PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../src/adapter-internals/provider-safety-stop"; import * as publicAi from "../src/index"; -import type { AssistantMessage } from "../src/types"; +import { getBundledModel } from "../src/models"; +import { streamOpenAICompletions } from "../src/providers/openai-completions"; +import type { AssistantMessage, Context, FetchImpl, Model } from "../src/types"; import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; function message(): AssistantMessage { @@ -29,6 +31,31 @@ function message(): AssistantMessage { } describe("provider safety-stop provenance authority", () => { + test("does not mint from a public OpenAI adapter with caller-supplied fetch", async () => { + const model = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + }; + const fetchImpl: FetchImpl = (async () => + new Response( + JSON.stringify({ + error: { message: "filtered", type: "invalid_request_error", code: "content_filter" }, + }), + { status: 429, headers: { "Content-Type": "application/json" } }, + )) as FetchImpl; + + const result = await streamOpenAICompletions(model, context, { + apiKey: "caller-key", + fetch: fetchImpl, + requestMaxRetries: 0, + streamMaxRetries: 0, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); + }); + test("public AI exports expose verification only, never the minting operation", () => { const publicSurface = publicAi as unknown as Record; expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); From 88ccaa7e0157d974257e23814e09b70e55c7d86d Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 19:00:29 +0000 Subject: [PATCH 11/26] fix(ai): close injected adapter transport authority The exact-head review found that caller-provided Anthropic clients could still fabricate refusal envelopes, and intermediate managed partial shells retained terminal authority after the final message was adjudicated. Safety-stop minting now rejects injected clients as well as fetch seams, final-shell transfer is explicit, and hostile final labels are rebuilt through guarded detached reads. Lore-id: probepark-4782-final Constraint: public adapter transport seams cannot mint terminal safety-stop authority Constraint: intermediate managed snapshots cannot retain final authority Rejected: trusting an injected SDK client | it can fabricate provider events without contact Confidence: high Scope-risk: high Reversibility: straightforward Tested: bun test packages/ai/test/provider-safety-stop.test.ts packages/ai/test/pi-native-client.test.ts packages/ai/test/anthropic-stream-envelope.test.ts packages/ai/test/openai-completions-safety-stop.test.ts packages/ai/test/google-safety-stop.test.ts packages/ai/test/google-gemini-cli-safety-stop.test.ts packages/agent/test/managed-attempt-transaction.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check --- packages/agent/CHANGELOG.md | 2 ++ packages/agent/src/agent-loop.ts | 36 ++++++++++++++----- packages/ai/CHANGELOG.md | 1 + .../adapter-internals/provider-safety-stop.ts | 15 ++++---- packages/ai/src/providers/anthropic.ts | 2 +- .../google-gemini-cli-safety-stop.test.ts | 24 +++++++------ packages/ai/test/provider-safety-stop.test.ts | 11 ++++++ 7 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6c98527355..23576de4b0 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- Managed fallback now transfers safety-stop authority only to the adjudicated final assistant shell; intermediate partial snapshots and hostile accessor-backed final messages cannot retain or bypass the provenance boundary (#4777 review). + - Managed assistant reconstruction now copies provider metadata through guarded property reads instead of an unguarded spread, so accessor-trapped metadata degrades without aborting the attempt or creating managed retry authority (#4777 review). diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index b09b1622d5..fc61154558 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -345,15 +345,19 @@ function managedContextOverflow(message: AssistantMessage, config: AgentLoopConf } /** Managed fallback owns retry policy; only attached typed transport facts may discard an attempt. */ -function managedProperty(value: unknown, key: string): unknown { - if (!value || typeof value !== "object") return undefined; +function managedPropertyRead(value: unknown, key: string): { ok: boolean; value: unknown } { + if (!value || typeof value !== "object") return { ok: true, value: undefined }; try { - return Reflect.get(value, key); + return { ok: true, value: Reflect.get(value, key) }; } catch { - return undefined; + return { ok: false, value: undefined }; } } +function managedProperty(value: unknown, key: string): unknown { + return managedPropertyRead(value, key).value; +} + function managedTransportFailure(failure: unknown) { const facts = managedProperty(failure, "transportFailure"); return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined; @@ -430,10 +434,21 @@ function promoteTypedEmptyResponseStop(message: AssistantMessage): void { * or Proxy-trapped final message is rebuilt as a plain mutable copy instead of * letting the strip abort the run. */ + function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): AssistantMessage { - if (message.errorKind !== "provider_safety_stop" || isManagedProviderSafetyStopAuthenticated(message)) { + const errorKindRead = managedPropertyRead(message, "errorKind"); + if ( + errorKindRead.ok && + (errorKindRead.value !== "provider_safety_stop" || isManagedProviderSafetyStopAuthenticated(message)) + ) { return message; } + const detached = managedAttemptSnapshotDetailed(message).snapshot; + if (isManagedPlainRecord(detached)) { + const rebuilt = { ...detached } as AssistantMessage; + delete rebuilt.errorKind; + return rebuilt; + } try { delete message.errorKind; return message; @@ -1216,6 +1231,7 @@ function managedAssistantShell( value: unknown, model: AgentLoopConfig["model"], degradedFieldDiagnostics: Set = new Set(), + transferSafetyStopAuthority = false, ): AssistantMessage { const detailed = managedAttemptSnapshotDetailed(value); const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined; @@ -1316,7 +1332,9 @@ function managedAssistantShell( // sanitize, so an unauthenticated label never reaches here. Mark the // runtime-owned destination only when this source is already authenticated; // no public AI API can perform this transfer (#4777 review). - if (errorKind && isManagedProviderSafetyStopAuthenticated(value)) managedProviderSafetyStops.add(rebuilt); + if (transferSafetyStopAuthority && errorKind && isManagedProviderSafetyStopAuthenticated(value)) { + managedProviderSafetyStops.add(rebuilt); + } return rebuilt; } @@ -2882,7 +2900,7 @@ async function runLoopBody( return; } if (attemptTransaction) { - message = managedAssistantShell(message, config.model); + message = managedAssistantShell(message, config.model, new Set(), true); const index = currentContext.messages.length - 1; if (index >= 0 && currentContext.messages[index]?.role === "assistant") { currentContext.messages[index] = message; @@ -3589,7 +3607,7 @@ async function streamAssistantResponse( case "error": { const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); const finalMessage = config.fallbackManaged - ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) + ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics, true) : finished; promoteTypedEmptyResponseStop(finalMessage); if (addedPartial) { @@ -3613,7 +3631,7 @@ async function streamAssistantResponse( const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); const trailing = config.fallbackManaged - ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics) + ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics, true) : finished; await finishChat(trailing); return trailing; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fd1a2e5587..256b33075f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -28,6 +28,7 @@ ## [0.14.2] - 2026-08-20 ### Fixed +- Safety-stop minting now fails closed for caller-controlled adapter transport seams, including injected Anthropic clients and custom fetch implementations, so public provider entry points cannot turn fabricated refusal envelopes into terminal authority (#4777 review). - Public provider adapter calls that inject a custom `fetch` can no longer mint terminal safety-stop authority from fabricated OpenAI, Anthropic, or Google responses; the adapter provenance boundary fails closed until a trusted invocation or authenticated transport exists (#4777 review). - Grok Build now gets the same 300s idle window as other long-turn providers, so turns no longer stall waiting on a shorter default. - `getCachedUsageReport` surfaces cached usage for API-key credentials, not only OAuth accounts (#4686). diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index 497dd17937..b5bedab31f 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -41,21 +41,22 @@ const STRUCTURED_REFUSAL_SIGNALS: ReadonlySet = new Set([ /** * Mint terminal authority only from a first-party adapter parse site. The * capability is branded by a module-private symbol and is not available from - * the public `@gajae-code/ai` surface. A caller-supplied fetch is also not a - * trusted adapter invocation: its response can be fabricated without any - * provider contact, so adapter call sites pass that seam explicitly and fail - * closed when it is present. An unrecognized structured signal fails closed, - * so adapter mistakes remain fallback-eligible. + * the public `@gajae-code/ai` surface. Caller-controlled transport seams are + * also not trusted adapter invocations: an injected fetch or SDK client can + * fabricate a refusal without any provider contact, so adapter call sites + * pass those seams explicitly and fail closed when one is present. An + * unrecognized structured signal fails closed, so adapter mistakes remain + * fallback-eligible. */ export function mintProviderSafetyStop( message: AssistantMessage, signal: string, capability: ProviderSafetyStopAdapterCapability, - callerFetch?: unknown, + callerTransport?: unknown, ): boolean { if ( capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY || - callerFetch !== undefined || + callerTransport !== undefined || !STRUCTURED_REFUSAL_SIGNALS.has(signal) ) return false; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 57c776e021..054c0efb50 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2476,7 +2476,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( output, stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - options?.fetch, + options?.fetch ?? options?.client, ); if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); diff --git a/packages/ai/test/google-gemini-cli-safety-stop.test.ts b/packages/ai/test/google-gemini-cli-safety-stop.test.ts index ea7555f52c..aa734fb156 100644 --- a/packages/ai/test/google-gemini-cli-safety-stop.test.ts +++ b/packages/ai/test/google-gemini-cli-safety-stop.test.ts @@ -35,16 +35,20 @@ function createSseResponseWithUrl(chunks: unknown[]): Response { async function streamResponse(provider: GeminiCliProvider, chunks: unknown[]) { let requestCount = 0; - const stream = streamGoogleGeminiCli(createModel(provider), context, { - apiKey: JSON.stringify({ token: "token", projectId: "project" }), - fetch: async () => { - requestCount += 1; - return createSseResponseWithUrl(chunks); - }, - }); - - const events = await collectEvents(stream); - return { events, requestCount, result: await stream.result() }; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + requestCount += 1; + return createSseResponseWithUrl(chunks); + }) as unknown as typeof fetch; + try { + const stream = streamGoogleGeminiCli(createModel(provider), context, { + apiKey: JSON.stringify({ token: "token", projectId: "project" }), + }); + const events = await collectEvents(stream); + return { events, requestCount, result: await stream.result() }; + } finally { + globalThis.fetch = originalFetch; + } } describe("Google Gemini CLI safety stops", () => { diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 4cfeb3fc1e..0fb69b548d 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -83,6 +83,17 @@ describe("provider safety-stop provenance authority", () => { expect(isProviderSafetyStopAuthenticated(unmarked)).toBe(false); }); + test("fails closed when a caller controls the adapter transport seam", () => { + for (const callerTransport of [() => undefined, {}]) { + const forged = message(); + expect( + mintProviderSafetyStop(forged, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, callerTransport), + ).toBe(false); + expect(forged.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(forged)).toBe(false); + } + }); + test("a structurally forged capability cannot mint authority", () => { const forged = message(); const forgedCapability = {} as Parameters[2]; From 0cee0200ab1116fcc521fab054ba741dc4425b68 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 19:14:12 +0000 Subject: [PATCH 12/26] fix(ai): close selected Google fetch provenance seam Codex found that Google prepare plans can select a caller fetch without populating options.fetch, and hostile Proxy traps could still reach the sanitizer spread fallback. Track the selected fetch explicitly and rebuild through guarded managed fields when detached snapshots are not records. Lore-id: probepark-4782-final-2 Constraint: prepare-selected transport is caller-controlled Constraint: hostile final messages must degrade without invoking proxy traps Rejected: infer trust from options.fetch alone | prepare can replace the transport Confidence: high Scope-risk: high Reversibility: straightforward Tested: bun test packages/ai/test/google-safety-stop.test.ts packages/ai/test/google-gemini-cli-safety-stop.test.ts packages/ai/test/provider-safety-stop.test.ts packages/agent/test/managed-attempt-transaction.test.ts Tested: bun --cwd=packages/ai run check:types Tested: bun --cwd=packages/agent run check:types --- packages/agent/src/agent-loop.ts | 18 +++---- .../test/managed-attempt-transaction.test.ts | 22 +++++--- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/google-shared.ts | 8 +-- packages/ai/test/google-safety-stop.test.ts | 52 ++++++++++++++----- 5 files changed, 67 insertions(+), 34 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fc61154558..5ffc39e604 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -435,7 +435,10 @@ function promoteTypedEmptyResponseStop(message: AssistantMessage): void { * letting the strip abort the run. */ -function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): AssistantMessage { +function sanitizeProviderSafetyStopProvenance( + message: AssistantMessage, + model: AgentLoopConfig["model"], +): AssistantMessage { const errorKindRead = managedPropertyRead(message, "errorKind"); if ( errorKindRead.ok && @@ -449,14 +452,7 @@ function sanitizeProviderSafetyStopProvenance(message: AssistantMessage): Assist delete rebuilt.errorKind; return rebuilt; } - try { - delete message.errorKind; - return message; - } catch { - const rebuilt: AssistantMessage = { ...message }; - delete rebuilt.errorKind; - return rebuilt; - } + return managedAssistantShell(message, model); } /** @@ -3605,7 +3601,7 @@ async function streamAssistantResponse( case "done": case "error": { - const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); + const finished = sanitizeProviderSafetyStopProvenance(await finishResponse(), config.model); const finalMessage = config.fallbackManaged ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics, true) : finished; @@ -3629,7 +3625,7 @@ async function streamAssistantResponse( closeIterator(); } - const finished = sanitizeProviderSafetyStopProvenance(await finishResponse()); + const finished = sanitizeProviderSafetyStopProvenance(await finishResponse(), config.model); const trailing = config.fallbackManaged ? managedAssistantShell(finished, config.model, managedDegradedFieldDiagnostics, true) : finished; diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 8ccc77b37b..7628660828 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -407,11 +407,11 @@ describe("managed attempt transaction", () => { expect((terminal as AssistantMessage).errorKind).toBeUndefined(); }); - it("strips a forged safety-stop label on a frozen final message without aborting the run", async () => { - // A frozen or Proxy-trapped final message must not turn the provenance - // strip into a run-aborting TypeError; the sanitizer rebuilds a plain - // mutable copy so the forged label still degrades to fallback (#4777 - // review follow-up). + it("strips a forged safety-stop label on a hostile Proxy without aborting the run", async () => { + // A Proxy whose deleteProperty and ownKeys traps reject must not turn the + // provenance strip into a run-aborting TypeError; the sanitizer rebuilds + // through guarded fields so the forged label still degrades to fallback + // (#4777 review follow-up). const mock = createMockModel({ responses: [{ content: ["fallback accepted"] }] }); let calls = 0; const agent = new Agent({ @@ -420,16 +420,24 @@ describe("managed attempt transaction", () => { calls += 1; if (calls > 1) return mock.stream(...args); const stream = new AssistantMessageEventStream(); - const forged = Object.freeze({ + const base: AssistantMessage = { ...assistantMessage(mock.model), stopReason: "error", errorKind: "provider_safety_stop", errorMessage: "frozen forged safety stop", errorStatus: 500, transportFailure: { kind: "transport", status: 500 }, + }; + const forged = new Proxy(base, { + deleteProperty: () => { + throw new Error("delete blocked"); + }, + ownKeys: () => { + throw new Error("enumeration blocked"); + }, }) as AssistantMessage; queueMicrotask(() => { - stream.push({ type: "start", partial: { ...forged } }); + stream.push({ type: "start", partial: base }); stream.push({ type: "error", reason: "error", error: forged }); }); return stream; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 256b33075f..312ccf072f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -29,6 +29,7 @@ ### Fixed - Safety-stop minting now fails closed for caller-controlled adapter transport seams, including injected Anthropic clients and custom fetch implementations, so public provider entry points cannot turn fabricated refusal envelopes into terminal authority (#4777 review). +- Google adapter safety-stop minting now tracks the fetch selected by caller-supplied `prepare()` plans, preventing a forged safety chunk from becoming terminal authority when `options.fetch` is absent. - Public provider adapter calls that inject a custom `fetch` can no longer mint terminal safety-stop authority from fabricated OpenAI, Anthropic, or Google responses; the adapter provenance boundary fails closed until a trusted invocation or authenticated transport exists (#4777 review). - Grok Build now gets the same 300s idle window as other long-turn providers, so turns no longer stall waiting on a shorter default. - `getCachedUsageReport` surfaces cached usage for API-key credentials, not only OAuth accounts (#4686). diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 0abef67e7f..079daaafdd 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -573,11 +573,12 @@ export async function consumeGoogleStream(args: { stream: AssistantMessageEventStream; model: Model; options: { signal?: AbortSignal; fetch?: unknown } | undefined; + callerFetch?: unknown; /** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */ retainTextSignature?: boolean; onFirstToken?: () => void; }): Promise { - const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args; + const { googleStream, output, stream, model, options, callerFetch, retainTextSignature, onFirstToken } = args; const blocks = output.content; const blockIndex = () => blocks.length - 1; let currentBlock: TextContent | ThinkingContent | null = null; @@ -669,7 +670,7 @@ export async function consumeGoogleStream(args: { output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - options?.fetch, + callerFetch, ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { @@ -685,7 +686,7 @@ export async function consumeGoogleStream(args: { if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). - mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); + mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, callerFetch); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; @@ -978,6 +979,7 @@ export function streamGoogleGenAI { firstTokenTime = Date.now(); diff --git a/packages/ai/test/google-safety-stop.test.ts b/packages/ai/test/google-safety-stop.test.ts index fbe7c736dd..7aa828b8c6 100644 --- a/packages/ai/test/google-safety-stop.test.ts +++ b/packages/ai/test/google-safety-stop.test.ts @@ -78,23 +78,49 @@ const promptBlockReasonFixtures = { async function streamGoogleResponse(response: unknown | unknown[], api: GoogleStreamApi = "google-generative-ai") { const model = createBaseModel(api); - const stream = streamGoogleGenAI({ - model, - api, - options: undefined, - prepare: () => ({ - params: { model: model.id, contents: [] }, - url: "https://google.example.test/stream", - headers: {}, - fetch: async () => createSseResponse(Array.isArray(response) ? response : [response]), - }), - }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + createSseResponse(Array.isArray(response) ? response : [response])) as unknown as typeof fetch; + try { + const stream = streamGoogleGenAI({ + model, + api, + options: undefined, + prepare: () => ({ + params: { model: model.id, contents: [] }, + url: "https://google.example.test/stream", + headers: {}, + }), + }); - await collectEvents(stream); - return stream.result(); + await collectEvents(stream); + return stream.result(); + } finally { + globalThis.fetch = originalFetch; + } } describe("Google safety stops", () => { + it("does not authenticate a safety chunk from caller-selected prepare fetch", async () => { + const model = createBaseModel("google-generative-ai"); + const stream = streamGoogleGenAI({ + model, + api: "google-generative-ai", + options: undefined, + prepare: () => ({ + params: { model: model.id, contents: [] }, + url: "https://google.example.test/stream", + headers: {}, + fetch: async () => createSseResponse([{ candidates: [{ finishReason: "SAFETY" }] }]), + }), + }); + + await collectEvents(stream); + const result = await stream.result(); + expect(result.stopReason).toBe("error"); + expect(result.errorKind).toBeUndefined(); + }); + it("classifies the exhaustive candidate finish-reason partition", async () => { for (const finishReason of candidateFinishReasonFixtures.commonSafety) { const result = await streamGoogleResponse({ candidates: [{ finishReason }] }); From 57126a8846a0115f09968801e81c47aec0dcfa04 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 19:46:23 +0000 Subject: [PATCH 13/26] fix(ai): bind safety minting to runtime dispatch The exact-head review found that absent transport overrides are not proof of provenance: a caller-controlled model can redirect ambient fetch to a fabricated refusal. Adapter minting now requires a module-branded runtime invocation token, and managed authority is revoked before context transforms and after final-shell transfer. Lore-id: probepark-4782-final-3 Constraint: caller-controlled models cannot mint terminal safety-stop authority Constraint: retained source identities cannot survive adjudication Rejected: treating absent fetch/client overrides as trust | cloned models can redirect the ambient transport Confidence: high Scope-risk: high Reversibility: straightforward Tested: 211 focused tests passed across AI, agent, and coding-agent safety suites Tested: package typechecks and checks passed with two pre-existing AI lint infos --- packages/agent/src/agent-loop.ts | 8 +- .../test/managed-attempt-transaction.test.ts | 25 +++++- .../adapter-internals/provider-safety-stop.ts | 37 +++++++++ packages/ai/src/providers/anthropic.ts | 2 + .../ai/src/providers/google-gemini-cli.ts | 3 + packages/ai/src/providers/google-shared.ts | 25 +++++- .../ai/src/providers/openai-completions.ts | 17 +++- packages/ai/src/stream.ts | 9 ++- .../ai/test/anthropic-stream-envelope.test.ts | 79 +++++++++++-------- .../google-gemini-cli-safety-stop.test.ts | 5 +- packages/ai/test/google-safety-stop.test.ts | 3 +- .../openai-completions-safety-stop.test.ts | 19 +++-- packages/ai/test/provider-safety-stop.test.ts | 50 ++++++++++-- .../agent-session-resilient-retry.test.ts | 9 ++- .../provider-safety-stop-hint.e2e.test.ts | 9 ++- 15 files changed, 239 insertions(+), 61 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5ffc39e604..d7de6d4510 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1330,6 +1330,8 @@ function managedAssistantShell( // no public AI API can perform this transfer (#4777 review). if (transferSafetyStopAuthority && errorKind && isManagedProviderSafetyStopAuthenticated(value)) { managedProviderSafetyStops.add(rebuilt); + revokeProviderSafetyStop(value); + if (typeof value === "object" && value !== null) managedProviderSafetyStops.delete(value); } return rebuilt; } @@ -3202,11 +3204,15 @@ async function streamAssistantResponse( const managedDegradedFieldDiagnostics = new Set(); // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; + // Revoke before invoking any caller-controlled transform so it cannot retain + // a live authenticated object and restore its role for a later custom stream. + expireProviderSafetyStopAuthority(messages); + if (messages !== context.messages) expireProviderSafetyStopAuthority(context.messages); if (config.transformContext) { messages = await config.transformContext(messages, signal, scope); } - // Expire residual terminal safety-stop authority before this dispatch: + // Expire residual terminal safety-stop authority again after the transform: // committed history (including a previously adjudicated stop) is handed // to the stream through convertToLlm below, and a live mark would let a // custom stream re-use the authenticated object to forge a terminal diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 7628660828..a859c8b066 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -16,6 +16,7 @@ import { logger } from "@gajae-code/utils"; import { mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, } from "../../ai/src/adapter-internals/provider-safety-stop"; /** @@ -145,7 +146,13 @@ describe("managed attempt transaction", () => { ...(errorKind ? { errorKind } : {}), }; if (authenticated) { - mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + message, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ); } queueMicrotask(() => { stream.push({ type: "start", partial: message }); @@ -184,7 +191,13 @@ describe("managed attempt transaction", () => { errorStatus: 500, transportFailure: { kind: "transport", status: 500 }, }; - mintProviderSafetyStop(message, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + message, + "content_filter", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ); queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); @@ -357,7 +370,13 @@ describe("managed attempt transaction", () => { errorStatus: 500, transportFailure: { kind: "transport", status: 500 }, }; - mintProviderSafetyStop(message, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + message, + "content_filter", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ); queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index b5bedab31f..f65b7ceef7 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -2,6 +2,8 @@ import type { AssistantMessage } from "../types"; /** This module is intentionally outside the package export map. */ const PROVIDER_SAFETY_STOP_ADAPTER_BRAND = Symbol("provider-safety-stop-adapter-brand"); +const PROVIDER_SAFETY_STOP_INVOCATION_BRAND = Symbol("provider-safety-stop-invocation-brand"); +const PROVIDER_SAFETY_STOP_INVOCATION_KEY = Symbol("provider-safety-stop-invocation"); export type ProviderSafetyStopAdapterCapability = { readonly [PROVIDER_SAFETY_STOP_ADAPTER_BRAND]: true; @@ -12,6 +14,39 @@ export const PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY = Object.freeze({ [PROVIDER_SAFETY_STOP_ADAPTER_BRAND]: true, }) as ProviderSafetyStopAdapterCapability; +export type ProviderSafetyStopAdapterInvocation = { + readonly [PROVIDER_SAFETY_STOP_INVOCATION_BRAND]: true; +}; + +export const PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION = Object.freeze({ + [PROVIDER_SAFETY_STOP_INVOCATION_BRAND]: true, +}) as ProviderSafetyStopAdapterInvocation; + +function hasCallerTransport(options: object): boolean { + try { + return Reflect.get(options, "fetch") !== undefined || Reflect.get(options, "client") !== undefined; + } catch { + return true; + } +} + +/** Attach runtime-owned adapter authority only when no caller transport seam is present. */ +export function withProviderSafetyStopAdapterInvocation(options: T): T { + if (hasCallerTransport(options)) return options; + return { ...options, [PROVIDER_SAFETY_STOP_INVOCATION_KEY]: PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION } as T; +} + +export function isProviderSafetyStopAdapterInvocation(value: unknown): ProviderSafetyStopAdapterInvocation | undefined { + if (!value || typeof value !== "object") return undefined; + try { + return Reflect.get(value, PROVIDER_SAFETY_STOP_INVOCATION_KEY) === PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION + ? PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION + : undefined; + } catch { + return undefined; + } +} + const authenticatedProviderSafetyStops = new WeakSet(); /** @@ -53,10 +88,12 @@ export function mintProviderSafetyStop( signal: string, capability: ProviderSafetyStopAdapterCapability, callerTransport?: unknown, + adapterInvocation?: ProviderSafetyStopAdapterInvocation, ): boolean { if ( capability !== PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY || callerTransport !== undefined || + adapterInvocation !== PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION || !STRUCTURED_REFUSAL_SIGNALS.has(signal) ) return false; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 054c0efb50..aba4e6c977 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -21,6 +21,7 @@ import { readSseEvents, } from "@gajae-code/utils"; import { + isProviderSafetyStopAdapterInvocation, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../adapter-internals/provider-safety-stop"; @@ -2477,6 +2478,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch ?? options?.client, + isProviderSafetyStopAdapterInvocation(options), ); if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 4369e00dea..3ad8ce421e 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -7,6 +7,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { scheduler } from "node:timers/promises"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@gajae-code/utils"; import { + isProviderSafetyStopAdapterInvocation, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../adapter-internals/provider-safety-stop"; @@ -577,6 +578,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch, + isProviderSafetyStopAdapterInvocation(options), ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { @@ -597,6 +599,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch, + isProviderSafetyStopAdapterInvocation(options), ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 079daaafdd..cb3d15006f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -3,7 +3,9 @@ */ import { extractHttpStatusFromError, readJsonl, readSseJson } from "@gajae-code/utils"; +import type { ProviderSafetyStopAdapterInvocation } from "../adapter-internals/provider-safety-stop"; import { + isProviderSafetyStopAdapterInvocation, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../adapter-internals/provider-safety-stop"; @@ -574,11 +576,22 @@ export async function consumeGoogleStream(args: { model: Model; options: { signal?: AbortSignal; fetch?: unknown } | undefined; callerFetch?: unknown; + adapterInvocation?: ProviderSafetyStopAdapterInvocation; /** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */ retainTextSignature?: boolean; onFirstToken?: () => void; }): Promise { - const { googleStream, output, stream, model, options, callerFetch, retainTextSignature, onFirstToken } = args; + const { + googleStream, + output, + stream, + model, + options, + callerFetch, + adapterInvocation, + retainTextSignature, + onFirstToken, + } = args; const blocks = output.content; const blockIndex = () => blocks.length - 1; let currentBlock: TextContent | ThinkingContent | null = null; @@ -671,6 +684,7 @@ export async function consumeGoogleStream(args: { candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, callerFetch, + adapterInvocation, ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { @@ -686,7 +700,13 @@ export async function consumeGoogleStream(args: { if (isGooglePromptSafetyStopReason(blockReason)) { // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). - mintProviderSafetyStop(output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, callerFetch); + mintProviderSafetyStop( + output, + blockReason, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + callerFetch, + adapterInvocation, + ); output.stopReason = "error"; } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { output.stopReason = "error"; @@ -980,6 +1000,7 @@ export function streamGoogleGenAI { firstTokenTime = Date.now(); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 5b9c294d77..ab38f633b0 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -11,6 +11,7 @@ import type { } from "openai/resources/chat/completions"; import packageJson from "../../package.json" with { type: "json" }; import { + isProviderSafetyStopAdapterInvocation, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../adapter-internals/provider-safety-stop"; @@ -837,7 +838,13 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // Terminal authority comes from the adapter mark, not the wire // field: this call site parsed the structured content_filter // finish reason from the provider's own response (#4777). - mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); + mintProviderSafetyStop( + output, + "content_filter", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, + isProviderSafetyStopAdapterInvocation(options), + ); if (errorMessage) output.errorMessage = errorMessage; }; @@ -1083,7 +1090,13 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // The structured content_filter code was parsed from the captured // HTTP response body; mint adapter provenance for the terminal // kind instead of trusting a wire-assignable field (#4777). - mintProviderSafetyStop(output, "content_filter", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch); + mintProviderSafetyStop( + output, + "content_filter", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + options?.fetch, + isProviderSafetyStopAdapterInvocation(options), + ); } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 265ea86bd7..9bca18b8af 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -7,6 +7,7 @@ import { extractHttpStatusFromError, getTrustedHomeDir, } from "@gajae-code/utils"; +import { withProviderSafetyStopAdapterInvocation } from "./adapter-internals/provider-safety-stop"; import { assertManagedAttempt, classifyFallbackTrigger, type TransportFailureFacts } from "./utils/fallback-transport"; const managedAttemptValidated = Symbol("managedAttemptValidated"); @@ -339,7 +340,11 @@ export function stream( // Vertex AI uses Application Default Credentials, not API keys if (model.api === "google-vertex") { - return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions); + return streamGoogleVertex( + model as Model<"google-vertex">, + context, + withProviderSafetyStopAdapterInvocation((options || {}) as GoogleVertexOptions), + ); } else if (model.api === "bedrock-converse-stream") { // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile. return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions); @@ -355,7 +360,7 @@ export function stream( if (!apiKey) { throw new Error(formatMissingApiKeyError(model.provider)); } - const providerOptions = { ...options, apiKey }; + const providerOptions = withProviderSafetyStopAdapterInvocation({ ...options, apiKey }); const api: Api = model.api; switch (api) { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 04bc256184..803e61739d 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,10 +1,21 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; import { Messages } from "@anthropic-ai/sdk/resources/messages/messages"; +import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; import { Effort } from "../src/model-thinking"; -import { applyClaudeToolPrefix, streamAnthropic, stripClaudeToolPrefix } from "../src/providers/anthropic"; +import { + applyClaudeToolPrefix, + streamAnthropic as streamAnthropicProvider, + stripClaudeToolPrefix, +} from "../src/providers/anthropic"; import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; +type AnthropicStreamOptions = NonNullable[2]>; + +function trustedStreamAnthropic(model: Model<"anthropic-messages">, context: Context, options: AnthropicStreamOptions) { + return streamAnthropicProvider(model, context, withProviderSafetyStopAdapterInvocation(options)); +} + const model: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", @@ -248,7 +259,7 @@ describe("anthropic stream envelope handling", () => { () => createMockRequest(createTextSuccessEvents("hello", { duplicateMessageStart: true })) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -286,7 +297,7 @@ describe("anthropic stream envelope handling", () => { ]) as never; }); - const stream = streamAnthropic(summarizedModel, context, { apiKey: "sk-ant-test", thinkingEnabled: true }); + const stream = trustedStreamAnthropic(summarizedModel, context, { apiKey: "sk-ant-test", thinkingEnabled: true }); const events: AssistantMessageEvent[] = []; for await (const event of stream) events.push(event); @@ -322,7 +333,7 @@ describe("anthropic stream envelope handling", () => { ]) as never; }); - const stream = streamAnthropic(unsupportedAdaptiveModel, context, { + const stream = trustedStreamAnthropic(unsupportedAdaptiveModel, context, { apiKey: "sk-ant-test", thinkingEnabled: true, }); @@ -382,7 +393,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -433,7 +444,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -497,7 +508,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -548,7 +559,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -601,7 +612,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -633,7 +644,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEventsWithPreamble("hello", [{ type: "ping" }])) as never; }); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -660,7 +671,7 @@ describe("anthropic stream envelope handling", () => { ) as never; }); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -688,7 +699,7 @@ describe("anthropic stream envelope handling", () => { }); vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -728,7 +739,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEvents(attempt === 2 ? "recovered" : "later")) as never; }); - const stream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); + const stream = trustedStreamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -746,7 +757,7 @@ describe("anthropic stream envelope handling", () => { ?.strictToolsDisabled, ).toBe(true); - const nextStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); + const nextStream = trustedStreamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); const nextEvents: AssistantMessageEvent[] = []; for await (const event of nextStream) { nextEvents.push(event); @@ -781,7 +792,7 @@ describe("anthropic stream envelope handling", () => { return createRejectedMockRequest(createOtherInvalidRequestError()) as never; }); - const stream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); + const stream = trustedStreamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -845,7 +856,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEvents("recovered")) as never; }); - const stream = streamAnthropic(gatewayModel, toolLoopContext, { + const stream = trustedStreamAnthropic(gatewayModel, toolLoopContext, { apiKey: "sk-ant-test", providerSessionState, }); @@ -870,7 +881,7 @@ describe("anthropic stream envelope handling", () => { // A later turn in the same session starts from the reduced budget instead of // re-triggering the rejection. - const nextStream = streamAnthropic(gatewayModel, toolLoopContext, { + const nextStream = trustedStreamAnthropic(gatewayModel, toolLoopContext, { apiKey: "sk-ant-test", providerSessionState, }); @@ -900,7 +911,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEvents("recovered")) as never; }); - const stream = streamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test", providerSessionState }); + const stream = trustedStreamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test", providerSessionState }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -936,7 +947,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEvents("recovered")) as never; }); - const stream = streamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -963,7 +974,7 @@ describe("anthropic stream envelope handling", () => { return createRejectedMockRequest(createOtherInvalidRequestError()) as never; }); - const stream = streamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test", providerSessionState }); + const stream = trustedStreamAnthropic(gatewayModel, context, { apiKey: "sk-ant-test", providerSessionState }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -987,7 +998,7 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createMalformedToolUseEvents()) as never; }); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -1020,7 +1031,7 @@ describe("anthropic stream envelope handling", () => { ) as never, ); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -1039,7 +1050,7 @@ describe("anthropic stream envelope handling", () => { ); vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(incompleteFrames) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -1067,7 +1078,7 @@ describe("anthropic stream envelope handling", () => { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -1099,7 +1110,7 @@ describe("anthropic stream envelope handling", () => { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -1144,7 +1155,7 @@ describe("anthropic stream envelope handling", () => { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); @@ -1181,7 +1192,7 @@ describe("anthropic stream envelope handling", () => { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(sensitiveEvents) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -1227,7 +1238,7 @@ describe("anthropic stream envelope handling", () => { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(eventsAfterSafety) as never); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const observedEvents: AssistantMessageEvent[] = []; for await (const event of stream) { observedEvents.push(event); @@ -1279,7 +1290,7 @@ describe("anthropic stream envelope handling", () => { }); vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); - const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(model, context, { apiKey: "sk-ant-test" }); const observedEvents: AssistantMessageEvent[] = []; for await (const event of stream) { observedEvents.push(event); @@ -1310,13 +1321,13 @@ describe("anthropic stream envelope handling", () => { return createMockRequest(createTextSuccessEvents("ok")) as never; }); - const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" }); + const eagerStream = trustedStreamAnthropic(model, toolContext, { apiKey: "sk-ant-test" }); for await (const _ of eagerStream) { // drain stream } await eagerStream.result(); - const disabledStream = streamAnthropic( + const disabledStream = trustedStreamAnthropic( { ...model, compat: { supportsEagerToolInputStreaming: false } }, toolContext, { apiKey: "sk-ant-test" }, @@ -1350,7 +1361,7 @@ describe("anthropic stream envelope handling", () => { baseUrl: "https://proxy.example.com/anthropic", }, ]) { - const stream = streamAnthropic(testModel, context, { + const stream = trustedStreamAnthropic(testModel, context, { apiKey: "sk-ant-test", cacheRetention: "long", }); @@ -1403,7 +1414,7 @@ describe("anthropic stream envelope handling", () => { }, ]) { // No cacheRetention passed: the provider default should drive the TTL. - const stream = streamAnthropic(testModel, context, { apiKey: "sk-ant-test" }); + const stream = trustedStreamAnthropic(testModel, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -1468,7 +1479,7 @@ describe("anthropic stream envelope handling", () => { ]) as never, ); - const stream = streamAnthropic(thinkingModel, context, { apiKey: "sk-ant-test", thinkingEnabled: true }); + const stream = trustedStreamAnthropic(thinkingModel, context, { apiKey: "sk-ant-test", thinkingEnabled: true }); const events: AssistantMessageEvent[] = []; for await (const event of stream) events.push(event); const result = await stream.result(); diff --git a/packages/ai/test/google-gemini-cli-safety-stop.test.ts b/packages/ai/test/google-gemini-cli-safety-stop.test.ts index aa734fb156..a1529d7717 100644 --- a/packages/ai/test/google-gemini-cli-safety-stop.test.ts +++ b/packages/ai/test/google-gemini-cli-safety-stop.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; import { streamGoogleGeminiCli } from "../src/providers/google-gemini-cli"; import type { Context, Model } from "../src/types"; import { collectEvents, createSseResponse } from "./openai-tool-choice-test-helpers"; @@ -42,7 +43,9 @@ async function streamResponse(provider: GeminiCliProvider, chunks: unknown[]) { }) as unknown as typeof fetch; try { const stream = streamGoogleGeminiCli(createModel(provider), context, { - apiKey: JSON.stringify({ token: "token", projectId: "project" }), + ...withProviderSafetyStopAdapterInvocation({ + apiKey: JSON.stringify({ token: "token", projectId: "project" }), + }), }); const events = await collectEvents(stream); return { events, requestCount, result: await stream.result() }; diff --git a/packages/ai/test/google-safety-stop.test.ts b/packages/ai/test/google-safety-stop.test.ts index 7aa828b8c6..fc70d54554 100644 --- a/packages/ai/test/google-safety-stop.test.ts +++ b/packages/ai/test/google-safety-stop.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; import { streamGoogleGenAI } from "../src/providers/google-shared"; import { collectEvents, createBaseModel, createSseResponse } from "./openai-tool-choice-test-helpers"; @@ -85,7 +86,7 @@ async function streamGoogleResponse(response: unknown | unknown[], api: GoogleSt const stream = streamGoogleGenAI({ model, api, - options: undefined, + options: withProviderSafetyStopAdapterInvocation({}), prepare: () => ({ params: { model: model.id, contents: [] }, url: "https://google.example.test/stream", diff --git a/packages/ai/test/openai-completions-safety-stop.test.ts b/packages/ai/test/openai-completions-safety-stop.test.ts index 259d480355..2a0db455ab 100644 --- a/packages/ai/test/openai-completions-safety-stop.test.ts +++ b/packages/ai/test/openai-completions-safety-stop.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it } from "bun:test"; import { streamOpenAICompletions } from "@gajae-code/ai/providers/openai-completions"; import type { AssistantMessageEvent, Context, Model } from "@gajae-code/ai/types"; +import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; + +function trustedOptions(): { apiKey: string } { + return withProviderSafetyStopAdapterInvocation({ apiKey: "test" }); +} const originalFetch = global.fetch; afterEach(() => { @@ -93,7 +98,7 @@ describe("chat-completions: provider safety stops", () => { "[DONE]", ]); - const result = await streamOpenAICompletions(model(), context(), { apiKey: "test" }).result(); + const result = await streamOpenAICompletions(model(), context(), trustedOptions()).result(); expect(result.errorKind).toBe("provider_safety_stop"); expect(result.stopReason).toBe("error"); expect(result.errorMessage).toBe("Provider finish_reason: content_filter"); @@ -102,7 +107,7 @@ describe("chat-completions: provider safety stops", () => { it("classifies a streamed refusal as a safety stop despite an ordinary finish reason", async () => { global.fetch = mockFetch([chunk({ refusal: "I cannot help with that." }, "stop"), "[DONE]"]); - const result = await streamOpenAICompletions(model(), context(), { apiKey: "test" }).result(); + const result = await streamOpenAICompletions(model(), context(), trustedOptions()).result(); expect(result.errorKind).toBe("provider_safety_stop"); expect(result.stopReason).toBe("error"); expect(result.content).toEqual([{ type: "text", text: "I cannot help with that." }]); @@ -127,7 +132,7 @@ describe("chat-completions: provider safety stops", () => { "[DONE]", ]); - const result = await streamOpenAICompletions(model(), context(), { apiKey: "test" }).result(); + const result = await streamOpenAICompletions(model(), context(), trustedOptions()).result(); expect(result.content.some(block => block.type === "toolCall")).toBe(true); expect(result.errorKind).toBe("provider_safety_stop"); expect(result.stopReason).toBe("error"); @@ -139,7 +144,7 @@ describe("chat-completions: provider safety stops", () => { "[DONE]", ]); - const result = await streamOpenAICompletions(model(), context(), { apiKey: "test" }).result(); + const result = await streamOpenAICompletions(model(), context(), trustedOptions()).result(); expect(result.errorKind).toBe("provider_safety_stop"); expect(result.stopReason).toBe("error"); expect(result.content).toEqual([{ type: "text", text: "I cannot help with that. Here is ordinary content." }]); @@ -148,7 +153,7 @@ describe("chat-completions: provider safety stops", () => { it("keeps the content-filter error after an earlier refusal", async () => { global.fetch = mockFetch([chunk({ refusal: "I cannot help with that." }), chunk({}, "content_filter"), "[DONE]"]); - const result = await streamOpenAICompletions(model(), context(), { apiKey: "test" }).result(); + const result = await streamOpenAICompletions(model(), context(), trustedOptions()).result(); expect(result.errorKind).toBe("provider_safety_stop"); expect(result.stopReason).toBe("error"); expect(result.errorMessage).toBe("Provider finish_reason: content_filter"); @@ -159,7 +164,7 @@ describe("chat-completions: provider safety stops", () => { error: { code: "content_filter", message: "Prompt rejected by policy" }, }); - const stream = streamOpenAICompletions(model(), context(), { apiKey: "test" }); + const stream = streamOpenAICompletions(model(), context(), trustedOptions()); const events: AssistantMessageEvent[] = []; for await (const event of stream) events.push(event); const result = await stream.result(); @@ -180,7 +185,7 @@ describe("chat-completions: provider safety stops", () => { }, }); - const stream = streamOpenAICompletions(model(), context(), { apiKey: "test" }); + const stream = streamOpenAICompletions(model(), context(), trustedOptions()); const events: AssistantMessageEvent[] = []; for await (const event of stream) events.push(event); const result = await stream.result(); diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 0fb69b548d..395f3411f3 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, } from "../src/adapter-internals/provider-safety-stop"; import * as publicAi from "../src/index"; import { getBundledModel } from "../src/models"; @@ -66,7 +67,15 @@ describe("provider safety-stop provenance authority", () => { test("mints the typed kind only for structured first-party refusal signals", () => { for (const signal of ["refusal", "sensitive", "content_filter", "SAFETY", "JAILBREAK", "RECITATION"]) { const marked = message(); - expect(mintProviderSafetyStop(marked, signal, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); + expect( + mintProviderSafetyStop( + marked, + signal, + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), + ).toBe(true); expect(marked.errorKind).toBe("provider_safety_stop"); expect(isProviderSafetyStopAuthenticated(marked)).toBe(true); } @@ -75,9 +84,15 @@ describe("provider safety-stop provenance authority", () => { test("fails closed on an unrecognized signal: no kind, no authority", () => { const unmarked = message(); unmarked.errorKind = "provider_safety_stop"; - expect(mintProviderSafetyStop(unmarked, "totally-not-a-refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe( - false, - ); + expect( + mintProviderSafetyStop( + unmarked, + "totally-not-a-refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), + ).toBe(false); // The pre-existing wire-assignable field stays exactly as unauthenticated // as it was; the adapter bug degraded to ordinary fallback, not a mint. expect(isProviderSafetyStopAuthenticated(unmarked)).toBe(false); @@ -94,6 +109,13 @@ describe("provider safety-stop provenance authority", () => { } }); + test("requires the runtime-owned adapter invocation token", () => { + const untrusted = message(); + expect(mintProviderSafetyStop(untrusted, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(false); + expect(untrusted.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(untrusted)).toBe(false); + }); + test("a structurally forged capability cannot mint authority", () => { const forged = message(); const forgedCapability = {} as Parameters[2]; @@ -104,7 +126,15 @@ describe("provider safety-stop provenance authority", () => { test("a public consumer cannot clone authority from a genuine marked source", () => { const marked = message(); - expect(mintProviderSafetyStop(marked, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); + expect( + mintProviderSafetyStop( + marked, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), + ).toBe(true); const forgedDestination = { ...marked }; expect(isProviderSafetyStopAuthenticated(marked)).toBe(true); @@ -114,7 +144,15 @@ describe("provider safety-stop provenance authority", () => { test("data alone is never authenticated: clones, JSON round-trips, and fresh copies lose authority", () => { const marked = message(); - expect(mintProviderSafetyStop(marked, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY)).toBe(true); + expect( + mintProviderSafetyStop( + marked, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), + ).toBe(true); const cloned = structuredClone(marked); expect(cloned.errorKind).toBe("provider_safety_stop"); diff --git a/packages/coding-agent/test/agent-session-resilient-retry.test.ts b/packages/coding-agent/test/agent-session-resilient-retry.test.ts index 6e105c125b..9d6d352d92 100644 --- a/packages/coding-agent/test/agent-session-resilient-retry.test.ts +++ b/packages/coding-agent/test/agent-session-resilient-retry.test.ts @@ -17,6 +17,7 @@ import { TempDir } from "@gajae-code/utils"; import * as z from "zod/v4"; import { mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../../ai/src/adapter-internals/provider-safety-stop"; @@ -198,7 +199,13 @@ describe("AgentSession resilient retry", () => { // carries the terminal authority rather than the wire field // alone (#4777). if (options.errorKind === "provider_safety_stop") { - mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + message, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ); } stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); diff --git a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts index 9559aeecbb..c47a85ddf4 100644 --- a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts +++ b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts @@ -24,6 +24,7 @@ import { import { TempDir } from "@gajae-code/utils"; import { mintProviderSafetyStop, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, } from "../../ai/src/adapter-internals/provider-safety-stop"; import { AgentSession, type AgentSessionEvent } from "../src/session/agent-session"; @@ -71,7 +72,13 @@ function safetyStopStream( // Omitting the mark simulates a wire/custom-stream payload that only // carries the forged field. if (options?.authenticated !== false) { - mintProviderSafetyStop(message, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY); + mintProviderSafetyStop( + message, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ); } else message.errorKind = "provider_safety_stop"; stream.push({ type: "start", partial: message }); stream.push({ type: "error", reason: "error", error: message }); From 3fd666bb71bb5f6a28f0bc084e8ce1705109f510 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 04:34:25 +0000 Subject: [PATCH 14/26] test(coding-agent): satisfy safety-stop import contract Keep the focused provider safety-stop test imports aligned with the package formatter so exact-head checks remain clean. Lore-id: 4782-import-contract Constraint: preserve provider safety-stop behavior while fixing only formatter-detected test ordering Confidence: high Scope-risk: narrow Reversibility: trivial Tested: bun --cwd=packages/coding-agent run check Not-tested: root check blocked by unrelated Bun 1.3.14 SDK rollback fixture --- .../coding-agent/test/agent-session-resilient-retry.test.ts | 2 +- .../coding-agent/test/provider-safety-stop-hint.e2e.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/agent-session-resilient-retry.test.ts b/packages/coding-agent/test/agent-session-resilient-retry.test.ts index 9d6d352d92..744626e11d 100644 --- a/packages/coding-agent/test/agent-session-resilient-retry.test.ts +++ b/packages/coding-agent/test/agent-session-resilient-retry.test.ts @@ -17,8 +17,8 @@ import { TempDir } from "@gajae-code/utils"; import * as z from "zod/v4"; import { mintProviderSafetyStop, - PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, } from "../../ai/src/adapter-internals/provider-safety-stop"; /** diff --git a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts index c47a85ddf4..7fc55712d2 100644 --- a/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts +++ b/packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts @@ -24,8 +24,8 @@ import { import { TempDir } from "@gajae-code/utils"; import { mintProviderSafetyStop, - PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, } from "../../ai/src/adapter-internals/provider-safety-stop"; import { AgentSession, type AgentSessionEvent } from "../src/session/agent-session"; import { AuthStorage } from "../src/session/auth-storage"; From a5b93bf710ad6654ee8f791c9699ae094bcb5b68 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 04:47:20 +0000 Subject: [PATCH 15/26] fix(ai): bind safety minting to bundled model provenance A public stream caller could clone a bundled model, redirect its base URL, and still receive the runtime invocation token even though the transport options were otherwise untouched. Register bundled model identities with an immutable endpoint fingerprint and mint adapter authority only for unchanged catalog objects; redirected or custom models remain fallback-eligible. Lore-id: 4782-model-provenance Constraint: caller-controlled model endpoints must not mint terminal provider safety-stop authority Constraint: unchanged bundled provider dispatches retain authenticated refusal handling Rejected: trust the public stream wrapper alone | it accepts caller-controlled model metadata Confidence: high Scope-risk: medium Reversibility: straightforward Tested: bun test packages/ai/test/provider-safety-stop.test.ts packages/ai/test/anthropic-stream-envelope.test.ts packages/ai/test/openai-completions-safety-stop.test.ts packages/ai/test/google-safety-stop.test.ts packages/ai/test/google-gemini-cli-safety-stop.test.ts packages/ai/test/pi-native-client.test.ts Tested: bun test packages/agent/test/managed-attempt-transaction.test.ts packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts Tested: bun test packages/coding-agent/test/provider-safety-stop-hint.test.ts packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts packages/coding-agent/test/agent-session-resilient-retry.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check Tested: bun --cwd=packages/coding-agent run check Not-tested: root bun run check remains blocked by the unrelated SDK rollback fixture/Bun subprocess mismatch --- .../adapter-internals/provider-safety-stop.ts | 30 +++++++++++++- packages/ai/src/models.ts | 5 ++- packages/ai/src/stream.ts | 41 +++++++++++++------ packages/ai/test/provider-safety-stop.test.ts | 30 +++++++++++++- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index f65b7ceef7..647be72064 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -1,10 +1,38 @@ -import type { AssistantMessage } from "../types"; +import type { Api, AssistantMessage, Model } from "../types"; /** This module is intentionally outside the package export map. */ const PROVIDER_SAFETY_STOP_ADAPTER_BRAND = Symbol("provider-safety-stop-adapter-brand"); const PROVIDER_SAFETY_STOP_INVOCATION_BRAND = Symbol("provider-safety-stop-invocation-brand"); const PROVIDER_SAFETY_STOP_INVOCATION_KEY = Symbol("provider-safety-stop-invocation"); +type ProviderSafetyStopModelIdentity = Pick, "api" | "provider" | "id" | "baseUrl">; +const trustedProviderSafetyStopModels = new WeakMap(); + +function providerSafetyStopModelIdentity(model: ProviderSafetyStopModelIdentity): string { + return `${model.api}\u0000${model.provider}\u0000${model.id}\u0000${model.baseUrl ?? ""}`; +} + +/** Register an immutable catalog identity for first-party provider dispatch. */ +export function registerProviderSafetyStopModel(model: Model): void { + try { + trustedProviderSafetyStopModels.set(model, providerSafetyStopModelIdentity(model)); + } catch { + // A malformed/hostile model must remain fallback-eligible. + } +} + +/** Verify that a model is the unchanged identity of a bundled catalog entry. */ +export function isProviderSafetyStopModelTrusted(model: unknown): boolean { + if (typeof model !== "object" || model === null) return false; + const expected = trustedProviderSafetyStopModels.get(model); + if (expected === undefined) return false; + try { + return expected === providerSafetyStopModelIdentity(model as ProviderSafetyStopModelIdentity); + } catch { + return false; + } +} + export type ProviderSafetyStopAdapterCapability = { readonly [PROVIDER_SAFETY_STOP_ADAPTER_BRAND]: true; }; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index e6616b3314..c32e85595d 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { registerProviderSafetyStopModel } from "./adapter-internals/provider-safety-stop"; import { getOpenAIModelCost } from "./model-pricing"; import { isRetiredModelKey } from "./model-retirements"; import { applyGeneratedModelPolicies, enrichModelThinking } from "./model-thinking"; @@ -42,7 +43,9 @@ function getProviderModels(provider: GeneratedProvider): Map> if (isRetiredModelKey(provider, id)) { continue; } - providerModels.set(id, applyBundledCompatDefaults(enrichModelThinking(model as Model))); + const bundledModel = applyBundledCompatDefaults(enrichModelThinking(model as Model)); + registerProviderSafetyStopModel(bundledModel); + providerModels.set(id, bundledModel); } providerModelRegistry.set(provider, providerModels); return providerModels; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 9bca18b8af..eb348a53a3 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -7,7 +7,10 @@ import { extractHttpStatusFromError, getTrustedHomeDir, } from "@gajae-code/utils"; -import { withProviderSafetyStopAdapterInvocation } from "./adapter-internals/provider-safety-stop"; +import { + isProviderSafetyStopModelTrusted, + withProviderSafetyStopAdapterInvocation, +} from "./adapter-internals/provider-safety-stop"; import { assertManagedAttempt, classifyFallbackTrigger, type TransportFailureFacts } from "./utils/fallback-transport"; const managedAttemptValidated = Symbol("managedAttemptValidated"); @@ -340,10 +343,13 @@ export function stream( // Vertex AI uses Application Default Credentials, not API keys if (model.api === "google-vertex") { + const vertexOptions = (options || {}) as GoogleVertexOptions; return streamGoogleVertex( model as Model<"google-vertex">, context, - withProviderSafetyStopAdapterInvocation((options || {}) as GoogleVertexOptions), + isProviderSafetyStopModelTrusted(model) + ? withProviderSafetyStopAdapterInvocation(vertexOptions) + : vertexOptions, ); } else if (model.api === "bedrock-converse-stream") { // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile. @@ -360,12 +366,15 @@ export function stream( if (!apiKey) { throw new Error(formatMissingApiKeyError(model.provider)); } - const providerOptions = withProviderSafetyStopAdapterInvocation({ ...options, apiKey }); + const providerOptions = { ...options, apiKey }; + const adapterProviderOptions = isProviderSafetyStopModelTrusted(model) + ? withProviderSafetyStopAdapterInvocation(providerOptions) + : providerOptions; const api: Api = model.api; switch (api) { case "anthropic-messages": { - const anthropicOptions = providerOptions as AnthropicOptions; + const anthropicOptions = adapterProviderOptions as AnthropicOptions; return streamAnthropic(model as Model<"anthropic-messages">, context, { ...anthropicOptions, isOAuth: anthropicOptions.isOAuth ?? model.isOAuth, @@ -373,32 +382,40 @@ export function stream( } case "openai-completions": - return streamOpenAICompletions(model as Model<"openai-completions">, context, providerOptions as any); + return streamOpenAICompletions(model as Model<"openai-completions">, context, adapterProviderOptions as any); case "openai-responses": - return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any); + return streamOpenAIResponses(model as Model<"openai-responses">, context, adapterProviderOptions as any); case "azure-openai-responses": - return streamAzureOpenAIResponses(model as Model<"azure-openai-responses">, context, providerOptions as any); + return streamAzureOpenAIResponses( + model as Model<"azure-openai-responses">, + context, + adapterProviderOptions as any, + ); case "openai-codex-responses": - return streamOpenAICodexResponses(model as Model<"openai-codex-responses">, context, providerOptions as any); + return streamOpenAICodexResponses( + model as Model<"openai-codex-responses">, + context, + adapterProviderOptions as any, + ); case "google-generative-ai": - return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions); + return streamGoogle(model as Model<"google-generative-ai">, context, adapterProviderOptions); case "google-gemini-cli": return streamGoogleGeminiCli( model as Model<"google-gemini-cli">, context, - providerOptions as GoogleGeminiCliOptions, + adapterProviderOptions as GoogleGeminiCliOptions, ); case "ollama-chat": - return streamOllama(model as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions); + return streamOllama(model as Model<"ollama-chat">, context, adapterProviderOptions as OllamaChatOptions); case "cursor-agent": - return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions); + return streamCursor(model as Model<"cursor-agent">, context, adapterProviderOptions as CursorOptions); default: throw new Error(`Unhandled API: ${api}`); diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 395f3411f3..e855fb74c9 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, vi } from "bun:test"; import { mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, @@ -7,6 +7,7 @@ import { import * as publicAi from "../src/index"; import { getBundledModel } from "../src/models"; import { streamOpenAICompletions } from "../src/providers/openai-completions"; +import { stream } from "../src/stream"; import type { AssistantMessage, Context, FetchImpl, Model } from "../src/types"; import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; @@ -57,6 +58,33 @@ describe("provider safety-stop provenance authority", () => { expect(isProviderSafetyStopAuthenticated(result)).toBe(false); }); + test("does not mint from a public stream when a caller redirects a cloned model", async () => { + const bundled = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; + const model = { ...bundled, baseUrl: "https://attacker.example/v1" }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + error: { message: "filtered", type: "invalid_request_error", code: "content_filter" }, + }), + { status: 429, headers: { "Content-Type": "application/json" } }, + ), + ); + try { + const result = await stream( + model, + { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + { apiKey: "caller-key", requestMaxRetries: 0, streamMaxRetries: 0 }, + ).result(); + + expect(fetchSpy).toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); + } finally { + fetchSpy.mockRestore(); + } + }); + test("public AI exports expose verification only, never the minting operation", () => { const publicSurface = publicAi as unknown as Record; expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); From 8be3dd8fe8b0143d1705ce3f094240b4df2c9214 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 04:49:24 +0000 Subject: [PATCH 16/26] docs(ai): record model provenance hardening Document the exact-head follow-up that blocks cloned or redirected public models from receiving adapter safety-stop authority. Lore-id: 4782-model-provenance-docs Confidence: high Scope-risk: narrow Tested: bun --cwd=packages/ai run check --- packages/ai/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6c96844d48..8142bb0d0e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. - The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. +- Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. From 6ffe885503a1d537c80b94f998e4f4a6d04eafca Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 05:00:37 +0000 Subject: [PATCH 17/26] fix(ai): preserve safety provenance through provider wrappers Kimi Code, Synthetic, and GitLab Duo delegated streams rebuilt their inner adapter options without the runtime invocation token, so genuine structured refusals became fallback-eligible. Copy provenance only from an already stamped runtime source across those wrapper boundaries; direct public wrapper calls remain unable to mint. Lore-id: 4782-wrapper-provenance Constraint: wrapper dispatches must preserve genuine adapter terminality Constraint: direct public wrappers must remain unable to mint authority Rejected: stamp every wrapper unconditionally | public callers could then forge terminal provenance Confidence: high Scope-risk: medium Reversibility: straightforward Tested: bun test packages/ai/test/provider-safety-stop.test.ts packages/ai/test/anthropic-stream-envelope.test.ts packages/ai/test/openai-completions-safety-stop.test.ts packages/ai/test/google-safety-stop.test.ts packages/ai/test/google-gemini-cli-safety-stop.test.ts packages/ai/test/pi-native-client.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check Tested: bun --cwd=packages/coding-agent run check Not-tested: GitLab/Kimi live provider responses; Synthetic wrapper regression uses a deterministic mocked transport --- packages/ai/CHANGELOG.md | 1 + .../adapter-internals/provider-safety-stop.ts | 7 ++ packages/ai/src/providers/gitlab-duo.ts | 13 +-- .../ai/src/providers/openai-anthropic-shim.ts | 97 +++++++++++-------- packages/ai/src/stream.ts | 44 ++++++--- packages/ai/test/provider-safety-stop.test.ts | 29 +++++- 6 files changed, 127 insertions(+), 64 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8142bb0d0e..8780122554 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. - The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. - Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). +- Kimi Code, Synthetic, and GitLab Duo wrapper dispatches now preserve the runtime safety-stop invocation provenance into their inner Anthropic/OpenAI adapters, so genuine structured refusals remain terminal without making wrapper options caller-mintable (#4777 review). - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. diff --git a/packages/ai/src/adapter-internals/provider-safety-stop.ts b/packages/ai/src/adapter-internals/provider-safety-stop.ts index 647be72064..55ecd4f34c 100644 --- a/packages/ai/src/adapter-internals/provider-safety-stop.ts +++ b/packages/ai/src/adapter-internals/provider-safety-stop.ts @@ -75,6 +75,13 @@ export function isProviderSafetyStopAdapterInvocation(value: unknown): ProviderS } } +/** Copy an existing runtime invocation token across a first-party wrapper boundary. */ +export function copyProviderSafetyStopAdapterInvocation(source: unknown, destination: T): T { + return isProviderSafetyStopAdapterInvocation(source) + ? ({ ...destination, [PROVIDER_SAFETY_STOP_INVOCATION_KEY]: PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION } as T) + : destination; +} + const authenticatedProviderSafetyStops = new WeakSet(); /** diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts index bf954724a6..026e9473b1 100644 --- a/packages/ai/src/providers/gitlab-duo.ts +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -1,3 +1,4 @@ +import { copyProviderSafetyStopAdapterInvocation } from "../adapter-internals/provider-safety-stop"; import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream"; import type { Api, Context, FetchImpl, Model, SimpleStreamOptions } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; @@ -261,7 +262,7 @@ export function streamGitLabDuo( baseUrl: ANTHROPIC_PROXY_URL, } as Model<"anthropic-messages">, context, - { + copyProviderSafetyStopAdapterInvocation(options, { apiKey: directAccess.token, isOAuth: true, temperature: options.temperature, @@ -289,7 +290,7 @@ export function streamGitLabDuo( : undefined, reasoning: reasoningEffort, toolChoice: mapAnthropicToolChoice(options.toolChoice), - }, + }), ) : mapping.openaiApiType === "responses" ? streamOpenAIResponses( @@ -300,7 +301,7 @@ export function streamGitLabDuo( baseUrl: OPENAI_PROXY_URL, } as Model<"openai-responses">, context, - { + copyProviderSafetyStopAdapterInvocation(options, { apiKey: directAccess.token, temperature: options.temperature, topP: options.topP, @@ -323,7 +324,7 @@ export function streamGitLabDuo( fetch: options.fetch, reasoning: reasoningEffort, toolChoice: options.toolChoice, - } satisfies OpenAIResponsesOptions, + }) satisfies OpenAIResponsesOptions, ) : streamOpenAICompletions( { @@ -333,7 +334,7 @@ export function streamGitLabDuo( baseUrl: OPENAI_PROXY_URL, } as Model<"openai-completions">, context, - { + copyProviderSafetyStopAdapterInvocation(options, { apiKey: directAccess.token, temperature: options.temperature, topP: options.topP, @@ -356,7 +357,7 @@ export function streamGitLabDuo( fetch: options.fetch, reasoning: reasoningEffort, toolChoice: options.toolChoice, - } satisfies OpenAICompletionsOptions, + }) satisfies OpenAICompletionsOptions, ); for await (const event of inner) { diff --git a/packages/ai/src/providers/openai-anthropic-shim.ts b/packages/ai/src/providers/openai-anthropic-shim.ts index 2d20e55af2..317c9d7071 100644 --- a/packages/ai/src/providers/openai-anthropic-shim.ts +++ b/packages/ai/src/providers/openai-anthropic-shim.ts @@ -8,6 +8,7 @@ * here once. */ +import { copyProviderSafetyStopAdapterInvocation } from "../adapter-internals/provider-safety-stop"; import { ANTHROPIC_THINKING } from "../stream"; import type { Context, Model, SimpleStreamOptions } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; @@ -73,28 +74,33 @@ export function streamOpenAIAnthropicShim( ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) : undefined; - const innerStream = streamAnthropic(anthropicModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - attemptScope: options?.attemptScope, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - fetch: options?.fetch, - streamIdleTimeoutMs: options?.streamIdleTimeoutMs, - streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs, - thinkingEnabled, - thinkingBudgetTokens: thinkingBudget, - }); + const innerStream = streamAnthropic( + anthropicModel, + context, + copyProviderSafetyStopAdapterInvocation(options, { + ...options, + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + attemptScope: options?.attemptScope, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + fetch: options?.fetch, + streamIdleTimeoutMs: options?.streamIdleTimeoutMs, + streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs, + thinkingEnabled, + thinkingBudgetTokens: thinkingBudget, + }), + ); for await (const event of innerStream) { stream.push(event); @@ -105,27 +111,32 @@ export function streamOpenAIAnthropicShim( : model; const reasoningEffort = options?.reasoning; - const innerStream = streamOpenAICompletions(openaiModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? model.maxTokens, - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - attemptScope: options?.attemptScope, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - fetch: options?.fetch, - streamIdleTimeoutMs: options?.streamIdleTimeoutMs, - streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs, - reasoning: reasoningEffort, - }); + const innerStream = streamOpenAICompletions( + openaiModel, + context, + copyProviderSafetyStopAdapterInvocation(options, { + ...options, + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? model.maxTokens, + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + attemptScope: options?.attemptScope, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + fetch: options?.fetch, + streamIdleTimeoutMs: options?.streamIdleTimeoutMs, + streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs, + reasoning: reasoningEffort, + }), + ); for await (const event of innerStream) { stream.push(event); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index eb348a53a3..c6e89474e7 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -8,6 +8,7 @@ import { getTrustedHomeDir, } from "@gajae-code/utils"; import { + copyProviderSafetyStopAdapterInvocation, isProviderSafetyStopModelTrusted, withProviderSafetyStopAdapterInvocation, } from "./adapter-internals/provider-safety-stop"; @@ -638,15 +639,22 @@ export function streamSimple( if (!apiKey) { throw new Error(formatMissingApiKeyError(model.provider)); } + const adapterOptions = isProviderSafetyStopModelTrusted(model) + ? withProviderSafetyStopAdapterInvocation(options ?? {}) + : options; // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens if (model.provider === "gitlab-duo") { return streamFromLazyImport(async () => { const { streamGitLabDuo } = await import("./providers/gitlab-duo"); - return streamGitLabDuo(model, context, { - ...options, - apiKey, - }); + return streamGitLabDuo( + model, + context, + copyProviderSafetyStopAdapterInvocation(adapterOptions, { + ...adapterOptions, + apiKey, + }), + ); }, options?.signal); } @@ -655,11 +663,15 @@ export function streamSimple( return streamFromLazyImport(async () => { const { streamKimi } = await import("./providers/kimi"); // Pass raw SimpleStreamOptions - streamKimi handles mapping internally - return streamKimi(model as Model<"openai-completions">, context, { - ...options, - apiKey, - format: options?.kimiApiFormat ?? "anthropic", - }); + return streamKimi( + model as Model<"openai-completions">, + context, + copyProviderSafetyStopAdapterInvocation(adapterOptions, { + ...adapterOptions, + apiKey, + format: options?.kimiApiFormat ?? "anthropic", + }), + ); }, options?.signal); } @@ -668,11 +680,15 @@ export function streamSimple( return streamFromLazyImport(async () => { const { streamSynthetic } = await import("./providers/synthetic"); // Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally - return streamSynthetic(model as Model<"openai-completions">, context, { - ...options, - apiKey, - format: options?.syntheticApiFormat ?? "openai", // Default to OpenAI format - }); + return streamSynthetic( + model as Model<"openai-completions">, + context, + copyProviderSafetyStopAdapterInvocation(adapterOptions, { + ...adapterOptions, + apiKey, + format: options?.syntheticApiFormat ?? "openai", // Default to OpenAI format + }), + ); }, options?.signal); } diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index e855fb74c9..ccbabef3b1 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -7,7 +7,7 @@ import { import * as publicAi from "../src/index"; import { getBundledModel } from "../src/models"; import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { stream } from "../src/stream"; +import { stream, streamSimple } from "../src/stream"; import type { AssistantMessage, Context, FetchImpl, Model } from "../src/types"; import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; @@ -85,6 +85,33 @@ describe("provider safety-stop provenance authority", () => { } }); + test("preserves authenticated safety stops through the Synthetic wrapper", async () => { + const model = getBundledModel("synthetic", "hf:deepseek-ai/DeepSeek-R1-0528"); + if (!model) throw new Error("Expected bundled Synthetic model"); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + error: { message: "filtered", type: "invalid_request_error", code: "content_filter" }, + }), + { status: 429, headers: { "Content-Type": "application/json" } }, + ), + ); + try { + const result = await streamSimple( + model, + { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + { apiKey: "synthetic-key", requestMaxRetries: 0, streamMaxRetries: 0 }, + ).result(); + + expect(fetchSpy).toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + } finally { + fetchSpy.mockRestore(); + } + }); + test("public AI exports expose verification only, never the minting operation", () => { const publicSurface = publicAi as unknown as Record; expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); From 1e1138f58bcd0db9508040a56b5330701e972dd3 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 05:10:45 +0000 Subject: [PATCH 18/26] fix(ai-agent): close remaining safety-stop review seams A hostile Proxy final could reintroduce a forged provider safety-stop label through the sanitizer fallback shell, and Google safety refusals could be overwritten by a later benign finish when provenance minting failed. Strip the rebuilt proxy fallback label and latch Google safety outcomes independently of the typed authority mark. Lore-id: 4782-review-seams Constraint: discarded forged labels must be absent before session policy reads them Constraint: Google safety refusals must not flip to stop or toolUse after a later benign finish Rejected: rely on errorKind as a latching flag | caller-controlled transport can prevent minting Confidence: high Scope-risk: medium Reversibility: straightforward Tested: bun test packages/ai/test/provider-safety-stop.test.ts packages/ai/test/anthropic-stream-envelope.test.ts packages/ai/test/openai-completions-safety-stop.test.ts packages/ai/test/google-safety-stop.test.ts packages/ai/test/google-gemini-cli-safety-stop.test.ts packages/ai/test/pi-native-client.test.ts Tested: bun test packages/agent/test/managed-attempt-transaction.test.ts packages/agent/test/agent-loop-escaped-nonascii-toolcall.test.ts Tested: bun test packages/coding-agent/test/provider-safety-stop-hint.test.ts packages/coding-agent/test/provider-safety-stop-hint.e2e.test.ts packages/coding-agent/test/agent-session-resilient-retry.test.ts Tested: bun --cwd=packages/ai run check Tested: bun --cwd=packages/agent run check Not-tested: full host-load package suites; serialized focused groups only per host-safety steer --- packages/agent/CHANGELOG.md | 1 + packages/agent/src/agent-loop.ts | 4 ++- .../test/managed-attempt-transaction.test.ts | 10 ++++-- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/google-gemini-cli.ts | 8 +++-- packages/ai/src/providers/google-shared.ts | 7 ++-- packages/ai/test/google-safety-stop.test.ts | 32 +++++++++++++++++++ 7 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 23576de4b0..7a6c3d419d 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,7 @@ - Managed fallback now transfers safety-stop authority only to the adjudicated final assistant shell; intermediate partial snapshots and hostile accessor-backed final messages cannot retain or bypass the provenance boundary (#4777 review). - Managed assistant reconstruction now copies provider metadata through guarded property reads instead of an unguarded spread, so accessor-trapped metadata degrades without aborting the attempt or creating managed retry authority (#4777 review). +- Hostile Proxy-wrapped final messages no longer reintroduce a forged `provider_safety_stop` label through the sanitizer fallback shell; discarded failure outcomes are now label-free before session policy can suppress provider fallback (#4777 review). ### Fixed diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index d7de6d4510..ab5418ce5a 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -452,7 +452,9 @@ function sanitizeProviderSafetyStopProvenance( delete rebuilt.errorKind; return rebuilt; } - return managedAssistantShell(message, model); + const rebuilt = managedAssistantShell(message, model); + delete rebuilt.errorKind; + return rebuilt; } /** diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index a859c8b066..731e37a5ce 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -433,6 +433,7 @@ describe("managed attempt transaction", () => { // (#4777 review follow-up). const mock = createMockModel({ responses: [{ content: ["fallback accepted"] }] }); let calls = 0; + let discardedFailureKind: AssistantMessage["errorKind"] | undefined; const agent = new Agent({ initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, streamFn: (...args) => { @@ -464,18 +465,21 @@ describe("managed attempt transaction", () => { }); const options = { fallbackManaged: true, - onManagedAttemptOutcome: () => - ({ + onManagedAttemptOutcome: (outcome: ManagedAttemptOutcome) => { + if (outcome.type === "retryable_discarded") discardedFailureKind = outcome.failure.message.errorKind; + return { type: "retry" as const, continuation: async (ownership: { isCurrent(): boolean }) => { if (ownership.isCurrent()) await agent.continue(options); }, - }) as const, + } as const; + }, }; await agent.prompt("run", options); expect(calls).toBe(2); + expect(discardedFailureKind).toBeUndefined(); const terminal = agent.state.messages.at(-1); expect(terminal).toMatchObject({ role: "assistant", content: [{ type: "text", text: "fallback accepted" }] }); expect((terminal as AssistantMessage).errorKind).toBeUndefined(); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8780122554..f7c26dddbc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ - The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. - Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). - Kimi Code, Synthetic, and GitLab Duo wrapper dispatches now preserve the runtime safety-stop invocation provenance into their inner Anthropic/OpenAI adapters, so genuine structured refusals remain terminal without making wrapper options caller-mintable (#4777 review). +- Google candidate and prompt safety refusals now remain terminal across later benign finish reasons even when caller-selected transport prevents provenance minting; the result stays an untyped error instead of flipping to `stop`/`toolUse` (#4777 review). - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 3ad8ce421e..e0a53fd9c9 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -53,7 +53,6 @@ import { mapStopReasonString, mapToolChoice, nextToolCallId, - PROVIDER_SAFETY_STOP, pushBlockEndEvent, pushToolCallEvents, retainThoughtSignature, @@ -481,6 +480,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } let hasContent = false; + let providerSafetyStop = false; let currentBlock: TextContent | ThinkingContent | null = null; const blocks = output.content; const blockIndex = () => blocks.length - 1; @@ -570,6 +570,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (candidate?.finishReason) { if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) { + providerSafetyStop = true; hasContent = true; // Adapter-minted terminal authority from the parsed // structured finish reason (#4777). @@ -581,7 +582,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( isProviderSafetyStopAdapterInvocation(options), ); output.stopReason = "error"; - } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { + } else if (!providerSafetyStop) { output.stopReason = mapStopReasonString(candidate.finishReason); if (output.stopReason === "stop" && output.content.some(b => b.type === "toolCall")) { output.stopReason = "toolUse"; @@ -593,6 +594,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (blockReason) { hasContent = true; if (isGooglePromptSafetyStopReason(blockReason)) { + providerSafetyStop = true; // Prompt-level block reason: adapter-minted authority (#4777). mintProviderSafetyStop( output, @@ -602,7 +604,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( isProviderSafetyStopAdapterInvocation(options), ); output.stopReason = "error"; - } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { + } else if (!providerSafetyStop) { output.stopReason = "error"; } } diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index cb3d15006f..fd62b0519a 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -596,6 +596,7 @@ export async function consumeGoogleStream(args: { const blockIndex = () => blocks.length - 1; let currentBlock: TextContent | ThinkingContent | null = null; let firstTokenSeen = false; + let providerSafetyStop = false; const flushCurrent = () => { if (!currentBlock) return; @@ -676,6 +677,7 @@ export async function consumeGoogleStream(args: { if (candidate?.finishReason) { if (isGoogleCandidateSafetyStopReason(candidate.finishReason)) { + providerSafetyStop = true; // Terminal authority is minted by the adapter after parsing the // structured candidate finish reason; a wire-assignable field // alone never carries it (#4777). @@ -687,7 +689,7 @@ export async function consumeGoogleStream(args: { adapterInvocation, ); output.stopReason = "error"; - } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { + } else if (!providerSafetyStop) { output.stopReason = mapStopReason(candidate.finishReason); if (output.stopReason === "stop" && output.content.some(b => b.type === "toolCall")) { output.stopReason = "toolUse"; @@ -698,6 +700,7 @@ export async function consumeGoogleStream(args: { const blockReason = getGooglePromptBlockReason(chunk.promptFeedback); if (blockReason) { if (isGooglePromptSafetyStopReason(blockReason)) { + providerSafetyStop = true; // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). mintProviderSafetyStop( @@ -708,7 +711,7 @@ export async function consumeGoogleStream(args: { adapterInvocation, ); output.stopReason = "error"; - } else if (output.errorKind !== PROVIDER_SAFETY_STOP) { + } else if (!providerSafetyStop) { output.stopReason = "error"; } } diff --git a/packages/ai/test/google-safety-stop.test.ts b/packages/ai/test/google-safety-stop.test.ts index fc70d54554..0869b6fdcb 100644 --- a/packages/ai/test/google-safety-stop.test.ts +++ b/packages/ai/test/google-safety-stop.test.ts @@ -122,6 +122,38 @@ describe("Google safety stops", () => { expect(result.errorKind).toBeUndefined(); }); + it("keeps an unauthenticated safety refusal terminal across a later benign finish", async () => { + const model = createBaseModel("google-generative-ai"); + const stream = streamGoogleGenAI({ + model, + api: "google-generative-ai", + options: undefined, + prepare: () => ({ + params: { model: model.id, contents: [] }, + url: "https://google.example.test/stream", + headers: {}, + fetch: async () => + createSseResponse([ + { + candidates: [ + { + content: { parts: [{ functionCall: { name: "blocked", args: {} } }] }, + finishReason: "SAFETY", + }, + ], + }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + }), + }); + + await collectEvents(stream); + const result = await stream.result(); + expect(result.stopReason).toBe("error"); + expect(result.errorKind).toBeUndefined(); + expect(result.content.some(block => block.type === "toolCall")).toBe(true); + }); + it("classifies the exhaustive candidate finish-reason partition", async () => { for (const finishReason of candidateFinishReasonFixtures.commonSafety) { const result = await streamGoogleResponse({ candidates: [{ finishReason }] }); From 893f3baa6d31dd3d6d12929916ed5f6880b08137 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 05:15:04 +0000 Subject: [PATCH 19/26] docs(ai): keep safety fixes under unreleased The published 0.14.2 changelog section must not claim the later safety-stop hardening shipped in that immutable tarball. Move the three review bullets into Unreleased and remove the duplicate GLM catalog entry. Lore-id: 4782-changelog-placement Confidence: high Scope-risk: narrow Reversibility: trivial Tested: changelog placement inspected against current release boundary --- packages/ai/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f7c26dddbc..27f4307023 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,7 +14,6 @@ - Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). - Kimi Code, Synthetic, and GitLab Duo wrapper dispatches now preserve the runtime safety-stop invocation provenance into their inner Anthropic/OpenAI adapters, so genuine structured refusals remain terminal without making wrapper options caller-mintable (#4777 review). - Google candidate and prompt safety refusals now remain terminal across later benign finish reasons even when caller-selected transport prevents provenance minting; the result stays an untyped error instead of flipping to `stop`/`toolUse` (#4777 review). -- GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata and sends the ZCode client headers required by the Z.AI endpoint. - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. - Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked. - OpenAI-family streams now give xAI Grok and the Grok Build (`grok-cli-responses`) wrapper the same 300-second default idle window as Anthropic, so long Grok reasoning gaps no longer surface as `OpenAI responses stream stalled while waiting for the next event` under the 120-second OpenAI default. Env overrides still win. The observed stall was `grok-build/grok-4.6` on `openai-responses`; keying only `xai` would have left that path on 120s because `streamGrokCli` keeps `model.provider === "grok-build"`. @@ -32,9 +31,6 @@ ## [0.14.2] - 2026-08-20 ### Fixed -- Safety-stop minting now fails closed for caller-controlled adapter transport seams, including injected Anthropic clients and custom fetch implementations, so public provider entry points cannot turn fabricated refusal envelopes into terminal authority (#4777 review). -- Google adapter safety-stop minting now tracks the fetch selected by caller-supplied `prepare()` plans, preventing a forged safety chunk from becoming terminal authority when `options.fetch` is absent. -- Public provider adapter calls that inject a custom `fetch` can no longer mint terminal safety-stop authority from fabricated OpenAI, Anthropic, or Google responses; the adapter provenance boundary fails closed until a trusted invocation or authenticated transport exists (#4777 review). - Grok Build now gets the same 300s idle window as other long-turn providers, so turns no longer stall waiting on a shorter default. - `getCachedUsageReport` surfaces cached usage for API-key credentials, not only OAuth accounts (#4686). - The auth gateway accepts explicit `null` fields on openai-chat requests instead of rejecting the payload (#4667). From 20c6bdee3836ead1101a4844b5a7033c18e37fdf Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 05:16:51 +0000 Subject: [PATCH 20/26] test(ai): isolate safety mint gate legs The caller-transport and capability regression pins now pass the genuine invocation token, so each assertion proves its intended failing leg rather than succeeding through the missing-token guard. Lore-id: 4782-gate-tests Confidence: high Scope-risk: narrow Reversibility: trivial Tested: bun test packages/ai/test/provider-safety-stop.test.ts Tested: bunx biome check --write packages/ai/test/provider-safety-stop.test.ts --- packages/ai/test/provider-safety-stop.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index ccbabef3b1..5e5be78527 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -157,7 +157,13 @@ describe("provider safety-stop provenance authority", () => { for (const callerTransport of [() => undefined, {}]) { const forged = message(); expect( - mintProviderSafetyStop(forged, "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, callerTransport), + mintProviderSafetyStop( + forged, + "refusal", + PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + callerTransport, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), ).toBe(false); expect(forged.errorKind).toBeUndefined(); expect(isProviderSafetyStopAuthenticated(forged)).toBe(false); @@ -174,7 +180,15 @@ describe("provider safety-stop provenance authority", () => { test("a structurally forged capability cannot mint authority", () => { const forged = message(); const forgedCapability = {} as Parameters[2]; - expect(mintProviderSafetyStop(forged, "refusal", forgedCapability)).toBe(false); + expect( + mintProviderSafetyStop( + forged, + "refusal", + forgedCapability, + undefined, + PROVIDER_SAFETY_STOP_ADAPTER_INVOCATION, + ), + ).toBe(false); expect(isProviderSafetyStopAuthenticated(forged)).toBe(false); expect(forged.errorKind).toBeUndefined(); }); From 05e47bcb52b462f588ec0c536e816f0b50b4c52d Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 05:27:07 +0000 Subject: [PATCH 21/26] fix(ai): stamp direct GitLab dispatch provenance The low-level stream() GitLab Duo branch bypassed the wrapper stamping path, so genuine refusal signals degraded to fallback-eligible errors even for unchanged bundled models. Preserve the runtime invocation token only for trusted catalog dispatches before entering the wrapper. Lore-id: 4782-gitlab-direct Constraint: direct and simple GitLab dispatches must preserve authenticated wrapper provenance Constraint: caller-controlled models and transports remain unable to mint Confidence: high Scope-risk: narrow Reversibility: trivial Tested: bun --cwd=packages/ai run check Tested: bun test packages/ai/test/provider-safety-stop.test.ts --- packages/ai/src/stream.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index c6e89474e7..3341532543 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -330,13 +330,13 @@ export function stream( if (!apiKey) { throw new Error(formatMissingApiKeyError(model.provider)); } + const adapterOptions = isProviderSafetyStopModelTrusted(model) + ? withProviderSafetyStopAdapterInvocation({ ...(options as SimpleStreamOptions | undefined), apiKey }) + : { ...(options as SimpleStreamOptions | undefined), apiKey }; return streamFromLazyImport( async () => { const { streamGitLabDuo } = await import("./providers/gitlab-duo"); - return streamGitLabDuo(model, context, { - ...(options as SimpleStreamOptions | undefined), - apiKey, - }); + return streamGitLabDuo(model, context, adapterOptions); }, (options as StreamOptions | undefined)?.signal, ); From 45571010d4b09eb6f77974c53c660ab0916b94c7 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 12:32:03 +0000 Subject: [PATCH 22/26] fix(ai): preserve safety-stop authority through provider seams Close exact-head review findings for the public provider boundary, streamSimple option mapping, and package export isolation. --- packages/ai/package.json | 1 + packages/ai/src/providers/anthropic.ts | 2 + packages/ai/src/stream.ts | 4 +- .../ai/test/anthropic-stream-envelope.test.ts | 78 +++++++++++++++++++ packages/ai/test/provider-safety-stop.test.ts | 1 + 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index 8780144d8a..b8634434a4 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -68,6 +68,7 @@ "import": "./src/index.ts" }, "./adapter-internals/*": null, + "./adapter-internals/*.js": null, "./*": { "types": "./src/*.ts", "import": "./src/*.ts", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index aba4e6c977..d1ee05f9c4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -24,6 +24,7 @@ import { isProviderSafetyStopAdapterInvocation, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + withProviderSafetyStopAdapterInvocation, } from "../adapter-internals/provider-safety-stop"; import { hasOpus47ApiRestrictions, @@ -1877,6 +1878,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( context: Context, options?: AnthropicOptions, ): AssistantMessageEventStream => { + options = withProviderSafetyStopAdapterInvocation(options ?? {}); const stream = new AssistantMessageEventStream(); (async () => { diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 3341532543..8e59d2cbe2 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -808,7 +808,7 @@ function mapOptionsForApi( options?: SimpleStreamOptions, apiKey?: string, ): OptionsForApi { - const base = { + const base = copyProviderSafetyStopAdapterInvocation(options, { temperature: options?.temperature, topP: options?.topP, topK: options?.topK, @@ -835,7 +835,7 @@ function mapOptionsForApi( attemptScope: options?.attemptScope, execHandlers: options?.execHandlers, [managedAttemptValidated]: hasValidatedManagedAttempt(options), - }; + }); switch (model.api) { case "anthropic-messages": { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 803e61739d..b605246520 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -3,12 +3,15 @@ import { scheduler } from "node:timers/promises"; import { Messages } from "@anthropic-ai/sdk/resources/messages/messages"; import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; import { Effort } from "../src/model-thinking"; +import { getBundledModel } from "../src/models"; import { applyClaudeToolPrefix, streamAnthropic as streamAnthropicProvider, stripClaudeToolPrefix, } from "../src/providers/anthropic"; +import { streamSimple } from "../src/stream"; import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; +import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; type AnthropicStreamOptions = NonNullable[2]>; @@ -1202,6 +1205,81 @@ describe("anthropic stream envelope handling", () => { expect(result.errorMessage).toBe("Content flagged by safety filters"); expect(result.errorKind).toBe("provider_safety_stop"); }); + + it("authenticates direct provider calls without caller transport seams", async () => { + const refusalEvents: MockAnthropicEvent[] = [ + { + type: "message_start", + message: { + id: "msg_direct_refusal", + usage: { + input_tokens: 5, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { + type: "message_delta", + delta: { + stop_reason: "end_turn", + stop_details: { type: "refusal", category: "safety", explanation: "Direct refusal" }, + }, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + { type: "message_stop" }, + ]; + vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); + + const stream = streamAnthropicProvider(model, context, { apiKey: "sk-ant-test" }); + for await (const _ of stream) { + // drain stream + } + const result = await stream.result(); + + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + }); + + it("preserves adapter provenance through streamSimple option mapping", async () => { + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!bundled) throw new Error("Expected bundled Anthropic model"); + const refusalEvents: MockAnthropicEvent[] = [ + { + type: "message_start", + message: { + id: "msg_simple_refusal", + usage: { + input_tokens: 5, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { + type: "message_delta", + delta: { + stop_reason: "end_turn", + stop_details: { type: "refusal", category: "safety", explanation: "Simple refusal" }, + }, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + { type: "message_stop" }, + ]; + vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); + + const stream = streamSimple(bundled, context, { apiKey: "sk-ant-test" }); + for await (const _ of stream) { + // drain stream + } + const result = await stream.result(); + + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + }); + it("keeps a safety stop terminal when later stop reasons and tool events arrive", async () => { const eventsAfterSafety: MockAnthropicEvent[] = [ { diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 5e5be78527..30756f5970 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -249,5 +249,6 @@ describe("provider safety-stop provenance authority", () => { await expect(import(deepImport)).rejects.toThrow(); const manifest = (await import("../package.json", { with: { type: "json" } })).default; expect(manifest.exports["./adapter-internals/*"]).toBeNull(); + expect(manifest.exports["./adapter-internals/*.js"]).toBeNull(); }); }); From 5bc5252342f9c65718b20597cb17597c6b9fecf9 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 13:25:06 +0000 Subject: [PATCH 23/26] fix(ai): close direct safety-stop trust gaps Keep direct provider authority bound to bundled model identity, preserve retryable facts for unauthenticated Google safety signals, and complete the persisted-session changelog statement. --- packages/ai/src/providers/anthropic.ts | 5 ++++- packages/ai/src/providers/google-shared.ts | 20 ++++++++++++++++--- .../ai/test/anthropic-stream-envelope.test.ts | 15 ++++++++++++-- packages/ai/test/google-safety-stop.test.ts | 10 ++++++++++ packages/coding-agent/CHANGELOG.md | 2 +- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index d1ee05f9c4..f3b5e16c3a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -22,6 +22,7 @@ import { } from "@gajae-code/utils"; import { isProviderSafetyStopAdapterInvocation, + isProviderSafetyStopModelTrusted, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, withProviderSafetyStopAdapterInvocation, @@ -1878,7 +1879,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( context: Context, options?: AnthropicOptions, ): AssistantMessageEventStream => { - options = withProviderSafetyStopAdapterInvocation(options ?? {}); + if (isProviderSafetyStopModelTrusted(model)) { + options = withProviderSafetyStopAdapterInvocation(options ?? {}); + } const stream = new AssistantMessageEventStream(); (async () => { diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index fd62b0519a..ad63c9b690 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -681,7 +681,7 @@ export async function consumeGoogleStream(args: { // Terminal authority is minted by the adapter after parsing the // structured candidate finish reason; a wire-assignable field // alone never carries it (#4777). - mintProviderSafetyStop( + const authenticated = mintProviderSafetyStop( output, candidate.finishReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, @@ -689,6 +689,13 @@ export async function consumeGoogleStream(args: { adapterInvocation, ); output.stopReason = "error"; + if (!authenticated) { + output.transportFailure = { + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }; + } } else if (!providerSafetyStop) { output.stopReason = mapStopReason(candidate.finishReason); if (output.stopReason === "stop" && output.content.some(b => b.type === "toolCall")) { @@ -703,7 +710,7 @@ export async function consumeGoogleStream(args: { providerSafetyStop = true; // Prompt-level block reasons carry the same adapter-minted // authority as candidate finish reasons (#4777). - mintProviderSafetyStop( + const authenticated = mintProviderSafetyStop( output, blockReason, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, @@ -711,6 +718,13 @@ export async function consumeGoogleStream(args: { adapterInvocation, ); output.stopReason = "error"; + if (!authenticated) { + output.transportFailure = { + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }; + } } else if (!providerSafetyStop) { output.stopReason = "error"; } @@ -1022,7 +1036,7 @@ export function streamGoogleGenAI { ]; vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); - const stream = streamAnthropicProvider(model, context, { apiKey: "sk-ant-test" }); + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5") as Model<"anthropic-messages"> | undefined; + if (!bundled) throw new Error("Expected bundled Anthropic model"); + const stream = streamAnthropicProvider(bundled, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { // drain stream } @@ -1240,10 +1242,19 @@ describe("anthropic stream envelope handling", () => { expect(result.errorKind).toBe("provider_safety_stop"); expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + + const cloned = { ...bundled, baseUrl: "https://attacker.example/anthropic" }; + const clonedStream = streamAnthropicProvider(cloned, context, { apiKey: "sk-ant-test" }); + for await (const _ of clonedStream) { + // drain stream + } + const clonedResult = await clonedStream.result(); + expect(clonedResult.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(clonedResult)).toBe(false); }); it("preserves adapter provenance through streamSimple option mapping", async () => { - const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5") as Model<"anthropic-messages"> | undefined; if (!bundled) throw new Error("Expected bundled Anthropic model"); const refusalEvents: MockAnthropicEvent[] = [ { diff --git a/packages/ai/test/google-safety-stop.test.ts b/packages/ai/test/google-safety-stop.test.ts index 0869b6fdcb..ae95420360 100644 --- a/packages/ai/test/google-safety-stop.test.ts +++ b/packages/ai/test/google-safety-stop.test.ts @@ -120,6 +120,11 @@ describe("Google safety stops", () => { const result = await stream.result(); expect(result.stopReason).toBe("error"); expect(result.errorKind).toBeUndefined(); + expect(result.transportFailure).toMatchObject({ + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }); }); it("keeps an unauthenticated safety refusal terminal across a later benign finish", async () => { @@ -152,6 +157,11 @@ describe("Google safety stops", () => { expect(result.stopReason).toBe("error"); expect(result.errorKind).toBeUndefined(); expect(result.content.some(block => block.type === "toolCall")).toBe(true); + expect(result.transportFailure).toMatchObject({ + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }); }); it("classifies the exhaustive candidate finish-reason partition", async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f3b4b19e2e..4712a09d3c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,7 +35,7 @@ - Queued SDK prompts now retain their dispatch-time ownership across selection fences instead of reclassifying from a contradictory later streaming snapshot. Fresh promotion, earlier follow-up ordering, and terminal-abort cancellation are reachable again, while `/btw` test fixtures now model the user-drainable queue count required by the current empty-submit contract. - Coordinator stop and idle-reap now initialize canonical namespace state before reading durable deletion recovery. Fresh or upgraded projection-only sessions were misreported as `state_corrupt` before the broker close was attempted, and completed deletion receipts were excluded from the idempotent missing-session lookup; both paths now preserve strict malformed-state rejection while allowing safe cleanup and replay. - Fixed provider safety-stop classification being lost before session persistence (#4777). The managed provider-envelope boundary now preserves only the allowlisted `provider_safety_stop` kind, typed safety stops remain terminal even with transport facts on a multi-model fallback chain, and the regression e2e test is selected both by the focused affected-path route and exactly one normal coding-agent shard. -- A forged `errorKind: "provider_safety_stop"` label can no longer suppress model fallback (#4777 review). Terminal safety-stop authority is now adapter-minted (module-scoped provenance set by the first-party anthropic/openai/google adapters when they parse a structured refusal) rather than carried on message data: the loop strips unauthenticated labels at the stream exit, the managed discard gate re-verifies identity, and transport facts never upgrade provenance, so a compromised provider that names the typed kind on an error envelope degrades to an ordinary fallback-eligible failure on every status (400 and 429 both keep advancing the chain). Authenticated envelopes remain single-dispatch terminal with the manual-switch hint intact, and persisted/reloaded … +- A forged `errorKind: "provider_safety_stop"` label can no longer suppress model fallback (#4777 review). Terminal safety-stop authority is now adapter-minted (module-scoped provenance set by the first-party anthropic/openai/google adapters when they parse a structured refusal) rather than carried on message data: the loop strips unauthenticated labels at the stream exit, the managed discard gate re-verifies identity, and transport facts never upgrade provenance, so a compromised provider that names the typed kind on an error envelope degrades to an ordinary fallback-eligible failure on every status (400 and 429 both keep advancing the chain). Authenticated envelopes remain single-dispatch terminal with the manual-switch hint intact, and persisted/reloaded sessions retain the legacy display classification without retaining live terminal authority, so restart/replay stays fail-closed. - The provider safety-stop minting path is now package-private and capability-branded; public AI imports and structural custom-stream payloads cannot forge terminal authority. Trailing stream completion now sanitizes the final message before managed-shell rebuilding when no `done` or `error` event was emitted, preserving fallback for forged labels (#4777 review). - Retained-publication acquisition diagnostics now name the stage that actually refused instead of always claiming a failed open, and the native errno vocabulary is closed over the actionable `open(2)` refusal set (#4764). `nativeRetainedObstruction()` rendered every unmatched reason as "could not be opened", so a `broker.json` FIFO — which opens fine `O_RDWR | O_NOFOLLOW | O_NONBLOCK` and then refuses `read_to_end` with `WouldBlock` — was reported as an open failure that had in fact succeeded, `clone`/`metadata` refusals of already-open descriptors were reported as opens, and `unsupported-platform` read as an open error. The structured native reason now drives the prose: `errno-`/`io-` render as open refusals, `read-` as a read failure, `clone-`/`metadata[-kind]` as inspection failures of an already-open object, `unsupported-platform` as unimplemented authority, and any future reason falls back to a stable "withheld publication authority (reason)" sentence instead of misnaming the stage; the structured reason is preferred whenever the native message parses, so the observed-state fallback is never entered for a stage-named refusal. The Rust `errno_name()` vocabulary adds `EAGAIN`, `EMFILE`, `ENAMETOOLONG`, `ENFILE`, `ENOMEM`, `EOVERFLOW`, and `EPERM` alongside the existing six — descriptor exhaustion and LSM/seccomp denial no longer collapse to `UNKNOWN` — while every unlisted errno still renders as `UNKNOWN` and no errno number or OS string ever leaves the native layer. - `move_session` no longer lets a cwd transition commit inside an in-flight tool's execution (#4629). The previous fence re-checked the cwd generation and then yielded, so a move that started inside a tool's first `await` retargeted work already admitted against the launcher root: bash computed `commandCwd` from the live session cwd only after `expandInternalUrls`, running a root-A command in root B. Relative-path tools now hold a shared read lease on the session cwd for their whole execution (`SessionManager.runWithCwdReadLease`), and writers drain outstanding leases before committing, so the cwd observed at admission stays authoritative until the tool finishes. Announced writers also block newly arriving readers, so a stream of tool calls cannot starve a queued move, and async bash jobs stay bound to the cwd they were admitted for. From 095f195b8381bdb378f12c95ac465894f6b92d51 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 14:18:45 +0000 Subject: [PATCH 24/26] fix(ai): keep safety-stop revocation private Remove the public authority-revocation escape hatch while retaining runtime cleanup through the package-private adapter seam. --- packages/agent/src/agent-loop.ts | 2 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/utils/provider-safety-stop.ts | 2 +- packages/ai/test/provider-safety-stop.test.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5198e99ab4..f75f948383 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,7 +14,6 @@ import { EventStream, isProviderSafetyStopAuthenticated, isZodSchema, - revokeProviderSafetyStop, streamSimple, type ToolChoice, type ToolResultMessage, @@ -36,6 +35,7 @@ import { } from "@gajae-code/ai/utils"; import { isCursorExecResolved } from "@gajae-code/ai/utils/block-symbols"; import { $credentialEnv, logger, sanitizeText } from "@gajae-code/utils"; +import { revokeProviderSafetyStop } from "../../ai/src/adapter-internals/provider-safety-stop"; import type { AttemptScope } from "./attempt-scope"; import { createHarmonyAuditEvent, diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 27f4307023..ff25776d2e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,7 +10,7 @@ - Codex stale-continuation recovery now also recognizes prose anchor references — `Previous response with id 'resp_1' not found.`, `The previous response 'resp_1' has expired.`, `Unknown previous response 'resp_1'.` — when the specific stale code is masked to `invalid_request_error` by codex-lb (the same masking already handled for `codex_previous_response_stale`) or omitted entirely (#4802). The #4752 matcher required the compact `previous_response_id` field token, so these shapes reached users as fatal `invalid_request_error` events. Prose matching is tempered against sub-field tokens (tool/function/custom-tool call, call/message/item id, output item): a fault naming something INSIDE the previous response (`Unknown item in previous response.`) is a deterministic history fault and stays fatal, since replaying full context re-sends the same offending item. Retry semantics are unchanged and remain one-shot with full context, gated on the failed request actually having carried an anchor. - GLM ZCode OAuth now refreshes its authenticated `/v1/models` catalog automatically, so newly available GLM models are selectable without waiting for the bundled catalog to catch up. Discovery preserves bundled model metadata, sanitizes and bounds every remote display name, rejects catalog entries whose model IDs cannot be handled safely (control-bearing, blank, or overlong IDs are dropped, never renamed — model-selector identity is rendered verbatim), uses the same trusted `ZCODE_PLAN_ANTHROPIC_BASE_URL` decision as model requests, and refreshes legacy static-only cache rows once when live discovery becomes available. - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. -- The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` plus the authority-removing `revokeProviderSafetyStop` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. +- The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. - Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). - Kimi Code, Synthetic, and GitLab Duo wrapper dispatches now preserve the runtime safety-stop invocation provenance into their inner Anthropic/OpenAI adapters, so genuine structured refusals remain terminal without making wrapper options caller-mintable (#4777 review). - Google candidate and prompt safety refusals now remain terminal across later benign finish reasons even when caller-selected transport prevents provenance minting; the result stays an untyped error instead of flipping to `stop`/`toolUse` (#4777 review). diff --git a/packages/ai/src/utils/provider-safety-stop.ts b/packages/ai/src/utils/provider-safety-stop.ts index 95bd5833a2..d4c85b3ff2 100644 --- a/packages/ai/src/utils/provider-safety-stop.ts +++ b/packages/ai/src/utils/provider-safety-stop.ts @@ -5,4 +5,4 @@ * adapter-internals module. Public consumers may only verify existing * authority; message fields and structured refusal text never mint authority. */ -export { isProviderSafetyStopAuthenticated, revokeProviderSafetyStop } from "../adapter-internals/provider-safety-stop"; +export { isProviderSafetyStopAuthenticated } from "../adapter-internals/provider-safety-stop"; diff --git a/packages/ai/test/provider-safety-stop.test.ts b/packages/ai/test/provider-safety-stop.test.ts index 30756f5970..31281f3791 100644 --- a/packages/ai/test/provider-safety-stop.test.ts +++ b/packages/ai/test/provider-safety-stop.test.ts @@ -116,6 +116,7 @@ describe("provider safety-stop provenance authority", () => { const publicSurface = publicAi as unknown as Record; expect(publicSurface.applyProviderSafetyStop).toBeUndefined(); expect(typeof publicSurface.isProviderSafetyStopAuthenticated).toBe("function"); + expect(publicSurface.revokeProviderSafetyStop).toBeUndefined(); expect(publicSurface.transferProviderSafetyStop).toBeUndefined(); }); From 5c785d52b9444f83369116d278412f7cdfad71e5 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 14:36:18 +0000 Subject: [PATCH 25/26] fix(ai): preserve sensitive stop provenance Recognize Anthropic sensitive stop details, keep caller-transport refusals retryable, and retain direct OpenAI bundled-model behavior. --- packages/ai/src/providers/anthropic.ts | 22 +++++++++++++++---- .../ai/src/providers/openai-completions.ts | 5 +++++ .../ai/test/anthropic-stream-envelope.test.ts | 15 +++++++++++++ .../openai-completions-safety-stop.test.ts | 12 ++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index f3b5e16c3a..b505ee440b 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2465,7 +2465,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const rawStopReason = event.delta.stop_reason as string | null | undefined; const stopDetails = event.delta.stop_details; const isProviderSafetyStop = - rawStopReason === "refusal" || rawStopReason === "sensitive" || stopDetails?.type === "refusal"; + rawStopReason === "refusal" || + rawStopReason === "sensitive" || + stopDetails?.type === "refusal" || + stopDetails?.type === "sensitive"; if (rawStopReason) { output.stopReason = isProviderSafetyStop ? "error" : mapStopReason(rawStopReason); sawTerminalEnvelope = true; @@ -2478,13 +2481,24 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( // structured refusal signal was parsed from the stream // delta, so the mark (not the wire field) carries the // authority (#4777). - mintProviderSafetyStop( + const authenticated = mintProviderSafetyStop( output, - stopDetails?.type === "refusal" ? "refusal" : (rawStopReason ?? "refusal"), + stopDetails?.type === "refusal" || stopDetails?.type === "sensitive" + ? stopDetails.type + : rawStopReason === "sensitive" + ? "sensitive" + : "refusal", PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, options?.fetch ?? options?.client, isProviderSafetyStopAdapterInvocation(options), ); + if (!authenticated) { + output.transportFailure = { + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }; + } if (stopDetails?.type === "refusal") { const explanation = stopDetails.explanation?.trim(); const category = stopDetails.category; @@ -2997,7 +3011,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const localAbortReason = activeAbortTracker.getLocalAbortReason(); output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error"; output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error); - output.transportFailure = transportFailureFacts(localAbortReason ?? error); + output.transportFailure = transportFailureFacts(localAbortReason ?? error) ?? output.transportFailure; if (output.errorKind !== "provider_safety_stop" || !output.errorMessage) { output.errorMessage = localAbortReason?.message ?? (await finalizeAnthropicErrorMessage(error, rawRequestDump)); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ab38f633b0..44201a8c0d 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -12,8 +12,10 @@ import type { import packageJson from "../../package.json" with { type: "json" }; import { isProviderSafetyStopAdapterInvocation, + isProviderSafetyStopModelTrusted, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, + withProviderSafetyStopAdapterInvocation, } from "../adapter-internals/provider-safety-stop"; import { type Effort, getSupportedEfforts } from "../model-thinking"; import { calculateCost } from "../models"; @@ -515,6 +517,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( context: Context, options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { + if (isProviderSafetyStopModelTrusted(model)) { + options = withProviderSafetyStopAdapterInvocation(options ?? {}); + } const stream = new AssistantMessageEventStream(); (async () => { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index fc9829627b..5e9f64cda8 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1204,6 +1204,7 @@ describe("anthropic stream envelope handling", () => { expect(result.stopReason).toBe("error"); expect(result.errorMessage).toBe("Content flagged by safety filters"); expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); }); it("authenticates direct provider calls without caller transport seams", async () => { @@ -1251,6 +1252,20 @@ describe("anthropic stream envelope handling", () => { const clonedResult = await clonedStream.result(); expect(clonedResult.errorKind).toBeUndefined(); expect(isProviderSafetyStopAuthenticated(clonedResult)).toBe(false); + + const callerTransportStream = streamAnthropicProvider(bundled, context, { + client: { messages: { create: () => createMockRequest(refusalEvents) } } as never, + }); + for await (const _ of callerTransportStream) { + // drain stream + } + const callerTransportResult = await callerTransportStream.result(); + expect(callerTransportResult.errorKind).toBeUndefined(); + expect(callerTransportResult.transportFailure).toMatchObject({ + kind: "transport", + status: 500, + providerCode: "untrusted_safety_stop", + }); }); it("preserves adapter provenance through streamSimple option mapping", async () => { diff --git a/packages/ai/test/openai-completions-safety-stop.test.ts b/packages/ai/test/openai-completions-safety-stop.test.ts index 2a0db455ab..5890ce21cf 100644 --- a/packages/ai/test/openai-completions-safety-stop.test.ts +++ b/packages/ai/test/openai-completions-safety-stop.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test"; import { streamOpenAICompletions } from "@gajae-code/ai/providers/openai-completions"; import type { AssistantMessageEvent, Context, Model } from "@gajae-code/ai/types"; import { withProviderSafetyStopAdapterInvocation } from "../src/adapter-internals/provider-safety-stop"; +import { getBundledModel } from "../src/models"; +import { isProviderSafetyStopAuthenticated } from "../src/utils/provider-safety-stop"; function trustedOptions(): { apiKey: string } { return withProviderSafetyStopAdapterInvocation({ apiKey: "test" }); @@ -79,6 +81,16 @@ function context(): Context { } describe("chat-completions: provider safety stops", () => { + it("authenticates direct calls for unchanged bundled models", async () => { + const bundled = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions"> | undefined; + if (!bundled) throw new Error("Expected bundled OpenAI model"); + global.fetch = mockFetch([chunk({}, "content_filter"), "[DONE]"]); + + const result = await streamOpenAICompletions(bundled, context(), { apiKey: "test" }).result(); + expect(result.errorKind).toBe("provider_safety_stop"); + expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + }); + it("keeps a content-filter safety stop when a later tool block finishes", async () => { global.fetch = mockFetch([ chunk({}, "content_filter"), From dd93b4334ffa0afb96017d1fdbd315de59fc6d1c Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 14:57:12 +0000 Subject: [PATCH 26/26] fix(ai): fail closed public provider adapters Keep terminal authority exclusively on dispatcher provenance so mutable global or caller-selected transports cannot mint authenticated safety stops. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/anthropic.ts | 5 ----- packages/ai/src/providers/openai-completions.ts | 5 ----- packages/ai/test/anthropic-stream-envelope.test.ts | 6 +++--- packages/ai/test/openai-completions-safety-stop.test.ts | 6 +++--- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ff25776d2e..7aa366c315 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Terminal provider safety-stop authority is now adapter-minted provenance instead of a wire-assignable field: a package-private, module-branded capability lets only first-party adapter parse sites mint authority from a structured refusal signal they actually validated (Anthropic `stop_reason` refusal/sensitive, OpenAI `content_filter`, Google prompt/candidate block reasons), while the public surface exposes only `isProviderSafetyStopAuthenticated` (#4777). Managed runtime rebuilds keep their own module-private destination set; public consumers cannot transfer authority from a genuine source to an arbitrary object. Unrecognized signals fail closed with no kind and no authority, so an adapter bug degrades to ordinary fallback rather than a forced refusal; clones, JSON/persistence round-trips, reloaded messages, and custom streams cannot mint authority from public imports or structural fields. The anthropic, openai-completions, google-shared, and google-gemini-cli adapters now mint through the private path instead of assigning `errorKind` directly. - The safety-stop mint module is now unreachable through the package export map: `./adapter-internals/*` resolves to a null export, so the `"./*"` wildcard can no longer deep-import `mintProviderSafetyStop` or the adapter capability, and public consumers keep only `isProviderSafetyStopAuthenticated` (#4777 review). Pi-native SSE payloads no longer regain safety-stop authority from a caller-controlled fetch or loopback URL; serialized gateway messages stay unauthenticated until the protocol has an authenticated gateway envelope. - Public `stream()` callers can no longer clone a bundled model and redirect its `baseUrl` while still receiving the runtime adapter-invocation token. Safety-stop minting now requires an unchanged bundled model identity and endpoint fingerprint; custom, redirected, or mutated models remain fallback-eligible (#4777 review). +- Low-level public provider adapters are now fail-closed parser seams: only the first-party `stream()` dispatcher supplies adapter provenance, while direct adapter calls (including bundled or manually constructed models) remain fallback-eligible. This prevents mutable global or caller-selected transports from minting terminal safety-stop authority; callers requiring terminal classification should use `stream()`/`streamSimple`. - Kimi Code, Synthetic, and GitLab Duo wrapper dispatches now preserve the runtime safety-stop invocation provenance into their inner Anthropic/OpenAI adapters, so genuine structured refusals remain terminal without making wrapper options caller-mintable (#4777 review). - Google candidate and prompt safety refusals now remain terminal across later benign finish reasons even when caller-selected transport prevents provenance minting; the result stays an untyped error instead of flipping to `stop`/`toolUse` (#4777 review). - Codex stale-continuation recovery now classifies anchor rejections by provider message as well as by error code, so a provider event shaped `{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"Invalid `previous_response_id`."}}` clears `previous_response_id` and the websocket append state and retries exactly once with full conversation context instead of terminating the active session (#4752). Previously only the `previous_response_not_found` and `codex_previous_response_stale` codes (#731) were recognized, and this shape reached users as a fatal `Codex error event: Invalid \`previous_response_id\`. (code=invalid_request_error)`. Three properties bound the new retry: `invalid_request_error` stays in the non-retryable code set so a generic invalid-request failure that does not implicate the anchor remains fatal; classification reads a new `CodexProviderStreamError.providerMessage` (the raw provider text) rather than the display message, because the display message appends `(code=…)` metadata that would otherwise supply a stale qualifier the provider never sent; and the matcher requires the canonical `previous_response_id` field token, so deterministic history faults such as `Previous response's tool call ID is malformed.` stay fatal instead of replaying full context. Recovery additionally requires that the failed request actually carried an anchor (`sentPreviousResponseId`, tracked from the dispatched websocket request), so a rejection naming the field on an anchor-free request — `Invalid request: previous_response_id is required` on a first turn or after an append reset — stays fatal instead of clearing valid session metadata and resending an identical non-retryable body. Recovery is one-shot per turn (`previousResponseRecoveryAttempted`) — once the anchor is cleared the replay carries no anchor, so a repeated rejection surfaces rather than consuming the five-attempt provider retry budget on full-context replays. diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index b505ee440b..004c69cbf4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -22,10 +22,8 @@ import { } from "@gajae-code/utils"; import { isProviderSafetyStopAdapterInvocation, - isProviderSafetyStopModelTrusted, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - withProviderSafetyStopAdapterInvocation, } from "../adapter-internals/provider-safety-stop"; import { hasOpus47ApiRestrictions, @@ -1879,9 +1877,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( context: Context, options?: AnthropicOptions, ): AssistantMessageEventStream => { - if (isProviderSafetyStopModelTrusted(model)) { - options = withProviderSafetyStopAdapterInvocation(options ?? {}); - } const stream = new AssistantMessageEventStream(); (async () => { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 44201a8c0d..ab38f633b0 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -12,10 +12,8 @@ import type { import packageJson from "../../package.json" with { type: "json" }; import { isProviderSafetyStopAdapterInvocation, - isProviderSafetyStopModelTrusted, mintProviderSafetyStop, PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY, - withProviderSafetyStopAdapterInvocation, } from "../adapter-internals/provider-safety-stop"; import { type Effort, getSupportedEfforts } from "../model-thinking"; import { calculateCost } from "../models"; @@ -517,9 +515,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( context: Context, options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { - if (isProviderSafetyStopModelTrusted(model)) { - options = withProviderSafetyStopAdapterInvocation(options ?? {}); - } const stream = new AssistantMessageEventStream(); (async () => { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 5e9f64cda8..2b47cdaaf0 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1207,7 +1207,7 @@ describe("anthropic stream envelope handling", () => { expect(isProviderSafetyStopAuthenticated(result)).toBe(true); }); - it("authenticates direct provider calls without caller transport seams", async () => { + it("keeps direct provider calls unauthenticated without dispatcher provenance", async () => { const refusalEvents: MockAnthropicEvent[] = [ { type: "message_start", @@ -1241,8 +1241,8 @@ describe("anthropic stream envelope handling", () => { } const result = await stream.result(); - expect(result.errorKind).toBe("provider_safety_stop"); - expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + expect(result.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); const cloned = { ...bundled, baseUrl: "https://attacker.example/anthropic" }; const clonedStream = streamAnthropicProvider(cloned, context, { apiKey: "sk-ant-test" }); diff --git a/packages/ai/test/openai-completions-safety-stop.test.ts b/packages/ai/test/openai-completions-safety-stop.test.ts index 5890ce21cf..6a59c677b5 100644 --- a/packages/ai/test/openai-completions-safety-stop.test.ts +++ b/packages/ai/test/openai-completions-safety-stop.test.ts @@ -81,14 +81,14 @@ function context(): Context { } describe("chat-completions: provider safety stops", () => { - it("authenticates direct calls for unchanged bundled models", async () => { + it("keeps direct calls unauthenticated without dispatcher provenance", async () => { const bundled = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions"> | undefined; if (!bundled) throw new Error("Expected bundled OpenAI model"); global.fetch = mockFetch([chunk({}, "content_filter"), "[DONE]"]); const result = await streamOpenAICompletions(bundled, context(), { apiKey: "test" }).result(); - expect(result.errorKind).toBe("provider_safety_stop"); - expect(isProviderSafetyStopAuthenticated(result)).toBe(true); + expect(result.errorKind).toBeUndefined(); + expect(isProviderSafetyStopAuthenticated(result)).toBe(false); }); it("keeps a content-filter safety stop when a later tool block finishes", async () => {