Skip to content

fix(proxy): degrade instead of refusing when no bundle applies - #70

Merged
dpup merged 3 commits into
mainfrom
fix/bundle-out-of-scope-degrades
Sep 23, 2026
Merged

dpup merged 3 commits into
mainfrom
fix/bundle-out-of-scope-degrades

Conversation

@dpup

@dpup dpup commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #67, found by running a real Codex session through a scoped bundle.

The problem

A request carrying a bundle placeholder that no bundle covers was answered with 403 credential bundle rejected. That reads as fail-closed but isn't buying anything: the placeholder is synthetic, so forwarding it grants nothing and the upstream rejects it like any bad credential. The cost was real, though — every route a scope doesn't name became a hard client failure.

Codex's built-in codex_apps connector opens an MCP session at https://chatgpt.com/backend-api/MCP, carrying the same Authorization placeholder as the Codex backend. With a bundle scoped to /backend-api/codex, that became:

⚠ MCP client for `codex_apps` failed to start: ... HTTP 403: credential bundle rejected

Codex itself worked fine — the bundle matched /backend-api/codex and real subscription data flowed. Only the unscoped connector broke, and it broke in a way that looks like a proxy bug rather than "this connector was never granted a credential."

The change

Out-of-scope requests are forwarded untouched with nothing injected, and the near miss goes to the policy log. Denied/Reason on the bundle result becomes Skipped/Reason.

The boundary is unchanged. Scope, origin, method, canonical path, and RequireAll all still decide injection exactly as before — the boundary is what gets injected, not what gets through. This is also what the original design specified: "if any placeholder is absent or different, inject nothing from the bundle." The 403 was an implementation choice beyond that.

The MCP relay follows the same rule: still never injects a bundle (it resolves its target from the registered server list, not from a scope), but records rather than refuses.

Verification

The codex_apps symptom was reproduced red before the fix. Scope enforcement is mutation-checked: neutering bundleScopeMatches fails the off-scope tests on all three paths because the credential leaks, not because a status code changed. go vet clean, go test -race ./... green.

A request carrying a bundle placeholder that no bundle covers was answered
with 403. That looked protective and was not. A placeholder is synthetic, so
forwarding it grants nothing and the upstream answers as it would for any
credential it does not accept. What refusing did do was turn every route a
scope does not name into a hard client failure.

Observed against Codex: its built-in codex_apps connector opens an MCP
session at https://chatgpt.com/backend-api/MCP carrying the same placeholder
as the Codex backend. A bundle scoped to /backend-api/codex made that a 403,
so Codex started with "MCP client for codex_apps failed to start: HTTP 403:
credential bundle rejected" — which reads as a proxy fault rather than as the
absence of a credential the connector was never granted. Codex itself worked
throughout; only the unscoped connector broke, and it broke loudly.

Such a request is now forwarded untouched with nothing injected, and the near
miss is recorded on the policy log so a bundle whose scope disagrees with
where its client actually goes stays visible instead of silent.

The boundary is unchanged and is what it always should have been: what gets
injected, not what gets through. Scope, origin, method, canonical path, and
RequireAll all still decide injection exactly as before — verified by
neutering bundleScopeMatches and confirming the off-scope tests fail because
the credential leaks, not because a status code changed.

The MCP relay follows the same rule: it still never injects a bundle, since
it resolves its target from the registered server list rather than from a
scope, but it records rather than refuses.
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 4s —— View job


Review: fix(proxy): degrade instead of refusing when no bundle applies

  • Gather context (diff, changed files, PR description)
  • Review credential_bundle.go, proxy.go, relay.go, mcp.go and their tests
  • Trace the policy-logger and CHANGELOG side effects of the behavior change
  • Post review summary

Summary: The core change is sound. injectCredentialBundles now returns Skipped instead of Denied on a scope/placeholder mismatch, and all three forwarding paths (handleHTTP, handleConnectWithInterception, handleRelay) plus the MCP relay were consistently updated to log-and-continue instead of blocking with 403. I traced each call site — the request-forwarding code after each check runs unconditionally (no orphaned returns, no unused snapshot variables like preBundleHeaders/originalReqHeaders), and credential_bundle_paths_test.go covers all four paths plus the malformed-bundle and grant-collision cases well. The "boundary is what gets injected, not what gets through" invariant genuinely holds — I couldn't find a path where the real value leaks off-scope.

Two things worth addressing before merge, plus one lower-priority note:

  1. policy_denial metric/alert now fires for non-denials — gatekeeper.go:431-452 (SetPolicyLogger) is the sink every logPolicy call feeds, and it unconditionally does slog.Warn("policy denial", ...) and proxy.RecordPolicyDenial(...). Before this PR every caller of logPolicy was a real denial. Now the four bundle-skip call sites (proxy/proxy.go:2364-2369, proxy/proxy.go:3180-3182, proxy/relay.go:148-150, proxy/mcp.go:411-414) also route through it, even though the PR description is explicit that these are not denials — nothing is blocked. The practical effect: an operator alerting on the policy_denial rate, or grepping logs for "policy denial", now gets paged/spammed for a harmless out-of-scope bundle forward — the same "looks like a proxy bug" outcome this PR is fixing for clients, just relocated to observability. Since gatekeeper.go wasn't touched by this PR, I couldn't leave an inline comment on it — worth a Blocking bool (or similar) on PolicyLogData so skip events stay visible without polluting the denial alert channel.

  2. Stale comment, proxy/proxy.go:3175-3176 (just above preBundleHeaders := req.Header.Clone(), outside this PR's diff hunk so not inline-commentable): "Credential bundles are validated and applied before ReverseProxy so a mismatch can be denied without ever constructing an upstream request." This is no longer true — a mismatch is now Skipped and forwarded through ReverseProxy exactly like any other request. Worth a one-line update so it doesn't mislead the next reader.

  3. Minor/optional: CHANGELOG.md's v0.23.0 entry (line 17) still documents the old behavior in detail — "any scope or placeholder mismatch is refused with 403... increment the policy_denial metric." Given how carefully this changelog is otherwise maintained, a new entry for this PR (or an amendment) would keep it accurate.

Nothing here is a security leak or a race — it's an observability/documentation gap introduced by moving from "block" to "degrade," which is exactly the kind of thing that's easy to miss when the fix itself is correct.

Review catch on the degrade change. Every PolicyLogData reaching
gatekeeper.go's sink was warned as "policy denial", given a policy.denial
span event, and counted by RecordPolicyDenial. That held while every caller
of logPolicy was a real refusal — routing the four bundle-skip sites through
it made a harmless out-of-scope forward page whoever alerts on the denial
rate. The same "looks like a proxy fault" outcome the degrade change fixed
for clients, relocated to the operator.

PolicyLogData gains Blocking. logPolicy sets it true, so every existing
caller is unchanged; a new logPolicyObservation sets it false and the bundle
skips use that. The sink logs an observation at info as "policy observation"
with a policy.observation span event, and only counts RecordPolicyDenial for
blocking entries. Skips stay visible without polluting the alert channel.

Also updates a comment that still said a mismatch is denied before an
upstream request is constructed; it is now forwarded like any other request.

Mutation-checked both directions: marking observations blocking, and
marking denials non-blocking, each fail their own test.
@dpup

dpup commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in 31e6b30.

1. policy_denial firing for non-denials — fixed, and it was the real defect here. You're right that this relocated the exact failure the PR set out to fix: a client no longer sees a spurious 403, but an operator gets paged for traffic nothing blocked. I'd reused logPolicy without noticing its only sink treats every entry as a refusal.

PolicyLogData gains Blocking. logPolicy sets it true, so every pre-existing caller is byte-for-byte unchanged; a new logPolicyObservation sets it false, and the four bundle-skip sites use that. gatekeeper.go's sink now logs observations at info as "policy observation" with a policy.observation span event, and calls RecordPolicyDenial only when Blocking. Skips stay visible; the denial rate stays clean.

Mutation-checked in both directions, since a single-sided test here would pass on a proxy that had simply stopped flagging anything:

MUTANT observations→blocking:  FAIL TestSkippedBundleIsNotReportedAsADenial
MUTANT denials→non-blocking:   FAIL TestNetworkPolicyDenialIsStillBlocking

2. Stale comment — fixed. It now reads that bundles are evaluated and applied before ReverseProxy so a match is in place on the request it forwards, and that a mismatch injects nothing and goes on unchanged.

3. CHANGELOG — deliberately not in this PR. This repo adds changelog entries in a separate docs/changelog-* PR after the feature merges (#64 → #65, #67 → #68), so adding one here would break that pattern. I'll open docs/changelog-v0.23.1 once this lands.

On the v0.23.0 entry specifically: I'd leave it as written rather than amend it. It accurately describes v0.23.0, which shipped with the 403 — editing it would make the changelog wrong about a released version. The v0.23.1 entry will carry the correction, including that scope mismatches no longer increment policy_denial.

@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 6m 23s —— View job


Code review: PR #70

  • Gather context (PR body, changed files)
  • Run /code-review skill against the diff
  • Post inline comments for confirmed findings
  • Post summary

Summary

The core behavioral change — degrading to "skip injection, forward unchanged" instead of a hard 403 when a bundle placeholder is out of scope — is sound and well-tested:

  • Real bundle Values are only ever written into request headers inside the scope-matched branch of injectCredentialBundles (proxy/credential_bundle.go); no path in proxy.go/relay.go/mcp.go can leak the real value off-scope. The scope boundary itself (origin, method, canonical path, RequireAll) is unchanged — only what happens on a miss changed.
  • Denied/Reason → Skipped/Reason rename is applied consistently, and I found no leftover references to the removed field.
  • Mutation-style scope tests (credential_bundle_paths_test.go) correctly fail via credential leakage when bundleScopeMatches is neutered, not via a status-code change, which is exactly the right thing to assert here.

One real bug found, posted as an inline comment on proxy/proxy.go:226:

This PR adds a Blocking field to PolicyLogData and gates both the log level and proxy.RecordPolicyDenial metric on it (gatekeeper.go). logPolicy/logPolicyObservation set it correctly for all HTTP-plane call sites touched by this PR. However, proxy/postgres.go:849 builds a PolicyLogData{} literal directly for the Postgres-plane network-policy denial — right before actually terminating the connection with deny(403, ...) — and never sets Blocking. It zero-values to false, so a real, connection-terminating Postgres denial now silently logs as "policy observation" at INFO and is excluded from denial-rate metrics. This is a live regression introduced by this PR's new field semantics, even though postgres.go itself isn't in the diff. Since this PR added the tests (TestNetworkPolicyDenialIsStillBlocking, TestSkippedBundleIsNotReportedAsADenial) that guard exactly this distinction for the HTTP plane, I'd suggest fixing postgres.go:849 (or routing it through a shared helper) in this PR too, or explicitly tracking it as a fast follow-up if you'd rather keep scope tight.

No other security, race-condition, or error-handling issues found in the diff. go vet and go test -race ./... were not re-run in this review session (read-only review, no build/test execution performed) — the PR description states both are clean.

Comment thread proxy/proxy.go Outdated
// request was stopped; false means policy noticed something and let it
// through. Consumers alert and count on denials, so an observation logged
// as one would page an operator for traffic that was never blocked.
Blocking bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces Blocking and gates both the log level and RecordPolicyDenial on it (gatekeeper.go:439-441,460-462), with logPolicy/logPolicyObservation as the only two call-sites that set it correctly.

But proxy/postgres.go:849 (untouched by this PR) builds a PolicyLogData{...} literal directly for the Postgres-plane network-policy denial, right before it calls deny(403, ...) and actually closes the connection:

p.proxy.policyLogger(PolicyLogData{
    RunID:     runID,
    Scope:     "network",
    Operation: "postgres.connect",
    Message:   "Host not in allow list: " + sniHost,
})

Since Blocking zero-values to false, this real, connection-terminating denial now gets logged as "policy observation" at INFO instead of "policy denial" at WARN, and skips proxy.RecordPolicyDenial entirely. Every Postgres-plane network-policy denial becomes invisible to denial-rate alerting — the same class of regression this PR's own tests (TestNetworkPolicyDenialIsStillBlocking, TestSkippedBundleIsNotReportedAsADenial) guard against on the HTTP plane, but with no equivalent coverage for postgres.go.

Suggest setting Blocking: true at postgres.go:849 (or routing it through a shared logPolicy-style helper) as part of this PR, since it's this PR's own new field/semantics that broke it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e0a5e36 — this was a real regression from my own new field, and a good catch on a file the PR never touched.

I went further than setting Blocking: true at that one site, because the site isn't really the bug — the default is. A flag meaning "this is a denial" that defaults to false means every direct PolicyLogData literal, present and future, silently drops out of alerting unless its author knows the field exists. That failure mode is silence in an alerting path, which is strictly worse than noise.

So the field is inverted: it's now NonBlocking, the zero value is a denial, and only logPolicyObservation opts out. postgres.go:849 needs no change and is correct as written; so is any literal someone adds later. Getting it wrong now over-reports rather than going quiet.

Two guards, since you noted there was no coverage on that plane:

  • TestZeroValuePolicyLogDataCountsAsADenial pins the invariant that protects every literal.
  • TestPostgresNetworkDenialIsCountedAsADenial mirrors the deny-site literal and asserts it survives as a denial.

Mutation-checked: forcing the zero value to NonBlocking fails the invariant test. go vet clean, go test -race ./... green.

Review catch: proxy/postgres.go builds PolicyLogData as a literal for its
network-policy denial, immediately before closing the connection. With
Blocking defaulting to false, that real refusal was logged as an observation
at info and skipped RecordPolicyDenial entirely — every Postgres-plane
denial silently left the alert channel.

Rather than set the flag at that one site, invert the sense. The field is now
NonBlocking, so the zero value is a denial and a literal is correct without
its author knowing the field exists. Only logPolicyObservation opts out. A
flag that defaults to "observation" fails by going quiet in an alerting path;
this one fails by over-reporting, which is the direction to err.

Guards added for both halves: the zero value must count as a denial, and the
Postgres deny-site literal must survive as one. Mutation-checked — forcing
the zero value to NonBlocking fails the invariant test.
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dpup's task in 1m 7s —— View job


Review: fix(proxy): degrade instead of refusing when no bundle applies

  • Gather context (diff since last review, changed files)
  • Check whether the postgres.go:849 Blocking issue flagged in the prior inline review was addressed
  • Review the rest of the diff (credential_bundle.go, proxy.go, relay.go, mcp.go, tests)
  • Post final review summary

The flagged issue is fixed, and fixed well. Commit e0a5e36 inverts the field from Blocking to NonBlocking so the zero value is a denial. That's the right call for this shape of bug: PolicyLogData is built as a bare struct literal at proxy/postgres.go:849 (untouched by this PR), and with the previous Blocking bool that literal silently defaulted to a non-denial. With NonBlocking bool, that same untouched literal now correctly defaults to NonBlocking: false — a denial — without its author ever needing to know the field exists. gatekeeper.go:436-466 gates both the log level and RecordPolicyDenial on !data.NonBlocking, so nothing regresses there.

Coverage for the fix is real, not just described:

  • TestZeroValuePolicyLogDataCountsAsADenial (credential_bundle_paths_test.go:613) pins the zero-value invariant directly.
  • TestPostgresNetworkDenialIsCountedAsADenial (credential_bundle_paths_test.go:623) mirrors the exact postgres.go:849 literal and asserts it's not NonBlocking.
  • TestNetworkPolicyDenialIsStillBlocking / TestSkippedBundleIsNotReportedAsADenial cover the two directions on the HTTP path, matching the mutation-check pairs described in the commit message.

One minor observation, not blocking: TestPostgresNetworkDenialIsCountedAsADenial hand-mirrors the postgres.go literal rather than driving an actual connection through PostgresServer, so it would not catch a future edit to that call site drifting out of sync with its mirror. Given the zero-value-is-a-denial design, that drift would have to be an explicit NonBlocking: true addition to regress — low risk, and not worth blocking on.

Rest of the diff (credential_bundle.go, proxy.go, relay.go, mcp.go) is unchanged in substance from the previously-reviewed version and remains sound: Denied→Skipped rename is consistent everywhere, all three forwarding paths plus the MCP relay log-and-continue instead of blocking, and the stale comment at the old handleConnectWithInterception bundle site was corrected as promised. The scope boundary itself (origin/method/path/RequireAll) is untouched — only what happens on mismatch changed.

I was not able to run go vet / go test -race ./... in this session — the sandboxed environment declined the go toolchain invocations without an approver available. Static review of the diff and test additions didn't surface anything that would fail either.

Nothing further to fix. This addresses the one open item from the previous review; ready to merge as far as this pass is concerned.

@dpup
dpup merged commit dbba50d into main Sep 23, 2026
2 checks passed
@dpup dpup mentioned this pull request Sep 23, 2026
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.

1 participant