feat(llm): native AWS Bedrock Converse provider (#205) - #461
initializ-mk wants to merge 3 commits into
Conversation
Add a `bedrock` LLM provider that speaks Bedrock's native Converse API (POST /model/<id>/converse[-stream]), so any Bedrock model works with tool-calling through one translation — no compat proxy, no per-model wire matching. SigV4 signing is intrinsic (no auth_scheme) and base_url derives from aws_region. - forge-core/llm/providers/bedrock.go: Chat/ChatStream + Converse request/response translation; region-derived base URL; model-id path percent-encoding. - bedrock_eventstream.go: hand-rolled vnd.amazon.eventstream decoder for converse-stream (stdlib only, prelude + message CRC validated). - sigv4_transport.go: fix the canonical URI to double-encode the path (sign EscapedPath, not Path) as SigV4 requires for every service except S3 — a colon in a model id otherwise 403s with SignatureDoesNotMatch. Passthrough paths (/v1/messages, /chat/completions) are byte-identical. - validation requires model.aws_region; egress allowlist derives the Bedrock host from provider + region. - forge init (TUI wizard + --model-provider/--aws-region flags) and the forge-ui create wizard offer Bedrock: prompt for a region, not a key. Verified live against us.amazon.nova-2-lite-v1:0 (Converse + streaming). go build / go vet clean; forge-core, forge-cli, forge-ui tests pass.
initializ-mk
left a comment
There was a problem hiding this comment.
Review — native AWS Bedrock Converse provider (#205)
Large but well-built. I reviewed the security core (Converse translation, SigV4, the hand-rolled event-stream decoder, egress derivation); the plumbing (init/wizard/UI/catalog/validate) was traced separately. No high-severity defects — the SigV4 double-encoding fix, model-id path encoding, and the binary decoder are careful and correct. Two MEDIUMs and a LOW cluster below.
⚠️ No CI has run on this branch ("no checks reported"). The description claims localgo build/vet/tests green, but that's unverified — please trigger CI before merge.
🟠 MEDIUM #1 — Event-stream decoder: unbounded allocation from an untrusted length (DoS)
bedrock_eventstream.go:70 — rest := make([]byte, totalLen-12) allocates on totalLen (a wire uint32) with no maximum-frame-size check. A 12-byte prelude with an attacker-valid CRC and totalLen ≈ 4 GB triggers a multi-GB allocation before any payload is read → memory-exhaustion DoS. Reachable via a malicious/compromised endpoint or a base_url override (or TLS MITM). AWS caps event-stream frames (~24 MB); reject totalLen above a sane bound right after the prelude-CRC check. (Everything else in the decoder is bounds-safe and CRC-correct.)
🟠 MEDIUM #2 — forge init egress derivation omits the Bedrock host → scaffolded agent blocked at forge run
forge-cli/cmd/init_egress.go (providerDomains map / deriveEgressDomains, ~lines 11-16 & 57-59) has openai/anthropic/gemini but no bedrock — and structurally can't, since the host is region-dependent. So a scaffolded Bedrock agent that also uses a channel/tool/skill/auth (→ allowlist mode) gets a forge.yaml egress allowlist without bedrock-runtime.<region>.amazonaws.com. I confirmed forge run builds its allowlist via EffectiveEgressAllowlist (platform_policy_enforce.go:176), which reads only cfg.Egress.AllowedDomains — it does not merge LLMProviderDomains (that's build-time only, called solely by egress_stage.go). So the agent's own Converse calls are blocked at local forge run. Deployed (forge build) is fine because the build stage adds the host. Fix: special-case bedrock in deriveEgressDomains to add bedrock-runtime.<opts.AWSRegion>.amazonaws.com.
🟡 LOW cluster
- Default model IDs likely uninvocable: catalog defaults to bare
anthropic.claude-sonnet-4-…/claude-3-5-haiku-…, but Anthropic-on-Bedrock generally needs a cross-region inference-profile ID (us.anthropic.*); on-demand throughput returnsValidationException. The default scaffold (Sonnet 4 + us-east-1) may fail at first call — while the PR's own live test used the profile-styleus.amazon.nova-2-lite-v1:0. Useus.-prefixed defaults or a doc note. aws_regionnot format-validated (validate/forge_config.go:107checks only non-empty; TUI/handler likewise). A typo (us-east-1x) passes every gate → bad host + poisons the region-derived egress host. The catalog already defines anaws_regionrule (used for the sigv4 auth field) that isn't applied tomodel.aws_region. Apply^[a-z0-9-]+$.- Empty
model.nameis only a warning but fatal for bedrock (forge_config.go:93) → URL path/model//converse. Make it a bedrock-specific error. - Region requirement not at the shared choke point:
scaffold()/createFunc(ui.go) bypassescollectNonInteractive; only the HTTP handler guards it. A future programmatic caller could scaffold a region-less Bedrock agent. Centralize the check inscaffold(). - TUI region enforcement untested; provider metadata drift (forge-ui hardcodes model lists vs catalog — Bedrock matches today, OpenAI already diverges; no cross-source consistency test).
✅ Verified correct
- SigV4 double-encoding fix: correct per the non-S3 canonical-URI rule; signing
EscapedPath()matches AWS SDK behavior./v1/messages&/chat/completionsare byte-identical (EscapedPath == Path), so the #202 passthrough signatures are unchanged. - Model-id path encoding:
uriEscape(model, true)→%3Asurvives asurl.RawPath, soEscapedPath()on the wire matches the double-encoded canonical URI SigV4 signs. Raw ARNs with/are explicitly out of scope (documented). - Converse translation (system blocks, toolUse/toolResult, tool-schema default, opt-in cachePoint) mirrors the Anthropic client; response + stream parsing correct. Consecutive-role / parallel-tool handling is identical to the working Anthropic provider — not a Bedrock defect, only a shared future gap if forge ever emits parallel tool calls.
- Event-stream decoder (apart from #1): header walk validates every offset, frame-length math uses
uint64(no underflow), prelude + message CRC32 both validated, no slice panics. - Region required at all 4 entry points (validate/init/TUI/forge-ui); no
api_keydemanded or stored for Bedrock; provider enum consistent; Bedrock model list matches catalog↔forge-ui; config wiring (region→client, factory routing) correct; tests + docs/CHANGELOG present.
Verdict: changes requested — the two MEDIUMs (decoder allocation bound; init egress Bedrock host) are the priorities; the LOW cluster is hardening/consistency per the standing convention that non-blocking findings are still tracked as requested changes.
| return nil, fmt.Errorf("eventstream: invalid frame lengths (total=%d headers=%d)", totalLen, headersLen) | ||
| } | ||
|
|
||
| rest := make([]byte, totalLen-12) |
There was a problem hiding this comment.
🟠 MEDIUM #1 — unbounded allocation from an untrusted length (DoS). totalLen is a wire uint32 validated only for >= 16 (line 66); there's no MAX. A 12-byte prelude with an attacker-valid prelude CRC and totalLen ≈ 4 GB reaches this make([]byte, totalLen-12) and allocates multiple GB before io.ReadFull reads any payload → memory-exhaustion DoS. Reachable from a malicious/compromised endpoint or a base_url override (the default AWS endpoint is trusted, but the client honors an operator/attacker base_url). AWS caps event-stream frames (~24 MB); add a bound like if totalLen > maxFrameSize { return error } right after the prelude-CRC check, before allocating.
…dation Review follow-ups on the native Bedrock Converse provider (#205): MEDIUM - bedrock_eventstream.go: bound frame totalLen (maxEventStreamFrame) before the make() allocation, so a hostile prelude claiming ~4 GB is rejected instead of exhausting memory (reachable via a base_url override / MITM). - init egress: deriveEgressDomains now adds bedrock-runtime.<region>.amazonaws.com (region-derived, so it can't live in the static providerDomains map). Without it a scaffolded Bedrock agent that also uses a channel/tool/skill/auth was blocked at `forge run`, which reads only cfg.Egress.AllowedDomains. LOW - Default model ids are US cross-region inference-profile ids (us.* prefix) across catalog / init / forge-ui — most current Bedrock models are no longer on-demand-invokable and a bare id ValidationExceptions on first call. Docs note the region-scoped prefix requirement. - validate: format-check model.aws_region (^[a-z0-9-]+$) and make empty model.name a bedrock error (it is the /model/<id>/converse path segment). - Enforce the region requirement at the shared scaffold() choke point, not only in collectNonInteractive (the forge-ui createFunc path bypasses it). - forge-ui sources its Bedrock model list from the shared catalog instead of hardcoding it, so the web wizard can't drift from the CLI/TUI. Tests: oversize-frame rejection, egress host derivation, region-format + empty-name validation, catalog↔forge-ui consistency, TUI region→context wiring. go build / vet clean; forge-core, forge-cli, forge-ui suites pass.
|
Thanks for the thorough review — all findings addressed in 🟠 MEDIUM #1 — event-stream unbounded allocationFixed. 🟠 MEDIUM #2 —
|
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review — fix commit 7aa29d37 ✅ all findings resolved
Verified each fix against source. Both MEDIUMs and the full LOW cluster are addressed, with genuine tests.
| Finding | Fix | Verified |
|---|---|---|
| 🟠 MEDIUM #1 — decoder DoS | maxEventStreamFrame = 32 MB; totalLen bounded after the prelude-CRC check, before make() |
✅ TestEventStreamDecoder_RejectsOversizeFrame builds a valid-CRC prelude with totalLen ≈ 4 GB and asserts a "too large" error — exercises the real allocation path |
| 🟠 MEDIUM #2 — init egress host | deriveEgressDomains adds bedrock-runtime.<region>.amazonaws.com for a Bedrock primary |
✅ + egress-host test |
| 🟡 default model IDs | catalog / init / forge-ui now use us.* inference-profile IDs, with a region-prefix doc note |
✅ |
🟡 aws_region format |
^[a-z0-9-]+$ enforced in ValidateForgeConfig |
✅ + test |
🟡 empty model.name for bedrock |
now a hard error (it's the /model/<id>/converse path segment) |
✅ + test |
| 🟡 shared choke point | scaffold() now enforces the region requirement (covers the forge-ui createFunc path) |
✅ |
| 🟡 forge-ui drift | forge-ui sources the Bedrock list from catalog.ProviderByID instead of hardcoding |
✅ + consistency test |
| 🟡 TUI enforcement untested | provider_step_test.go added |
✅ |
⚠️ Blocking before merge: CI still isn't running
The branch still shows "no checks reported." The description says the suites pass locally, and this fix adds solid tests — but a multi-module change to security-critical SigV4 signing and a hand-rolled binary parser should not merge without CI actually executing. Please get the workflows to run (and go green) on this branch before merging.
Minor residual (informational, non-blocking)
deriveEgressDomains (and the build-time security.LLMProviderDomains) derive the Bedrock host only for the primary model, not a Bedrock fallback — so provider: openai with a bedrock fallback would omit the fallback's Converse host from the allowlist. Narrow edge, symmetric across both egress paths, and not introduced by this PR's changes — worth a follow-up if Bedrock-as-fallback becomes a supported pattern.
Verdict: all review findings resolved. The provider is well-built — correct SigV4 double-encoding, careful (now allocation-bounded) event-stream decoder, translation faithful to the Converse API, region required and format-checked at every entry point, egress wired for both forge run and deploy. The only gate left is getting CI to run green on the branch.
initializ-mk
left a comment
There was a problem hiding this comment.
Final check — merge 472730d1d clean, CI green ✅
The conflict resolution and CI gate are both cleared.
Conflict resolution verified. The merge of origin/main had exactly one conflict: forge-core/validate/forge_config.go, where this branch's Bedrock validation met main's #464 bearer-auth_scheme changes. The resolution correctly kept both sides — aws_region required + format-checked (^[a-z0-9-]+$) + empty-model.name error, alongside main's AuthSchemeBearer additions — with no conflict markers or dropped lines. Every other Bedrock and security-core file (provider, event-stream decoder, SigV4, egress, catalog, init_egress) is byte-identical across the merge, so all the review fixes are intact.
CI now green — the blocker from my last re-review is cleared: all 10 checks pass (Build ×6, Lint, Test, Integration Tests, Doc-link), including the new oversize-frame DoS-guard test and the egress/region/validation tests.
Verdict: all review findings resolved, merge is clean, CI green — good to merge. Only mergeStateStatus: BLOCKED remains, which is branch protection expecting an approving review — and since this is your own PR, GitHub won't let me (or you) self-approve, so that needs a second reviewer's approval. Nothing left on the code side.
Recap of the full arc: two MEDIUMs (event-stream allocation bound; init-egress Bedrock host for forge run) and a LOW cluster (uninvocable default IDs → us.* profiles, aws_region format validation, empty-name-fatal error, shared scaffold() choke point, forge-ui catalog sourcing to kill drift) — all fixed with genuine tests, on a well-built provider (correct SigV4 double-encoding, careful binary decoder, faithful Converse translation).
Summary
Adds a native
bedrockLLM provider that speaks Bedrock's model-agnostic Converse API (POST /model/<id>/converse[-stream]), so any Bedrock model (Claude, Nova, Llama, Mistral, Titan) works with tool-calling through one translation — no compat proxy, no per-model wire matching. This is the #205 follow-up to the #202auth_scheme: aws_sigv4passthrough (which only signs an OpenAI/Anthropic-shaped request).Minimal config — SigV4 is intrinsic,
base_urlderives from the region:What's included
forge-core/llm/providers/bedrock.go—Chat/ChatStream/ModelID, Converse request/response translation (system blocks,toolUse/toolResult,toolConfig,inferenceConfig), region-derived base URL, model-id path percent-encoding, opt-incachePointprompt caching.bedrock_eventstream.go— hand-rolledvnd.amazon.eventstreamdecoder forconverse-stream(stdlib only — no aws-sdk-go-v2, matching the SigV4 signer; prelude + message CRC32 validated).sigv4_transport.go) — canonical URI now double-encodes the path (signsEscapedPath(), notPath) as SigV4 mandates for every service except S3. Without it a colon in a model id 403s withSignatureDoesNotMatch. The Support Anthropic-format custom URLs (incl. AWS Bedrock) via provider: anthropic #202 passthrough paths (/v1/messages,/chat/completions) contain no reserved chars, so their signatures are byte-identical — no behavior change.forge validaterequiresmodel.aws_regionforprovider: bedrock; the egress allowlist derivesbedrock-runtime.<region>.amazonaws.comfrom provider + region.forge init— TUI wizard offers "AWS Bedrock" (prompts for region + model, no API key); non-interactive path takes--model-provider bedrock --aws-region <region>. Sourced from the sharedforge-core/catalog.aws_region.Testing
bedrock.go, the eventstream decoder (CRC/truncation/exception frames), the SigV4 double-encoding regression, catalog, validation, init scaffold, and the forge-ui handler.us.amazon.nova-2-lite-v1:0— both non-streaming Converse and streaming (converse-stream) returned correctly.go build+go vetclean;forge-core,forge-cli,forge-uitest suites pass.Scope / follow-ups
ChatMessagebody grows beyond a string.AWS_ACCESS_KEY_ID/_SECRET_ACCESS_KEY/_SESSION_TOKEN); IRSA/STS resolution remains the same follow-up as Support Anthropic-format custom URLs (incl. AWS Bedrock) via provider: anthropic #202.