Skip to content

fix: L7 method_matches denies HEAD where GET is allowed (RFC 9110 §9.3.2) #1357

@raktes

Description

@raktes

Agent Diagnostic

method_matches in sandbox-policy.rego is the policy gate. No compensating HEAD allowance exists downstream (searched opa.rs, l7/rest.rs). The only HEAD-aware logic is response-body suppression in is_bodiless_response at l7/rest.rs:1265, which fires after the gate accepts. Existing tests cover HEAD only via the read-only access preset (expand_access_preset in crates/openshell-policy/src/merge.rs:619-625 expands to [GET, HEAD, OPTIONS]); no test covers "explicit method: GET rule + HEAD request".

Skills inventory (debug-openshell-cluster, debug-inference, openshell-cli, generate-sandbox-policy) targets runtime / deployment / policy authoring, not the matcher. Investigation done by source reading; disclosed honestly rather than faking a skill-output trace.

Proposed Fix

# RFC 9110 §9.3.2: HEAD has identical semantics to GET except no body.
method_matches(actual, expected) if {
    expected != "*"
    upper(actual) == "HEAD"
    upper(expected) == "GET"
}

One-way: HEAD against POST/PUT/PATCH/DELETE-only paths stays denied. OPTIONS unchanged (preflight semantics differ; the read-only preset already grants it explicitly).

Reference patch + 3 regression tests (l7_head_allowed_where_get_is_allowed, l7_head_denied_when_only_post_allowed, l7_options_not_implicitly_allowed_by_get) verified locally — all 122 opa::tests pass.

Description

method_matches in crates/openshell-sandbox/data/sandbox-policy.rego is case-folded exact match plus "*". A rule pinning method: GET, path: <url> therefore rejects HEAD <url> with 403 X-OpenShell-Policy: <name>, even though RFC 9110 §9.3.2 defines HEAD as identical to GET except no body.

Allowing HEAD wherever GET is allowed reveals strictly less than GET already does. Rejecting it breaks package managers (uv, pip, poetry, cargo sparse index, npm) that HEAD-probe .metadata / Content-Length / ETag before the GET. Under uv's concurrent downloads the 403s degenerate into connect-timeouts on the subsequent GETs, so the policy denial hides behind what looks like upstream network flake.

Reproduction Steps

Policy with one explicit method: GET rule on example.com:

network_policies:
  egress:
    name: egress
    endpoints:
      - host: example.com
        port: 443
        protocol: rest
        tls: terminate
        enforcement: enforce
        rules:
          - allow:
              method: GET
              path: "/"
    binaries:
      - { path: /usr/bin/curl }

openshell sandbox create --policy <above> --from base -- /bin/bash, then inside:

$ curl -sS  -o /dev/null -w "status=%{http_code}\n"  https://example.com/
status=200
$ curl -sSI -o /dev/null -w "status=%{http_code}\n"  https://example.com/
status=403

The 403 body confirms it's the L7 policy gate, not upstream:

{"error":"policy_denied","policy":"egress","method":"HEAD","path":"/","detail":"HEAD / not permitted by policy","rule":"HEAD /","layer":"l7", ...}

Response carries X-OpenShell-Policy: egress.

Environment

Not environment-specific. Bug is in the embedded Rego matcher.

Logs

Agent-First Checklist

  • I pointed my agent at the repo and had it investigate this issue
  • I loaded relevant skills (e.g., debug-openshell-cluster, debug-inference, openshell-cli)
  • My agent could not resolve this — the diagnostic above explains why

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions