Skip to content

fix(a2a): redact transport errors, reject mixed sidecar config (lr-890fae) - #31

Merged
clagentic-merger[bot] merged 7 commits into
mainfrom
fix/lr-890fae-a2a-error-redaction-sidecar-guard
Aug 17, 2026
Merged

fix(a2a): redact transport errors, reject mixed sidecar config (lr-890fae)#31
clagentic-merger[bot] merged 7 commits into
mainfrom
fix/lr-890fae-a2a-error-redaction-sidecar-guard

Conversation

@clagentic-builder

Copy link
Copy Markdown
Contributor

Fold-in unit on already-merged PR #30 (lr-890fae), addressing MILLER's post-merge adjudication (lr-890fae comment #8). Both blocking items confirmed by MILLER as real (though PEACHES's stated F2 mechanism was refuted), plus the stale-doc nit. Built to MILLER's corrected analysis, not the original reviewer framing.

What changed

  1. AUDIT-PATH REDACTION (F1, primary). internal/a2atoken now returns a distinct *a2atoken.TransportError for any OpenBao HTTP-call failure, carrying an already-bounded/redacted message. internal/a2amint.Service.Mint routes AuditEvent.Reason through a new auditReason helper that applies this bound only to the TransportError class -- every other error (attestation, entitlement, missing broker/config, key-parse failure) keeps its full message, since none of those carry third-party response content. cmd/gatekeeper/main.go's stderr Fprintf inherits the bound with no separate change needed there.

  2. ERROR ENVELOPE PARSING. internal/a2atoken/a2atoken.go's two raw string(respBody) interpolation sites (jwt auth login, identity/oidc/token read) now parse OpenBao's documented errors-list envelope (errors: [ ... ]) and surface only those strings; a body that does not match that shape (e.g. an intervening proxy's own error page) is truncated to a fixed excerpt rather than echoed verbatim. The existing comment reasoning that OpenBao's own error bodies carry no secrets is preserved and updated, not deleted -- the fix targets unbounded third-party proxy content, not OpenBao itself.

  3. MIXED LEGACY+NEW SIDECAR CONFIG (F2, hardening). MILLER refuted the reviewer's claimed mechanism (a session sidecar satisfying DomainA2A via fallthrough -- no such fallthrough exists) while confirming a different real defect: cmd/gatekeeper passes chainSidecars[0] alone into the domain-scoped PerSpawn resolver, so if an operator's legacy attestation.sidecar names a session namespace while sidecars[0] names the per-spawn namespace, the deliberate legacy-first merge order (unchanged, still asserted by config_test.go's BackCompat test) puts the session entry at index 0 and it gets installed as PerSpawn -- a confused-deputy outcome. Fixed by rejecting the combination outright at config.Load (AttestationConfig.validate), chosen over warn because docs/SIDECAR-READ-CONTRACT.md section 2 mandates spawn-first and config.example.yaml already deprecates the legacy block -- nothing is lost by refusing it. Added the missing cmd-level wiring test (TestRunMintA2A_MixedLegacyAndNewSidecarConfigRejected) that traces a mixed config through runMintA2A and asserts which namespace the rejection covers, per MILLER's finding that no prior test crossed the config -> cmd boundary.

  4. STALE DOC. docs/SETUP.md's Status-in-this-repository paragraph said no A2A mint command existed yet in cmd/gatekeeper, self-contradicting the same file's extensive mint-a2a documentation a few hundred lines later. Corrected; no other stale A2A-absent statements found elsewhere in the file.

Explicitly out of scope

The --json subject field (a2atoken.go:240-244, main.go:259-262) is documented-deliberate per MILLER's adjudication; left untouched.

Standing constraints preserved

Roster-agnostic throughout. No signing-key handling for the peer-facing token added or touched. Fail-closed ordering unchanged (refusal before any OpenBao call). Additive/off-by-default: a2a_provider absent still leaves the GitHub-domain path byte-identical -- the new config.Load validation only fires on the sidecar-mixing combination, which is unrelated to a2a_provider being configured.

Tests

go build / go vet / go test ./... all pass. New tests: a2atoken envelope-parsing + non-OpenBao-body truncation at both call sites; a2amint cross-boundary redaction test driving the real a2atoken.Issue against a stub proxy response; config unit-level mixed-sidecar rejection/non-conflict cases; cmd-level TestRunMintA2A_MixedLegacyAndNewSidecarConfigRejected wiring test. Both pre-existing negative-control tests and both pre-existing DomainA2A fail-closed tests preserved unchanged.

TASK: lr-890fae

…lr-890fae)

OpenBao's {"errors":[...]} envelope is parsed and surfaced verbatim (the
existing comment reasoning OpenBao's own error bodies carry no secrets is
preserved). A body that does NOT match that shape -- unbounded content an
intervening proxy could substitute -- is truncated via a new TransportError
type instead of interpolated raw, at both call sites (jwt auth login,
identity/oidc/token read).

MILLER adjudication (lr-890fae comment #8, item F1): true at the code
level that a2atoken.go interpolated string(respBody) verbatim; the claimed
echo path was unproven but the missing bound was real. TransportError is a
distinct type so a2amint can target redaction at this error class without
degrading diagnosability of its own internal errors.

Tests: envelope parsing, non-OpenBao-shaped body truncation (both call
sites), full suite green.
…(lr-890fae)

AuditEvent.Reason previously took err.Error() verbatim, which -- when the
error is an a2atoken.TransportError -- now carries an already-bounded
message, and main.go's stderr Fprintf inherits that bound with no extra
work needed there. Every other a2amint error class (attestation, entitlement,
missing broker/config, key-parse failure) keeps its full message; none of
those carry third-party response content and blanket redaction would have
degraded their diagnosability for no benefit.

Cross-boundary regression test drives Service.Mint with the real
a2atoken.Issue against a stub HTTP server standing in for a proxy returning
an oversized non-OpenBao-shaped error body, and asserts the bounded value
-- not the raw body -- is what lands in the audit record. Per MILLER's
adjudication (lr-890fae comment #8): prior passes verified per-package
invariants and never traced a value across the a2atoken -> a2amint
boundary, which is why this gap existed.
…t (lr-890fae)

ResolveSidecars' prepend ordering (legacy first) was already correct and
stays unchanged -- config_test.go's BackCompat test still asserts it. The
defect was downstream: cmd/gatekeeper passes chainSidecars[0] alone into
the domain-scoped PerSpawn resolver. If an operator's legacy sidecar
block names a SESSION namespace while sidecars[0] names the per-spawn
namespace, the merge silently puts the session entry first and it gets
installed as PerSpawn -- DomainA2A then fail-closes correctly against the
WRONG namespace, a confused-deputy outcome.

Reject over warn: docs/SIDECAR-READ-CONTRACT.md section 2 mandates
spawn-first, and config.example.yaml already deprecates the legacy block
in favor of sidecars, so nothing is lost by refusing the combination
outright -- consistent with this repo's fail-closed posture.

New tests: internal/config unit-level rejection/non-conflict cases, plus
a cmd-level cross-boundary test (TestRunMintA2A_MixedLegacyAndNewSidecarConfigRejected)
that wires a mixed config through runMintA2A and asserts the rejection --
not just the isolated merge -- per MILLER's finding that no prior test
traced this value across the config -> cmd boundary.

MILLER adjudication (lr-890fae comment #8, item F2): the reviewer's
claimed mechanism (session sidecar satisfying DomainA2A via fallthrough)
was refuted -- no such fallthrough exists, DomainA2A has no Chain
fallback. The real defect underneath is the wrong-namespace-as-PerSpawn
wiring described above, reachable only via misconfiguration, on an
off-by-default path.
SETUP.md said no A2A mint command existed yet in cmd/gatekeeper, while the
same file documents 'gatekeeper mint-a2a' extensively a few hundred lines
later -- self-contradicting since the PR that added the command. AC7 miss,
confirmed by MILLER adjudication (lr-890fae comment #8, NIT 2).
… (lr-890fae)

Two gaps in the error-redaction bound left by the earlier pass in this PR:

1. parseOpenBaoErrors' parsed-envelope branch returned
   strings.Join(envelope.Errors, "; ") with no bound at all, while the
   raw-body fallback branch was truncated to maxRawBodyExcerpt (200
   bytes). A deployment could return an oversized errors[] array and the
   bound would never apply.

2. Both exchangeAssertion and readOIDCToken called io.ReadAll(resp.Body)
   with no read cap, so the full body materialized in memory BEFORE
   either parse branch ran - the 200-byte string bound was never a
   memory bound on any path. Added maxResponseBodyBytes (64KiB),
   applied via io.LimitReader at both read sites: comfortably larger
   than any legitimate OpenBao response this package parses (both
   envelopes are well under 4KiB), small enough to be a real memory
   bound.

Corrected stale comments in this file that asserted a bound the code
did not actually have on the parsed-envelope branch.

Regression coverage: TestIssueLoginFailureTruncatesOversizedOpenBaoEnvelope
(envelope-shaped body >200 bytes) and
TestIssueLoginFailureCapsResponseBodyRead (body > maxResponseBodyBytes,
proves the read itself is capped, not just the rendered string).

TASK: lr-890fae
…(lr-890fae)

auditReason's doc comment claimed a2atoken.Issue's errors were already
bounded/redacted at the source for third-party response content - true
of the raw-fallback branch before this PR's a2atoken fix, but the
parsed-OpenBao-envelope branch was unbounded until the prior commit.
Corrected the comment to name both a2atoken.maxRawBodyExcerpt and
maxResponseBodyBytes explicitly, so it describes what the code actually
guarantees now rather than what a reviewer had to independently verify.

Added TestMintAuditRedactsOversizedOpenBaoErrorEnvelope: drives the
REAL a2atoken.Issue (not a stub IssueFunc) against a stub OpenBao
server returning an oversized {"errors":[...]} envelope on the
jwt-auth-login leg, asserting AuditEvent.Reason - what a real
deployment's log sink would see - is bounded on this branch, tracing
the value across the a2atoken -> a2amint package boundary rather than
asserting only in-package.

TASK: lr-890fae
…90fae)

Corrected main.go's audit-hook comment, which asserted ev.Reason
carries no additional filtering concern without accounting for the %q
verb's expansion: strconv.Quote semantics on control-heavy input can
run roughly 4x the input length, so a 200-byte-bounded Reason can
still print up to ~800 bytes at this sink. Documented that expansion
explicitly rather than leaving the comment silent on it. No code
change to the %q usage itself - MILLER's adjudication refuted the
injection concern for this sink (%q already escapes newlines/control
bytes), so this is a documentation correction, not a new escaping
layer.

Added TestRunMintA2A_StderrAuditBoundedForOversizedOpenBaoErrorEnvelope:
the full cross-boundary sink test - a2atoken -> a2amint -> main.go's
os.Stderr Fprintf - driving an oversized OpenBao {"errors":[...]}
envelope through runMintA2A end to end and asserting the actual bytes
written to stderr are bounded, not an unbounded echo of a
proxy/OpenBao-controlled body. This is the layer the prior in-package
and package-boundary tests did not reach.

TASK: lr-890fae
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean

All three critical fixes verified:

F1: parseOpenBaoErrors envelope-join truncation
Both branches now bounded identically to maxRawBodyExcerpt (200 bytes).
Lines 637, 639 in internal/a2atoken/a2atoken.go

F2: io.LimitReader memory bound at both response-read sites
Both HTTP responses capped to maxResponseBodyBytes (64 KiB) BEFORE parsing.
exchangeAssertion line 321 and readOIDCToken line 379.
Cap is 64 KiB, comfortably above legitimate OpenBao envelopes, genuine memory bound.

F3: Three stale comments now describe actual guarantee
a2atoken.go lines 46-73: maxRawBodyExcerpt and maxResponseBodyBytes documented
a2amint.go lines 136-157: auditReason explains targeted TransportError redaction
a2amint.go lines 59-62: AuditEvent.Reason field comment names boundary routing
main.go lines 380-393: Sink comment documents strconv.Quote expansion (~4x on control)
200-byte Reason prints ~800 bytes worst-case, bounded and accounted for

Data-flow trace: a2atoken → a2amint → main.go stderr sink

  1. entry: io.LimitReader(maxResponseBodyBytes=64KiB) at both response-read sites
  2. parse: parseOpenBaoErrors joins errors or raw body via truncateExcerpt
  3. truncate: both branches bounded to maxRawBodyExcerpt=200 bytes
  4. construct: TransportError.detail holds bounded excerpt
  5. return: TransportError.Error() builds bounded message
  6. cross-boundary (a2atoken→a2amint): Mint catches error via auditReason(err)
  7. route: auditReason routes TransportError to bounded Error(), other errors full
  8. audit: AuditEvent.Reason carries bounded message
  9. sink (main.go line 395): fmt.Fprintf os.Stderr with ev.Reason via %q
  10. expansion: %q escapes control bytes (log-injection closed), ~800-byte bound
  11. final bound: audit line bounded by maxRawBodyExcerpt transitively

Cross-boundary test coverage:
TestRunMintA2A_StderrAuditBoundedForOversizedOpenBaoErrorEnvelope: full sink test
TestMintAuditRedactsTransportErrorBody: proxy body across boundary
TestMintAuditRedactsOversizedOpenBaoErrorEnvelope: OpenBao envelope-join branch

Protected tests unchanged:
Negative controls: PerSpawnMissRefusesBeforeIssuance, NotEntitledRefusesBeforeIssuance
Config validation: MixedLegacyAndNewSidecarConfigRejected
Back-compat: PermittedIssuesJSON
Roster-agnostic, fail-closed, additive/off-by-default

No issues found. All bounds verified at sink.

{"reviewer": "peaches", "review_status": "clean", "head_sha": "eb220039d50899923c3e6fe70ada299bac832917", "pr_number": 31}

@clagentic-security

Copy link
Copy Markdown

BOBBIE — clean

Re-audit of PR #31 at head eb22003 (moved from 99d346b per 3 fix commits closing the prior findings). Traced an OpenBao error body end-to-end through internal/a2atoken -> internal/a2amint -> cmd/gatekeeper/main.go under the stated threat model (hostile/intervening proxy shaping content to satisfy the {"errors":[...]} envelope parse).

Verified fixed:

  • internal/a2atoken/a2atoken.go:136-140 (parseOpenBaoErrors) both branches now route through truncateExcerpt (maxRawBodyExcerpt=200) identically; confirmed by TestIssueLoginFailureTruncatesOversizedOpenBaoEnvelope.
  • internal/a2atoken/a2atoken.go:321,379 io.LimitReader(resp.Body, maxResponseBodyBytes=64KiB) precedes io.ReadAll at BOTH exchangeAssertion and readOIDCToken, before either parse branch runs -- closes the unbounded-materialization-before-truncation gap MILLER rated worse than the join gap (comment fix(build): drop hardcoded /root Go paths from Makefile (lr-3458) #11). 64KiB is comfortably above both legitimate OpenBao envelope shapes; confirmed by TestIssueLoginFailureCapsResponseBodyRead.
  • Sink trace confirmed at both stderr call sites: TransportError.Error() (a2atoken.go:108-113) is bounded (Op+StatusCode+<=200-byte detail, ~215 bytes worst case) BEFORE any wrapping. main.go:395 audit sink uses %q (strconv.Quote, ~4x expansion, ~800 bytes worst case, a fixed bound -- accounted for in the corrected comment at main.go:379-393). main.go:40 raw fmt.Fprintln(os.Stderr, err) is also safe: the string reaching it is already length-bounded at TransportError.Error() itself, independent of %q -- refuted log-injection claim not re-raised.
  • F2 (mixed legacy sidecar + sidecars) closed at internal/config/config.go:134-142 AttestationConfig.validate(), called from Load() (config.go:345-347) before applyDefaults and before any broker/attestation wiring -- fail-closed, static error string, no config content interpolated. TestRunMintA2A_MixedLegacyAndNewSidecarConfigRejected confirms the rejection reaches runMintA2A.
  • Fail-closed ordering preserved: config.Load error -> A2AProvider.Enabled() check -> broker construction -> attestation wiring -> Service.Mint (attestation -> entitlement -> role-mapping completeness -> broker read -> issuance), every gate before any OpenBao network call.
  • Assertion JWT and OpenBao client token never appear in any error, log, or audit output on any path (full read of a2atoken.go, a2amint.go, main.go, config.go); key material zeroed after use (zeroKey); PEM parse errors generic only.
  • Roster-agnostic throughout.

Six protected pre-existing tests confirmed unchanged: internal/attestation/ (the two DomainA2A fail-closed tests) has zero diff in this PR; internal/config/config_test.go and internal/a2atoken/a2atoken_test.go show additions only -- TestAttestationConfig_ResolveSidecars_BackCompat, TestIssueUntrustedKeyRejected, TestIssueBogusSubjectRejected all untouched.

go.mod/go.sum untouched by this diff -- osv-scanner stdlib findings are pre-existing to the go directive, not introduced here.

scanners_run: gitleaks (2 hits, both env-var names in test fixtures -- GATEKEEPER_TEST_A2A_KEY_LR890FAE / GATEKEEPER_TEST_A2A_SINK_KEY_LR890FAE -- dropped consistent with PR #30 and the prior #31 pass), semgrep p/golang+p/secrets+p/security-audit (0), osv-scanner (0 new).

0 findings.

{"reviewer": "bobbie", "review_status": "clean", "head_sha": "eb220039d50899923c3e6fe70ada299bac832917", "pr_number": 31}

@clagentic-merger
clagentic-merger Bot merged commit 0b30b6a into main Aug 17, 2026
1 check passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.2.0

Field Value
Gated HEAD SHA eb220039d50899923c3e6fe70ada299bac832917
Merged SHA eb220039d50899923c3e6fe70ada299bac832917
Reviews clagentic-reviewer[bot], clagentic-security[bot]
CI status no-runner-by-design (0 commit-status entries at HEAD)
task_id lr-890fae

@clagentic-merger
clagentic-merger Bot deleted the fix/lr-890fae-a2a-error-redaction-sidecar-guard branch August 17, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants