Skip to content

deny rules in network policy schema #565

Description

@nopcorn

Problem Statement

The current policy schema only supports allow rules. Every permitted method+path combination must be explicitly enumerated. This works well for simple use cases (e.g., read-only access to one API), but becomes unmanageable when the goal is to block a small set of dangerous operations on services with large API surfaces.

Real-world example: We run AI agents that need full developer-level access to GitHub and internal Teleport servers, but must be prevented from performing admin and strictly-human operations (approving PRs, changing rulesets, etc).

For GitHub's REST API, blocking ~10 admin endpoints (branch protection, rulesets, org settings, PR approvals, workflow approvals) requires enumerating ~60+ allow rules for every legitimate developer operation. Any new GitHub API endpoint is blocked by default until we update the policy, which means agent workflows break silently on GitHub API additions.

For services like Teleport, the problem is worse. Teleport's web API has hundreds of routes that change with every release, served alongside gRPC-web on the same port. Maintaining an exhaustive allow-list is not feasible — we currently have to choose between verbose allow-lists that break on Teleport upgrades, or falling back to TCP passthrough with no L7 protection at all.

With deny rules, both policies collapse to something like:

yamlrules:
  # Allow everything by default
  - allow:
      method: "*"
      path: "/**"
  # Block specific dangerous paths
  - deny:
      method: POST
      path: "/repos/*/pulls/*/reviews"
  - deny:
      method: PUT
      path: "/repos/*/branches/*/protection"
  - deny:
      method: PUT
      path: "/webapi/sites/*/accessrequest/*"

Proposed Design

  • Add a deny rule type alongside the existing allow rule type.
  • deny rules take precedence over allow rules (deny wins on conflict).
  • Evaluation order: if a request matches any deny rule, it is blocked regardless of allow rules. If no deny rule matches, existing allow logic applies.
  • The access: full shorthand combined with deny rules would cover the "allow-all-except" pattern without needing to enumerate individual allows.

For example:

endpoints:
  - host: api.github.com
    port: 443
    protocol: rest
    tls: terminate
    enforcement: enforce
    access: full  # allow all methods and paths by default
    deny_rules:
      - deny:
          method: POST
          path: "/repos/*/pulls/*/reviews"
      - deny:
          method: "*"
          path: "/repos/*/branches/*/protection"
      - deny:
          method: "*"
          path: "/repos/*/branches/*/protection/**"
      - deny:
          method: "*"
          path: "/repos/*/rulesets"
      - deny:
          method: "*"
          path: "/repos/*/rulesets/*"
      - deny:
          method: POST
          path: "/repos/*/actions/runs/*/approve"
      - deny:
          method: POST
          path: "/repos/*/actions/runs/*/pending-deployments"
      - deny:
          method: POST
          path: "/graphql"
      - deny:
          method: "PUT"
          path: "/repos/*/actions/permissions"
      - deny:
          method: "PUT"
          path: "/repos/*/actions/permissions/**"
      - deny:
          method: "*"
          path: "/orgs/**"
          except_methods: [GET, HEAD, OPTIONS]

Alternatives Considered

Exhaustive allow-list (current approach)

This is what we're doing today. For GitHub, it requires ~60 allow rules to cover normal developer operations while omitting ~10 admin paths. For Teleport, it requires enumerating every safe POST endpoint across a web API with hundreds of routes.

Why it's insufficient: The policy is fragile. When GitHub or Teleport adds a new non-admin API endpoint, it's blocked by default until we update the allow-list. This creates silent failures in agent workflows that are hard to diagnose. For Teleport specifically, the API surface changes with every release — maintaining an exhaustive allow-list means coupling our policy update cycle to every vendor upgrade. The policy file also becomes extremely long (300+ lines for just two services), making review and auditing harder.

Rely solely on service-side RBAC

Trust that GitHub's permissions and Teleport's role-based access control will block unauthorized operations, and don't attempt L7 enforcement in OpenShell at all.

Why it's insufficient: Our threat model specifically covers the case where the user has admin permissions (granted via Terraform elevation) and forgets to de-escalate before starting an agent session. Service-side RBAC says "yes, this token is authorized" — the whole point of the OpenShell policy layer is to enforce a narrower scope than the token allows. Defense-in-depth requires that the sandbox restrict what the agent can do even when the credential is over-privileged.

Agent Investigation

No response

Checklist

  • I've reviewed existing issues and the architecture docs
  • This is a design proposal, not a "please build this" request

