diff --git a/authbridge/authlib/listener/extproc/mcpreject_test.go b/authbridge/authlib/listener/extproc/mcpreject_test.go new file mode 100644 index 000000000..7639a0860 --- /dev/null +++ b/authbridge/authlib/listener/extproc/mcpreject_test.go @@ -0,0 +1,110 @@ +package extproc + +import ( + "encoding/json" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// pctxWithMCP builds the minimum pctx needed to trigger the JSON-RPC +// rendering path on the extproc listener. +func pctxWithMCP(id any) *pipeline.Context { + return &pipeline.Context{ + Extensions: pipeline.Extensions{ + MCP: &pipeline.MCPExtension{ + Method: "tools/call", + RPCID: id, + }, + }, + } +} + +func ibacBlocked() pipeline.Action { + return pipeline.DenyStatus(403, "ibac.blocked", "intent does not align") +} + +// immediateBody fishes the ImmediateResponse body out of a +// ProcessingResponse — saves boilerplate in every assertion below. +func immediateBody(t *testing.T, resp *extprocv3.ProcessingResponse) (typev3.StatusCode, []byte, []*corev3.HeaderValueOption) { + t.Helper() + imm, ok := resp.GetResponse().(*extprocv3.ProcessingResponse_ImmediateResponse) + if !ok { + t.Fatalf("response is not ImmediateResponse: %T", resp.GetResponse()) + } + var hs []*corev3.HeaderValueOption + if imm.ImmediateResponse.Headers != nil { + hs = imm.ImmediateResponse.Headers.SetHeaders + } + return imm.ImmediateResponse.Status.Code, imm.ImmediateResponse.Body, hs +} + +// TestRejectFromActionForRequest_MCPRequest verifies the extproc +// listener emits an HTTP 200 ImmediateResponse with a JSON-RPC error +// body when the rejected request was MCP — the envoy-sidecar twin of +// the forwardproxy fix. +func TestRejectFromActionForRequest_MCPRequest(t *testing.T) { + resp := rejectFromActionForRequest(ibacBlocked(), pctxWithMCP(float64(7))) + status, body, headers := immediateBody(t, resp) + + if status != typev3.StatusCode_OK { + t.Fatalf("status = %v, want OK (200) — JSON-RPC errors travel over a 200 transport", status) + } + gotCT := false + for _, h := range headers { + if h.Header.Key == "content-type" && string(h.Header.RawValue) == "application/json" { + gotCT = true + } + } + if !gotCT { + t.Errorf("content-type header missing or wrong: %v", headers) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("body is not JSON: %v\n%s", err, body) + } + if parsed["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want 2.0", parsed["jsonrpc"]) + } + if id, _ := parsed["id"].(float64); id != 7 { + t.Errorf("id = %v, want 7", parsed["id"]) + } + errObj, _ := parsed["error"].(map[string]any) + if errObj["code"] != float64(-32000) { + t.Errorf("error.code = %v, want -32000", errObj["code"]) + } + if errObj["message"] != "intent does not align" { + t.Errorf("error.message = %v, want IBAC reason", errObj["message"]) + } +} + +// TestRejectFromActionForRequest_NonMCPFallsBack: non-MCP outbound +// denials keep today's transport-level error shape. Asserts the +// status mirrors the violation, not 200. +func TestRejectFromActionForRequest_NonMCPFallsBack(t *testing.T) { + resp := rejectFromActionForRequest(ibacBlocked(), &pipeline.Context{}) + status, _, _ := immediateBody(t, resp) + if status != typev3.StatusCode_Forbidden { + t.Fatalf("status = %v, want Forbidden (403) — non-MCP request should keep HTTP-level shape", status) + } +} + +// TestRejectFromActionForRequest_NotificationFallsBack: JSON-RPC +// notifications (id == nil) get no JSON-RPC response by spec, so we +// fall through to the HTTP-level shape rather than echoing a null id. +func TestRejectFromActionForRequest_NotificationFallsBack(t *testing.T) { + pctx := &pipeline.Context{ + Extensions: pipeline.Extensions{ + MCP: &pipeline.MCPExtension{Method: "tools/call", RPCID: nil}, + }, + } + resp := rejectFromActionForRequest(ibacBlocked(), pctx) + status, _, _ := immediateBody(t, resp) + if status != typev3.StatusCode_Forbidden { + t.Fatalf("status = %v, want Forbidden (403) — notification should keep HTTP-level shape", status) + } +} diff --git a/authbridge/authlib/listener/extproc/server.go b/authbridge/authlib/listener/extproc/server.go index 557e14c22..4cbc6d00f 100644 --- a/authbridge/authlib/listener/extproc/server.go +++ b/authbridge/authlib/listener/extproc/server.go @@ -22,6 +22,7 @@ import ( "google.golang.org/grpc/status" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/httpx" "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/internal/sseframe" "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/skiphost" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" @@ -499,7 +500,7 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer if action.Type == pipeline.Reject { s.recordOutboundReject(pctx, action) s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx)) - return rejectFromAction(action), nil + return rejectFromActionForRequest(action, pctx), nil } s.recordOutboundSession(pctx) @@ -551,7 +552,7 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe if action.Type == pipeline.Reject { s.recordOutboundReject(pctx, action) s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx)) - return rejectFromAction(action), nil + return rejectFromActionForRequest(action, pctx), nil } s.recordOutboundSession(pctx) @@ -851,6 +852,30 @@ func replaceTokenResponse(token string) *extprocv3.ProcessingResponse { } } +// rejectFromActionForRequest is the MCP-aware sibling of rejectFromAction. +// When pctx carries an MCP JSON-RPC request shape (Method + non-nil RPCID), +// the response is an HTTP 200 carrying a JSON-RPC 2.0 error frame so the +// caller's MCP client surfaces this as one failed tool call rather than a +// transport break. All other shapes fall through to rejectFromAction. +func rejectFromActionForRequest(action pipeline.Action, pctx *pipeline.Context) *extprocv3.ProcessingResponse { + if pctx != nil && pctx.Extensions.MCP != nil && + pctx.Extensions.MCP.Method != "" && pctx.Extensions.MCP.RPCID != nil { + body := httpx.MarshalMCPRejectionBody(action, pctx.Extensions.MCP.RPCID) + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extprocv3.ImmediateResponse{ + Status: &typev3.HttpStatus{Code: typev3.StatusCode(http.StatusOK)}, + Body: body, + Headers: &extprocv3.HeaderMutation{SetHeaders: []*corev3.HeaderValueOption{{ + Header: &corev3.HeaderValue{Key: "content-type", RawValue: []byte("application/json")}, + }}}, + }, + }, + } + } + return rejectFromAction(action) +} + // rejectFromAction turns a pipeline Reject into an Envoy ImmediateResponse, // preserving the plugin's status/headers/body. Replaces the old // denyResponse helper which hardcoded {"error":...,"message":...} at each diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index 788834f2e..6c4a6ad71 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -260,7 +260,12 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { if action.Type == pipeline.Reject { s.recordOutboundReject(pctx, action) - httpx.WriteRejection(w, action) + // Render as a JSON-RPC error frame when the rejected + // request was MCP JSON-RPC, so the agent's MCP client + // surfaces this as one failed tool call rather than a + // transport break. Falls through to plain HTTP-level + // rejection for non-MCP traffic. + httpx.WriteRejectionForRequest(w, action, pctx) return } } @@ -748,7 +753,12 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { action := s.OutboundPipeline.Run(r.Context(), pctx) if action.Type == pipeline.Reject { s.recordOutboundReject(pctx, action) - httpx.WriteRejection(w, action) + // Render as a JSON-RPC error frame when the rejected + // request was MCP JSON-RPC, so the agent's MCP client + // surfaces this as one failed tool call rather than a + // transport break. Falls through to plain HTTP-level + // rejection for non-MCP traffic. + httpx.WriteRejectionForRequest(w, action, pctx) return } } diff --git a/authbridge/authlib/listener/httpx/render.go b/authbridge/authlib/listener/httpx/render.go index 740d24d9f..414f673b5 100644 --- a/authbridge/authlib/listener/httpx/render.go +++ b/authbridge/authlib/listener/httpx/render.go @@ -4,6 +4,7 @@ package httpx import ( + "encoding/json" "net/http" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" @@ -28,3 +29,128 @@ func WriteRejection(w http.ResponseWriter, action pipeline.Action) { w.WriteHeader(status) _, _ = w.Write(body) } + +// WriteRejectionForRequest renders a Reject the same way as +// WriteRejection EXCEPT when the rejected request was a JSON-RPC +// request that an MCP-aware parser already classified — in that case +// it writes a JSON-RPC 2.0 error frame at HTTP 200 with the original +// id echoed back, so the caller's MCP client surfaces this as a +// failed tool call rather than a transport break. +// +// The MCP-shape detection is conservative: we only rewrite when +// pctx.Extensions.MCP is populated with a non-empty Method AND a +// non-nil RPCID. JSON-RPC notifications (no id) deliberately fall +// through to plain WriteRejection — by spec the client expects no +// response, so emitting a JSON-RPC error frame would violate the +// notification contract. +// +// All non-MCP requests fall through to WriteRejection, so this is a +// safe drop-in replacement at any call site. +func WriteRejectionForRequest(w http.ResponseWriter, action pipeline.Action, pctx *pipeline.Context) { + if !shouldRenderMCPError(pctx) { + WriteRejection(w, action) + return + } + writeMCPRejection(w, action, pctx.Extensions.MCP.RPCID) +} + +func shouldRenderMCPError(pctx *pipeline.Context) bool { + if pctx == nil || pctx.Extensions.MCP == nil { + return false + } + mcp := pctx.Extensions.MCP + if mcp.Method == "" || mcp.RPCID == nil { + return false + } + return true +} + +// JSON-RPC 2.0 error code for application errors. -32000..-32099 is +// the "implementation-defined server-error" range reserved by the +// spec; -32000 is the conventional generic value used when there's +// no protocol-level reason for a more specific code. Authbridge's +// denials are policy decisions outside the JSON-RPC layer, so the +// generic server-error code fits — operators read the human reason +// and structured details, not the numeric code, to understand what +// happened. +const jsonRPCServerError = -32000 + +func writeMCPRejection(w http.ResponseWriter, action pipeline.Action, id any) { + body := MarshalMCPRejectionBody(action, id) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +// mcpRejectionFallback is a precomputed JSON-RPC 2.0 error frame used as +// the last-resort body when both the full and minimal marshal attempts +// fail. It is guaranteed to round-trip through json.Unmarshal so MCP +// clients always see a parseable frame on a 200 application/json +// response. +var mcpRejectionFallback = []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"request rejected"}}`) + +// MarshalMCPRejectionBody renders a Reject Action as a JSON-RPC 2.0 +// error frame body. It is guaranteed to return a non-empty, parseable +// JSON byte slice so callers can safely send it on a 200 +// application/json response: marshal the full frame first; on error +// (e.g. a plugin populated Violation.Details with an unmarshalable +// value, or id is unmarshalable), retry with a minimal frame that omits +// optional data and falls back to id=null; on further error, return a +// constant precomputed frame. +func MarshalMCPRejectionBody(action pipeline.Action, id any) []byte { + v := action.Violation + message := "request rejected" + var data map[string]any + if v != nil { + if v.Reason != "" { + message = v.Reason + } + data = map[string]any{} + if v.Code != "" { + data["error"] = v.Code + } + if v.PluginName != "" { + data["plugin"] = v.PluginName + } + if v.Description != "" { + data["description"] = v.Description + } + if len(v.Details) > 0 { + data["details"] = v.Details + } + if len(data) == 0 { + data = nil + } + } + if body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": jsonRPCServerError, + "message": message, + "data": data, + }, + }); err == nil { + return body + } + // Full frame failed to marshal — retry without optional data, and + // keep the original id only if it survives marshaling on its own + // (otherwise drop to null per JSON-RPC 2.0 §5.1). + safeID := any(nil) + if id != nil { + if _, err := json.Marshal(id); err == nil { + safeID = id + } + } + if body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": safeID, + "error": map[string]any{ + "code": jsonRPCServerError, + "message": message, + }, + }); err == nil { + return body + } + return mcpRejectionFallback +} diff --git a/authbridge/authlib/listener/httpx/render_test.go b/authbridge/authlib/listener/httpx/render_test.go new file mode 100644 index 000000000..93d3f8d29 --- /dev/null +++ b/authbridge/authlib/listener/httpx/render_test.go @@ -0,0 +1,247 @@ +package httpx + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// mcpAction matches what mcp-parser populates plus what ibac (or any +// outbound gate) would return when denying the request — the ibac.blocked +// shape with status 403 is the IBAC-deny case that motivates this code +// path. +func mcpAction() pipeline.Action { + return pipeline.DenyStatus(403, "ibac.blocked", "intent does not align") +} + +// pctxWithMCP builds the minimum pctx needed to trigger the JSON-RPC +// rendering path — a populated MCPExtension with a method and an id. +func pctxWithMCP(id any) *pipeline.Context { + return &pipeline.Context{ + Extensions: pipeline.Extensions{ + MCP: &pipeline.MCPExtension{ + Method: "tools/call", + RPCID: id, + }, + }, + } +} + +// TestWriteRejectionForRequest_MCPRequest_RendersJSONRPCError is the +// core assertion: when the rejected request was MCP JSON-RPC, the +// listener must emit HTTP 200 carrying a JSON-RPC 2.0 error frame +// with the original id, not a transport-level 4xx/5xx. The agent's +// MCP client surfaces the failure as a per-tool-call error rather +// than a session break. +func TestWriteRejectionForRequest_MCPRequest_RendersJSONRPCError(t *testing.T) { + rec := httptest.NewRecorder() + WriteRejectionForRequest(rec, mcpAction(), pctxWithMCP(float64(42))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (JSON-RPC errors travel over a 200 transport)", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("content-type = %q, want application/json", got) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v\n%s", err, rec.Body.String()) + } + if body["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want 2.0", body["jsonrpc"]) + } + if id, ok := body["id"].(float64); !ok || id != 42 { + t.Errorf("id = %v (%T), want 42", body["id"], body["id"]) + } + errObj, ok := body["error"].(map[string]any) + if !ok { + t.Fatalf("error field missing or wrong type: %v", body["error"]) + } + if errObj["code"] != float64(-32000) { + t.Errorf("error.code = %v, want -32000", errObj["code"]) + } + if errObj["message"] != "intent does not align" { + t.Errorf("error.message = %v, want IBAC reason", errObj["message"]) + } + data, ok := errObj["data"].(map[string]any) + if !ok { + t.Fatalf("error.data missing or wrong type: %v", errObj["data"]) + } + if data["error"] != "ibac.blocked" { + t.Errorf("error.data.error = %v, want ibac.blocked", data["error"]) + } +} + +// TestWriteRejectionForRequest_MCPStringID_PreservesType verifies the +// id round-trip preserves the JSON-RPC id type. JSON-RPC 2.0 §4 +// allows string, number, or null ids; mcp-parser carries whatever the +// caller sent verbatim, and the response MUST echo the same type. +func TestWriteRejectionForRequest_MCPStringID_PreservesType(t *testing.T) { + rec := httptest.NewRecorder() + WriteRejectionForRequest(rec, mcpAction(), pctxWithMCP("call-abc")) + + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["id"] != "call-abc" { + t.Errorf("id = %v (%T), want \"call-abc\"", body["id"], body["id"]) + } +} + +// TestWriteRejectionForRequest_NotificationFallsBack covers JSON-RPC +// notifications: requests without an id. Per spec the client expects +// no response, so writing a JSON-RPC error frame would violate the +// notification contract. We fall through to the HTTP-level rejection. +func TestWriteRejectionForRequest_NotificationFallsBack(t *testing.T) { + rec := httptest.NewRecorder() + pctx := &pipeline.Context{ + Extensions: pipeline.Extensions{ + MCP: &pipeline.MCPExtension{Method: "tools/call", RPCID: nil}, + }, + } + WriteRejectionForRequest(rec, mcpAction(), pctx) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (notification → fall through to HTTP-level)", rec.Code) + } +} + +// TestWriteRejectionForRequest_NonMCPFallsBack covers the broad case: +// IBAC denies plenty of non-MCP traffic (plain HTTP, A2A egress, +// inference). Those continue to render as today's HTTP-level errors +// because there's no JSON-RPC client expecting a JSON-RPC response. +func TestWriteRejectionForRequest_NonMCPFallsBack(t *testing.T) { + rec := httptest.NewRecorder() + WriteRejectionForRequest(rec, mcpAction(), &pipeline.Context{}) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (no MCP ext → fall through)", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("content-type = %q, want application/json (default Render shape)", got) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + if _, isJSONRPC := body["jsonrpc"]; isJSONRPC { + t.Errorf("non-MCP request must NOT render as JSON-RPC: %s", rec.Body.String()) + } +} + +// TestWriteRejectionForRequest_NilContextSafe is a defensive guard. +// Listeners always pass a non-nil pctx today, but a nil-safe helper +// is one less footgun. +func TestWriteRejectionForRequest_NilContextSafe(t *testing.T) { + rec := httptest.NewRecorder() + WriteRejectionForRequest(rec, mcpAction(), nil) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (nil pctx → fall through)", rec.Code) + } +} + +// TestWriteRejectionForRequest_503JudgeUnavailable_StillJSONRPC verifies +// the IBAC-judge-down case. Pre-fix, this surfaced as a transport 503 +// — the worst kind of failure for an MCP session. Post-fix, even the +// availability error renders as a JSON-RPC error frame so the agent +// only loses the one tool call. +func TestWriteRejectionForRequest_503JudgeUnavailable_StillJSONRPC(t *testing.T) { + rec := httptest.NewRecorder() + action := pipeline.DenyStatus(503, "ibac.judge_unavailable", "judge timed out") + WriteRejectionForRequest(rec, action, pctxWithMCP("call-1")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 — even 503 codes ride a 200 over JSON-RPC", rec.Code) + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + errObj, _ := body["error"].(map[string]any) + if errObj["message"] != "judge timed out" { + t.Errorf("error.message = %v, want \"judge timed out\"", errObj["message"]) + } + data, _ := errObj["data"].(map[string]any) + if data["error"] != "ibac.judge_unavailable" { + t.Errorf("error.data.error = %v, want ibac.judge_unavailable", data["error"]) + } +} + +// TestWriteRejection_Unchanged sanity-checks that the existing +// WriteRejection helper still behaves the same — this fix only adds a +// new entry point; existing callers must keep working unchanged. +func TestWriteRejection_Unchanged(t *testing.T) { + rec := httptest.NewRecorder() + WriteRejection(rec, mcpAction()) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["error"] != "ibac.blocked" { + t.Errorf("error = %v, want ibac.blocked", body["error"]) + } +} + +// TestMarshalMCPRejectionBody_BadDetailsFallsBackToMinimalFrame: a +// plugin can populate Violation.Details with anything (it's +// map[string]any). If the value is unmarshalable (e.g. a channel), +// the full frame fails. The body MUST still be a parseable JSON-RPC +// 2.0 error frame so the MCP client surfaces this as a tool-call +// failure rather than a parse error on a 200 application/json +// response. The fallback drops optional data; everything else +// (jsonrpc version, id, error.code, error.message) survives. +func TestMarshalMCPRejectionBody_BadDetailsFallsBackToMinimalFrame(t *testing.T) { + action := pipeline.DenyWithDetails("ibac.blocked", "intent does not align", map[string]any{ + "unmarshalable": make(chan int), // json.Marshal returns UnsupportedTypeError + }) + body := MarshalMCPRejectionBody(action, "call-1") + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("body must round-trip even when details fail to marshal: %v\n%s", err, body) + } + if parsed["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want 2.0", parsed["jsonrpc"]) + } + if parsed["id"] != "call-1" { + t.Errorf("id = %v, want call-1 (id alone marshals fine, must be preserved)", parsed["id"]) + } + errObj, ok := parsed["error"].(map[string]any) + if !ok { + t.Fatalf("error field missing or wrong type: %v", parsed["error"]) + } + if errObj["code"] != float64(-32000) { + t.Errorf("error.code = %v, want -32000", errObj["code"]) + } + if errObj["message"] != "intent does not align" { + t.Errorf("error.message = %v, want IBAC reason", errObj["message"]) + } + if _, hasData := errObj["data"]; hasData { + t.Errorf("error.data must be omitted on fallback; got %v", errObj["data"]) + } +} + +// TestMarshalMCPRejectionBody_BadIDFallsBackToNullID: if the request +// id itself can't be marshaled (defensive — mcp-parser only stores +// string/number/null today, but RPCID is `any`), we fall back to +// id=null per JSON-RPC 2.0 §5.1, which permits null when the +// original id can't be detected. The body must still be a valid +// JSON-RPC error frame. +func TestMarshalMCPRejectionBody_BadIDFallsBackToNullID(t *testing.T) { + body := MarshalMCPRejectionBody(mcpAction(), make(chan int)) + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("body must round-trip even when id fails to marshal: %v\n%s", err, body) + } + if parsed["id"] != nil { + t.Errorf("id = %v, want null (unmarshalable id must drop to null)", parsed["id"]) + } + errObj, _ := parsed["error"].(map[string]any) + if errObj["message"] != "intent does not align" { + t.Errorf("error.message = %v, want IBAC reason (preserved across fallback)", errObj["message"]) + } +}