From 6eeb0ee9c092625c9d28a806ba7e66d4332d7722 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 13 Oct 2026 16:38:29 +0000 Subject: [PATCH] Capture destination-specific HTTP evidence --- docs/roadmap/implementation.md | 5 + internal/llm/http_evidence.go | 165 +++++++++++++++++ internal/llm/http_evidence_test.go | 275 +++++++++++++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 internal/llm/http_evidence.go create mode 100644 internal/llm/http_evidence_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b471f87..adb3c67 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -417,6 +417,11 @@ response, no response, credential-stripping redirect, failed redirected hop, and redirect-policy termination. Verify the supplied client is not mutated and evidence is isolated across concurrent calls. Test facts, not wrapper choreography. +**Completed: 2026-10-13.** Added invocation-local last-hop HTTP evidence and +plain GET support over a shallow copy of the configured client. Validation +passed: `go test -race ./internal/llm`, `go vet ./internal/llm`, +`go test ./...`, and `git diff --check`. + ## Stage 8: Retain Generation Evidence Before Result Discard Scope: `PreparedGeneration` execution in `internal/llm`. diff --git a/internal/llm/http_evidence.go b/internal/llm/http_evidence.go new file mode 100644 index 0000000..205a820 --- /dev/null +++ b/internal/llm/http_evidence.go @@ -0,0 +1,165 @@ +package llm + +import ( + "context" + "io" + "net/http" + "sync" + "time" +) + +// HTTPExecutionEvidence contains only facts observable at the configured +// RoundTripper boundary. It intentionally excludes request headers and bodies. +type HTTPExecutionEvidence struct { + DispatchInvoked bool + RequestObserved bool + Endpoint string + CredentialsSupplied bool + ResponseReceived bool + HTTPStatus *int +} + +type httpEvidenceCollector struct { + mu sync.Mutex + evidence HTTPExecutionEvidence +} + +func (c *httpEvidenceCollector) observeRequest(request *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + + c.evidence.RequestObserved = true + c.evidence.Endpoint = endpointWithoutUserInfo(request) + c.evidence.CredentialsSupplied = requestHasCredentials(request) + c.evidence.ResponseReceived = false + c.evidence.HTTPStatus = nil +} + +func (c *httpEvidenceCollector) observeResponse(response *http.Response) { + if response == nil { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + status := response.StatusCode + c.evidence.ResponseReceived = true + c.evidence.HTTPStatus = &status +} + +func (c *httpEvidenceCollector) snapshot() HTTPExecutionEvidence { + c.mu.Lock() + defer c.mu.Unlock() + + evidence := c.evidence + if c.evidence.HTTPStatus != nil { + status := *c.evidence.HTTPStatus + evidence.HTTPStatus = &status + } + return evidence +} + +type observingRoundTripper struct { + next http.RoundTripper + evidence *httpEvidenceCollector +} + +func (t observingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + t.evidence.observeRequest(request) + response, err := t.next.RoundTrip(request) + t.evidence.observeResponse(response) + return response, err +} + +// doWithEvidence executes one request with a shallow copy of client. The copy +// preserves timeout, redirect, and cookie-jar behavior without changing client. +func doWithEvidence(client *http.Client, request *http.Request) (*http.Response, error, HTTPExecutionEvidence) { + if client == nil { + client = &http.Client{} + } + + collector := &httpEvidenceCollector{} + clientCopy := *client + transport := clientCopy.Transport + if transport == nil { + transport = http.DefaultTransport + } + clientCopy.Transport = observingRoundTripper{next: transport, evidence: collector} + + response, err := clientCopy.Do(request) + observed := collector.snapshot() + observed.DispatchInvoked = true + return response, err, observed +} + +// GetWithEvidence performs a plain GET with the configured built-in HTTP +// client. A positive timeoutSeconds applies to the request through response +// body closure, matching the whole-request behavior of http.Client.Timeout. +func (c *OpenAICompatibleClient) GetWithEvidence( + ctx context.Context, + endpoint string, + timeoutSeconds int, +) (*http.Response, HTTPExecutionEvidence, error) { + requestContext := ctx + var cancel context.CancelFunc + if timeoutSeconds > 0 { + requestContext, cancel = context.WithTimeout(ctx, secondsDuration(timeoutSeconds)) + } + + request, err := http.NewRequestWithContext(requestContext, http.MethodGet, endpoint, nil) + if err != nil { + if cancel != nil { + cancel() + } + return nil, HTTPExecutionEvidence{}, err + } + + response, err, evidence := doWithEvidence(c.httpClient, request) + if response == nil || response.Body == nil { + if cancel != nil { + cancel() + } + return response, evidence, err + } + if cancel != nil { + response.Body = &cancelOnCloseReadCloser{ReadCloser: response.Body, cancel: cancel} + } + return response, evidence, err +} + +func secondsDuration(seconds int) time.Duration { + return time.Duration(seconds) * time.Second +} + +type cancelOnCloseReadCloser struct { + io.ReadCloser + cancel context.CancelFunc + once sync.Once +} + +func (r *cancelOnCloseReadCloser) Close() error { + r.once.Do(r.cancel) + return r.ReadCloser.Close() +} + +func endpointWithoutUserInfo(request *http.Request) string { + if request == nil || request.URL == nil { + return "" + } + endpoint := *request.URL + endpoint.User = nil + return endpoint.String() +} + +func requestHasCredentials(request *http.Request) bool { + if request == nil { + return false + } + if request.Header.Get("Authorization") != "" { + return true + } + return request.URL != nil && request.URL.User != nil +} + +var _ http.RoundTripper = observingRoundTripper{} +var _ io.ReadCloser = (*cancelOnCloseReadCloser)(nil) diff --git a/internal/llm/http_evidence_test.go b/internal/llm/http_evidence_test.go new file mode 100644 index 0000000..7423006 --- /dev/null +++ b/internal/llm/http_evidence_test.go @@ -0,0 +1,275 @@ +package llm + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestGetWithEvidenceCapturesResponseAndOmitsURLUserInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") == "" { + t.Error("request did not carry URL user credentials") + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: time.Second}) + if err != nil { + t.Fatalf("construct client: %v", err) + } + endpoint := strings.Replace(server.URL, "http://", "http://user:secret@", 1) + "/health" + response, evidence, err := client.GetWithEvidence(context.Background(), endpoint, 0) + if err != nil { + t.Fatalf("GET endpoint: %v", err) + } + if response == nil { + t.Fatal("GET returned no response") + } + response.Body.Close() + + if !evidence.DispatchInvoked || !evidence.RequestObserved || !evidence.ResponseReceived { + t.Fatalf("incomplete response evidence: %+v", evidence) + } + if evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusNoContent { + t.Fatalf("status evidence = %v, want %d", evidence.HTTPStatus, http.StatusNoContent) + } + if !evidence.CredentialsSupplied { + t.Fatal("evidence did not record URL credentials") + } + if strings.Contains(evidence.Endpoint, "user") || strings.Contains(evidence.Endpoint, "secret") { + t.Fatalf("exposed endpoint contains URL user info: %q", evidence.Endpoint) + } + if want := server.URL + "/health"; evidence.Endpoint != want { + t.Fatalf("endpoint = %q, want %q", evidence.Endpoint, want) + } +} + +func TestDoWithEvidenceRecordsRequestWithoutResponse(t *testing.T) { + transportErr := errors.New("transport unavailable") + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + })} + request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + response, err, evidence := doWithEvidence(client, request) + if response != nil || !errors.Is(err, transportErr) { + t.Fatalf("response/error = %v/%v, want nil/transport error", response, err) + } + if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived { + t.Fatalf("unexpected request evidence: %+v", evidence) + } + if evidence.HTTPStatus != nil || evidence.Endpoint != request.URL.String() || evidence.CredentialsSupplied { + t.Fatalf("unexpected evidence values: %+v", evidence) + } +} + +func TestDoWithEvidenceRetainsResponseReturnedAlongsideError(t *testing.T) { + transportErr := errors.New("transport reported an error with a response") + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return responseFor(request, http.StatusBadGateway, ""), transportErr + })} + request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + response, err, evidence := doWithEvidence(client, request) + if !errors.Is(err, transportErr) { + t.Fatalf("error = %v, want transport error", err) + } + if response != nil { + response.Body.Close() + } + if !evidence.RequestObserved || !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusBadGateway { + t.Fatalf("observed response was lost alongside error: %+v", evidence) + } +} + +func TestDoWithEvidenceUsesLastRedirectRequest(t *testing.T) { + var observedAuthorization []string + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + observedAuthorization = append(observedAuthorization, request.Header.Get("Authorization")) + if request.URL.Host == "source.example" { + return responseFor(request, http.StatusFound, "https://other.example/final"), nil + } + return responseFor(request, http.StatusUnauthorized, ""), nil + }) + client := &http.Client{Transport: transport} + request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + request.Header.Set("Authorization", "Bearer private-token") + + response, err, evidence := doWithEvidence(client, request) + if err != nil { + t.Fatalf("follow redirect: %v", err) + } + if response == nil { + t.Fatal("redirect returned no response") + } + response.Body.Close() + if len(observedAuthorization) != 2 || observedAuthorization[0] == "" || observedAuthorization[1] != "" { + t.Fatalf("authorization across redirect = %#v", observedAuthorization) + } + if evidence.Endpoint != "https://other.example/final" || evidence.CredentialsSupplied { + t.Fatalf("evidence did not describe credential-stripped last hop: %+v", evidence) + } + if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized { + t.Fatalf("last-hop response evidence = %+v", evidence) + } +} + +func TestDoWithEvidenceClearsPriorResponseOnFailedRedirect(t *testing.T) { + transportErr := errors.New("redirect target unavailable") + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "source.example" { + return responseFor(request, http.StatusTemporaryRedirect, "https://target.example/unavailable"), nil + } + return nil, transportErr + }) + client := &http.Client{Transport: transport} + request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + response, err, evidence := doWithEvidence(client, request) + if response != nil || !errors.Is(err, transportErr) { + t.Fatalf("response/error = %v/%v, want nil/transport error", response, err) + } + if !evidence.RequestObserved || evidence.Endpoint != "https://target.example/unavailable" { + t.Fatalf("evidence did not retain failed last request: %+v", evidence) + } + if evidence.ResponseReceived || evidence.HTTPStatus != nil { + t.Fatalf("prior redirect response was not cleared: %+v", evidence) + } +} + +func TestDoWithEvidenceRetainsResponseWhenRedirectPolicyStops(t *testing.T) { + redirectErr := errors.New("redirect rejected") + transportCalls := 0 + client := &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + transportCalls++ + return responseFor(request, http.StatusFound, "https://other.example/never-requested"), nil + }), + CheckRedirect: func(*http.Request, []*http.Request) error { return redirectErr }, + } + request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + response, err, evidence := doWithEvidence(client, request) + if !errors.Is(err, redirectErr) { + t.Fatalf("redirect error = %v, want policy error", err) + } + if response == nil || response.StatusCode != http.StatusFound { + t.Fatalf("redirect response = %v, want original response", response) + } + response.Body.Close() + if transportCalls != 1 || evidence.Endpoint != "https://source.example/start" { + t.Fatalf("redirect policy inferred or dispatched another request: calls=%d evidence=%+v", transportCalls, evidence) + } + if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusFound { + t.Fatalf("original response evidence was lost: %+v", evidence) + } +} + +func TestGetWithEvidencePreservesClientAndIsolatesConcurrentCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + redirect := func(*http.Request, []*http.Request) error { return nil } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("construct cookie jar: %v", err) + } + + transport := http.DefaultTransport + supplied := &http.Client{Transport: transport, Timeout: 7 * time.Second, CheckRedirect: redirect, Jar: jar} + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{HTTPClient: supplied}) + if err != nil { + t.Fatalf("construct client: %v", err) + } + + const calls = 8 + evidences := make([]HTTPExecutionEvidence, calls) + errs := make([]error, calls) + var wait sync.WaitGroup + for index := 0; index < calls; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + endpoint := server.URL + "/call/" + strconv.Itoa(index) + response, evidence, getErr := client.GetWithEvidence(context.Background(), endpoint, 0) + if response != nil { + response.Body.Close() + } + evidences[index], errs[index] = evidence, getErr + }(index) + } + wait.Wait() + + if supplied.Transport != transport || supplied.Timeout != 7*time.Second || supplied.CheckRedirect == nil || supplied.Jar != jar { + t.Fatal("diagnostic GET mutated the supplied HTTP client") + } + for index, evidence := range evidences { + if errs[index] != nil { + t.Fatalf("GET %d: %v", index, errs[index]) + } + want := server.URL + "/call/" + strconv.Itoa(index) + if !evidence.RequestObserved || evidence.Endpoint != want { + t.Fatalf("GET %d evidence = %+v, want endpoint %q", index, evidence, want) + } + } +} + +func TestGetWithEvidenceAppliesTargetTimeout(t *testing.T) { + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: 5 * time.Second, HTTPClient: &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + <-request.Context().Done() + return nil, request.Context().Err() + }), + }}) + if err != nil { + t.Fatalf("construct client: %v", err) + } + + response, evidence, err := client.GetWithEvidence(context.Background(), "https://diagnostic.example/slow", 1) + if response != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("response/error = %v/%v, want nil/deadline exceeded", response, err) + } + if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived { + t.Fatalf("timeout evidence = %+v", evidence) + } +} + +func responseFor(request *http.Request, status int, location string) *http.Response { + header := make(http.Header) + if location != "" { + header.Set("Location", location) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(strings.NewReader("")), + Request: request, + } +}