Activity

  1. self-assigned this
    on Mar 25, 2026
  2. johntmyers commented on Mar 25, 2026

    @johntmyers
    Collaborator

    Hi, thank you. We'll get a plan together for this and implement over the next few days.

  3. mjamiv commented on Apr 6, 2026

    @mjamiv
    Contributor

    +1 — we have a concrete use case for deny rules.

    We run 4 OpenClaw AI agent sandboxes with GitHub API access (api.github.com, access: full). Our security hardening plan calls for restricting dangerous GitHub API operations — e.g., deny PUT /repos/{owner}/{repo}/branches/{branch}/protection (modify branch protection), deny POST /repos/{owner}/{repo}/merges (force merge), deny DELETE /repos/{owner}/{repo}/branches/* — while allowing all other API paths.

    Similarly, for Slack we want to allow most API methods but deny sensitive ones like admin.conversations.delete, admin.users.remove, etc.

    Currently this is blocked because OpenShell policies are allow-only with no path/method filtering. We documented this as "Blocked on OpenShell L7" in our security hardening plan (items #8, #9, #1.1).

    With deny rules at the HTTP path/method level, we could:

    github:
      endpoints:
        - host: api.github.com
          port: 443
          access: full
      deny:
        - path: "/repos/*/branches/*/protection"
          method: PUT
        - path: "/repos/*/merges"
          method: POST

    This would unblock a significant portion of our security hardening without requiring us to enumerate every allowed API path.

  4. mjamiv commented on Apr 8, 2026

    @mjamiv
    Contributor

    Hey @johntmyers — friendly follow-up on this. You mentioned a plan was coming together around Mar 25. Any update on timing? No rush, just checking in since we're scoping our next security hardening pass and deny rules would let us tighten GitHub API access across our 4 sandboxes.

  5. mjamiv commented on Apr 8, 2026

    @mjamiv
    Contributor

    Hey @johntmyers — friendly follow-up on this. You mentioned a plan was coming together around Mar 25. Any update on timing? No rush, just checking in since we're scoping our next security hardening pass and deny rules would let us tighten GitHub API access across our 4 sandboxes.

  6. nopcorn commented on Apr 13, 2026

    @nopcorn
    Author

    Seconded, any updates on whether this will be selected for dev @johntmyers ?

  7. johntmyers commented on Apr 13, 2026

    @johntmyers
    Collaborator

    Implementation Plan (extracted from Provider v2 RFC)

    This is being pulled out of the Provider v2 RFC as a standalone deliverable. Deny rules are a pure policy schema feature -- once they exist, provider profiles get them for free since provider-generated rules use the same NetworkEndpoint schema.

    Proto Changes

    Add deny_rules to NetworkEndpoint and define L7DenyRule:

    message NetworkEndpoint {
        // ... existing fields ...
        repeated L7DenyRule deny_rules = 20;
    }
    
    message L7DenyRule {
        string method = 1;
        string path = 2;
    }

    Policy YAML Schema

    endpoints:
      - host: api.github.com
        port: 443
        access: read-write
        protocol: rest
        enforcement: enforce
        deny_rules:
          - method: POST
            path: "/repos/*/pulls/*/reviews"
          - method: PUT
            path: "/repos/*/branches/*/protection"

    Rego Evaluation

    Deny rules are evaluated after allow rules. If a request matches any deny rule, it is blocked regardless of the access preset:

    1. Check access preset (read-only, read-write, full) -> generates allow rules
    2. Check deny rules -> if any match, block (deny wins)
    

    New deny_request rule added to sandbox-policy.rego.

    Scope

    • Add L7DenyRule to proto and policy structs in openshell-policy
    • Add deny_rules field to NetworkEndpointDef serde structs
    • Add deny_request Rego rule with glob path matching
    • Unit tests for deny evaluation (deny overrides allow, glob matching, multiple deny rules)
    • E2E test: policy with deny rule blocks specific method+path while allowing others

    Relationship to Provider v2

    Provider v2 PR 1 (Profile Registry) will ship YAML profiles with deny rules pre-populated (e.g., GitHub profile blocks branch protection changes, PR approvals). This issue delivers the underlying schema and evaluation; providers consume it.

  8. johntmyers commented on Apr 13, 2026

    @johntmyers
    Collaborator

    🏗️ build-plan

    Implementation Plan

    Issue type: feat
    Complexity: Medium
    Confidence: High — clear path, follows existing allow rule patterns exactly

    Summary

    Add deny rules to the L7 network policy schema. Deny rules mirror the full capability set of allow rules (method, path, query params, SQL command) but with inverted effect. Deny rules are evaluated after allow rules and take precedence — if a request matches any deny rule, it is blocked regardless of allow rules. This lets users express "allow everything except these specific operations" without enumerating every allowed endpoint.

    Key Design Decision: Full Feature Parity with Allow Rules

    Deny rules support all the same matching capabilities as allow rules:

    // Mirrors L7Allow exactly — same fields, same semantics, inverted effect
    message L7DenyRule {
      string method = 1;                      // HTTP method or "*"
      string path = 2;                        // URL path glob
      string command = 3;                     // SQL command or "*"
      map<string, L7QueryMatcher> query = 4;  // Query parameter matchers (REST)
    }

    This means you can deny by method + path + query params for REST, and by command for SQL — same as allows. The Rego evaluation reuses the existing method_matches, path_matches, query_params_match, and command_matches helpers for both allow and deny paths.

    L4-only endpoints (no protocol set) cannot have deny rules — deny rules require L7 inspection to evaluate method/path/command.

    Scope

    • proto/sandbox.proto: Add L7DenyRule message and deny_rules repeated field on NetworkEndpoint
    • crates/openshell-policy/src/lib.rs: Add L7DenyRuleDef serde struct, add deny_rules to NetworkEndpointDef, update to_proto/from_proto conversions
    • crates/openshell-sandbox/src/opa.rs: Pass deny_rules through to Rego in proto_to_opa_data_json
    • crates/openshell-sandbox/src/l7/mod.rs: Validate deny_rules in validate_l7_policies (require protocol, validate method/path/command, glob syntax checks)
    • crates/openshell-sandbox/data/sandbox-policy.rego: Add deny_request rule, modify allow_request to check not deny_request, update request_deny_reason for deny-specific messaging
    • docs/reference/policy-schema.mdx: Document deny rules, add deny rule object table, add examples

    Implementation Steps

    1. Proto schema: Add L7DenyRule message (mirrors L7Allow) and deny_rules field on NetworkEndpoint
    2. Rust policy types: Add L7DenyRuleDef serde struct, add deny_rules field to NetworkEndpointDef, wire up to_proto/from_proto conversions
    3. JSON preprocessing: Pass deny_rules array through to Rego data in proto_to_opa_data_json
    4. L7 validation: Validate deny rules in validate_l7_policies — require protocol, validate method/path/command fields, check glob syntax, reject empty deny_rules list
    5. Rego evaluation: Add deny_request rule that reuses existing matchers (method_matches, path_matches, query_params_match, command_matches). Modify allow_request to add not deny_request guard. Add deny-specific request_deny_reason
    6. Tests: Unit tests for policy parsing round-trip with deny rules. Rego tests for deny-wins-over-allow, query param deny matching, wildcard method deny, non-matching requests still allowed, deny reason populated. Validation tests for deny_rules requiring protocol, empty list rejection
    7. Documentation: Update docs/reference/policy-schema.mdx with deny rule object table and examples. Update generate-sandbox-policy skill

    Test Plan

    • Unit tests (openshell-policy):

      • Parse YAML with deny_rules and verify proto fields
      • Round-trip: serialize → parse → verify deny_rules survive
      • Reject unknown fields in deny rule via deny_unknown_fields
    • Unit tests (openshell-sandbox, Rego):

      • Deny rule blocks request that would be allowed by access: read-write
      • Deny rule with query param matching blocks specific query values
      • Deny rule with wildcard method (*) blocks all methods on path
      • Non-matching requests are still allowed
      • Deny reason string populated when deny rule matches
      • Multiple deny rules: first match wins
      • Deny rule with SQL command matching
    • Unit tests (openshell-sandbox, validation):

      • deny_rules requires protocol (L7-only feature)
      • Empty deny_rules list rejected
      • Unknown HTTP method in deny rule produces warning
      • Valid deny config accepted
    • E2E tests: N/A — this is a policy schema change, testable through unit/integration tests against the OPA engine

    Risks & Open Questions

    • Rego rule conflict avoidance: request_deny_reason uses incremental rules that must be mutually exclusive. Use deny_request / not deny_request guards to prevent "complete rule conflict" errors.
    • Audit mode: In EnforcementMode::Audit, deny rules are evaluated but logged rather than enforced — matches existing audit behavior for allows. No Rust-side changes needed.
    • SQL deny rules: The proto and Rego support SQL deny rules, but the SQL L7 runtime is not yet implemented (falls back to passthrough). SQL deny rules will only take effect once the SQL provider ships. This is consistent with how SQL allow rules work today.

    Documentation Impact

    • docs/reference/policy-schema.mdx: Add deny rule object table, update endpoint object table with deny_rules field, add examples
    • .agents/skills/generate-sandbox-policy/SKILL.md: Update to include deny_rules in generated policies

    Revision 1 — initial plan

  9. added a commit that references this issue on Apr 13, 2026
    921db61
  10. johntmyers commented on Apr 13, 2026

    @johntmyers
    Collaborator

    🏗️ build-from-issue-agent

    Implementation Complete

    PR: #822

    What was built

    L7 deny rules added to the network policy schema with full feature parity with allow rules (method, path, query params, SQL command). Deny rules are evaluated after allow rules and take precedence. This enables the "allow everything except these specific operations" pattern requested in the issue.

    Tests

    • Unit (openshell-policy): 4 tests added
    • Unit (openshell-sandbox/Rego): 10 tests added
    • E2E: N/A

    Docs updated

    • docs/reference/policy-schema.mdx: Deny Rule Object section
    • .agents/skills/generate-sandbox-policy/SKILL.md: Deny rules guidance

    The issue will auto-close when the PR is merged.

  11. added a commit that references this issue on Apr 15, 2026
    28e1ff7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

state:agent-readyApproved for agent implementationstate:pr-openedPR has been opened for this issue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions