Skip to content

Commit d85b4e3

Browse files
committed
fix: Address code review on the tool-prune series
Correctness and privacy: - snapshot.go: gjson's String() on an object or array returns that node's RAW JSON, so a structured error.type put response body content — anything the provider quoted from the request — into the unauthenticated session store, defeating the reason error.message is excluded. Now accepts only a JSON string or number. A test plants a credential in a structured value. - toolprune: modelRates.rateFor reports whether a usable rate exists. set() ORs three fields, so a model configured with only a cache-read rate resolved as priced and then charged a cache-write request zero — vanishing from the total with no `requests unpriced` row. - toolprune: the built-in per-model table now precedes the flat fallback. The flat fields are documented as covering models "absent from pricing", and a model in the table is not absent; one flat rate shadowing every per-model default reintroduced flat-rate mispricing, silently, and claimed to be operator-configured. - toolprune: forcedToolChoice replaces forcedToolName. An object tool_choice naming nothing recognisable (Bedrock Converse nests it as tool.name) now declines to prune rather than reading it as "nothing forced" and risking removal of the one required tool. - abctl: a response carrying a RequestID that fails to pair exactly — a retry, or a stream recorded twice — no longer falls through to the adjacency heuristic, where it could claim an unrelated earlier request. The same guard gates pricing, since a mismatched response supplies the wrong cache tier and the tiers are 12.5x apart. - toolscan: PatchConfig writes via temp file + Sync + rename. os.WriteFile truncates in place, so a crash left a truncated config with no recovery copy, and the proxy's fsnotify reloader could observe the partial file. - demo.go: writeDemoConfig keeps an existing demo.yaml. It runs before any port binds, so an unconditional write meant a --demo start that then failed on a port clash destroyed the operator's edits — including a prune list written by `abctl tools scan --write`, which the config's own comment recommends. sparc declared WritesRequestBody but calls pctx.SetBody nowhere; the flag was stale from the undirected capability and occupied the single request-mutator slot, so [sparc, tool-prune] could not build. Dropped — which is the payoff this series was arguing for, now pinned by a test. Tests: real byte-exactness for the prune (reconstructing expected output from the original bytes, covering first/middle/last element and validating with encoding/json — the old test asserted only fragments and a shorter length, and never removed a first or last element); tool_choice string forms; OpenAI-dialect all-removed; and a reflection-driven clone check that fails if a future slice/map capability is aliased. Also: bytes.Contains on the scan hot path; names_unresolved distinguished from no_configured_tool_present; an in-flight guard so the 2s refresh tick cannot stack fetches against a 10s timeout; the dead crypto/rand fallback removed (cannot fail as of Go 1.24); and docs corrected — WritesResponseBody added to the capability snippet, the duplicated rate-derivation section removed, the "ships with on_error: observe" claim replaced with the empty remove list that is the actual guard, counters noted as resetting on hot-reload too, the BodyAccess changelog line marked as since-removed, and the README's 20-25% figure attributed to the traffic it was measured on. Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 1fc8cf6 commit d85b4e3

23 files changed

Lines changed: 568 additions & 91 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — de
4040
## Cut Claude Code token cost on your laptop
4141

4242
Already using Claude Code? Cortex can strip the tool definitions your agent never
43-
calls out of every request — typically 20–25% of the prompt you pay for on each
44-
turn. Four steps, about two minutes:
43+
calls out of every request. On the traffic this was measured against that is
44+
20–25% of the prompt billed per turn; your share depends on how many of the
45+
tools you actually use. Four steps, about two minutes:
4546
**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**.
4647

4748
## Running on Kubernetes

authbridge/authlib/pipeline/bodydirection_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,26 @@ func TestValidateCapabilities_Directional(t *testing.T) {
163163
})
164164
}
165165
}
166+
167+
// TestValidateCapabilities_ResponseAndRequestMutatorsCoexist is the payoff the
168+
// directional split was arguing for. Before it, SPARC's undirected flag occupied
169+
// the only mutator slot, so a request-only mutator could not share a chain with
170+
// it even though the two write different bodies. Now the real in-tree shape —
171+
// parser, response mutator, request mutator — builds.
172+
func TestValidateCapabilities_ResponseAndRequestMutatorsCoexist(t *testing.T) {
173+
err := validateCapabilities([]Plugin{
174+
&stubPlugin{name: "inference-parser", caps: PluginCapabilities{ReadsBody: true}},
175+
&stubPlugin{name: "sparc", caps: PluginCapabilities{WritesResponseBody: true}},
176+
&stubPlugin{name: "tool-prune", caps: PluginCapabilities{WritesRequestBody: true}},
177+
})
178+
if err != nil {
179+
t.Errorf("[parser, sparc, tool-prune] should build: %v", err)
180+
}
181+
// Two mutators on the SAME side are still rejected.
182+
if err := validateCapabilities([]Plugin{
183+
&stubPlugin{name: "sparc", caps: PluginCapabilities{WritesResponseBody: true}},
184+
&stubPlugin{name: "cpex", caps: PluginCapabilities{WritesResponseBody: true}},
185+
}); err == nil {
186+
t.Error("two response mutators must still be rejected")
187+
}
188+
}

authbridge/authlib/pipeline/errorkind_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package pipeline
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
)
47

58
// TestUpstreamErrorKind: a bare "backend_error / 400" gives an operator nothing
69
// to act on. The provider's own classification does — and it must be the
@@ -75,3 +78,36 @@ func TestDeriveError_PopulatesKindFrom4xxBody(t *testing.T) {
7578
t.Errorf("bare 5xx = %+v, want backend_error/503 with empty message", bare)
7679
}
7780
}
81+
82+
// TestUpstreamErrorKind_RefusesStructuredValues is the privacy regression for a
83+
// leak the earlier test could not see: gjson's String() on an object or array
84+
// returns that node's RAW JSON. A provider (or a proxy in between) returning a
85+
// structured error.type therefore put response body content — including anything
86+
// quoted from the request — straight into the unauthenticated session store,
87+
// defeating the whole reason error.message is excluded.
88+
func TestUpstreamErrorKind_RefusesStructuredValues(t *testing.T) {
89+
secret := "sk-live-DEADBEEF"
90+
for _, body := range []string{
91+
`{"error":{"type":{"secret":"` + secret + `","nested":true}}}`,
92+
`{"error":{"type":["` + secret + `"]}}`,
93+
`{"error":{"code":{"inner":"` + secret + `"}}}`,
94+
`{"error":{"type":true}}`,
95+
`{"error":{"type":null}}`,
96+
} {
97+
got := upstreamErrorKind([]byte(body))
98+
if got != "" {
99+
t.Errorf("structured value leaked %q from %s", got, body)
100+
}
101+
if strings.Contains(got, secret) {
102+
t.Fatalf("CREDENTIAL LEAK: %q", got)
103+
}
104+
}
105+
// A numeric code carries no payload and stays useful.
106+
if got := upstreamErrorKind([]byte(`{"error":{"code":429}}`)); got != "429" {
107+
t.Errorf("numeric code = %q, want 429", got)
108+
}
109+
// The normal string path is unaffected.
110+
if got := upstreamErrorKind([]byte(`{"error":{"type":"rate_limit_error"}}`)); got != "rate_limit_error" {
111+
t.Errorf("string type = %q", got)
112+
}
113+
}

authbridge/authlib/pipeline/requestid.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,8 @@ package pipeline
33
import (
44
"crypto/rand"
55
"encoding/hex"
6-
"strconv"
7-
"sync/atomic"
86
)
97

10-
// requestIDCounter is the fallback when crypto/rand is unavailable, so an id is
11-
// always produced rather than an empty string that would silently disable
12-
// pairing.
13-
var requestIDCounter atomic.Uint64
14-
158
// newRequestID returns a short, unique-per-process request identifier.
169
//
1710
// Not a UUID on purpose: it exists to pair a request event with its response
@@ -20,9 +13,9 @@ var requestIDCounter atomic.Uint64
2013
// cryptographically meaningful.
2114
func newRequestID() string {
2215
var b [6]byte
23-
if _, err := rand.Read(b[:]); err != nil {
24-
return "r" + strconv.FormatUint(requestIDCounter.Add(1), 36)
25-
}
16+
// crypto/rand.Read never returns an error as of Go 1.24 — it panics on an
17+
// unusable system source instead — so there is no failure branch to write.
18+
_, _ = rand.Read(b[:])
2619
return hex.EncodeToString(b[:])
2720
}
2821

authbridge/authlib/pipeline/snapshot.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,15 @@ func upstreamErrorKind(body []byte) string {
165165
if !gjson.ValidBytes(body) {
166166
return ""
167167
}
168-
t := gjson.GetBytes(body, "error.type").String()
168+
// Only accept a JSON string. gjson's String() on an object or array returns
169+
// that node's RAW JSON, so {"error":{"type":{...}}} would put response body
170+
// content — quoted request data, credentials — straight into the
171+
// unauthenticated session store, defeating the whole point of excluding
172+
// error.message. A numeric code is accepted because a number carries no
173+
// payload; anything structured is refused.
174+
t := stringOrNumber(gjson.GetBytes(body, "error.type"))
169175
if t == "" {
170-
t = gjson.GetBytes(body, "error.code").String()
176+
t = stringOrNumber(gjson.GetBytes(body, "error.code"))
171177
}
172178
if t == "" {
173179
return ""
@@ -177,3 +183,15 @@ func upstreamErrorKind(body []byte) string {
177183
}
178184
return t
179185
}
186+
187+
// stringOrNumber returns the value only when the node is a JSON string or
188+
// number. Every other type — object, array, true/false, absent — yields "",
189+
// because String() on a container returns its raw JSON and that is body content.
190+
func stringOrNumber(r gjson.Result) string {
191+
switch r.Type {
192+
case gjson.String, gjson.Number:
193+
return r.String()
194+
default:
195+
return ""
196+
}
197+
}

authbridge/authlib/plugins/registry_capsclone_test.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,41 @@ func TestCloneCatalog_PreservesEveryCapabilityField(t *testing.T) {
6060
}
6161
}
6262

63-
// TestCloneCatalog_DeepCopiesSlices: the clone must not alias the caller's
64-
// slices, or a mutation through /v1/plugins would reach into the registry.
63+
// TestCloneCatalog_DeepCopiesEveryReferenceField walks PluginCapabilities by
64+
// reflection and asserts that no field of a reference kind is aliased. The
65+
// struct copy in cloneCatalog is correct for today's two slices, but a future
66+
// map or slice capability would be silently shared with the registry — the same
67+
// class of bug the field-by-field copy had, which is why this is driven by the
68+
// struct rather than by a hand-written list.
69+
func TestCloneCatalog_DeepCopiesEveryReferenceField(t *testing.T) {
70+
caps := nonZeroCaps(t)
71+
in := []CatalogEntry{{Name: "probe", Capabilities: caps}}
72+
out := cloneCatalog(in)
73+
74+
src := reflect.ValueOf(&in[0].Capabilities).Elem()
75+
dst := reflect.ValueOf(&out[0].Capabilities).Elem()
76+
for i := 0; i < src.NumField(); i++ {
77+
name := src.Type().Field(i).Name
78+
switch src.Field(i).Kind() {
79+
case reflect.Slice:
80+
if src.Field(i).Len() == 0 {
81+
t.Fatalf("%s: nonZeroCaps left it empty, so aliasing cannot be detected", name)
82+
}
83+
if src.Field(i).UnsafePointer() == dst.Field(i).UnsafePointer() {
84+
t.Errorf("%s aliases the registry's slice", name)
85+
}
86+
case reflect.Map, reflect.Pointer:
87+
if src.Field(i).UnsafePointer() == dst.Field(i).UnsafePointer() {
88+
t.Errorf("%s is a %s shared with the registry — cloneCatalog needs to copy it",
89+
name, src.Field(i).Kind())
90+
}
91+
}
92+
}
93+
}
94+
95+
// TestCloneCatalog_DeepCopiesSlices keeps the concrete mutation check: the clone
96+
// must not alias the caller's slices, or a mutation through /v1/plugins would
97+
// reach into the registry.
6598
func TestCloneCatalog_DeepCopiesSlices(t *testing.T) {
6699
in := []CatalogEntry{{
67100
Name: "probe",

authbridge/authlib/plugins/sparc/plugin.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,14 @@ func (p *SPARC) Capabilities() pipeline.PluginCapabilities {
209209
// conversation + tool specs (both modes); mcp-parser provides the tool
210210
// call (mcp mode). RequiresAny is a static "at least one" check; the
211211
// per-mode runtime requirements are validated/handled below.
212-
RequiresAny: []string{"inference-parser", "mcp-parser"},
213-
ReadsBody: true,
214-
WritesRequestBody: true, // MCP result (mcp mode) / completion rewrite (inference mode)
215-
WritesResponseBody: true, // respond.go rewrites the upstream response
212+
RequiresAny: []string{"inference-parser", "mcp-parser"},
213+
ReadsBody: true,
214+
// Response-only: SPARC rewrites the upstream response (respond.go), and
215+
// calls pctx.SetBody nowhere. Declaring WritesRequestBody was carried over
216+
// from the undirected flag and cost it the single request-mutator slot for
217+
// nothing, so a chain like [sparc, tool-prune] could not build even though
218+
// the two write different bodies.
219+
WritesResponseBody: true,
216220
Description: "SPARC pre-tool reflection: blocks ungrounded/hallucinated tool calls.",
217221
}
218222
}

authbridge/authlib/plugins/sparc/plugin_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,22 @@ func TestInference_MCPModeOnResponseIsNoop(t *testing.T) {
335335
}
336336
}
337337

338+
// TestCapabilities pins SPARC as a RESPONSE-side mutator. It rewrites the
339+
// upstream response (respond.go) and calls pctx.SetBody nowhere, so declaring
340+
// WritesRequestBody was carried over from the undirected flag and cost it the
341+
// single request-mutator slot for nothing — a chain like [sparc, tool-prune]
342+
// could not build even though the two write different bodies.
338343
func TestCapabilities(t *testing.T) {
339344
caps := NewSPARC().Capabilities()
340-
if !caps.WritesRequestBody || !caps.ReadsBody {
341-
t.Error("expected ReadsBody+WritesRequestBody")
345+
if !caps.WritesResponseBody {
346+
t.Error("expected WritesResponseBody — SPARC rewrites the response")
347+
}
348+
if caps.WritesRequestBody {
349+
t.Error("must not declare WritesRequestBody: SPARC never calls pctx.SetBody, " +
350+
"and the claim blocks any real request mutator from sharing the chain")
351+
}
352+
if !caps.Normalize().ReadsBody {
353+
t.Error("a write flag must promote ReadsBody")
342354
}
343355
if len(caps.RequiresAny) == 0 {
344356
t.Error("expected RequiresAny parsers")

authbridge/authlib/plugins/toolprune/metrics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func (m *metrics) observeSaving(tokens float64, t tier, usd float64, src rateSou
119119
// snapshot renders the counters as operator-facing metrics. Every derived row
120120
// carries the sample it was computed from, so a figure can never be read as
121121
// more certain than it is.
122-
func (m *metrics) snapshot(cfg *config) []pipeline.Metric {
122+
func (m *metrics) snapshot() []pipeline.Metric {
123123
m.mu.Lock()
124124
defer m.mu.Unlock()
125125

0 commit comments

Comments
 (0)