Skip to content

bug: TUI shows no pending rule when a denial is for an existing rule whose allowed_ips is stale #1245

@maxdubrinsky

Description

@maxdubrinsky

Agent Diagnostic

  • Loaded skill: openshell-cli for sandbox/log inspection.
  • Ran: openshell sandbox list and openshell logs <sandbox> against an active sandbox where a previously-working hostname rule started rejecting traffic. Observed a clean transition from allow to deny for the same (binary, host, port):
    NET:OPEN [INFO] ALLOWED <python>(499) -> internal-api.example.com:443
        [policy:allow_internal_api_example_com_443 engine:opa]
    ...
    NET:OPEN [MED] DENIED <python>(499) -> internal-api.example.com:443
        [policy:- engine:ssrf]
        [reason:internal-api.example.com resolves to <ip> which is not in allowed_ips, co...]
    
    Same binary, same host, same port — the OPA L4 check still matches the existing rule, but the SSRF override layer rejects the connection because the resolved IP is no longer in the rule's allowed_ips list. The most likely trigger here was a network/VPN session refresh causing DNS to return a different load-balanced backend.
  • Found in code:
    • crates/openshell-sandbox/src/proxy.rs:1964 — for hostname rules with allowed_ips, the proxy re-resolves on every CONNECT and validates each resolved IP via allowed_ips.iter().any(|net| net.contains(&addr.ip())). A miss returns ... resolves to <ip> which is not in allowed_ips, connection rejected (proxy.rs:1966–1970), which matches the observed reason string verbatim.
    • crates/openshell-sandbox/src/mechanistic_mapper.rs:56 (generate_proposals) groups denial summaries by (host, port, binary) and emits a deterministic rule name via generate_rule_name(host, port) (lines 75, 248–255). The proto comment states "DB-level dedup on (sandbox_id, host, port, binary) handles collisions." When an approved rule already exists for that key, the proposal is suppressed at persistence — no draft chunk is created.
    • crates/openshell-tui/src/ui/sandbox_detail.rs:39-48 — the "X pending network rule(s)" prompt is sourced from app.sandbox_draft_counts (populated by GetDraftPolicy in crates/openshell-tui/src/lib.rs:2308-2335), which reads persisted pending chunks. The TUI does not subscribe to a live denial stream.
  • Tried:
    • Verified in the TUI that no pending rule prompt appeared for the denied connection.
    • Confirmed via openshell logs <sandbox> that the denial is being emitted to OCSF (engine:ssrf), so the data exists at the sandbox boundary; it just doesn't reach a surface the operator looks at during normal use.
  • Conclusion: The denial is observable in raw logs but invisible in the in-product remediation UX, because the mapper's dedup design treats this as "already covered" rather than "covered host, stale allowed_ips."

Description

Actual behavior: When a sandbox has an approved network rule for host:port with an explicit allowed_ips allowlist, and a connection to that host is denied because the resolved IP is not in allowed_ips, the TUI shows no pending rule, no banner, and no count change. The denial is only visible in raw logs (engine:ssrf in OCSF). From the operator's view, the request was rejected by a rule they themselves approved, and the in-product remediation workflow surfaces nothing to act on.

Expected behavior: When a denied connection's (host, port, binary) matches an existing approved rule but the resolved IP is not covered by allowed_ips, the TUI should produce some operator-visible signal (a pending rule, a "stale allowed_ips" banner, a denial counter, etc.) so the operator can extend the rule without parsing raw logs.

Reproduction Steps

  1. Create a sandbox with a policy that has a rule for a hostname whose DNS resolution can change between sessions (e.g., a hostname behind a load-balanced or VPN-routed endpoint), with allowed_ips pinned to specific IPs:
    network_policies:
      example:
        endpoints:
          - host: internal-api.example.com
            port: 443
            allowed_ips:
              - "10.0.5.10"
              - "10.0.5.11"
        binaries:
          - { path: /usr/bin/curl }
  2. From the sandbox, make a request to https://internal-api.example.com/... — confirm it succeeds while the resolved IP is in the list. Logs show engine:opa ALLOWED.
  3. Cause the resolution to drift to an IP outside the list (e.g., reset the host's network/VPN session so DNS returns a different backend).
  4. Re-run the request from the sandbox — it is rejected with ... resolves to <ip> which is not in allowed_ips, connection rejected. Logs show engine:ssrf DENIED.
  5. Open the TUI (openshell term) and select the sandbox.

Observed: No pending rule appears for the denied connection. Pending count is unchanged.

Environment

  • OS: macOS 26.3.1 (build 25D771280a)
  • Docker: 29.3.0
  • OpenShell: 0.0.37-dev.139+g028763d4
  • Gateway deployment: local Docker (docker-dev gateway, http://127.0.0.1:18080)

Logs

[ts] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] ALLOWED <python>(499) -> internal-api.example.com:443 [policy:allow_internal_api_example_com_443 engine:opa]
[ts] [sandbox] [OCSF ] [ocsf] HTTP:POST [INFO] ALLOWED POST http://internal-api.example.com:443/v1/chat/completions
...
[ts] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED]  DENIED  <python>(499) -> internal-api.example.com:443 [policy:- engine:ssrf] [reason:internal-api.example.com resolves to <ip> which is not in allowed_ips, co...]
[ts] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED]  DENIED  <python>(499) -> internal-api.example.com:443 [policy:- engine:ssrf] [reason:internal-api.example.com resolves to <ip> which is not in allowed_ips, co...]

Agent-First Checklist

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions