Skip to content

feat(llm,shared): wire media input adapters + SSRF primitive (1.AE)#32

Merged
cemililik merged 7 commits into
mainfrom
development
Jun 18, 2026
Merged

feat(llm,shared): wire media input adapters + SSRF primitive (1.AE)#32
cemililik merged 7 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

1.AE — Media input adapters + SSRF primitive

Implements roadmap item 1.AE (media input adapters, SSRF primitive, OpenAI textOf fix).

What changed

SSRF range-block primitive (@relavium/shared):

  • isPrivateOrLocalHost(), extractHttpsHost(), urlHasCredentials() — pure sync platform-free functions
  • 40+ tests covering loopback, RFC-1918, CGNAT, metadata, link-local, IPv4-mapped IPv6, NAT64, URL credentials, smuggling
  • assertHttpsBaseUrl in openai.ts delegates to shared functions + new URL() normalization
  • MEDIA_URL_SOURCE_ENABLED flipped to true with per-URL SSRF validation at seam boundary

OpenAI adapter (openai.ts):

  • toOpenAiUserContent() replaces textOf for user messages — unflattens image_url and input_audio parts
  • OPENAI_SUPPORTS: vision: true, media.input {image,audio,document:true}, media.outputCombinations
  • DEEPSEEK_SUPPORTS: stays all-false

Anthropic adapter (anthropic.ts):

  • toAnthropicContentBlocks() maps media to image/document blocks

Gemini adapter (gemini.ts):

  • toGeminiMediaPart() maps base64 media to inlineData
  • GEMINI_SUPPORTS: all four input modalities true

Media capability gate (shared.ts):

  • assertNoMediaRequestedassertMediaCapabilities — per-modality input gate + output MEMBERSHIP check
  • LlmMessageSchema.parse() activated at each adapter entry (defense-in-depth)
  • UnsupportedCapabilityError gains optional detail string

capabilities.ts: requiredCapabilities now includes vision when request has media parts

Test plan

  • pnpm turbo run lint typecheck test build — all green
  • SSRF tests: blocked/allowed hosts, IPv4-mapped IPv6, NAT64, URL credentials, smuggling
  • Per-modality gate tests: pass for supported, reject unsupported, fail-closed on unknown MIME
  • LlmMessageSchema.parse() defense-in-depth: PDF/video ceiling=0, unsupported MIME ZodError

Forward obligations (deferred, in deferred-tasks.md)

  • Conformance fixtures for media-in — needs live API recordings
  • mediaUnits mapping (OpenAI audio tokens) — wires at 1.AF
  • OpenAI reasoning-model caps — wires at 1.AF/1.AG
  • Handle/URL source resolution — wires at 1.AF (MediaStore)
  • Host-side EgressCapability.fetch DNS+connect-by-IP — Phase 2

References

  • ADR-0031, ADR-0037, security-review.md, llm-provider-seam.md

Summary by CodeRabbit

  • New Features
    • Enabled multimodal input support for supported image/audio modalities across LLM adapters, converting canonical media into provider-specific request formats.
  • Security
    • Turned on HTTPS url media sources with strengthened SSRF checks (blocks credentials and private/local destinations) and safer base-URL error reporting.
  • Bug Fixes
    • Improved capability gating for media and tool-result media, added stricter validation for unsupported media/subtypes, and now rejects media included on assistant turns instead of ignoring it.
  • Documentation
    • Updated the multimodal I/O roadmap with newly completed items and clarified follow-up areas.

SSRF range-block primitive (@relavium/shared):
- isPrivateOrLocalHost(), extractHttpsHost(), urlHasCredentials() — pure
  sync platform-free functions covering loopback, RFC-1918, CGNAT, metadata,
  link-local, IPv4-mapped IPv6, NAT64-mapped, URL credentials, smuggling
- 40+ SSRF tests in content.test.ts
- assertHttpsBaseUrl in openai.ts delegates to shared + new URL() normalization
- MEDIA_URL_SOURCE_ENABLED flipped to true with per-URL SSRF validation in
  refineInFlightMediaPart (the seam ingestion boundary)

OpenAI adapter:
- toOpenAiUserContent() replaces textOf for user messages — unflattens
  image_url and input_audio parts (the section 1.4 textOf fix)
- OPENAI_SUPPORTS: vision=true, media.input {image,audio,document:true},
  media.outputCombinations [['text'],['text','audio']]
- DEEPSEEK_SUPPORTS: stays all-false (no multimodal support)

Anthropic adapter:
- toAnthropicContentBlocks() maps media parts to image/document blocks
- SUPPORTS: vision=true, media.input {image,document:true}

Gemini adapter:
- toGeminiMediaPart() maps base64 media parts to inlineData
- GEMINI_SUPPORTS: all four input modalities true,
  outputCombinations [['text'],['text','image'],['text','audio']]

Media capability gate (shared.ts):
- assertNoMediaRequested -> assertMediaCapabilities — per-modality input
  gate + output modality MEMBERSHIP check against outputCombinations
- LlmMessageSchema.parse() activated at each adapter entry for
  defense-in-depth (ceiling/caps/URL guard/MIME validation)
- UnsupportedCapabilityError gains optional detail string

capabilities.ts:
- requiredCapabilities now includes vision when request has media parts
  (FallbackChain skip check)

docs(roadmap): update deferred-tasks.md — SSRF primitive checked off,
1.AE forward-obligations added

Refs: ADR-0031, ADR-0037, security-review.md

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @cemililik, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR enables URL media sources at the seam boundary with SSRF-safe validation (isPrivateOrLocalHost, urlHasCredentials, extractHttpsHost), replaces the blanket assertNoMediaRequested gate with a per-modality assertMediaCapabilities function, extends UnsupportedCapabilityError with a detail field for clearer error messaging, and wires base64 media conversion into OpenAI, Anthropic, and Gemini adapters. It also consolidates HTTP egress URL validation by importing the shared helper into tools registry.

Changes

Multimodal Media Gating and SSRF URL Primitives

Layer / File(s) Summary
SSRF URL primitives and seam validation
packages/shared/src/content.ts, packages/shared/src/content.test.ts, packages/llm/src/errors.ts, packages/llm/src/types.test.ts, packages/core/src/tools/registry.ts
Flips MEDIA_URL_SOURCE_ENABLED to true; adds isPrivateOrLocalHost, urlHasCredentials, extractHttpsHost exports; expands refineInFlightMediaPart to enforce HTTPS-only, no-credentials, no-private-host policy. InvalidBaseUrlError now sanitizes URLs using summarizeBaseUrl. Tools registry imports shared extractHttpsHost instead of local version. Schema test updated to expect url-source acceptance.
assertMediaCapabilities gate and error detail
packages/llm/src/adapters/shared.ts, packages/llm/src/adapters/shared.test.ts, packages/llm/src/errors.ts, packages/llm/src/capabilities.ts, packages/llm/src/capabilities.test.ts
Removes assertNoMediaRequested; adds assertMediaCapabilities that gates each media/tool_result part by MIME→modality against supports.media.input and validates outputModalities against outputCombinations. Extends UnsupportedCapabilityError with optional detail field. requiredCapabilities now derives vision from any media content part. Comprehensive test suite covers per-provider input/output combinations and error cases.
OpenAI adapter multimodal wiring
packages/llm/src/adapters/openai.ts, packages/llm/src/adapters/openai.test.ts
Updates OPENAI_SUPPORTS to advertise vision and image/audio inputs. Adds user-content conversion emitting image_url (base64 data URLs) and input_audio (base64, format-normalized) parts. Hardens assertHttpsBaseUrl to reject credential-bearing URLs. Switches both generate and stream to assertMediaCapabilities. Tests verify capability surface, unsupported modality rejection, media lowering, and baseURL credential redaction.
Anthropic adapter multimodal wiring
packages/llm/src/adapters/anthropic.ts, packages/llm/src/adapters/anthropic.test.ts
Updates ANTHROPIC_SUPPORTS for vision and image inputs. Introduces toAnthropicMediaBlock and toAnthropicContentBlocks to emit image/document/thinking blocks; reworks toAnthropicMessage to include media-converted blocks for user messages and reject media on assistant messages. Switches both entry points to assertMediaCapabilities. Tests verify capability surface, wire-format conversion, and media source/subtype validation.
Gemini adapter multimodal wiring
packages/llm/src/adapters/gemini.ts, packages/llm/src/adapters/gemini.test.ts
Updates GEMINI_SUPPORTS to enable vision and image/audio inputs with output combinations. Adds toGeminiMediaPart emitting inlineData for base64 sources; extends toGeminiParts to invoke it and hardens toGeminiContents to reject assistant media. Switches both entry points to assertMediaCapabilities. Tests verify capability surface, base64 conversion, handle/url rejection, and output modality validation.
Roadmap docs
docs/roadmap/deferred-tasks.md
Marks shared SSRF primitive complete with helper names and validation placement. Adds 1.AE forward-obligations section for conformance fixtures, Usage.mediaUnits mapping (OpenAI audio wired; others deferred), OpenAI reasoning-model matrix, and deferred handle/url adapter wiring.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant assertMediaCapabilities
  participant LlmMessageSchema
  participant mediaModalityOf
  participant Adapter Conversion
  participant Provider API

  Client->>assertMediaCapabilities: LlmRequest {messages with media, outputModalities}
  assertMediaCapabilities->>LlmMessageSchema: parse each message
  LlmMessageSchema-->>assertMediaCapabilities: validated messages
  loop each media part
    assertMediaCapabilities->>mediaModalityOf: mimeType
    mediaModalityOf-->>assertMediaCapabilities: modality
    alt modality not in supports.media.input
      assertMediaCapabilities-->>Client: throw UnsupportedCapabilityError(detail)
    end
  end
  alt outputModalities present
    assertMediaCapabilities->>assertMediaCapabilities: filter text, check against outputCombinations
    alt no matching combination
      assertMediaCapabilities-->>Client: throw UnsupportedCapabilityError(detail)
    end
  end
  assertMediaCapabilities-->>Client: pass validation
  Client->>Adapter Conversion: ContentPart[] (text + media)
  alt OpenAI
    Adapter Conversion->>Adapter Conversion: image → image_url with base64 data URL
    Adapter Conversion->>Adapter Conversion: audio → input_audio with base64 + format
  else Anthropic
    Adapter Conversion->>Adapter Conversion: image/document → image/document block with base64
    Adapter Conversion->>Adapter Conversion: reasoning → thinking block if replayable
  else Gemini
    Adapter Conversion->>Adapter Conversion: media → inlineData with mimeType and base64 data
  end
  Adapter Conversion-->>Client: provider-specific wire format
  Client->>Provider API: POST request with converted media
  Provider API-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • HodeTech/Relavium#9: The main PR's OpenAI/DeepSeek adapter and error changes (e.g., InvalidBaseUrlError SSRF/baseURL credential handling and related assertHttpsBaseUrl/URL parsing behavior) directly build on the retrieved PR's introduction of the OpenAI baseURL SSRF guard and InvalidBaseUrlError plumbing.
  • HodeTech/Relavium#11: The main PR builds directly on the ADR-0031 multimodal seam work by updating the same shared media ingestion logic (e.g., flipping MEDIA_URL_SOURCE_ENABLED to true and changing refineInFlightMediaPart URL/SSRF enforcement in packages/shared/src/content.ts), plus aligning adapter/media capability gating with the ADR's media shape.

Poem

🐇 Hopping through the multimodal maze,
Base64 images sent in Anthropic's ways,
SSRF guards block the private IP trails,
assertMediaCapabilities never fails,
With vision enabled and credentials banned —
The bunny wired it all with a careful paw's hand! 🎨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: implementing media input adapters across LLM providers and adding an SSRF validation primitive to the shared package, directly matching the PR's primary objectives.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 1.AE of the media input integration and SSRF policy validation. It enables per-modality media capabilities (image, audio, document) across the Anthropic, Gemini, and OpenAI adapters, replacing the previous blanket media block with a more granular capability gate. It also introduces a shared SSRF range-block primitive (isPrivateOrLocalHost) and URL policy helpers in @relavium/shared to validate egress targets. Feedback on these changes highlights a critical bug in the SSRF filter where legitimate public domains starting with fc or fd (such as fda.gov) are incorrectly blocked because IPv6 unique-local and link-local checks are executed on all hostnames instead of being restricted to IPv6 literals. Additionally, there is an opportunity to reduce code duplication in the Anthropic adapter's message serialization logic.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/shared/src/content.ts Outdated
Comment on lines +732 to +754
if (h.startsWith('fe80:') || h.startsWith('fe80:')) {
return true;
}

if (h.startsWith('fc') || h.startsWith('fd')) {
const next = h.charCodeAt(2);
if ((next >= 0x30 && next <= 0x39) || (next >= 0x61 && next <= 0x66)) {
return true;
}
}

