Skip to content

feat: expose current and candidate effective policies to validate-phase gateway interceptors #4793

Open

Description

@mohamed-gradient

User Story

I work at Gradient Technologies and I am building an OpenShell integration for our product. Our authorization service would sit behind a gateway interceptor and needs to approve or deny sandbox policy changes before they commit.

To decide, it has to see both the policy that is currently enforced and the one that would be enforced after the change, with provider layers composed. Today a validate-phase interceptor only gets the request, so our service can't see what is enforced now or what will be enforced after the change. The alternative is to move the check after commit, which cannot block anything.

I am happy to implement this.

Problem Statement

A validate-phase interceptor cannot see either effective policy.

Current. ValidateEvaluation.current_state is the field that could carry it, but the gateway always sets
validate_current_state: None. The docs record this under Current Limitations: "current_state is available only in the validate contract. The gateway does not yet populate it with method-specific state."

Candidate. There is no field for it. Provider layers are composed by compose_effective_policy inside the RPC handlers, which run after the interceptor pipeline in the multiplex wrapper.

OpenShell already builds both for human reviewers: current_effective_policy, candidate_effective_policy and both hashes are delivered for draft-chunk review. This asks for the same pair at the interceptor contract.

Impact / Why This Matters

  • An interceptor that approves or denies policy changes is checking the wrong thing. For a full policy update, the request has the new base policy without the provider rules. For a merge update, it has only the changes, so there is no complete policy to check.
  • Without the current effective policy there is no before-and-after comparison, so the decision cannot be about what the change actually does.
  • validate is the last phase that can deny and the only one that sees the request after all modifications. An interceptor that cannot see the effective policy there cannot prevent anything.
  • post_commit receives only committed_response. For UpdateConfig that is a version, a base-policy hash, a settings revision and annotations. No policy. Detecting a bad effective policy means a separate GetSandboxConfig read after the change is already live.
  • Composing outside the gateway means mirroring gateway-owned state: attached providers, the profile catalog, and layer derivation. Any drift produces a wrong decision.
  • The same gap affects any out-of-process check against an effective policy, including running openshell-prover externally.

Proposed Design

Keep current_state to its defined meaning (state loaded before the operation) and add a field for the candidate:

message ValidateEvaluation {
  google.protobuf.Struct proposed_operation = 1;
  // Read-only gateway state loaded before applying the operation.
  google.protobuf.Struct current_state = 2;
  // Effective policy the gateway would apply if this operation commits.
  google.protobuf.Struct effective_candidate = 3;
}

What an opted-in interceptor would receive:

{
  "interceptorName": "authz-service",
  "bindingId": "validate-update-config",
  "service": "openshell.v1.OpenShell",
  "method": "UpdateConfig",
  "principal": { "kind": "user", "subject": "..." },
  "validate": {
    "proposedOperation": { "...": "as today" },
    "currentState": {
      "effectivePolicy": { "...": "current base composed with provider layers" },
      "policyHash": "<hex>"
    },
    "effectiveCandidate": {
      "policy": { "...": "candidate base composed with provider layers" },
      "policyHash": "<hex>",
      "providerLayers": ["vault-prod", "github-ro"]
    }
  }
}

Opt in per binding, defaulting to off, so interceptors that do not need it pay nothing:

[[openshell.gateway.interceptors]]
name           = "authz-service"
grpc_endpoint  = "unix:///run/authz.sock"
failure_policy = "fail_closed"
binding_policy = "allowlist"

[[openshell.gateway.interceptors.bindings]]
rpc                      = "openshell.v1.OpenShell/UpdateConfig"
phases                   = ["validate"]
include_effective_policy = true

Scope: sandbox-scoped UpdateConfig. Global UpdateConfig applies across sandboxes and the effective policy is per sandbox, so one evaluation cannot carry it.

Field names and the config key are illustrative. The requirement is that a validate-phase interceptor can see the current effective policy and the one the commit will produce, and bind its decision to the candidate's hash.

Acceptance Criteria

  • A validate-phase interceptor on sandbox-scoped UpdateConfig receives the current and candidate effective policy, including composed provider layers.
  • effectiveCandidate.policyHash equals the SandboxConfigSnapshot.policy_hash delivered to the supervisor for the committed revision, for an unchanged candidate.
  • Bindings that do not opt in see no change in payload or latency.
  • CreateSandbox, AttachSandboxProvider, DetachSandboxProvider, ApproveDraftChunk and ApproveAllDraftChunks behave consistently with UpdateConfig. SubmitPolicyAnalysis with auto-approval enabled carries a candidate reflecting the auto-approved chunks.
  • A denial from a validate-phase interceptor prevents the change, with existing failure-policy semantics unchanged.
  • The Current Limitations entry in docs/extensibility/gateway-interceptors.mdx is updated.

Alternatives Considered

  • Supervisor middleware. Per request at runtime. Cannot gate a policy change.
  • post_commit interceptor. Audit-only, and receives only the RPC response: version and base-policy hash, not the policy. Observing the effective policy requires a separate GetSandboxConfig read after commit.
  • Link openshell-policy and compose in the interceptor. compose_effective_policy is public, but the provider layers it takes are built from gateway-owned state: attached provider records, the workspace's profile catalog, endpoint activation and token-grant ownership (provider_policy_context_from_records, private to the server). An interceptor would have to mirror all of it, and any drift produces a wrong decision.
  • Interceptor as provider_profile_source plus attach/detach bindings. Same objection with more surface: the interceptor tracks attachment state and layer derivation outside the gateway, and the failure mode is silent.
  • Read back via GetSandboxConfig. Only available after commit, and racy.
  • Do nothing. External validation stays advisory.

Agent Investigation

Read at d2239437 (main, 2026-10-21):

  • gateway_interceptor.proto L74-75: current_state documented as state loaded before applying the operation; no field carries the candidate.
  • multiplex.rs L702: validate_current_state: None.
  • gateway-interceptors.mdx L171: the limitation stated in the docs.
  • openshell.proto L3513-3518: draft-chunk review already carries current_effective_policy, candidate_effective_policy and both hashes.
  • multiplex.rs L441: hydrate_update_provider_identity already loads gateway state before interceptors run, which is the precedent for composing in the wrapper.
  • policy_identity.rs L175: deterministic_policy_hash already hashes the effective policy for SandboxConfigSnapshot and draft-chunk review. No new hash machinery needed.
  • policy.rs L4734: UpdateConfigResponse.policy_hash hashes the candidate base policy, not the candidate effective policy.
  • lib.rs L37-40: exports compose_effective_policy.
  • policy.rs L3528: (provider_policy_context_from_records) builds its layers and is private to the server.

Checklist

  • I've reviewed existing issues and the published docs
  • This is a design proposal, not a "please build this" request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions