Bound agent capability HTTP responses

Contract-Neutral: Enforces client-side response resource limits without changing the agent wire contract.
This commit is contained in:
pulse-triage[bot]
2026-08-31 22:18:30 +01:00
parent c6307b81c9
commit 57598cbf3e
2 changed files with 102 additions and 2 deletions
+18 -2
View File
@@ -9,6 +9,8 @@ import (
"net/http"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
const (
@@ -18,6 +20,9 @@ const (
AgentAPITokenHeader = "X-API-Token"
AgentSurfaceHeader = "X-Pulse-Agent-Surface"
AgentSurfacePulseMCP = "pulse_mcp"
maxManifestResponseBodyBytes int64 = 1 << 20 // 1 MiB
maxCapabilityResponseBodyBytes int64 = 16 << 20 // 16 MiB
)
// HTTPDoer is the shared minimum interface for agent-surface HTTP clients.
@@ -130,8 +135,12 @@ func FetchManifest(ctx context.Context, client HTTPDoer, baseURL string) (*Manif
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", AgentCapabilitiesPath, resp.StatusCode)
}
body, err := readBoundedResponseBody(resp, maxManifestResponseBodyBytes)
if err != nil {
return nil, fmt.Errorf("GET %s response: %w", AgentCapabilitiesPath, err)
}
var manifest Manifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
if err := json.Unmarshal(body, &manifest); err != nil {
return nil, fmt.Errorf("decode manifest: %w", err)
}
return &manifest, nil
@@ -174,7 +183,7 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str
return HTTPCallResponse{}, fmt.Errorf("%s %s: %w", cap.Method, projected.Path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readBoundedResponseBody(resp, maxCapabilityResponseBodyBytes)
if err != nil {
return HTTPCallResponse{}, fmt.Errorf("read %s %s response: %w", cap.Method, projected.Path, err)
}
@@ -187,6 +196,13 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str
}, nil
}
func readBoundedResponseBody(resp *http.Response, limit int64) ([]byte, error) {
if err := securityutil.LimitResponseBody(resp, limit); err != nil {
return nil, err
}
return io.ReadAll(resp.Body)
}
// CallCapabilityHTTPByName resolves and executes a named manifest capability
// through the shared discovery/projection/auth header path.
func CallCapabilityHTTPByName(ctx context.Context, client HTTPDoer, baseURL, token string, capabilities []Capability, name string, args map[string]any) (HTTPCallResponse, error) {
+84
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -39,6 +40,27 @@ func TestFetchManifestUsesSharedDiscoveryPath(t *testing.T) {
}
}
func TestFetchManifestRejectsDeclaredOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", fmt.Sprint(maxManifestResponseBodyBytes+1))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
_, err := FetchManifest(context.Background(), server.Client(), server.URL)
assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes)
}
func TestFetchManifestRejectsUndeclaredOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeUndeclaredOversizedResponse(w, maxManifestResponseBodyBytes)
}))
defer server.Close()
_, err := FetchManifest(context.Background(), server.Client(), server.URL)
assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes)
}
func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) {
cap := Capability{
Name: SetOperatorStateCapabilityName,
@@ -197,6 +219,68 @@ func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) {
}
}
func TestCallCapabilityHTTPRejectsDeclaredOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", fmt.Sprint(maxCapabilityResponseBodyBytes+1))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
_, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{
Name: FleetContextCapabilityName,
Method: http.MethodGet,
Path: FleetContextCapabilityPath,
}, nil)
assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes)
}
func TestCallCapabilityHTTPRejectsUndeclaredOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeUndeclaredOversizedResponse(w, maxCapabilityResponseBodyBytes)
}))
defer server.Close()
_, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{
Name: FleetContextCapabilityName,
Method: http.MethodGet,
Path: FleetContextCapabilityPath,
}, nil)
assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes)
}
func assertResponseSizeLimitError(t *testing.T, err error, limit int64) {
t.Helper()
want := fmt.Sprintf("response body exceeds %d bytes", limit)
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
}
func writeUndeclaredOversizedResponse(w http.ResponseWriter, limit int64) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
prefix := []byte(`{"padding":"`)
if _, err := w.Write(prefix); err != nil {
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
chunk := strings.Repeat("x", 32*1024)
remaining := limit + 1 - int64(len(prefix))
for remaining > 0 {
writeSize := int64(len(chunk))
if writeSize > remaining {
writeSize = remaining
}
if _, err := io.WriteString(w, chunk[:int(writeSize)]); err != nil {
return
}
remaining -= writeSize
}
}
func TestCallCapabilityHTTPByNameResolvesAndExecutesManifestProjection(t *testing.T) {
var got struct {
Method string