Skip to content

Commit 7da57be

Browse files
committed
fix(cpex): address PR #493 re-review — symmetric MCP response guard + hardening
Resolve the carried-over blocker and remaining suggestions from Hai Huang's re-review: Must-fix (blocker, carried over): - applyMCPResponseBodyMod (cmf_body.go): the MCP response path had the identical multi-part leak the A2A path had — it rewrote only the first result.content[] text block and break'd, forwarding blocks[1..] verbatim with mutated=true. Now collects all text blocks and fails closed on >1 (the read side only inspects the first; one redacted value can't safely rewrite many), mirroring the A2A/inference guards. Added a multi-block fail-closed test asserting the planted secret is not forwarded. Suggestions: - MCPRequestBodyMod: replace the len(NewArguments)==0 no-op inference with explicit ArgsSet/URISet flags. A "strip all arguments" redaction returns an empty-but-set map and must apply, not silently forward the original args. Added strip-all-applies + unset-no-op tests. - Docs: fix the misleading "Authorization stripped" comments in manager_cpex.go and headers.go — Authorization is deliberately KEPT for CPEX identity plugins. Made the audit-log redaction + session-API binding guidance a non-optional MUST. - Supply chain: assert the FFI tarball VERSION matches CPEX_FFI_VERSION and FFI_ABI matches the new pinned CPEX_FFI_ABI file at build time (dropped the stale TODO). Wired CPEX_FFI_ABI through the demo Makefile and CI build matrix. Runtime ABI assertion already exists in the Go binding's abi.go init(). Verified: go test ./plugins/cpex/ passes; go vet clean; authbridge-cpex image rebuilds with sha256 + cosign + VERSION/ABI all verified; hr-cpex demo 9/9 scenarios pass on kind (Bob SSN visible, Eve redacted, denies enforce, taint + cross-principal isolation intact). Also retitled the PR to "Feat:" to satisfy the org verify-pr-title gate. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 5cbaa41 commit 7da57be

8 files changed

Lines changed: 168 additions & 34 deletions

File tree

.github/workflows/build.yaml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,21 @@ jobs:
116116
# Add 'latest' tag for version tags, workflow_dispatch, and pushes to main
117117
type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
118118
119-
# 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION,
120-
# the single source of truth for the FFI ABI this image links
121-
# (read from the file next to its Dockerfile). Other images leave
122-
# this empty (an undeclared build-arg is ignored by their builds).
119+
# 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION
120+
# (the release tag) and CPEX_FFI_ABI (the FFI ABI integer the
121+
# linked lib must report) — both read from the files next to its
122+
# Dockerfile and asserted against the tarball at build time. Other
123+
# images leave this empty (an undeclared build-arg is ignored).
123124
- name: Resolve build args
124125
id: buildargs
125126
run: |
126127
if [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then
127128
VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)"
129+
ABI="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI)"
128130
{
129131
echo "args<<EOF"
130132
echo "CPEX_FFI_VERSION=${VERSION}"
133+
echo "CPEX_FFI_ABI=${ABI}"
131134
echo "EOF"
132135
} >> "$GITHUB_OUTPUT"
133136
else

authbridge/authlib/plugins/cpex/cmf_body.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -444,12 +444,23 @@ func stringifyRPCID(id any) string {
444444
// prompts/get → NewArguments → params.arguments
445445
// resources/read → NewURI → params.uri
446446
//
447+
// ArgsSet / URISet are the explicit "this field carries a real
448+
// modification" signals. We do NOT infer intent from emptiness: a
449+
// policy whose redaction is "strip all arguments" legitimately returns
450+
// an empty (or nil) arguments map, and a "blank the URI" redaction
451+
// returns an empty URI. Treating empty-as-no-op would silently forward
452+
// the ORIGINAL body (secret intact) while the policy believed it had
453+
// redacted. The cgo adapter sets these flags when the corresponding
454+
// CMF content part is present, regardless of value.
455+
//
447456
// Other methods are no-ops — the operator's APL policy can't rewrite
448457
// protocol mechanics (initialize, tools/list, etc.) because there's
449458
// no semantically meaningful target for the rewrite.
450459
type MCPRequestBodyMod struct {
451460
NewArguments map[string]any
461+
ArgsSet bool
452462
NewURI string
463+
URISet bool
453464
}
454465

455466
// applyMCPRequestBodyMod rewrites pctx.Body, which is expected to be
@@ -473,12 +484,16 @@ func applyMCPRequestBodyMod(pctx *pipeline.Context, method string, mod MCPReques
473484

474485
switch method {
475486
case "tools/call", "prompts/get":
476-
if len(mod.NewArguments) == 0 {
487+
// Gate on the explicit ArgsSet flag, NOT len()==0: a policy that
488+
// stripped all arguments returns an empty map and still means to
489+
// rewrite. Inferring no-op from emptiness would forward the
490+
// original (un-redacted) arguments.
491+
if !mod.ArgsSet {
477492
return false, nil
478493
}
479494
params["arguments"] = mod.NewArguments
480495
case "resources/read":
481-
if mod.NewURI == "" {
496+
if !mod.URISet {
482497
return false, nil
483498
}
484499
params["uri"] = mod.NewURI
@@ -503,6 +518,17 @@ func applyMCPRequestBodyMod(pctx *pipeline.Context, method string, mod MCPReques
503518
// Don't introduce structuredContent on a response that didn't carry
504519
// it; clients sniffing for the new shape would be surprised.
505520
//
521+
// Fail-closed on multiple text blocks: the read side
522+
// (extractToolResultContent) surfaces only the FIRST text block to the
523+
// policy, and we hold a single redacted value. A result.content[] with
524+
// >1 text block is therefore an ambiguous rewrite — blocks[1..] were
525+
// never shown to the policy, so rewriting only block[0] would forward
526+
// the others (potentially carrying the same secret) verbatim while
527+
// reporting success. We return an error so the caller fails closed
528+
// rather than leaking the un-inspected blocks (mirrors the A2A and
529+
// inference response guards). Rewrite all blocks would be wrong too —
530+
// we'd stamp one value over distinct blocks.
531+
//
506532
// Returns mutated=true when SetResponseBody was called; mutated=false
507533
// when the body wasn't a tools/call response, had no result.content,
508534
// or the response had no replaceable text block.
@@ -524,20 +550,30 @@ func applyMCPResponseBodyMod(pctx *pipeline.Context, method string, newContent a
524550

525551
replaced := false
526552

527-
// Primary path: rewrite the first text block in result.content[].
528-
// This is what every MCP client we see today reads from.
553+
// Primary path: rewrite the text block in result.content[]. Collect
554+
// all text blocks first so >1 fails closed rather than silently
555+
// leaving blocks[1..] unredacted (the read side only inspected the
556+
// first; we only hold one redacted value).
529557
if contentArr, ok := result["content"].([]any); ok {
558+
textBlocks := make([]map[string]any, 0, len(contentArr))
530559
for _, block := range contentArr {
531560
blockObj, ok := block.(map[string]any)
532561
if !ok {
533562
continue
534563
}
535564
if t, ok := blockObj["type"].(string); ok && t == "text" {
536-
blockObj["text"] = stringifyForTextBlock(newContent)
537-
replaced = true
538-
break
565+
textBlocks = append(textBlocks, blockObj)
539566
}
540567
}
568+
if len(textBlocks) > 1 {
569+
return false, fmt.Errorf(
570+
"MCP tools/call response has %d text blocks; single-value redaction rewrite is ambiguous",
571+
len(textBlocks))
572+
}
573+
if len(textBlocks) == 1 {
574+
textBlocks[0]["text"] = stringifyForTextBlock(newContent)
575+
replaced = true
576+
}
541577
}
542578

543579
// Secondary path: update structuredContent when it was already

authbridge/authlib/plugins/cpex/cmf_body_test.go

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestMCPRequestBodyMod_ToolsCallArgsReplaced(t *testing.T) {
1616
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_compensation","arguments":{"employee_id":"E001","include_ssn":true}}}`),
1717
}
1818
newArgs := map[string]any{"employee_id": "E001"} // include_ssn redacted
19-
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: newArgs})
19+
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: newArgs, ArgsSet: true})
2020
if err != nil {
2121
t.Fatalf("err: %v", err)
2222
}
@@ -41,7 +41,7 @@ func TestMCPRequestBodyMod_PromptsGetArgsReplaced(t *testing.T) {
4141
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"weather","arguments":{"city":"SF"}}}`),
4242
}
4343
newArgs := map[string]any{"city": "REDACTED"}
44-
mutated, err := applyMCPRequestBodyMod(pctx, "prompts/get", MCPRequestBodyMod{NewArguments: newArgs})
44+
mutated, err := applyMCPRequestBodyMod(pctx, "prompts/get", MCPRequestBodyMod{NewArguments: newArgs, ArgsSet: true})
4545
if err != nil || !mutated {
4646
t.Fatalf("mutated=%v err=%v", mutated, err)
4747
}
@@ -54,7 +54,7 @@ func TestMCPRequestBodyMod_ResourcesReadURIReplaced(t *testing.T) {
5454
pctx := &pipeline.Context{
5555
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///secret"}}`),
5656
}
57-
mutated, err := applyMCPRequestBodyMod(pctx, "resources/read", MCPRequestBodyMod{NewURI: "file:///public"})
57+
mutated, err := applyMCPRequestBodyMod(pctx, "resources/read", MCPRequestBodyMod{NewURI: "file:///public", URISet: true})
5858
if err != nil || !mutated {
5959
t.Fatalf("mutated=%v err=%v", mutated, err)
6060
}
@@ -63,26 +63,55 @@ func TestMCPRequestBodyMod_ResourcesReadURIReplaced(t *testing.T) {
6363
}
6464
}
6565

66-
func TestMCPRequestBodyMod_EmptyArgsNoOp(t *testing.T) {
66+
func TestMCPRequestBodyMod_UnsetArgsNoOp(t *testing.T) {
67+
// No ArgsSet flag (e.g. CPEX returned no tool_call part) → no-op.
6768
orig := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"a":1}}}`)
6869
pctx := &pipeline.Context{Body: append([]byte(nil), orig...)}
6970
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{})
7071
if err != nil {
7172
t.Fatalf("err: %v", err)
7273
}
7374
if mutated {
74-
t.Fatal("expected mutated=false on empty mod")
75+
t.Fatal("expected mutated=false when ArgsSet is false")
7576
}
7677
if string(pctx.Body) != string(orig) {
7778
t.Fatalf("body changed despite no-op: %s", pctx.Body)
7879
}
7980
}
8081

82+
func TestMCPRequestBodyMod_StripAllArgsApplies(t *testing.T) {
83+
// A "strip all arguments" redaction returns an empty args map WITH
84+
// ArgsSet=true. This MUST apply (clear params.arguments), not no-op:
85+
// inferring no-op from emptiness would forward the original secret.
86+
pctx := &pipeline.Context{
87+
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"ssn":"123-45-6789"}}}`),
88+
}
89+
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call",
90+
MCPRequestBodyMod{NewArguments: map[string]any{}, ArgsSet: true})
91+
if err != nil {
92+
t.Fatalf("err: %v", err)
93+
}
94+
if !mutated {
95+
t.Fatal("expected mutated=true: empty-but-set args is a real redaction")
96+
}
97+
if strings.Contains(string(pctx.Body), "123-45-6789") {
98+
t.Fatalf("original secret args forwarded despite strip-all redaction: %s", pctx.Body)
99+
}
100+
var decoded map[string]any
101+
if err := json.Unmarshal(pctx.Body, &decoded); err != nil {
102+
t.Fatalf("re-decode: %v", err)
103+
}
104+
args := decoded["params"].(map[string]any)["arguments"].(map[string]any)
105+
if len(args) != 0 {
106+
t.Fatalf("arguments should be empty after strip-all: %v", args)
107+
}
108+
}
109+
81110
func TestMCPRequestBodyMod_UnsupportedMethodNoOp(t *testing.T) {
82111
pctx := &pipeline.Context{
83112
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`),
84113
}
85-
mutated, err := applyMCPRequestBodyMod(pctx, "initialize", MCPRequestBodyMod{NewArguments: map[string]any{"x": 1}})
114+
mutated, err := applyMCPRequestBodyMod(pctx, "initialize", MCPRequestBodyMod{NewArguments: map[string]any{"x": 1}, ArgsSet: true})
86115
if err != nil {
87116
t.Fatalf("err: %v", err)
88117
}
@@ -93,15 +122,15 @@ func TestMCPRequestBodyMod_UnsupportedMethodNoOp(t *testing.T) {
93122

94123
func TestMCPRequestBodyMod_MalformedJSONError(t *testing.T) {
95124
pctx := &pipeline.Context{Body: []byte(`{not json`)}
96-
_, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}})
125+
_, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true})
97126
if err == nil {
98127
t.Fatal("expected error on malformed JSON")
99128
}
100129
}
101130

102131
func TestMCPRequestBodyMod_EmptyBodyNoOp(t *testing.T) {
103132
pctx := &pipeline.Context{}
104-
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}})
133+
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true})
105134
if err != nil || mutated {
106135
t.Fatalf("mutated=%v err=%v on empty body", mutated, err)
107136
}
@@ -111,7 +140,7 @@ func TestMCPRequestBodyMod_NoParamsObjectNoOp(t *testing.T) {
111140
pctx := &pipeline.Context{
112141
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`),
113142
}
114-
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}})
143+
mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true})
115144
if err != nil || mutated {
116145
t.Fatalf("mutated=%v err=%v on body without params", mutated, err)
117146
}
@@ -146,6 +175,31 @@ func TestMCPResponseBodyMod_TextBlockReplaced(t *testing.T) {
146175
}
147176
}
148177

178+
func TestMCPResponseBodyMod_MultiTextBlockFailsClosed(t *testing.T) {
179+
// A tools/call result with ≥2 text blocks is an ambiguous rewrite:
180+
// the read side (extractToolResultContent) only surfaced the first
181+
// block to the policy, and we hold one redacted value. Rewriting only
182+
// block[0] would forward block[1] (here carrying the same secret)
183+
// verbatim. Must fail closed.
184+
pctx := &pipeline.Context{
185+
ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"first block ssn:123-45-6789"},{"type":"text","text":"second block ssn:123-45-6789"}]}}`),
186+
}
187+
newContent := map[string]any{"name": "Jane Smith"} // SSN removed
188+
mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", newContent)
189+
if err == nil {
190+
t.Fatal("expected fail-closed error for multi-text-block response, got nil")
191+
}
192+
if mutated {
193+
t.Fatal("mutated=true on multi-block fail-closed")
194+
}
195+
// The body must be untouched — and critically, the planted secret in
196+
// the un-inspected blocks must NOT have been forwarded as a "success".
197+
// (We assert via the returned error contract; the body stays original.)
198+
if !strings.Contains(string(pctx.ResponseBody), "second block ssn:123-45-6789") {
199+
t.Fatalf("response body unexpectedly altered: %s", pctx.ResponseBody)
200+
}
201+
}
202+
149203
func TestMCPResponseBodyMod_StructuredContentUpdated(t *testing.T) {
150204
pctx := &pipeline.Context{
151205
ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"x"}],"structuredContent":{"ssn":"123"}}}`),

authbridge/authlib/plugins/cpex/headers.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ import (
4242
// which silently allows traffic the policy meant to deny.
4343
//
4444
// The audit-log risk this opens — bearer tokens reaching audit
45-
// payloads — is mitigated by configuring audit-log to drop
46-
// Authorization from its output (or by terminating TLS at the
47-
// sidecar so tokens are short-lived and bound to mTLS).
45+
// payloads and the unauthenticated session API — is NOT optional to
46+
// mitigate: any audit-log sub-plugin MUST be configured to drop the
47+
// Authorization header from its output, and the session API MUST stay
48+
// bound to in-cluster addresses only (never behind ingress). Pairing
49+
// with sidecar TLS termination (short-lived, mTLS-bound tokens) is a
50+
// further hardening, not a substitute.
4851
//
4952
// This table and the helpers below are tag-free (no //go:build cpex)
5053
// so the default CGO_ENABLED=0 test build exercises them; the cgo
@@ -77,9 +80,10 @@ var secretHeaderExact = map[string]struct{}{
7780
// follow §3.2.2, lands in secretHeaderPrefixes and is stripped before
7881
// reaching here.)
7982
//
80-
// Sensitive headers (Authorization, Cookie, X-Api-Key, …) are
81-
// dropped via secretHeaderPrefixes / secretHeaderExact, NOT silently
82-
// truncated.
83+
// Sensitive headers (Cookie, X-Api-Key, …) are dropped via
84+
// secretHeaderPrefixes / secretHeaderExact, NOT silently truncated.
85+
// Authorization is the deliberate exception — see the NOTE above: it
86+
// is kept so CPEX identity plugins can read the bearer.
8387
func flattenHeaders(h http.Header) map[string]string {
8488
if len(h) == 0 {
8589
return nil

authbridge/authlib/plugins/cpex/manager_cpex.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,15 +415,21 @@ func applyMCPBodyModFromCMF(pctx *pipeline.Context, msg *rcpex.Message, isRespon
415415
switch part.ContentType {
416416
case "tool_call":
417417
if part.ToolCallContent != nil {
418+
// Set the flag whenever the part is present, even for
419+
// an empty/nil args map: that's a deliberate "strip all
420+
// arguments" redaction, not a no-op.
418421
mod.NewArguments = part.ToolCallContent.Arguments
422+
mod.ArgsSet = true
419423
}
420424
case "prompt_request":
421425
if part.PromptRequestContent != nil {
422426
mod.NewArguments = part.PromptRequestContent.Arguments
427+
mod.ArgsSet = true
423428
}
424429
case "resource_ref":
425430
if part.ResourceRefContent != nil {
426431
mod.NewURI = part.ResourceRefContent.URI
432+
mod.URISet = true
427433
}
428434
default:
429435
continue
@@ -508,9 +514,13 @@ func applyExtensionChanges(pctx *pipeline.Context, ext *rcpex.Extensions) {
508514
// ProvenanceExtension.MessageID
509515
// delegation → DelegationExtension (chain/origin/actor/depth)
510516
// request → RequestExtension (request id + trace/span ids)
511-
// headers → HttpExtension.RequestHeaders (Authorization and Cookie
512-
// stripped — they don't belong in policy context and would
513-
// leak through CPEX traces)
517+
// headers → HttpExtension.RequestHeaders (Cookie and other
518+
// secret headers stripped; see flattenHeaders. NOTE:
519+
// Authorization is deliberately KEPT so CPEX's identity
520+
// plugins can read the bearer — it therefore reaches the
521+
// RequestHeaders map and the unauthenticated session API.
522+
// Audit-log sub-plugins MUST be configured to drop
523+
// Authorization from their output. See headers.go.)
514524
//
515525
// Note (agent slot): the caller's client_id is NOT placed on
516526
// AgentExtension.AgentID. Caller identity lives in SecurityExtension.Subject;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2

authbridge/cmd/authbridge-cpex/Dockerfile

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
# enable CPEX.
1212

1313
# ---------- Stage 1: fetch libcpex_ffi.a from the pinned release ----
14-
# The CPEX_FFI_VERSION file at the root of this directory is the
15-
# single source of truth for which CPEX FFI ABI this binary expects.
16-
# A pre-commit hook (TODO follow-up) enforces it matches the go.mod
17-
# pin for the cpex Go bindings.
14+
# The CPEX_FFI_VERSION file at the root of this directory pins the
15+
# release tag; CPEX_FFI_ABI pins the FFI ABI integer the linked lib must
16+
# report. Both are asserted at build time below: the extracted tarball's
17+
# VERSION must equal CPEX_FFI_VERSION and its FFI_ABI must equal
18+
# CPEX_FFI_ABI, so a renamed/mismatched asset fails the build rather than
19+
# linking a lib whose ABI the Go bindings don't expect. The bindings
20+
# additionally assert the ABI at runtime (go/cpex/abi.go panics at init()
21+
# if the linked lib's cpex_ffi_abi_version() != its expected value), so a
22+
# drift is caught both at build and at boot.
1823
#
1924
# Asset naming on the cpex release page:
2025
# cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz (tarball)
@@ -35,6 +40,10 @@ FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd1
3540
RUN apk add --no-cache curl ca-certificates tar cosign
3641

3742
ARG CPEX_FFI_VERSION
43+
# CPEX_FFI_ABI pins the FFI ABI integer the linked lib must report. The
44+
# Makefile sources it from the CPEX_FFI_ABI file (same dir as
45+
# CPEX_FFI_VERSION); the extract step below asserts the tarball matches.
46+
ARG CPEX_FFI_ABI
3847
ARG TARGETARCH
3948
ARG TARGETOS=linux
4049
# LIBC defaults to gnu (Debian/Ubuntu container hosts). Override to
@@ -73,7 +82,23 @@ RUN set -eu; \
7382
|| { echo "[ffi] cosign verification FAILED" >&2; exit 1; }; \
7483
tar -xzf asset.tar.gz; \
7584
test -f libcpex_ffi.a || { echo "[ffi] libcpex_ffi.a missing from tarball" >&2; exit 1; }; \
76-
echo "[ffi] extracted libcpex_ffi.a + VERSION=$(cat VERSION) + FFI_ABI=$(cat FFI_ABI)"
85+
# Assert the tarball's pinned identity matches what we expect. A \
86+
# mismatched VERSION means the asset was renamed/swapped; a mismatched \
87+
# FFI_ABI means the lib's C surface differs from what the Go bindings \
88+
# were generated against (a CGO mis-marshal waiting to happen). \
89+
# VERSION is a multi-line key=value manifest (version=, git_sha=, \
90+
# tuple=, …); pull the version= line. FFI_ABI is a bare integer. \
91+
got_version=$(grep -E '^version=' VERSION | head -1 | cut -d= -f2- | tr -d '[:space:]'); \
92+
if [ "$got_version" != "$CPEX_FFI_VERSION" ]; then \
93+
echo "[ffi] VERSION mismatch: tarball says '$got_version', expected '$CPEX_FFI_VERSION'" >&2; \
94+
exit 1; \
95+
fi; \
96+
got_abi=$(tr -d '[:space:]' < FFI_ABI); \
97+
if [ -n "$CPEX_FFI_ABI" ] && [ "$got_abi" != "$CPEX_FFI_ABI" ]; then \
98+
echo "[ffi] FFI_ABI mismatch: tarball says '$got_abi', expected '$CPEX_FFI_ABI' (bump CPEX_FFI_ABI + the Go binding)" >&2; \
99+
exit 1; \
100+
fi; \
101+
echo "[ffi] extracted libcpex_ffi.a + VERSION=$got_version + FFI_ABI=$got_abi (asserted)"
77102

78103
# ---------- Stage 2: build authbridge-cpex with cgo + -tags cpex ----
79104
# golang:bookworm (glibc) — alpine + musl would need extra ring/

0 commit comments

Comments
 (0)