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
Agent Diagnostic
method_matchesinsandbox-policy.regois the policy gate. No compensating HEAD allowance exists downstream (searchedopa.rs,l7/rest.rs). The only HEAD-aware logic is response-body suppression inis_bodiless_responseatl7/rest.rs:1265, which fires after the gate accepts. Existing tests cover HEAD only via theread-onlyaccess preset (expand_access_presetincrates/openshell-policy/src/merge.rs:619-625expands to[GET, HEAD, OPTIONS]); no test covers "explicitmethod: GETrule + 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
One-way: HEAD against POST/PUT/PATCH/DELETE-only paths stays denied. OPTIONS unchanged (preflight semantics differ; the
read-onlypreset 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 122opa::testspass.Description
method_matchesincrates/openshell-sandbox/data/sandbox-policy.regois case-folded exact match plus"*". A rule pinningmethod: GET, path: <url>therefore rejectsHEAD <url>with403 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: GETrule onexample.com:openshell sandbox create --policy <above> --from base -- /bin/bash, then inside: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
debug-openshell-cluster,debug-inference,openshell-cli)