From 90a78c33aac1ee2990b09e9aab936791f2629191 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 15:35:10 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(receipt):=20Exposure=20Receipt=20in=20?= =?UTF-8?q?qmax-code=20=E2=80=94=20LLM=20+=20cloud-API=20egress=20(QUA-131?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Exposure Receipt to the qmax-code terminal agent so both customer-facing binaries emit receipts — making "Receipts, not promises" a platform property, not a CLI-only one. qmax-code has its own egress (LLM inference + internal/api cloud calls) that the qmax CLI's receipt does not cover; they are peers, not one wrapping the other. - internal/httpx: single egress chokepoint. Recording RoundTripper stream-hashes the request body (no buffering), records destination/category/size/SHA-256 — never content. NewClient + NewRequest replace all raw http.Client / http.NewRequest across the ~13 egress sites (api client, auth, codex/cc connect, anthropic + cerebras + ollama LLM calls, script fetch/update, vision, login, ollama probe). WithModel attributes the model on LLM entries. - internal/httpx/guard_test.go: static egress guard fails the build if any package outside httpx constructs a raw http client/request or imports a third-party HTTP lib. Wired into ci.yml via a new `go test` step (CI ran no tests before) — un-receipted egress can't merge. Verified by injection. - internal/exposure: qmax-code's own taxonomy (llm-prompt, llm-completion, cloud-api, mcp-traffic, control, uncategorized) + Classify. - Import shared schema github.com/Quality-Max/qmax-receipt v0.1.0 — one versioned contract, one receipt_version, no local copy. - Session-scoped receipt: NewCurrent at startup, finalized at exit (only if the session egressed). BaseDir = ~/.qmax-code keeps identity/store separate from the CLI's ~/.qamax. - `qmax-code receipt `: offline verify prints the provenance-not-honesty caveat. - release.yml stamps agent_build_sha via -X qmax-receipt.BuildSHA. End-to-end verified: a real session writes a signed ~/.qmax-code/receipts/.json; `qmax-code receipt verify` validates it offline with correct category, hash and size, and no content. Parent: QUA-1315 --- .github/workflows/ci.yml | 7 ++ .github/workflows/release.yml | 4 +- cmd_receipt.go | 106 +++++++++++++++++++ go.mod | 1 + go.sum | 2 + internal/agent/agent.go | 12 ++- internal/agent/cerebras.go | 5 +- internal/agent/ollama.go | 5 +- internal/agent/tools.go | 14 +-- internal/api/auth.go | 12 ++- internal/api/claude_code_connection.go | 3 +- internal/api/client.go | 13 +-- internal/api/codex_connection.go | 3 +- internal/exposure/exposure.go | 72 +++++++++++++ internal/exposure/exposure_test.go | 37 +++++++ internal/httpx/guard_test.go | 94 +++++++++++++++++ internal/httpx/httpx.go | 141 +++++++++++++++++++++++++ internal/httpx/httpx_test.go | 123 +++++++++++++++++++++ internal/setup/interactive.go | 8 +- internal/tui/tui_backend.go | 4 +- main.go | 15 +++ receipt.go | 65 ++++++++++++ 22 files changed, 711 insertions(+), 35 deletions(-) create mode 100644 cmd_receipt.go create mode 100644 internal/exposure/exposure.go create mode 100644 internal/exposure/exposure_test.go create mode 100644 internal/httpx/guard_test.go create mode 100644 internal/httpx/httpx.go create mode 100644 internal/httpx/httpx_test.go create mode 100644 receipt.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02772e5..ca99ab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,10 @@ jobs: - name: Vet run: go vet ./... + + # Runs the unit suite AND the static egress guard + # (internal/httpx/guard_test.go), which fails the build if any package + # outside internal/httpx constructs a raw http.Client / http.NewRequest — + # so un-receipted egress can't merge. + - name: Test + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75095f3..167da4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,9 @@ jobs: run: | mkdir -p build VERSION="${GITHUB_REF_NAME#v}" - LDFLAGS="-s -w -X main.Version=${VERSION}" + # Stamp the build SHA into the Exposure Receipt so a receipt names the + # exact binary that produced it (agent_build_sha). + LDFLAGS="-s -w -X main.Version=${VERSION} -X github.com/Quality-Max/qmax-receipt.BuildSHA=${GITHUB_SHA}" GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o build/qmax-code-darwin-amd64 . GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o build/qmax-code-darwin-arm64 . GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o build/qmax-code-linux-amd64 . diff --git a/cmd_receipt.go b/cmd_receipt.go new file mode 100644 index 0000000..a4bef6b --- /dev/null +++ b/cmd_receipt.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +// handleReceiptCommand implements `qmax-code receipt [id|latest]` +// — the customer-facing surface for the Exposure Receipt: "here is exactly what +// left your network this session, and it verifies offline." verify is fully +// offline and prints the provenance-not-honesty caveat. +func handleReceiptCommand(args []string) { + initReceiptPaths() + + sub := "list" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "list": + receiptList() + case "show": + receiptShow(argAt(args, 1)) + case "verify": + receiptVerify(argAt(args, 1)) + default: + fmt.Fprintln(os.Stderr, "Usage: qmax-code receipt [id|latest]") + os.Exit(1) + } +} + +func argAt(args []string, i int) string { + if len(args) > i { + return args[i] + } + return "" +} + +func receiptList() { + paths, err := receipt.List() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if len(paths) == 0 { + fmt.Println("No exposure receipts found.") + return + } + fmt.Printf("%-34s %-20s %-8s %s\n", "RUN ID", "KIND", "REQUESTS", "DESTINATIONS") + for _, p := range paths { + r, err := receipt.Load(p) + if err != nil { + continue + } + fmt.Printf("%-34s %-20s %-8d %v\n", r.RunID, r.RunKind, r.Summary.TotalRequests, r.Destinations) + } +} + +func receiptShow(idOrLatest string) { + r := mustResolveReceipt(idOrLatest) + data, _ := json.MarshalIndent(r, "", " ") + fmt.Println(string(data)) +} + +func receiptVerify(idOrLatest string) { + r := mustResolveReceipt(idOrLatest) + if err := receipt.Verify(r); err != nil { + fmt.Fprintf(os.Stderr, "✗ INVALID: %v\n", err) + os.Exit(1) + } + fmt.Printf("✓ VALID — run %s (%s), %d request(s) across %v\n", + r.RunID, r.RunKind, r.Summary.TotalRequests, r.Destinations) + fmt.Println("Note: this proves the receipt was produced by this agent's key (provenance),") + fmt.Println("not that the agent disclosed everything. Cross-check against your own egress logs.") +} + +func mustResolveReceipt(idOrLatest string) *receipt.Receipt { + dir, err := receipt.ReceiptsDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if idOrLatest == "" || idOrLatest == "latest" { + paths, err := receipt.List() + if err != nil || len(paths) == 0 { + fmt.Fprintln(os.Stderr, "No exposure receipts found.") + os.Exit(1) + } + r, err := receipt.Load(paths[len(paths)-1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return r + } + r, err := receipt.Load(filepath.Join(dir, idOrLatest+".json")) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return r +} diff --git a/go.mod b/go.mod index 9cd4145..6f838b7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/BourgeoisBear/rasterm v1.1.2 + github.com/Quality-Max/qmax-receipt v0.1.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index bb4236e..c75efb8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPkoLF4Ss= github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw= +github.com/Quality-Max/qmax-receipt v0.1.0 h1:eHFs+2KhmWxldWWyEz1Tu4Oh+FuUOHt1JYG8IXLgUWw= +github.com/Quality-Max/qmax-receipt v0.1.0/go.mod h1:1GaT7lO+l9itgDgcVHUKF54pbMxKz5671f74Q1c3rDg= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9c44284..ecff927 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -13,6 +13,7 @@ import ( "time" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/session" "github.com/qualitymax/qmax-code/internal/sysutil" "github.com/qualitymax/qmax-code/internal/tui" @@ -118,7 +119,7 @@ func NewAgent(cfg AgentConfig) *Agent { Cfg: cfg, History: []api.Message{}, tools: BuildToolDefs(), - client: &http.Client{Timeout: 300 * time.Second}, + client: httpx.NewClient(300 * time.Second), } } @@ -574,8 +575,10 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten fmt.Printf("[API] Streaming request: %d bytes, %d messages\n", len(data), len(a.History)) } + // Attribute the model on the Exposure Receipt entry for this prompt. + ctx = httpx.WithModel(ctx, model) resp, err := doWithRetry(ctx, a.client, func() (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, "POST", api.AnthropicMessagesURL, bytes.NewReader(data)) + req, err := httpx.NewRequest(ctx, "POST", api.AnthropicMessagesURL, bytes.NewReader(data)) if err != nil { return nil, err } @@ -778,8 +781,9 @@ func (a *Agent) callAPI() (*api.APIResponse, error) { fmt.Printf("[API] Request: %d bytes, %d messages\n", len(data), len(a.History)) } - resp, err := doWithRetry(context.Background(), a.client, func() (*http.Request, error) { - req, err := http.NewRequest("POST", api.AnthropicMessagesURL, bytes.NewReader(data)) + ctx := httpx.WithModel(context.Background(), a.Cfg.Model) + resp, err := doWithRetry(ctx, a.client, func() (*http.Request, error) { + req, err := httpx.NewRequest(ctx, "POST", api.AnthropicMessagesURL, bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/internal/agent/cerebras.go b/internal/agent/cerebras.go index f8f3571..f89ac65 100644 --- a/internal/agent/cerebras.go +++ b/internal/agent/cerebras.go @@ -11,6 +11,7 @@ import ( "time" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" ) // Cerebras integration — drive the full native agent loop through Cerebras's @@ -44,7 +45,7 @@ func NewCerebrasClient(cfg *api.Config) *CerebrasClient { Model: model, APIKey: cfg.CerebrasKey, ReasoningEffort: api.NormalizeCerebrasReasoningEffort(cfg.CerebrasReasoningEffort), - HTTP: &http.Client{Timeout: 300 * time.Second}, + HTTP: httpx.NewClient(300 * time.Second), } } @@ -207,7 +208,7 @@ func (c *CerebrasClient) Chat(ctx context.Context, msgs []oaiMessage, tools []oa return nil, fmt.Errorf("marshal request: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/chat/completions", bytes.NewReader(data)) + req, err := httpx.NewRequest(httpx.WithModel(ctx, c.Model), "POST", c.BaseURL+"/chat/completions", bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/internal/agent/ollama.go b/internal/agent/ollama.go index 798272f..5bf3554 100644 --- a/internal/agent/ollama.go +++ b/internal/agent/ollama.go @@ -14,6 +14,7 @@ import ( "time" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/tui" ) @@ -78,7 +79,7 @@ func NewOllamaClient(cfg *api.Config) *OllamaClient { BaseURL: strings.TrimRight(cfg.OllamaURL, "/"), Model: cfg.OllamaModel, AgentModel: agentModel, - HTTP: &http.Client{Timeout: 120 * time.Second}, + HTTP: httpx.NewClient(120 * time.Second), cooldownSeconds: ollamaCooldownSec, } } @@ -172,7 +173,7 @@ func (o *OllamaClient) ChatStreaming(ctx context.Context, system string, history } reqURL := o.BaseURL + "/v1/chat/completions" - req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(data)) + req, err := httpx.NewRequest(httpx.WithModel(ctx, o.Model), "POST", reqURL, bytes.NewReader(data)) if err != nil { return "", err } diff --git a/internal/agent/tools.go b/internal/agent/tools.go index c1205f3..0824a88 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "math" - "net/http" "os" "os/exec" "path/filepath" @@ -17,6 +16,7 @@ import ( "time" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/security" "github.com/qualitymax/qmax-code/internal/sysutil" "github.com/qualitymax/qmax-code/internal/tui" @@ -2277,13 +2277,13 @@ func fetchScriptCode(sctx *api.SessionContext, ctx context.Context, scriptID str } url := fmt.Sprintf("%s/api/automation/scripts/%s", apiURL, scriptID) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := httpx.NewRequest(ctx, "GET", url, nil) if err != nil { return fmt.Sprintf(`{"error": %q}`, security.RedactSensitive(err.Error())) } req.Header.Set("Authorization", "Bearer "+token) - client := &http.Client{Timeout: 30 * time.Second} + client := httpx.NewClient(30 * time.Second) resp, err := client.Do(req) if err != nil { return fmt.Sprintf(`{"error": %q}`, security.RedactSensitive(err.Error())) @@ -2311,14 +2311,14 @@ func updateScriptCode(sctx *api.SessionContext, ctx context.Context, scriptID, n "code": code, }) - req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(payload)) + req, err := httpx.NewRequest(ctx, "PUT", url, bytes.NewReader(payload)) if err != nil { return fmt.Sprintf(`{"error": %q}`, security.RedactSensitive(err.Error())) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 30 * time.Second} + client := httpx.NewClient(30 * time.Second) resp, err := client.Do(req) if err != nil { return fmt.Sprintf(`{"error": %q}`, security.RedactSensitive(err.Error())) @@ -2647,7 +2647,7 @@ func callVisionAnalysis(sctx *api.SessionContext, imageURL, prompt string) strin } data, _ := json.Marshal(reqBody) - req, err := http.NewRequest("POST", api.AnthropicMessagesURL, bytes.NewReader(data)) + req, err := httpx.NewRequest(httpx.WithModel(context.Background(), api.ModelHaiku), "POST", api.AnthropicMessagesURL, bytes.NewReader(data)) if err != nil { return jsonError("Failed to create vision request: " + err.Error()) } @@ -2655,7 +2655,7 @@ func callVisionAnalysis(sctx *api.SessionContext, imageURL, prompt string) strin req.Header.Set("x-api-key", apiKey) req.Header.Set("anthropic-version", api.AnthropicVersion) - client := &http.Client{Timeout: 30 * time.Second} + client := httpx.NewClient(30 * time.Second) resp, err := client.Do(req) if err != nil { return jsonError("Vision API request failed: " + err.Error()) diff --git a/internal/api/auth.go b/internal/api/auth.go index 3f812ed..26303b6 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -1,14 +1,16 @@ package api import ( + "context" "encoding/json" "fmt" "io" - "net/http" "os" "path/filepath" "strings" "time" + + "github.com/qualitymax/qmax-code/internal/httpx" ) // AuthConfig holds QualityMax authentication credentials. @@ -116,8 +118,8 @@ func LoginWithAPIKey(apiKey string) (*AuthConfig, error) { cloudURL := envOr("QUALITYMAX_URL", DefaultCloudURL) // Validate by calling /api/me - client := &http.Client{Timeout: 10 * time.Second} - req, _ := http.NewRequest("GET", cloudURL+"/api/me", nil) + client := httpx.NewClient(10 * time.Second) + req, _ := httpx.NewRequest(context.Background(), "GET", cloudURL+"/api/me", nil) req.Header.Set("Authorization", "Bearer "+stripKeyPrefix(apiKey)) resp, err := client.Do(req) @@ -156,8 +158,8 @@ func LoginWithAPIKey(apiKey string) (*AuthConfig, error) { // fetchMe calls /api/me to get user info (email, id) from the API. func fetchMe(cfg *AuthConfig) map[string]interface{} { - client := &http.Client{Timeout: 5 * time.Second} - req, err := http.NewRequest("GET", cfg.GetCloudURL()+"/api/me", nil) + client := httpx.NewClient(5 * time.Second) + req, err := httpx.NewRequest(context.Background(), "GET", cfg.GetCloudURL()+"/api/me", nil) if err != nil { return nil } diff --git a/internal/api/claude_code_connection.go b/internal/api/claude_code_connection.go index de331a8..2312635 100644 --- a/internal/api/claude_code_connection.go +++ b/internal/api/claude_code_connection.go @@ -8,6 +8,7 @@ import ( "io" "net/http" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/security" ) @@ -27,7 +28,7 @@ func (c *APIClient) ConnectClaudeCode(ctx context.Context, authJSON string) (*Cl return nil, fmt.Errorf("encode Claude Code credentials: %w", err) } - req, err := http.NewRequestWithContext( + req, err := httpx.NewRequest( ctx, http.MethodPost, c.BaseURL+"/api/integrations/claude-code/connect", diff --git a/internal/api/client.go b/internal/api/client.go index 1ea2f5d..cf01963 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/security" "github.com/qualitymax/qmax-code/internal/sysutil" ) @@ -30,7 +31,7 @@ func NewAPIClient(auth *AuthConfig) *APIClient { return &APIClient{ BaseURL: auth.GetCloudURL(), APIKey: auth.APIKey, - HTTP: &http.Client{Timeout: 120 * time.Second}, + HTTP: httpx.NewClient(120 * time.Second), } } @@ -764,7 +765,7 @@ func (c *APIClient) CheckBackgroundJob(ctx context.Context, jobID string) string // --- Delete helper --- func (c *APIClient) delete(ctx context.Context, path string) string { - req, err := http.NewRequestWithContext(ctx, "DELETE", c.BaseURL+path, nil) + req, err := httpx.NewRequest(ctx, "DELETE", c.BaseURL+path, nil) if err != nil { return jsonError(err.Error()) } @@ -774,7 +775,7 @@ func (c *APIClient) delete(ctx context.Context, path string) string { // --- HTTP helpers --- func (c *APIClient) get(ctx context.Context, path string) string { - req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+path, nil) + req, err := httpx.NewRequest(ctx, "GET", c.BaseURL+path, nil) if err != nil { return jsonError(err.Error()) } @@ -787,7 +788,7 @@ func (c *APIClient) post(ctx context.Context, path string, body interface{}) str data, _ := json.Marshal(body) reqBody = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, reqBody) + req, err := httpx.NewRequest(ctx, "POST", c.BaseURL+path, reqBody) if err != nil { return jsonError(err.Error()) } @@ -803,7 +804,7 @@ func (c *APIClient) put(ctx context.Context, path string, body interface{}) stri data, _ := json.Marshal(body) reqBody = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, "PUT", c.BaseURL+path, reqBody) + req, err := httpx.NewRequest(ctx, "PUT", c.BaseURL+path, reqBody) if err != nil { return jsonError(err.Error()) } @@ -819,7 +820,7 @@ func (c *APIClient) patch(ctx context.Context, path string, body interface{}) st data, _ := json.Marshal(body) reqBody = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, "PATCH", c.BaseURL+path, reqBody) + req, err := httpx.NewRequest(ctx, "PATCH", c.BaseURL+path, reqBody) if err != nil { return jsonError(err.Error()) } diff --git a/internal/api/codex_connection.go b/internal/api/codex_connection.go index 2a26234..db73156 100644 --- a/internal/api/codex_connection.go +++ b/internal/api/codex_connection.go @@ -8,6 +8,7 @@ import ( "io" "net/http" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/security" ) @@ -38,7 +39,7 @@ func (c *APIClient) ConnectCodex(ctx context.Context, authJSON string) (*CodexCo return nil, fmt.Errorf("encode Codex credentials: %w", err) } - req, err := http.NewRequestWithContext( + req, err := httpx.NewRequest( ctx, http.MethodPost, c.BaseURL+"/api/integrations/codex/connect", diff --git a/internal/exposure/exposure.go b/internal/exposure/exposure.go new file mode 100644 index 0000000..9ef5eb8 --- /dev/null +++ b/internal/exposure/exposure.go @@ -0,0 +1,72 @@ +// Package exposure holds qmax-code's traffic taxonomy — the category constants +// and the Classify function that maps a (host, path) pair to an exposure +// category for the Exposure Receipt. +// +// It is deliberately agent-specific. The shared receipt schema +// (github.com/Quality-Max/qmax-receipt) treats Entry.Category as a free-form +// string and never enumerates categories; each agent supplies its own. The +// qmax CLI has its own taxonomy (test-plan / behavioral-snapshot / …); this is +// qmax-code's, which is dominated by LLM inference and QualityMax cloud API +// traffic rather than crawl/test artifacts. +// +// Path templatization is shared, so Classify delegates to receipt.Templatize. +package exposure + +import ( + "strings" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +// Category constants — qmax-code's taxonomy (QUA-1316). +// +// The receipt records one Entry per *outbound* request, so LLM traffic is +// classified from the prompt side: a request to an inference endpoint carries +// the prompt (CatLLMPrompt), and the model's answer comes back as that entry's +// response bytes. CatLLMCompletion is reserved for any future response-side +// accounting (and keeps the taxonomy symmetric with how humans describe LLM +// traffic); Classify does not emit it today. CatMCPTraffic is reserved for +// outbound MCP-over-HTTP egress — qmax-code's MCP server is stdio-only today, +// so no request is classified as MCP yet, but the constant documents the slot. +const ( + CatLLMPrompt = "llm-prompt" // outbound inference request carrying a prompt + CatLLMCompletion = "llm-completion" // reserved: response-side LLM accounting + CatCloudAPI = "cloud-api" // QualityMax cloud REST API (projects, scripts, integrations) + CatMCPTraffic = "mcp-traffic" // reserved: outbound MCP-over-HTTP egress + CatControl = "control" // metadata only: auth check, login poll, health/reachability probes + CatUncategorized = "uncategorized" // unknown host/path — flagged so it can't hide +) + +// Classify maps a (host, path) pair to an exposure category. It takes plain +// strings so this package never imports net/http. path should be the raw URL +// path; classification is done against the templatized form. +// +// LLM inference endpoints are matched by path shape (not host) so both the +// hosted providers (Anthropic /v1/messages, Cerebras /chat/completions) and a +// local or remote Ollama endpoint (/v1/chat/completions) land in CatLLMPrompt. +func Classify(host, path string) string { + p := receipt.Templatize(path) + switch { + // LLM inference — the request carries the prompt. + case strings.HasSuffix(p, "/v1/messages"), + strings.HasSuffix(p, "/chat/completions"), + strings.HasSuffix(p, "/api/chat"), + strings.HasSuffix(p, "/api/generate"): + return CatLLMPrompt + + // Control plane — metadata-only calls that carry no source/prompt content. + case strings.HasSuffix(p, "/api/me"), // auth/identity check + strings.Contains(p, "/api/auth/cli-login"), + strings.Contains(p, "/api/auth/cli-poll"), + strings.HasSuffix(p, "/api/tags"), // Ollama reachability probe + strings.Contains(p, "/api/job-health/"): + return CatControl + + // QualityMax cloud REST API — projects, scripts, crawl, integrations, etc. + case strings.Contains(p, "/api/"): + return CatCloudAPI + + default: + return CatUncategorized + } +} diff --git a/internal/exposure/exposure_test.go b/internal/exposure/exposure_test.go new file mode 100644 index 0000000..c2f1fa6 --- /dev/null +++ b/internal/exposure/exposure_test.go @@ -0,0 +1,37 @@ +package exposure + +import "testing" + +func TestClassify(t *testing.T) { + cases := []struct { + name string + host string + path string + want string + }{ + {"anthropic messages", "api.anthropic.com", "/v1/messages", CatLLMPrompt}, + {"cerebras completions", "api.cerebras.ai", "/v1/chat/completions", CatLLMPrompt}, + {"ollama openai chat", "localhost:11434", "/v1/chat/completions", CatLLMPrompt}, + {"ollama native chat", "localhost:11434", "/api/chat", CatLLMPrompt}, + {"ollama generate", "localhost:11434", "/api/generate", CatLLMPrompt}, + + {"auth me", "app.qualitymax.io", "/api/me", CatControl}, + {"cli login", "app.qualitymax.io", "/api/auth/cli-login", CatControl}, + {"cli poll", "app.qualitymax.io", "/api/auth/cli-poll", CatControl}, + {"ollama tags probe", "localhost:11434", "/api/tags", CatControl}, + {"job health", "app.qualitymax.io", "/api/job-health/background/abc", CatControl}, + + {"projects list", "app.qualitymax.io", "/api/projects", CatCloudAPI}, + {"script fetch", "app.qualitymax.io", "/api/automation/scripts/1234", CatCloudAPI}, + {"codex connect", "app.qualitymax.io", "/api/integrations/codex/connect", CatCloudAPI}, + + {"unknown", "example.com", "/some/other/path", CatUncategorized}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Classify(tc.host, tc.path); got != tc.want { + t.Errorf("Classify(%q, %q) = %q, want %q", tc.host, tc.path, got, tc.want) + } + }) + } +} diff --git a/internal/httpx/guard_test.go b/internal/httpx/guard_test.go new file mode 100644 index 0000000..b35b202 --- /dev/null +++ b/internal/httpx/guard_test.go @@ -0,0 +1,94 @@ +package httpx + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestNoEgressOutsideHttpx is the static Egress Guard. It walks the whole +// qmax-code module and fails if any package other than httpx constructs an HTTP +// client/request/transport or imports a third-party HTTP library. This makes an +// un-receipted egress path impossible to merge — the load-bearing half of the +// "Receipts, not promises" guarantee. +// +// net/http may still be IMPORTED elsewhere for types and constants (http.Request, +// http.MethodPost, http.StatusOK, http.StatusText); only the egress-CREATING +// symbols below are forbidden. Route all outbound HTTP through +// httpx.NewClient / httpx.NewRequest. +func TestNoEgressOutsideHttpx(t *testing.T) { + // Egress-creating symbols. Their only legitimate home is this package. + forbiddenSymbols := []string{ + "http.Client{", + "http.NewRequest(", + "http.NewRequestWithContext(", + "http.Get(", + "http.Post(", + "http.PostForm(", + "http.Head(", + "http.DefaultClient", + "http.DefaultTransport", + } + // Third-party HTTP clients that would bypass net/http entirely. + forbiddenImports := []string{ + "go-resty", "levigross/grequests", "h2non/gentleman", + "valyala/fasthttp", "imroc/req", "jmcvetta/napping", + } + + root := filepath.Join("..", "..") // module root: qmax-code/ + var violations []string + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + // Skip this package (the sanctioned chokepoint), build output, and + // hidden/vendor dirs. + name := info.Name() + if name == "httpx" || name == "vendor" || name == "build" || strings.HasPrefix(name, ".") { + if path != root { + return filepath.SkipDir + } + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + data, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + rel, _ := filepath.Rel(root, path) + for i, line := range strings.Split(string(data), "\n") { + // Ignore comments to avoid false positives in prose. + code := line + if idx := strings.Index(code, "//"); idx >= 0 { + code = code[:idx] + } + for _, sym := range forbiddenSymbols { + if strings.Contains(code, sym) { + violations = append(violations, + rel+":"+strconv.Itoa(i+1)+" forbidden egress symbol "+sym) + } + } + for _, imp := range forbiddenImports { + if strings.Contains(line, imp) { + violations = append(violations, + rel+":"+strconv.Itoa(i+1)+" forbidden HTTP library import "+imp) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk failed: %v", err) + } + if len(violations) > 0 { + t.Fatalf("egress guard: %d violation(s) outside httpx — route all outbound HTTP through httpx.NewClient/NewRequest:\n%s", + len(violations), strings.Join(violations, "\n")) + } +} diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go new file mode 100644 index 0000000..23a2e4c --- /dev/null +++ b/internal/httpx/httpx.go @@ -0,0 +1,141 @@ +// Package httpx is the SINGLE egress chokepoint for qmax-code. Every outbound +// HTTP request in the codebase must be built with NewRequest and sent through a +// client from NewClient, so that the Exposure Receipt records it — destination, +// category, byte size, and content SHA-256, never content itself. +// +// The static guard in guard_test.go enforces that no other package constructs +// HTTP clients/requests or imports a third-party HTTP library, making an +// un-receipted egress path impossible to merge — the load-bearing half of the +// "Receipts, not promises" guarantee. +// +// Quality commitments enforced here: +// - Stream, don't buffer: request bodies are hashed by a streaming reader as +// the transport sends them, so a multi-MB prompt (inlined files, images) is +// never doubled in memory (io.ReadAll on the request body is forbidden). +// - Never silent: every attempt is recorded, including transport errors. The +// receipt module routes entries to the process-global session receipt when +// no receipt-bearing context is present, so even a plain context.Background +// request is accounted for. +package httpx + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "hash" + "io" + "net/http" + "time" + + receipt "github.com/Quality-Max/qmax-receipt" + "github.com/qualitymax/qmax-code/internal/exposure" +) + +// NewClient returns an *http.Client whose Transport records every request into +// the active Exposure Receipt. Timeout semantics are unchanged from a plain +// &http.Client{Timeout: timeout}. +func NewClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &receiptTransport{base: http.DefaultTransport}, + } +} + +// NewRequest builds an *http.Request bound to ctx. It is the only sanctioned way +// to construct an outbound request outside this package: callers that need to set +// custom headers or stream the response (LLM SSE) use NewRequest + NewClient.Do +// rather than a raw http.NewRequest, which the egress guard forbids. +// +// Pass context.Background() when no request-scoped context is available; the +// receipt still routes to the process-global session run. +func NewRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { + if ctx == nil { + ctx = context.Background() + } + return http.NewRequestWithContext(ctx, method, url, body) +} + +// modelKey carries the LLM model id for the current request so the receipt can +// attribute which model saw a prompt. It is optional; non-LLM requests leave it +// unset and the recorded Entry.Model stays nil. +type modelKey struct{} + +// WithModel annotates ctx with the LLM model id for requests built from it. +// LLM call sites (Anthropic, Cerebras, Ollama) wrap their context with this so +// the receipt records model attribution; all other traffic omits it. +func WithModel(ctx context.Context, model string) context.Context { + if model == "" { + return ctx + } + return context.WithValue(ctx, modelKey{}, model) +} + +func modelFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if m, ok := ctx.Value(modelKey{}).(string); ok { + return m + } + return "" +} + +type receiptTransport struct{ base http.RoundTripper } + +// hashingBody hashes and counts bytes as the transport reads the request body +// to send it — no full-body buffering. +type hashingBody struct { + rc io.ReadCloser + h hash.Hash + n int64 +} + +func (b *hashingBody) Read(p []byte) (int, error) { + n, err := b.rc.Read(p) + if n > 0 { + _, _ = b.h.Write(p[:n]) // hash.Hash.Write never errors + b.n += int64(n) + } + return n, err +} + +func (b *hashingBody) Close() error { return b.rc.Close() } + +func (t *receiptTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rec := receipt.FromContext(req.Context()) + + entry := receipt.Entry{ + Timestamp: time.Now().UTC(), + Method: req.Method, + Host: req.URL.Host, + Path: receipt.Templatize(req.URL.Path), + Category: exposure.Classify(req.URL.Host, req.URL.Path), + Allowed: true, // qmax-code records egress; runtime allow-listing is tracked separately on the epic. + } + if m := modelFromContext(req.Context()); m != "" { + entry.Model = &m + } + + // Wrap the body so it is hashed+counted as it streams to the wire. + var hb *hashingBody + if req.Body != nil { + hb = &hashingBody{rc: req.Body, h: sha256.New()} + req.Body = hb + } else { + hb = &hashingBody{rc: io.NopCloser(bytes.NewReader(nil)), h: sha256.New()} + } + + resp, err := t.base.RoundTrip(req) + + entry.ReqBytes = hb.n + entry.ReqSHA256 = hex.EncodeToString(hb.h.Sum(nil)) + if err != nil { + entry.Note = "transport-error: " + err.Error() + } else if resp != nil { + entry.RespStatus = resp.StatusCode + entry.RespBytes = resp.ContentLength + } + rec.Record(entry) + return resp, err +} diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go new file mode 100644 index 0000000..704403f --- /dev/null +++ b/internal/httpx/httpx_test.go @@ -0,0 +1,123 @@ +package httpx + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +// TestRecordsRequestHashAndSize drives a real request through the recording +// client and asserts the receipt captured the method, category, byte size and +// content hash — and never the content itself. +func TestRecordsRequestHashAndSize(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + rec := receipt.NewCurrent("test:session") + + body := strings.Repeat("prompt-", 1000) // ~7KB + ctx := WithModel(context.Background(), "claude-sonnet-5") + req, err := NewRequest(ctx, http.MethodPost, srv.URL+"/v1/messages", strings.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := NewClient(10 * time.Second).Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + if rec.EntryCount() != 1 { + t.Fatalf("expected 1 recorded entry, got %d", rec.EntryCount()) + } + e := rec.Entries[0] + if e.Method != http.MethodPost { + t.Errorf("method = %q, want POST", e.Method) + } + if e.Category != "llm-prompt" { + t.Errorf("category = %q, want llm-prompt", e.Category) + } + if e.ReqBytes != int64(len(body)) { + t.Errorf("req_bytes = %d, want %d", e.ReqBytes, len(body)) + } + wantHash := sha256.Sum256([]byte(body)) + if e.ReqSHA256 != hex.EncodeToString(wantHash[:]) { + t.Errorf("req_sha256 = %q, want %q", e.ReqSHA256, hex.EncodeToString(wantHash[:])) + } + if e.Model == nil || *e.Model != "claude-sonnet-5" { + t.Errorf("model = %v, want claude-sonnet-5", e.Model) + } + if e.RespStatus != http.StatusOK { + t.Errorf("resp_status = %d, want 200", e.RespStatus) + } +} + +// TestTransportErrorStillRecorded proves an egress attempt that never connects +// is still accounted for — no request leaves the machine unrecorded. +func TestTransportErrorStillRecorded(t *testing.T) { + rec := receipt.NewCurrent("test:err") + + // Reserved-for-documentation TEST-NET-1 address; connection will fail fast. + req, err := NewRequest(context.Background(), http.MethodGet, "http://192.0.2.1:1/api/projects", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + _, _ = NewClient(500 * time.Millisecond).Do(req) + + if rec.EntryCount() != 1 { + t.Fatalf("expected the failed attempt to be recorded, got %d entries", rec.EntryCount()) + } + if note := rec.Entries[0].Note; !strings.Contains(note, "transport-error") { + t.Errorf("note = %q, want transport-error", note) + } + if rec.Entries[0].Category != "cloud-api" { + t.Errorf("category = %q, want cloud-api", rec.Entries[0].Category) + } +} + +// TestFinalizeAndVerifyRoundTrip proves qmax-code can produce a signed receipt +// and verify it offline through the shared module. +func TestFinalizeAndVerifyRoundTrip(t *testing.T) { + receipt.BaseDir = t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + rec := receipt.NewCurrent("test:roundtrip") + req, _ := NewRequest(context.Background(), http.MethodGet, srv.URL+"/api/projects", nil) + resp, err := NewClient(5 * time.Second).Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + + path, err := rec.Finalize() + if err != nil { + t.Fatalf("Finalize: %v", err) + } + loaded, err := receipt.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if err := receipt.Verify(loaded); err != nil { + t.Fatalf("Verify: %v", err) + } + if loaded.Summary.TotalRequests != 1 { + t.Errorf("total_requests = %d, want 1", loaded.Summary.TotalRequests) + } +} diff --git a/internal/setup/interactive.go b/internal/setup/interactive.go index 0d2984f..b3e687d 100644 --- a/internal/setup/interactive.go +++ b/internal/setup/interactive.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "net/http" "net/url" "os" "os/exec" @@ -16,6 +15,7 @@ import ( "time" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/tui" ) @@ -58,10 +58,10 @@ func LoginViaBrowser() (*api.AuthConfig, error) { if cloudURL == "" { cloudURL = api.DefaultCloudURL } - client := &http.Client{Timeout: 10 * time.Second} + client := httpx.NewClient(10 * time.Second) // Step 1: Get a CLI auth code - req, err := http.NewRequest("POST", cloudURL+"/api/auth/cli-login", nil) + req, err := httpx.NewRequest(context.Background(), "POST", cloudURL+"/api/auth/cli-login", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -103,7 +103,7 @@ func LoginViaBrowser() (*api.AuthConfig, error) { for time.Now().Before(deadline) { time.Sleep(2 * time.Second) - pollReq, _ := http.NewRequest("GET", pollURL, nil) + pollReq, _ := httpx.NewRequest(context.Background(), "GET", pollURL, nil) pollResp, err := client.Do(pollReq) if err != nil { // Network hiccup — keep trying diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index 6a80714..6a10c9f 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "net/http" "net/url" "strings" "time" @@ -11,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/httpx" "github.com/qualitymax/qmax-code/internal/sysutil" ) @@ -192,7 +192,7 @@ func probeOllamaReachable(rawURL string) bool { // Strip credentials before logging; keep them for the actual request. probe := *u probe.Path = "/api/tags" - c := &http.Client{Timeout: 2 * time.Second} + c := httpx.NewClient(2 * time.Second) resp, err := c.Get(probe.String()) if err != nil { return false diff --git a/main.go b/main.go index 7a4a84e..ed052fa 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,21 @@ func main() { defer sysutil.FlushErrorReporting() defer sysutil.RecoverPanic() + // Exposure Receipt: record every outbound request this session makes (LLM + // prompts, cloud-API calls, integration connects) into a signed, offline- + // verifiable manifest under ~/.qmax-code/receipts/. Installed before any + // subcommand dispatch so MCP-serve, login, cc/codex and the interactive flow + // are all covered; written at exit only if the session actually egressed. + initReceiptPaths() + sessionRec := beginSessionReceipt(receiptKind(os.Args)) + defer finalizeSessionReceipt(sessionRec) + + // `receipt` inspects prior manifests offline and does no egress of its own. + if len(os.Args) > 1 && os.Args[1] == "receipt" { + handleReceiptCommand(os.Args[2:]) + return + } + // Handle "serve --mcp" subcommand: start MCP server for Claude Code integration. // CC spawns this automatically when qmax-code is listed as an MCP server. if len(os.Args) > 1 && os.Args[1] == "serve" { diff --git a/receipt.go b/receipt.go new file mode 100644 index 0000000..b615584 --- /dev/null +++ b/receipt.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +// initReceiptPaths points the shared receipt module at qmax-code's own state +// directory (~/.qmax-code) and stamps this build's version into every receipt. +// It must run before any receipt operation (session start or `receipt` command) +// so identity/store stay separate from the qmax CLI's ~/.qamax layout. Both +// binaries emit the same receipt_version schema; only BaseDir differs. +func initReceiptPaths() { + if home, err := os.UserHomeDir(); err == nil { + receipt.BaseDir = filepath.Join(home, ".qmax-code") + } + receipt.AgentVersion = Version +} + +// beginSessionReceipt installs a process-global Exposure Receipt for this +// qmax-code run. Because the shared module routes any egress with no +// receipt-bearing context to the current run, every outbound request from this +// process — LLM prompts, cloud-API calls, integration connects — is captured +// without threading a context through every call site. +func beginSessionReceipt(kind string) *receipt.Receipt { + return receipt.NewCurrent("session:" + kind) +} + +// receiptKind derives a short run-kind label for the session receipt from the +// invocation's first argument, so a reviewer can tell an MCP-serve run from an +// interactive one at a glance in `qmax-code receipt list`. +func receiptKind(args []string) string { + if len(args) > 1 { + switch args[1] { + case "serve": + return "mcp" + case "login": + return "login" + case "cc": + return "cc" + case "codex": + return "codex" + case "config": + return "config" + } + } + return "interactive" +} + +// finalizeSessionReceipt signs and writes the session receipt at exit, but only +// when the session actually egressed — a no-egress run (--version, config show, +// help) leaves the receipts directory clean. +func finalizeSessionReceipt(r *receipt.Receipt) { + if r == nil || r.EntryCount() == 0 { + return + } + if path, err := r.Finalize(); err != nil { + fmt.Fprintf(os.Stderr, "WARN: failed to write exposure receipt: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Exposure receipt: %s\n", path) + } +} From e7219a7403ded11dd512baf266e942dc3b6a69e5 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 19:30:26 +0200 Subject: [PATCH 2/3] fix(receipt): finalizer on signal-exit + WebSocket guard carve-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address diff-risk-review findings on PR #149: B1 (blocker): signal-based exits (Ctrl+C x2, SIGTERM) called os.Exit(0) in repl.Run, bypassing main's deferred finalizeSessionReceipt — so the most common quit path silently dropped the receipt. Pass the finalizer into repl.Run as a sync.Once-wrapped callback; saveAndExit now calls it before os.Exit. Normal exits (defer) and signal exits (callback) both converge on the same once-guarded write. W1: VNC live-browser streaming dials a coder/websocket connection to the cloud sandbox, bypassing http.Transport and the receipt RoundTripper. Add coder/websocket to the egress guard's forbidden-import list and allowlist the vnc package as a documented carve-out (carries pixels, never source/prompts). Any new WebSocket egress must be reviewed. W2: Document the body-hash assumption — ReqBytes/ReqSHA256 are read after RoundTrip returns, which is accurate for all current endpoints (they drain the request body before responding); noted for future early-responding servers. --- internal/httpx/guard_test.go | 10 +++++++--- internal/httpx/httpx.go | 6 ++++++ internal/repl/repl.go | 9 +++++++-- internal/vnc/stream.go | 11 ++++++++++- main.go | 12 ++++++++++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/internal/httpx/guard_test.go b/internal/httpx/guard_test.go index b35b202..aae0d02 100644 --- a/internal/httpx/guard_test.go +++ b/internal/httpx/guard_test.go @@ -35,6 +35,7 @@ func TestNoEgressOutsideHttpx(t *testing.T) { forbiddenImports := []string{ "go-resty", "levigross/grequests", "h2non/gentleman", "valyala/fasthttp", "imroc/req", "jmcvetta/napping", + "coder/websocket", } root := filepath.Join("..", "..") // module root: qmax-code/ @@ -45,10 +46,13 @@ func TestNoEgressOutsideHttpx(t *testing.T) { return err } if info.IsDir() { - // Skip this package (the sanctioned chokepoint), build output, and - // hidden/vendor dirs. + // Skip sanctioned carve-outs, build output, and hidden/vendor dirs. + // httpx — the chokepoint itself (creates the clients). + // vnc — documented WebSocket carve-out (see internal/vnc/stream.go + // package doc): live-browser screen streaming carries pixels, + // never source/prompts. Reviewed and accepted on QUA-1316. name := info.Name() - if name == "httpx" || name == "vendor" || name == "build" || strings.HasPrefix(name, ".") { + if name == "httpx" || name == "vnc" || name == "vendor" || name == "build" || strings.HasPrefix(name, ".") { if path != root { return filepath.SkipDir } diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go index 23a2e4c..0b7d46a 100644 --- a/internal/httpx/httpx.go +++ b/internal/httpx/httpx.go @@ -128,6 +128,12 @@ func (t *receiptTransport) RoundTrip(req *http.Request) (*http.Response, error) resp, err := t.base.RoundTrip(req) + // Read the accumulated hash/size after RoundTrip returns. This is accurate + // because every current endpoint (Anthropic, Cerebras, Ollama, cloud REST) + // reads the full request body before sending response headers — so the + // transport has fully drained hashingBody by the time we get here. An + // early-responding server (rare; HTTP/2 with a 4xx before body drain) would + // see a partial count; acceptable given the no-buffering design constraint. entry.ReqBytes = hb.n entry.ReqSHA256 = hex.EncodeToString(hb.h.Sum(nil)) if err != nil { diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 7440e87..4db7997 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -26,8 +26,10 @@ import ( ) // Run is the REPL entrypoint. version is the qmax-code build version, -// surfaced in the welcome banner. -func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version string) { +// surfaced in the welcome banner. finalizeReceipt is called inside saveAndExit +// so that signal-based exits (Ctrl+C x2, SIGTERM — which call os.Exit and +// bypass main's deferred finalizer) still write the Exposure Receipt. +func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version string, finalizeReceipt func()) { term := tui.NewTerminal() defer term.Close() if cliAgent != nil { @@ -93,6 +95,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin saveAndExit := func() { _ = session.SaveSession(sessionID, ag.History, ag.Cfg.Context.ProjectID, ag.Usage, ag.Cfg.Model) completeCloudSession() + if finalizeReceipt != nil { + finalizeReceipt() + } fmt.Fprintf(os.Stderr, "Session %s saved.\n", sessionID) } diff --git a/internal/vnc/stream.go b/internal/vnc/stream.go index 0b9f810..3cfc8ee 100644 --- a/internal/vnc/stream.go +++ b/internal/vnc/stream.go @@ -1,8 +1,17 @@ package vnc +// EGRESS CARVE-OUT (QUA-1316): this package is the sole non-httpx egress path +// in qmax-code. It opens a WebSocket (coder/websocket) to the cloud-sandbox +// noVNC endpoint to stream the live browser framebuffer. This traffic carries +// rendered pixels (screenshots), never source code, prompts, or API keys, so it +// is outside the Exposure Receipt's content-accountability scope. The static +// egress guard (internal/httpx/guard_test.go) allowlists this package +// explicitly — any *new* WebSocket or raw-socket egress must be reviewed and +// added to the carve-out list, not silently merged. +// // Minimal RFB 3.8 client over WebSocket, tailored for QM Cloud Sandbox // noVNC endpoints. Decodes Raw + CopyRect into a 32-bit BGRX framebuffer -// so the renderer can emit ANSI half-blocks. Keeps the dependency surface +// so the renderer can emit ANSI half blocks. Keeps the dependency surface // to one websocket library — RFB framing is small enough to roll by hand // and lets us pin the wire format we want from the server. diff --git a/main.go b/main.go index ed052fa..22001fc 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "sync" xterm "golang.org/x/term" @@ -58,7 +59,14 @@ func main() { // are all covered; written at exit only if the session actually egressed. initReceiptPaths() sessionRec := beginSessionReceipt(receiptKind(os.Args)) - defer finalizeSessionReceipt(sessionRec) + // sync.Once makes the finalizer idempotent: signal-based exits call it via + // repl.Run's saveAndExit (before os.Exit), normal exits call it via this + // defer — whichever fires first wins, the other is a no-op. + var receiptOnce sync.Once + finalizeReceipt := func() { + receiptOnce.Do(func() { finalizeSessionReceipt(sessionRec) }) + } + defer finalizeReceipt() // `receipt` inspects prior manifests offline and does no egress of its own. if len(os.Args) > 1 && os.Args[1] == "receipt" { @@ -567,7 +575,7 @@ func main() { agent.CleanupStaleMCPConfigs() // Interactive REPL - repl.Run(ag, cliAgent, *quiet, Version) + repl.Run(ag, cliAgent, *quiet, Version, finalizeReceipt) } // resolveModel expands shorthand model names to full model IDs. From 25d9fde35051666802137e109fe7a4bc1479531a Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 19:37:14 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20Sigilix=20review=20?= =?UTF-8?q?=E2=80=94=20receiptShow=20error=20handling=20+=20guard=20inject?= =?UTF-8?q?ion=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - receiptShow: check json.MarshalIndent error instead of discarding it; print diagnostic to stderr and exit non-zero on failure (Sigilix P2). - Refactor egress guard: extract scanForEgressViolations into a testable function and add TestEgressGuardDetectsInjection, which writes a raw http.Client{} to a temp dir and asserts the scan catches it — making the 'verified by injection' acceptance criterion permanent in CI (Sigilix P3). --- cmd_receipt.go | 6 +- internal/httpx/guard_test.go | 125 ++++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/cmd_receipt.go b/cmd_receipt.go index a4bef6b..75edf55 100644 --- a/cmd_receipt.go +++ b/cmd_receipt.go @@ -62,7 +62,11 @@ func receiptList() { func receiptShow(idOrLatest string) { r := mustResolveReceipt(idOrLatest) - data, _ := json.MarshalIndent(r, "", " ") + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: cannot marshal receipt: %v\n", err) + os.Exit(1) + } fmt.Println(string(data)) } diff --git a/internal/httpx/guard_test.go b/internal/httpx/guard_test.go index aae0d02..880e71d 100644 --- a/internal/httpx/guard_test.go +++ b/internal/httpx/guard_test.go @@ -8,51 +8,49 @@ import ( "testing" ) -// TestNoEgressOutsideHttpx is the static Egress Guard. It walks the whole -// qmax-code module and fails if any package other than httpx constructs an HTTP -// client/request/transport or imports a third-party HTTP library. This makes an -// un-receipted egress path impossible to merge — the load-bearing half of the -// "Receipts, not promises" guarantee. -// -// net/http may still be IMPORTED elsewhere for types and constants (http.Request, -// http.MethodPost, http.StatusOK, http.StatusText); only the egress-CREATING -// symbols below are forbidden. Route all outbound HTTP through -// httpx.NewClient / httpx.NewRequest. -func TestNoEgressOutsideHttpx(t *testing.T) { - // Egress-creating symbols. Their only legitimate home is this package. - forbiddenSymbols := []string{ - "http.Client{", - "http.NewRequest(", - "http.NewRequestWithContext(", - "http.Get(", - "http.Post(", - "http.PostForm(", - "http.Head(", - "http.DefaultClient", - "http.DefaultTransport", - } - // Third-party HTTP clients that would bypass net/http entirely. - forbiddenImports := []string{ - "go-resty", "levigross/grequests", "h2non/gentleman", - "valyala/fasthttp", "imroc/req", "jmcvetta/napping", - "coder/websocket", - } +// forbiddenSymbols are the egress-creating constructs. Their only legitimate +// home is this package. +var forbiddenSymbols = []string{ + "http.Client{", + "http.NewRequest(", + "http.NewRequestWithContext(", + "http.Get(", + "http.Post(", + "http.PostForm(", + "http.Head(", + "http.DefaultClient", + "http.DefaultTransport", +} - root := filepath.Join("..", "..") // module root: qmax-code/ - var violations []string +// forbiddenImports are third-party HTTP/WebSocket clients that would bypass +// net/http entirely. +var forbiddenImports = []string{ + "go-resty", "levigross/grequests", "h2non/gentleman", + "valyala/fasthttp", "imroc/req", "jmcvetta/napping", + "coder/websocket", +} +// sanctionedCarveOuts are packages allowed to bypass the guard. Each must have +// a documented justification in its package doc. +// httpx — the chokepoint itself (creates the clients). +// vnc — documented WebSocket carve-out (see internal/vnc/stream.go package +// doc): live-browser screen streaming carries pixels, never +// source/prompts. Reviewed and accepted on QUA-1316. +var sanctionedCarveOuts = map[string]bool{"httpx": true, "vnc": true} + +// scanForEgressViolations walks root and returns any forbidden egress symbols or +// third-party HTTP imports outside sanctioned carve-out packages. It is +// extracted from the guard test so the injection test can exercise the same +// logic against a temp directory. +func scanForEgressViolations(root string) ([]string, error) { + var violations []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { - // Skip sanctioned carve-outs, build output, and hidden/vendor dirs. - // httpx — the chokepoint itself (creates the clients). - // vnc — documented WebSocket carve-out (see internal/vnc/stream.go - // package doc): live-browser screen streaming carries pixels, - // never source/prompts. Reviewed and accepted on QUA-1316. name := info.Name() - if name == "httpx" || name == "vnc" || name == "vendor" || name == "build" || strings.HasPrefix(name, ".") { + if sanctionedCarveOuts[name] || name == "vendor" || name == "build" || strings.HasPrefix(name, ".") { if path != root { return filepath.SkipDir } @@ -68,7 +66,6 @@ func TestNoEgressOutsideHttpx(t *testing.T) { } rel, _ := filepath.Rel(root, path) for i, line := range strings.Split(string(data), "\n") { - // Ignore comments to avoid false positives in prose. code := line if idx := strings.Index(code, "//"); idx >= 0 { code = code[:idx] @@ -88,11 +85,61 @@ func TestNoEgressOutsideHttpx(t *testing.T) { } return nil }) + return violations, err +} + +// TestNoEgressOutsideHttpx is the static Egress Guard. It walks the whole +// qmax-code module and fails if any package other than a sanctioned carve-out +// constructs an HTTP client/request/transport or imports a third-party HTTP +// library. This makes an un-receipted egress path impossible to merge — the +// load-bearing half of the "Receipts, not promises" guarantee. +// +// net/http may still be IMPORTED elsewhere for types and constants (http.Request, +// http.MethodPost, http.StatusOK, http.StatusText); only the egress-CREATING +// symbols are forbidden. Route all outbound HTTP through +// httpx.NewClient / httpx.NewRequest. +func TestNoEgressOutsideHttpx(t *testing.T) { + root := filepath.Join("..", "..") // module root: qmax-code/ + violations, err := scanForEgressViolations(root) if err != nil { t.Fatalf("walk failed: %v", err) } if len(violations) > 0 { - t.Fatalf("egress guard: %d violation(s) outside httpx — route all outbound HTTP through httpx.NewClient/NewRequest:\n%s", + t.Fatalf("egress guard: %d violation(s) outside sanctioned carve-outs — route all outbound HTTP through httpx.NewClient/NewRequest:\n%s", len(violations), strings.Join(violations, "\n")) } } + +// TestEgressGuardDetectsInjection proves the guard actually catches a raw +// http.Client construction and a forbidden import — so the guard's detection +// logic itself is validated in CI, not just the absence of violations today. +func TestEgressGuardDetectsInjection(t *testing.T) { + dir := t.TempDir() + // Simulate a package that bypasses the chokepoint. + if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte( + `package bad + +import "net/http" + +var client = &http.Client{} +`), 0644); err != nil { + t.Fatal(err) + } + + violations, err := scanForEgressViolations(dir) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + if len(violations) == 0 { + t.Fatal("guard failed to detect an injected raw http.Client{} — detection logic is broken") + } + found := false + for _, v := range violations { + if strings.Contains(v, "forbidden egress symbol http.Client{") { + found = true + } + } + if !found { + t.Fatalf("guard did not flag http.Client{}: %v", violations) + } +}