if (h.includes(':')) {
const embeddedDotted = /^(?:::ffff:|64:ff9b::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(h);
if (embeddedDotted !== null) {
return isPrivateOrLocalHost(embeddedDotted[1] ?? '');
}
const embeddedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
if (embeddedHex !== null) {
const hi = Number.parseInt(embeddedHex[1] ?? '0', 16);
const lo = Number.parseInt(embeddedHex[2] ?? '0', 16);
return isPrivateOrLocalHost(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The unique-local (fc00::/7) and link-local (fe80::/10) IPv6 checks are executed on all hostnames, even if they do not contain colons. This causes a critical bug where legitimate public domain names starting with fc or fd followed by a hex character (such as fda.gov or fca.com) are incorrectly classified as private/local hosts and blocked by the SSRF filter.\n\nMoving these checks inside the h.includes(':') block ensures they are only applied to IPv6 literals, preventing false positives for valid public domains.

  if (h.includes(':')) {
    if (h.startsWith('fe80:')) {
      return true;
    }
    if (h.startsWith('fc') || h.startsWith('fd')) {
      const next = h.charCodeAt(2);
      if ((next >= 0x30 && next <= 0x39) || (next >= 0x61 && next <= 0x66)) {
        return true;
      }
    }
    const embeddedDotted = /^(?:::ffff:|64:ff9b::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(h);
    if (embeddedDotted !== null) {
      return isPrivateOrLocalHost(embeddedDotted[1] ?? '');
    }
    const embeddedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
    if (embeddedHex !== null) {
      const hi = Number.parseInt(embeddedHex[1] ?? '0', 16);
      const lo = Number.parseInt(embeddedHex[2] ?? '0', 16);
      return isPrivateOrLocalHost((hi >> 8) + '.' + (hi & 0xff) + '.' + (lo >> 8) + '.' + (lo & 0xff));
    }
  }

Comment on lines +278 to 316
function toAnthropicMessage(message: LlmMessage): Anthropic.MessageParam {
const role = message.role === 'assistant' ? 'assistant' : 'user';
if (role === 'assistant') {
const content: Anthropic.ContentBlockParam[] = [];
for (const part of message.content) {
if (part.type === 'media') {
continue;
}
if (part.type === 'reasoning') {
if (part.redacted === true || part.signature === undefined) {
continue;
}
content.push({ type: 'thinking', thinking: part.text, signature: part.signature });
continue;
}
content.push(toAnthropicBlock(part));
}
return { role: 'assistant', content };
}
const hasMedia = message.content.some((part) => part.type === 'media');
if (!hasMedia) {
const content: Anthropic.ContentBlockParam[] = [];
for (const part of message.content) {
if (part.type === 'media') {
continue;
}
if (part.type === 'reasoning') {
if (part.redacted === true || part.signature === undefined) {
continue;
}
content.push({ type: 'thinking', thinking: part.text, signature: part.signature });
continue;
}
content.push(toAnthropicBlock(part));
}
return { role: 'user', content };
}
return { role: message.role === 'assistant' ? 'assistant' : 'user', content };
return { role: 'user', content: toAnthropicContentBlocks(message.content) };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication in toAnthropicMessage between the role === 'assistant' and role === 'user' (when !hasMedia) branches. We can simplify this function by filtering out media parts for assistant messages and reusing toAnthropicContentBlocks for both roles, which drastically improves maintainability and readability.

function toAnthropicMessage(message: LlmMessage): Anthropic.MessageParam {
  const role = message.role === 'assistant' ? 'assistant' : 'user';
  const contentParts = role === 'assistant'
    ? message.content.filter((part) => part.type !== 'media')
    : message.content;
  return { role, content: toAnthropicContentBlocks(contentParts) };
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/llm/src/adapters/gemini.ts (1)

280-284: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent whole-turn elision when media source kinds are unsupported.

toGeminiMediaPart returns undefined for handle/url, and toGeminiContents drops messages with zero serialized parts. A request can pass capability checks and still lose entire turns before transport. Fail fast on unsupported source kinds rather than silently omitting them.

Also applies to: 296-300

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/llm/src/adapters/gemini.ts` around lines 280 - 284, The issue is
that unsupported media source kinds (handle, url) in the toGeminiMediaPart
function return undefined, causing entire message turns to be silently dropped
when toGeminiParts yields zero parts, even after passing capability checks.
Modify the toGeminiMediaPart function to throw an error immediately when
encountering unsupported source kinds rather than returning undefined, and
ensure this error handling applies to all message processing locations including
the loop at lines 280-284 and the similar block referenced at 296-300. This will
fail fast and alert users to unsupported source types instead of silently
eliding turns.
🧹 Nitpick comments (2)
packages/shared/src/content.ts (1)

693-757: ⚖️ Poor tradeoff

Consider refactoring to reduce cognitive complexity.

SonarCloud flags this function at cognitive complexity 25 (limit 15). While the security logic is correct, extracting helper functions for each address family check would improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/content.ts` around lines 693 - 757, The
isPrivateOrLocalHost function has cognitive complexity of 25 which exceeds the
limit of 15. Refactor this function by extracting helper functions for each
logical check group: localhost checks, IPv4 private ranges (10.x, 172.16-31.x,
192.168.x, 100.64-127.x, 169.254.x, 0.x), IPv6 patterns (fe80:, fc/fd prefixes,
embedded addresses), and special cases like ::1 and ::. Replace the
corresponding if-blocks in the main isPrivateOrLocalHost function with calls to
these helper functions, keeping the main function as a coordinator that returns
true if any helper function returns true.
packages/llm/src/adapters/shared.ts (1)

33-103: ⚖️ Poor tradeoff

Consider extracting helper functions to reduce cognitive complexity.

SonarCloud reports cognitive complexity of 39 (limit 15). While the logic is correct and well-documented, extracting helpers like gateInputModality(part, inputCaps, provider) and validateOutputCombinations(outputModalities, combinations, provider) would improve readability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/llm/src/adapters/shared.ts` around lines 33 - 103, The
assertMediaCapabilities function has cognitive complexity of 39, exceeding the
limit of 15. Extract the input modality validation logic (the nested loops
checking part.type === 'media' and part.type === 'tool_result' with modality
checks) into a helper function like gateInputModalities(messages, inputCaps,
provider), and extract the output modalities validation logic (filtering
non-text modalities and checking against outputCombinations) into a helper
function like validateOutputCombinations(outputModalities, outputCombinations,
provider). Replace the respective code blocks in assertMediaCapabilities with
calls to these new helpers to improve readability and reduce cognitive
complexity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/llm/src/adapters/anthropic.ts`:
- Around line 254-264: In the `toAnthropicContentBlocks` function, the media
handling branches for document and image modalities only process base64 source
kinds and silently skip other source kinds like 'url' or 'handle' via continue
statements. Instead of dropping these unsupported sources, check for all
possible source kinds and throw a typed capability error when encountering
unsupported source kinds that aren't base64. Apply this same fix to all media
handling branches in the hasMedia section of the function to ensure all source
kinds are explicitly handled rather than silently dropped.

In `@packages/llm/src/adapters/openai.ts`:
- Around line 265-275: In the audio modality handling block, add explicit error
handling for unsupported audio source kinds instead of silently ignoring them.
Currently, when part.source.kind is not 'base64', the code falls through without
processing, causing content loss. Add an else clause after the if
(part.source.kind === 'base64') check within the audio modality block that
throws an error indicating that the specific source kind (such as 'url' or
'handle') is not yet supported for audio, ensuring requests fail explicitly
rather than silently dropping audio content.
- Around line 453-455: The hostname normalization in the host constant is
removing IPv6 brackets but not trailing dots, which allows hostnames like
localhost. or service.internal. to bypass the isPrivateOrLocalHost() check and
weaken SSRF protection. Modify the hostname normalization logic to also strip
trailing dots (in addition to the existing bracket removal) before passing the
normalized host to the isPrivateOrLocalHost() function.

In `@packages/shared/src/content.ts`:
- Around line 732-734: The if statement in the link-local IPv6 check contains a
duplicate condition where h.startsWith('fe80:') is checked twice, which is a
copy-paste error. Remove the duplicate condition from the if statement so that
only one h.startsWith('fe80:') check remains, or if a second check was intended
for a different IPv6 format or range, replace the second h.startsWith('fe80:')
with an appropriate alternative condition that adds meaningful value to the
check.

---

Outside diff comments:
In `@packages/llm/src/adapters/gemini.ts`:
- Around line 280-284: The issue is that unsupported media source kinds (handle,
url) in the toGeminiMediaPart function return undefined, causing entire message
turns to be silently dropped when toGeminiParts yields zero parts, even after
passing capability checks. Modify the toGeminiMediaPart function to throw an
error immediately when encountering unsupported source kinds rather than
returning undefined, and ensure this error handling applies to all message
processing locations including the loop at lines 280-284 and the similar block
referenced at 296-300. This will fail fast and alert users to unsupported source
types instead of silently eliding turns.

---

Nitpick comments:
In `@packages/llm/src/adapters/shared.ts`:
- Around line 33-103: The assertMediaCapabilities function has cognitive
complexity of 39, exceeding the limit of 15. Extract the input modality
validation logic (the nested loops checking part.type === 'media' and part.type
=== 'tool_result' with modality checks) into a helper function like
gateInputModalities(messages, inputCaps, provider), and extract the output
modalities validation logic (filtering non-text modalities and checking against
outputCombinations) into a helper function like
validateOutputCombinations(outputModalities, outputCombinations, provider).
Replace the respective code blocks in assertMediaCapabilities with calls to
these new helpers to improve readability and reduce cognitive complexity.

In `@packages/shared/src/content.ts`:
- Around line 693-757: The isPrivateOrLocalHost function has cognitive
complexity of 25 which exceeds the limit of 15. Refactor this function by
extracting helper functions for each logical check group: localhost checks, IPv4
private ranges (10.x, 172.16-31.x, 192.168.x, 100.64-127.x, 169.254.x, 0.x),
IPv6 patterns (fe80:, fc/fd prefixes, embedded addresses), and special cases
like ::1 and ::. Replace the corresponding if-blocks in the main
isPrivateOrLocalHost function with calls to these helper functions, keeping the
main function as a coordinator that returns true if any helper function returns
true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 67230d89-c700-4ce3-ac4a-5f090a7c67d7

📥 Commits

Reviewing files that changed from the base of the PR and between bda9d49 and 4c7a335.

📒 Files selected for processing (14)
  • docs/roadmap/deferred-tasks.md
  • packages/llm/src/adapters/anthropic.test.ts
  • packages/llm/src/adapters/anthropic.ts
  • packages/llm/src/adapters/gemini.test.ts
  • packages/llm/src/adapters/gemini.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.test.ts
  • packages/llm/src/adapters/shared.ts
  • packages/llm/src/capabilities.ts
  • packages/llm/src/errors.ts
  • packages/llm/src/types.test.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts

Comment thread packages/llm/src/adapters/anthropic.ts Outdated
Comment thread packages/llm/src/adapters/openai.ts Outdated
Comment thread packages/llm/src/adapters/openai.ts
Comment thread packages/shared/src/content.ts Outdated
cemililik and others added 2 commits June 18, 2026 00:08
…nitive complexity

Security bugs fixed:
- content.ts: fe80: duplicate condition removed, fc/fd link-local checks moved
  inside h.includes(':') block — fda.gov/fca.com were falsely blocked as
  private (CVE-class SSRF bypass)
- openai.ts: assertHttpsBaseUrl strips trailing dots from hostname —
  localhost. and service.internal. were bypassing isPrivateOrLocalHost

Silent media drop fixes (the section 1.4 bug class):
- openai.ts: handle/url sources for image/audio now throw LlmProviderError
  instead of silently falling through
- anthropic.ts: handle/url sources for image/document now throw
  LlmProviderError instead of silently falling through
- gemini.ts: toGeminiMediaPart throws for handle/url sources and unknown
  MIME types instead of returning undefined

Cognitive complexity reductions:
- content.ts: isPrivateOrLocalHost extracted into isPrivate172, isCgnat100,
  isPrivateIpv6 helpers (25 -> below threshold)
- shared.ts: assertMediaCapabilities extracted into gateInputModalities,
  gateModality, gateOutputCombinations helpers (39 -> below threshold)

Tests:
- SSRF regression tests for fda.gov, fca.com, fe80.com public hostnames

Refs: PR #32 review
Consolidates an independent multi-dimensional review with a second reviewer's findings on
PR #32; every confirmed BLOCKER/HIGH and the actionable MEDIUM/LOW items are folded in.

Security (blocker/high):
- errors.ts: InvalidBaseUrlError no longer interpolates the raw base URL into its message or
  stores it on `.url` — a `user:pass@host` base URL leaked the credential to logs/events. It
  now keeps only a credential-free scheme+host summary (reusing the shared extractHttpsHost).
- content.ts: the shared SSRF range-primitive (isPrivateOrLocalHost) now NORMALIZES before the
  range check, closing real bypasses — numeric IPv4 (decimal 2130706433 / hex 0x7f000001 /
  octal 0177.0.0.1 / inet_aton short forms), the FQDN trailing dot (`localhost.`), and IPv6
  forms (`0::1`, fully-expanded loopback, IPv4-mapped/NAT64 via a proper 8-group parser).
  extractHttpsHost strips trailing dots; urlHasCredentials is HTTPS-only (consistent).
- openai.ts: a url-source media part is no longer forwarded to the provider as `image_url.url`
  (ADR-0031 §A7 — a media url is fetched by host/engine, never the adapter); url/handle are
  rejected with a typed bad_request, matching Anthropic/Gemini.

Correctness/honesty (medium):
- Capability matrices are honest at 1.AE: document:false (all three) + video:false (Gemini) —
  base64 video/document are ceiling-blocked and handle/url are deferred to 1.AF, so advertising
  them was "advertised-but-unsendable".
- openai.ts: audio/mpeg (canonical MP3 MIME) maps to format 'mp3'; an unsupported audio subtype
  (ogg/flac/…) is rejected, never silently coerced to 'wav'; PDF no longer mis-mapped to image_url.
- anthropic.ts + openai.ts: media on an assistant turn throws bad_request instead of a silent drop.
- capabilities.ts: documented that requiredCapabilities() coarsely requires `vision` for any
  media (per-modality FallbackChain gating lands in 1.AF).
- registry.ts: imports the shared extractHttpsHost/hasSmugglingChar instead of a duplicate copy
  (one primitive, never a second parser); the false "shared by enforceHttpEgress" docstring is now true.
- shared.ts: gateModality is typed against CapabilityFlags['media']['input'], not Record<string,boolean>.
- Anthropic content lowering deduped to one helper; deferred-tasks.md 1.AE PR ref corrected (#27#32).

Tests: SSRF negatives for every new encoding (numeric/trailing-dot/compressed+expanded IPv6) +
over-block guards; OpenAI positive media wire (image_url + input_audio) on generate AND stream;
audio/mpeg→mp3 + audio-subtype reject; document-gate + assistant-media reject; InvalidBaseUrlError
credential-redaction; the coarse requiredCapabilities media behavior. Full turbo gate green;
format/seam/shared-purity clean; Leakwatch 0.

Refs: ADR-0031, ADR-0029, ADR-0037, security-review.md, 1.AE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/shared/src/content.ts (1)

895-920: ⚠️ Potential issue | 🔴 Critical

Reject malformed and percent-encoded authorities before extracting the host.

extractHttpsHost() currently accepts invalid cases that downstream URL clients reject or normalize differently:

  • Empty host (https://:443/a)
  • Unmatched brackets (https://[::1/a)
  • Invalid port suffixes after IPv6 literals (https://[2606:4700::1111]evil/a)
  • Percent-encoded authorities (https://%31%32%37.0.0.1/a decodes to 127.0.0.1 in downstream clients)

This creates a parser divergence: extractHttpsHost() validates a different host string than what downstream URL clients canonicalize. The percent-encoded loopback case bypasses both extractHttpsHost() and isPrivateOrLocalHost() since neither decodes percent-encoding.

Add test fixtures for these cases and implement the validation tightening proposed in the diff to reject empty authorities, percent-encoded bytes, unmatched brackets, and invalid port formats.

Proposed direction
   const rawAuthority = match[1] ?? '';
-  if (hasSmugglingChar(rawAuthority)) {
+  if (rawAuthority === '' || hasSmugglingChar(rawAuthority) || /%[0-9a-f]{2}/i.test(rawAuthority)) {
     return null;
   }
@@
   if (authority.startsWith('[')) {
     const end = authority.indexOf(']');
-    host = end === -1 ? authority : authority.slice(1, end);
+    if (end === -1) {
+      return null;
+    }
+    const suffix = authority.slice(end + 1);
+    if (suffix !== '' && !/^:\d{1,5}$/.test(suffix)) {
+      return null;
+    }
+    host = authority.slice(1, end);
   } else {
     const colon = authority.indexOf(':');
     host = colon === -1 ? authority : authority.slice(0, colon);
+    const port = colon === -1 ? '' : authority.slice(colon + 1);
+    if (port !== '' && !/^\d{1,5}$/.test(port)) {
+      return null;
+    }
   }
+  if (host === '' || host.includes('[') || host.includes(']')) {
+    return null;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/content.ts` around lines 895 - 920, The function
extractHttpsHost() currently accepts malformed authorities that downstream URL
clients reject or normalize differently. After extracting rawAuthority from the
regex match, add validation checks to reject: empty authorities (when
rawAuthority is empty string), percent-encoded bytes (by detecting %
characters), unmatched brackets for IPv6 literals (checking that closing ]
exists when opening [ is present), and invalid port formats after closing
bracket. Additionally, create test fixtures covering the four invalid cases
mentioned: empty host with port, unmatched brackets, invalid port suffixes after
IPv6 literals, and percent-encoded authority strings.
🧹 Nitpick comments (1)
packages/shared/src/content.ts (1)

749-787: ⚡ Quick win

Split the IPv4/IPv6 parsers to clear the quality gate.

Sonar reports canonicalizeNumericIpv4 at complexity 17 and parseIpv6Groups at 18, above the allowed 15. Extracting parseNumericIpv4Part, validateInetAtonParts, and an IPv6 expandDoubleColonGroups helper should keep the security parser easier to audit and unblock the check.

Also applies to: 794-838

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/content.ts` around lines 749 - 787, The function
canonicalizeNumericIpv4 has a cyclomatic complexity of 17, exceeding the allowed
threshold of 15. Extract the numeric part parsing logic (the section that
handles hexadecimal, octal, and decimal parsing within the for loop) into a
separate helper function called parseNumericIpv4Part, and extract the validation
logic for inet_aton parts (the two loops that check if values are within valid
ranges) into a separate helper function called validateInetAtonParts.
Additionally, apply similar refactoring to parseIpv6Groups by extracting the
double colon expansion logic into an expandDoubleColonGroups helper function to
reduce its complexity from 18 to below 15. This will make both parsers simpler,
easier to audit for security, and compliant with the quality gate.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/shared/src/content.ts`:
- Around line 693-711: The normalization in the variable assignment for h does
not strip brackets from IPv6 addresses like [::1], so when h.includes(':') check
passes the bracketed form to parseIpv6Groups, it returns null instead of parsing
it. After the current lowercase and trailing dot removal operations in the h
variable assignment, add code to also strip leading [ and trailing ] characters
so that bracketed IPv6 addresses are properly unbracketed before being passed to
parseIpv6Groups for range checking.

---

Outside diff comments:
In `@packages/shared/src/content.ts`:
- Around line 895-920: The function extractHttpsHost() currently accepts
malformed authorities that downstream URL clients reject or normalize
differently. After extracting rawAuthority from the regex match, add validation
checks to reject: empty authorities (when rawAuthority is empty string),
percent-encoded bytes (by detecting % characters), unmatched brackets for IPv6
literals (checking that closing ] exists when opening [ is present), and invalid
port formats after closing bracket. Additionally, create test fixtures covering
the four invalid cases mentioned: empty host with port, unmatched brackets,
invalid port suffixes after IPv6 literals, and percent-encoded authority
strings.

---

Nitpick comments:
In `@packages/shared/src/content.ts`:
- Around line 749-787: The function canonicalizeNumericIpv4 has a cyclomatic
complexity of 17, exceeding the allowed threshold of 15. Extract the numeric
part parsing logic (the section that handles hexadecimal, octal, and decimal
parsing within the for loop) into a separate helper function called
parseNumericIpv4Part, and extract the validation logic for inet_aton parts (the
two loops that check if values are within valid ranges) into a separate helper
function called validateInetAtonParts. Additionally, apply similar refactoring
to parseIpv6Groups by extracting the double colon expansion logic into an
expandDoubleColonGroups helper function to reduce its complexity from 18 to
below 15. This will make both parsers simpler, easier to audit for security, and
compliant with the quality gate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e2b19778-4a3d-4b10-8370-2db48dbb88ae

📥 Commits

Reviewing files that changed from the base of the PR and between 4c7a335 and 9d2bb62.

📒 Files selected for processing (15)
  • docs/roadmap/deferred-tasks.md
  • packages/core/src/tools/registry.ts
  • packages/llm/src/adapters/anthropic.test.ts
  • packages/llm/src/adapters/anthropic.ts
  • packages/llm/src/adapters/gemini.test.ts
  • packages/llm/src/adapters/gemini.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.test.ts
  • packages/llm/src/adapters/shared.ts
  • packages/llm/src/capabilities.test.ts
  • packages/llm/src/capabilities.ts
  • packages/llm/src/errors.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/roadmap/deferred-tasks.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/llm/src/capabilities.ts
  • packages/llm/src/adapters/gemini.ts
  • packages/llm/src/adapters/shared.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.ts

Comment thread packages/shared/src/content.ts
…exity

The PR quality gate failed on one condition only: new_security_hotspots_reviewed = 0%
(the duplication blocker is now passing after the earlier dedup). All four hotspots and the
two CRITICAL new-code smells are resolved at the code level (durable, no dashboard disposition):

- 3× S5852 "regex backtracking" hotspots were all the same trailing-dot strip `/\.+$/` (linear,
  not actually ReDoS-prone, but flagged). Replaced with a non-regex `stripTrailingDots` loop used
  by isPrivateOrLocalHost + extractHttpsHost; the redundant strip in the openai baseURL guard is
  dropped (isPrivateOrLocalHost normalizes internally).
- 1× S5332 "clear-text protocol" hotspot is the deliberate `http://` input under test in
  content.test.ts — annotated `// NOSONAR` (matching the existing convention in openai.test.ts).
- 2× CRITICAL S3776 cognitive-complexity (canonicalizeNumericIpv4=17, parseIpv6Groups=18) on the
  new SSRF code: extracted parseIpv4Octet/packIpv4 and substituteEmbeddedIpv4/expandIpv6 helpers —
  each function now well under the threshold, same behavior.
- S6353: `[0-9]` → `\d` in the decimal-octet regex.

Behavior is unchanged — the full SSRF negative-case suite (116 content tests incl. every numeric/
trailing-dot/compressed+expanded IPv6 bypass) stays green. Full turbo gate green; format clean;
Leakwatch 0. (The ~37 S1313 "hardcoded IP" items are SSRF TEST FIXTURES, non-gate-blocking — best
excluded for **/*.test.ts in the SonarCloud UI rather than churned in code.)

Refs: 1.AE, security-review.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/shared/src/content.ts (1)

949-956: 💤 Low value

Consider returning null for malformed bracketed IPv6.

When the authority starts with [ but has no closing ] (e.g., https://[::1/path), the function returns the entire authority including the bracket as the host. While such malformed URLs would fail at the HTTP level anyway, returning null would provide cleaner fail-fast behavior and prevent isPrivateOrLocalHost from receiving an invalid host string.

Proposed fix
   if (authority.startsWith('[')) {
     const end = authority.indexOf(']');
-    host = end === -1 ? authority : authority.slice(1, end);
+    if (end === -1) {
+      return null; // Malformed bracketed IPv6
+    }
+    host = authority.slice(1, end);
   } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/content.ts` around lines 949 - 956, When the authority
starts with '[' (IPv6 bracket notation), check if a closing ']' bracket exists
by examining the end variable. If end equals -1 (no closing bracket found), the
function should return null to indicate a malformed IPv6 address instead of
assigning the entire authority (including the opening bracket) to the host
variable. This provides fail-fast behavior and prevents invalid host strings
from being processed by downstream code like stripTrailingDots and
isPrivateOrLocalHost.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/shared/src/content.ts`:
- Around line 949-956: When the authority starts with '[' (IPv6 bracket
notation), check if a closing ']' bracket exists by examining the end variable.
If end equals -1 (no closing bracket found), the function should return null to
indicate a malformed IPv6 address instead of assigning the entire authority
(including the opening bracket) to the host variable. This provides fail-fast
behavior and prevents invalid host strings from being processed by downstream
code like stripTrailingDots and isPrivateOrLocalHost.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a72eb80f-07f0-424e-bd03-e1e51bbca71e

📥 Commits

Reviewing files that changed from the base of the PR and between 9d2bb62 and bfb0c2f.

📒 Files selected for processing (3)
  • packages/llm/src/adapters/openai.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/shared/src/content.test.ts
  • packages/llm/src/adapters/openai.ts

cemililik and others added 3 commits June 18, 2026 10:58
…ed authority)

Verified each reviewer finding against current code; fixed the still-valid ones, skipped the
rest with reasons.

- isPrivateOrLocalHost: unbracket a `[::1]`-style IPv6 literal before the range check (a direct
  caller passing a bracketed host previously slipped through parseIpv6Groups as null → not blocked).
  Defense-in-depth: extractHttpsHost + the baseURL guard already strip brackets, so no live caller
  was affected, but the primitive is now robust standalone.
- extractHttpsHost: fail closed on a percent-encoded authority (a literal host never contains `%`;
  decoding it could mask a blocked address) and on an unmatched IPv6 bracket (was returning the
  malformed authority as the host).
- nits: parseIpv6Groups null/length guard → optional chain; a test's escaped-backslash URL → String.raw.

Skipped: the 2 cognitive-complexity findings are already resolved in the prior commit
(parseIpv4Octet/packIpv4 + substituteEmbeddedIpv4/expandIpv6, not yet re-scanned); the ~37 S1313
"hardcoded IP" items are intentional SSRF test fixtures (the IPs are the inputs under test),
non-gate-blocking, to be excluded for **/*.test.ts in the SonarCloud UI.

Tests: bracketed `[::1]`/`[fe80::1]` blocked; percent-encoded + unmatched-bracket authorities
rejected. Full turbo gate green; format clean; Leakwatch 0.

Refs: 1.AE, security-review.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ound)

A final Sonnet-powered 8-dimension adversarial review (34 agents; SSRF findings ran a 3-skeptic
panel) found NO genuine SSRF bypass — the hand-rolled primitive is sound. The 17 confirmed
correctness/coverage/maintainability findings are all folded in:

- MEDIUM: isPrivateOrLocalHost no longer false-positives on public FQDNs whose first label spells a
  private-range prefix (`10.ai`, `127.example.com`, `192.168.fm`). The dotted-prefix range checks now
  run ONLY on a successfully-canonicalized numeric IPv4 (the `?? h` hostname fallback is removed).
- HIGH: Gemini now throws on assistant-role media instead of silently forwarding it to the wire
  (parity with the OpenAI/Anthropic M2 guards).
- LOW: Anthropic image input rejects an unsupported subtype pre-egress (jpeg/png/gif/webp allowlist,
  no unsafe cast); openAiBadRequest threads the real provider (correct attribution for the shared
  OpenAI/DeepSeek adapter); expandIpv6 rejects a no-op `::` (RFC 4291 §2.2); each adapter's
  tool_result.media drop is marked as a 1.AF deferral; Gemini mapContent's deferred output-inlineData
  drop is documented.
- NIT: removed a dangling parseIpv6Groups docstring; extracted the duplicated HTTPS-authority regex to
  one constant; documented the O(history) cost of the per-call seam validation + the base64 scan;
  bumped the deferred-tasks date.

Tests: ALLOWED_HOSTS pins the false-positive fix (5 public FQDNs); the url-carrier integration test
covers metadata/bracketed-IPv6/CGNAT end-to-end; Gemini assistant-media + doc-handle reject; Anthropic
doc-handle reject + image-subtype reject + video assertion; OpenAI url-source reject. Full turbo gate
green; format/seam/shared-purity clean; Leakwatch 0.

(The separately-reported extractHttpsHost unmatched-bracket finding was already fixed in d1b2323.)

Refs: 1.AE, security-review.md, ADR-0031
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cemililik
cemililik merged commit 7325ec3 into main Jun 18, 2026
7 checks passed
@sonarqubecloud

Copy link
Copy Markdown

cemililik added a commit that referenced this pull request Jun 18, 2026
PR #32 (media-input adapters + the shared SSRF policy primitive) is
merged, so flip 1.AE from ◇ to ✅ Done everywhere it is tracked:

- phase-1-engine-and-llm.md: §1.AE bullet header + a "Landed (PR #32)"
  sub-bullet (what shipped vs. what is deferred to 1.AF) + matrix row.
- current.md: the live-status line + the 1.m6 sub-spine callout.
- CLAUDE.md: both status paragraphs.
- README.md: the public status line.

The SSRF *mechanism* half (host DNS-resolve + connect-by-validated-IP +
per-hop redirect), per-modality FallbackChain gating, mediaUnits, recorded
media-in conformance fixtures, and handle/url resolution stay deferred to
1.AF (already recorded in deferred-tasks.md). 1.AF-1.AH remain on 1.m6.

Refs: ADR-0031, ADR-0037

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant