Skip to content

refactor(network): consolidate proxy egress pipeline - #2373

Open
johntmyers wants to merge 23 commits into
mainfrom
refactor/proxy-egress-pipeline
Open

refactor(network): consolidate proxy egress pipeline#2373
johntmyers wants to merge 23 commits into
mainfrom
refactor/proxy-egress-pipeline

Conversation

@johntmyers

@johntmyers johntmyers commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactor sandbox proxy egress so CONNECT and forward-proxy traffic share explicit destination authorization and relay primitives while preserving the existing user-facing network behavior. This establishes the RFC #2155 foundation without introducing the later DNS or arbitrary TCP features, and keeps the shared path compatible with the middleware framework from #2027.

User-facing behavior

  • Existing CONNECT and HTTP proxy flows continue to support the same policy enforcement, metadata access, inference routing, credential injection, upgrades, and denial responses.
  • Ambiguous endpoint policies are rejected at policy update time with a clear validation reason instead of becoming partially active.
  • Rejected updates fail closed by default. Operators that prefer availability can set policy_validation_failure_mode = "retain_last_valid" under [openshell.gateway] in gateway.toml.
  • OCSF events state why validation failed and whether the previous policy remains active.
  • This PR does not enable DNS interception or arbitrary TCP egress; those remain follow-up work.

Related Issue

Related design: #2155
Related middleware implementation: #2027

Changes

  • Introduce a shared egress pipeline with explicit destination intent, authorization, endpoint decisions, and relay preparation.
  • Route CONNECT and non-CONNECT traffic through common HTTP, raw-stream, credential, and policy-generation boundaries.
  • Preserve metadata-service handling, inference routing, credential injection, HTTP upgrades, raw relay, and current denial behavior.
  • Pin relays to policy generations and close stale HTTP, upgraded, long-lived, and raw streams after policy activation changes.
  • Reject overlapping endpoint selectors when connection or request-processing metadata is ambiguous.
  • Configure rejected policy generation posture with [openshell.gateway] policy_validation_failure_mode in gateway.toml; default to fail_closed and allow the explicit retain_last_valid availability mode.
  • Propagate the gateway TOML posture to sandbox supervisors as part of the effective config revision; individual sandboxes cannot override it.
  • Emit OCSF configuration, finding, and denial rationale for validation failures and quarantine state.
  • Add compatibility baselines, middleware coverage, ambiguity/recovery coverage, and shared-path E2E tests.
  • Update gateway, Helm, architecture, and policy documentation for the new invariants and operator-visible failure posture.

Testing

  • mise run pre-commit passes after rebasing onto current main
  • Unit and integration tests added/updated
  • Targeted config-revision, settings-poll, and proxy-denial tests pass
  • Live Docker gateway proxy E2E target: 10 passed on the branch
  • mise run helm:lint
  • mise run helm:test (64 passed)

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

@copy-pr-bot

copy-pr-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions

Copy link
Copy Markdown

@johntmyers
johntmyers force-pushed the refactor/proxy-egress-pipeline branch from 96addbc to 0c67990 Compare July 23, 2026 21:44
@johntmyers
johntmyers marked this pull request as ready for review July 23, 2026 21:44
@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 0c67990

@johntmyers johntmyers added the test:e2e Requires end-to-end coverage label Jul 23, 2026
@github-actions

Copy link
Copy Markdown

Label test:e2e applied for 0c67990. Open the existing run and click Re-run all jobs to execute with the label set. The run will execute the standard E2E suite after building the required gateway and supervisor images once. The matching required CI gate status on this PR will flip green automatically once the run finishes.

@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 79d2123

@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 1d568e3

@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 92efd90

@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 344a2eb

@johntmyers

Copy link
Copy Markdown
Collaborator Author

/ok to test 0f9a6bf

Comment on lines +367 to +370
enum GlobToken {
Literal(char),
Star { crosses_delimiter: bool },
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The glob we use for matching endpoint to the policy supports other placeholders, like ? and brackets:

fn glob_uses_wildcard(glob: &str) -> bool {
glob.bytes()
.any(|b| matches!(b, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
}

For example if the policy has foo.com/v? and foo.com/v1 these won't be considered ambiguous right now.

entrypoint_pid: u32,
require_binary_identity: bool,
) -> Result<Self> {
let ambiguities = openshell_policy::find_endpoint_ambiguities(proto);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we also run this validation in the gateway before persisting changes whose affected scope is known, while keeping this runtime check as defense in depth?

The current behavior differs depending on how the effective policy changes:

  • A direct policy set/update persists a new pending base-policy revision first. Ambiguity is discovered only when the supervisor reloads it, so --wait eventually fails, but without --wait the mutation reports success and the invalid revision remains in history.
  • Approving a policy chunk merges it into and persists a new base-policy revision, then marks the chunk approved before activation. That revision can still fail here.
  • Attaching a provider persists the attachment via CAS but creates no new policy revision and does not wait for activation. The composed effective policy changes asynchronously, and a conflict is reported against the existing base-policy version.
  • Updating a provider profile has fan-out: with provider policy composition enabled, the profile is resolved dynamically and changes the effective policy/hash of every sandbox in scope that has a provider of that profile attached, without rewriting any base-policy revision.

For the first three single-sandbox paths, could the gateway compose the candidate effective policy and call find_endpoint_ambiguities before the write? That would return an immediate FAILED_PRECONDITION and avoid persisting an invalid revision, approved chunk, or attachment. Profile update is broader, but profile_attached_sandbox_diagnostics already enumerates affected sandboxes for token-grant conflicts; ambiguity validation could potentially join that preflight and reject the whole profile update if any affected sandbox would become invalid.

I think the runtime validation should remain regardless, to cover races, external profile sources, global changes, and defense in depth. The open semantic question for fan-out changes is whether one incompatible sandbox should reject the entire profile update or allow the update and quarantine only that sandbox. If the latter is intentional, it likely needs an activation status keyed to the effective config revision rather than only the existing base-policy version.

@pimlock pimlock left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[agent-review]

Agent infrastructure also needs a companion update: .agents/skills/generate-sandbox-policy/SKILL.md:442 still says all overlapping endpoint coverage causes OPA evaluation errors. This PR permits compatible overlaps and adds rejected-generation quarantine behavior, so the policy-generation, CLI, and cluster-debugging skills should reflect the new semantics.

Focused verification passed: 11 ambiguity tests, 4 relay-generation tests, and 4 validation-failure posture tests.

opa_engine: &OpaEngine,
decision: &EgressDecision,
) -> Option<PolicyGenerationGuard> {
let expected_generation = route.map_or(decision.l4_policy_generation, |route| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Preserve the L4 authorization generation here

For CONNECT, policy can reload after authorize_egress_intent returns but before hydrate_l7_route. This code then replaces the old allow decision's generation with the newer route-lookup generation. If the newer generation denies the destination or represents fail-closed quarantine, the endpoint query can still produce a current-generation snapshot, so the stale L4 allow receives a current guard and remains active. The forward-proxy path explicitly pins L4 and rejects an L7 generation mismatch. CONNECT needs the same invariant: pin decision.l4_policy_generation before hydration and reject every later snapshot from another generation.

!endpoint.protocol.is_empty() || !endpoint.allowed_ips.is_empty() || !endpoint.tls.is_empty()
}

fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec<String> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Include WebSocket GraphQL classification in ambiguity checks

websocket_graphql_policy is derived from operation rules and persisted-query configuration, but this comparison omits that derived value while allowing rule differences. Two equal-rank overlapping WebSocket endpoints can therefore pass validation while one config selects GraphQL message inspection and the other selects generic transport inspection. Route selection then chooses one config; if transport inspection wins, operation-specific GraphQL deny rules receive no GraphQL metadata and a generic WEBSOCKET_TEXT allow can pass. Please compare the derived classification or aggregate it deterministically.

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
@johntmyers
johntmyers force-pushed the refactor/proxy-egress-pipeline branch from 0f9a6bf to b913aa0 Compare July 27, 2026 15:11
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
&normalized_body_limit(right.graphql_max_body_bytes),
);
}
if is_json_rpc_family(&left.protocol) && is_json_rpc_family(&right.protocol) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is_json_rpc_family() covers both json-rpc and mcp; it's possible for this condition to be true when left is one and right is the other. It seems like it should be an exact match like graphql above and mcp below, or this block should be moved below the mcp block.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants