diff --git a/components/egress/policy_server.go b/components/egress/policy_server.go index a62eefc5a..8d85746b8 100644 --- a/components/egress/policy_server.go +++ b/components/egress/policy_server.go @@ -147,8 +147,10 @@ func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) { s.handlePost(w, r) case http.MethodPatch: s.handlePatch(w, r) + case http.MethodDelete: + s.handleDelete(w, r) default: - w.Header().Set("Allow", "GET, POST, PUT, PATCH") + w.Header().Set("Allow", "GET, POST, PUT, PATCH, DELETE") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } @@ -222,15 +224,16 @@ func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) { defer s.mu.Unlock() raw, err := readPolicyRequestBody(r) - if err != nil || raw == "" { - if err != nil { - logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) - } else { - logEgressUpdateFailedWarn("empty patch body") - } + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) return } + if raw == "" { + logEgressUpdateFailedWarn("empty patch body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } var patchRules []policy.EgressRule if err := json.Unmarshal([]byte(raw), &patchRules); err != nil { @@ -268,6 +271,84 @@ func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) { }) } +func (s *policyServer) handleDelete(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := readPolicyRequestBody(r) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) + http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) + return + } + if raw == "" { + logEgressUpdateFailedWarn("empty delete body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + var targets []string + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("invalid delete targets: %v", err)) + http.Error(w, fmt.Sprintf("invalid delete targets: %v", err), http.StatusBadRequest) + return + } + if len(targets) == 0 { + logEgressUpdateFailedWarn("empty delete targets array") + http.Error(w, "invalid delete targets: empty array", http.StatusBadRequest) + return + } + + base := s.proxy.CurrentPolicy() + if base == nil { + base = policy.DefaultDenyPolicy() + } + oldCount := len(base.Egress) + newEgress, removedRules := removeRulesByTarget(base.Egress, targets) + removed := oldCount - len(newEgress) + + if removed == 0 { + mode := modeFromPolicy(base) + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + Reason: "no matching targets found", + }) + return + } + + rawMerged, err := json.Marshal(policy.NetworkPolicy{ + DefaultAction: base.DefaultAction, + Egress: newEgress, + }) + if err != nil { + logEgressUpdateFailedError(fmt.Sprintf("failed to marshal updated policy: %v", err)) + http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError) + return + } + newPolicy, err := policy.ParsePolicy(string(rawMerged)) + if err != nil { + logEgressUpdateFailedError(fmt.Sprintf("invalid policy after delete: %v", err)) + http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError) + return + } + + mode := modeFromPolicy(newPolicy) + log.Infof("policy API: deleting %d egress rule(s) by target, removed=%d, mode=%s, enforcement=%s", len(targets), removed, mode, s.enforcementMode) + if !s.commitPolicy(r.Context(), w, newPolicy, "delete") { + return + } + logEgressUpdated(newPolicy.DefaultAction, removedRules) + log.Infof("policy API: delete applied successfully") + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + }) +} + // commitPolicy applies one logical change: optional disk persist → merge always file rules → nft // static (with nameserver allow-IPs) → then update in-memory user policy (POST/PATCH/GET view). func (s *policyServer) commitPolicy(ctx context.Context, w http.ResponseWriter, pol *policy.NetworkPolicy, op string) bool { diff --git a/components/egress/policy_server_test.go b/components/egress/policy_server_test.go index 74e33771e..a2a0aacbd 100644 --- a/components/egress/policy_server_test.go +++ b/components/egress/policy_server_test.go @@ -245,6 +245,150 @@ func TestHandlePatch_RejectsWhenOverMaxEgressRules(t *testing.T) { require.Len(t, proxy.updated.Egress, 2, "policy should be unchanged") } +func TestHandleDelete_RemovesMatchingTargets(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "example.com"}, + {Action: policy.ActionDeny, Target: "blocked.com"}, + {Action: policy.ActionAllow, Target: "keep.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["blocked.com","nonexistent.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.NotNil(t, proxy.updated, "expected proxy policy updated") + require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "defaultAction should be preserved") + require.Len(t, proxy.updated.Egress, 2, "expected 2 rules remaining after delete") + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action) + require.Equal(t, "example.com", proxy.updated.Egress[0].Target) + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action) + require.Equal(t, "keep.com", proxy.updated.Egress[1].Target) +} + +func TestHandleDelete_CaseInsensitiveMatch(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "Example.COM"}, + {Action: policy.ActionDeny, Target: "Blocked.COM"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["example.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.NotNil(t, proxy.updated) + require.Len(t, proxy.updated.Egress, 1, "expected 1 rule remaining") + require.Equal(t, "Blocked.COM", proxy.updated.Egress[0].Target, "unmatched rule should remain") +} + +func TestHandleDelete_NoMatchReturns200(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "keep.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["nonexistent.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK even when no targets match") + require.Equal(t, 0, nft.calls, "nft should not be called when nothing changes") + require.Len(t, proxy.updated.Egress, 1, "policy should be unchanged") +} + +func TestHandleDelete_EmptyBodyReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader("")) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty body") +} + +func TestHandleDelete_EmptyArrayReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + body := `[]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty array") +} + +func TestHandleDelete_InvalidJSONReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + body := `not-json` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for invalid JSON") +} + +func TestHandleDelete_NftFailureReturns500(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "example.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{err: errors.New("nft apply failed")} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["example.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500 on nft failure") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.Len(t, proxy.updated.Egress, 1, "proxy should not be updated on nft failure") + require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "original rule should remain") +} + func TestHandlePost_RejectsWhenOverMaxEgressRules(t *testing.T) { proxy := &stubProxy{} nft := &stubNft{} diff --git a/components/egress/policy_utils.go b/components/egress/policy_utils.go index 10c0a6cad..b3aa42176 100644 --- a/components/egress/policy_utils.go +++ b/components/egress/policy_utils.go @@ -83,6 +83,32 @@ func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule { return out } +// removeRulesByTarget returns a new slice with rules matching targets removed, +// plus the removed rules. Domain targets are matched case-insensitively. +// Targets not found are silently ignored. +func removeRulesByTarget(rules []policy.EgressRule, targets []string) (kept, removed []policy.EgressRule) { + if len(targets) == 0 || len(rules) == 0 { + return rules, nil + } + removeSet := make(map[string]struct{}, len(targets)) + for _, t := range targets { + key := strings.ToLower(strings.TrimSpace(t)) + if key == "" { + continue + } + removeSet[key] = struct{}{} + } + kept = make([]policy.EgressRule, 0, len(rules)) + for _, r := range rules { + if _, ok := removeSet[strings.ToLower(r.Target)]; ok { + removed = append(removed, r) + } else { + kept = append(kept, r) + } + } + return kept, removed +} + // mergeKey: domain targets lowercased for dedupe; IP/CIDR left as-is. func mergeKey(r policy.EgressRule) string { if r.Target == "" { diff --git a/components/egress/tests/smoke-nft.sh b/components/egress/tests/smoke-nft.sh index eac5c232e..ff704f7dd 100755 --- a/components/egress/tests/smoke-nft.sh +++ b/components/egress/tests/smoke-nft.sh @@ -155,6 +155,47 @@ else pass "www.mozilla.org blocked after patch" fi +info "DELETE: deny two hosts, then delete one rule" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"api.github.com"},{"action":"deny","target":"www.cloudflare.com"}]}' + +info "Test: both hosts should be blocked before delete" +if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then + fail "api.github.com should be blocked before delete" +fi +if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then + fail "www.cloudflare.com should be blocked before delete" +fi +pass "both hosts blocked before delete" + +info "Deleting api.github.com rule" +curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '["api.github.com"]' + +info "Test: api.github.com allowed, www.cloudflare.com still blocked after delete" +run_in_app -I https://api.github.com --max-time 20 >/dev/null 2>&1 || fail "api.github.com should be allowed after delete" +pass "api.github.com allowed after delete" +if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then + fail "www.cloudflare.com should remain blocked after delete" +fi +pass "www.cloudflare.com still blocked" + +info "Deleting non-existent target (idempotent)" +resp="$(curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '["nonexistent.com"]')" +if echo "${resp}" | grep -q '"no matching targets found"'; then + pass "idempotent delete returns no matching targets found" +else + fail "expected no matching targets found, got: ${resp}" +fi + +info "Deleting with empty body (expect 400)" +http_code="$(curl -s -o /dev/null -w '%{http_code}' -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '')" +if [ "${http_code}" = "400" ]; then + pass "empty body returns 400" +else + fail "empty body should return 400, got ${http_code}" +fi + info "Always-rule dynamic check (single transition)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"api.github.com"}]}' diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs index 05eb00527..dd913783a 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs @@ -54,6 +54,13 @@ public async Task PatchRulesAsync( await _client.PatchAsync("/policy", normalizedRules, cancellationToken).ConfigureAwait(false); } + public async Task DeleteRulesAsync( + IReadOnlyList targets, + CancellationToken cancellationToken = default) + { + await _client.DeleteAsync("/policy", targets.ToList(), cancellationToken).ConfigureAwait(false); + } + private static NetworkPolicy ParseNetworkPolicy(JsonElement element) { var policy = new NetworkPolicy(); diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs b/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs index dbc575598..af6462533 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs @@ -189,6 +189,23 @@ public async Task DeleteAsync( await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false); } + public async Task DeleteAsync( + string path, + object body, + CancellationToken cancellationToken) + { + var url = BuildUrl(path); + _logger.LogDebug("HTTP DELETE {Url}", url); + using var request = new HttpRequestMessage(HttpMethod.Delete, url); + ApplyDefaultHeaders(request); + + var json = JsonSerializer.Serialize(body, JsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false); + } + public async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken = default) diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs index 132b26451..a93193f66 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs @@ -593,6 +593,23 @@ public async Task PatchEgressRulesAsync( await _egress.PatchRulesAsync(rules, cancellationToken).ConfigureAwait(false); } + /// + /// Deletes egress rules for this sandbox by target. + /// + /// Each entry is a FQDN or wildcard domain. Matching rules are removed + /// from the currently enforced policy. Targets not present in the policy + /// are silently ignored (idempotent). The current defaultAction is + /// preserved. + /// + /// Target FQDNs or wildcard domains to remove. + /// Cancellation token. + public async Task DeleteEgressRulesAsync( + IReadOnlyList targets, + CancellationToken cancellationToken = default) + { + await _egress.DeleteRulesAsync(targets, cancellationToken).ConfigureAwait(false); + } + /// /// Gets the endpoint for a port. /// diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs b/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs index aaaca49c4..5f8fde0b4 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs @@ -26,4 +26,8 @@ public interface IEgress Task PatchRulesAsync( IReadOnlyList rules, CancellationToken cancellationToken = default); + + Task DeleteRulesAsync( + IReadOnlyList targets, + CancellationToken cancellationToken = default); } diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs index 685539a6f..b33ffa24e 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -55,12 +55,15 @@ await sandbox.PatchEgressRulesAsync([new NetworkRule Action = NetworkRuleAction.Allow, Target = "www.github.com" }]); + await sandbox.DeleteEgressRulesAsync(["www.github.com", "*.blocked.org"]); sandboxes.EndpointCalls.Should().Equal(Constants.DefaultExecdPort, Constants.DefaultEgressPort); adapterFactory.EgressStackCallCount.Should().Be(1); adapterFactory.LastEgressBaseUrl.Should().Be($"http://127.0.0.1:{Constants.DefaultEgressPort}"); egress.GetPolicyCallCount.Should().Be(1); egress.PatchRulesCallCount.Should().Be(1); + egress.DeleteRulesCallCount.Should().Be(1); + egress.LastDeleteTargets.Should().Equal("www.github.com", "*.blocked.org"); } [Fact] @@ -300,6 +303,10 @@ private sealed class StubEgress : IEgress public int PatchRulesCallCount { get; private set; } + public int DeleteRulesCallCount { get; private set; } + + public IReadOnlyList LastDeleteTargets { get; private set; } = []; + public Task GetPolicyAsync(CancellationToken cancellationToken = default) { GetPolicyCallCount++; @@ -319,6 +326,13 @@ public Task PatchRulesAsync(IReadOnlyList rules, CancellationToken PatchRulesCallCount++; return Task.CompletedTask; } + + public Task DeleteRulesAsync(IReadOnlyList targets, CancellationToken cancellationToken = default) + { + DeleteRulesCallCount++; + LastDeleteTargets = targets.ToList(); + return Task.CompletedTask; + } } private sealed class StubFiles : ISandboxFiles diff --git a/sdks/sandbox/go/egress.go b/sdks/sandbox/go/egress.go index 6a5ef1cf4..d0536ea23 100644 --- a/sdks/sandbox/go/egress.go +++ b/sdks/sandbox/go/egress.go @@ -55,3 +55,15 @@ func (c *EgressClient) PatchPolicy(ctx context.Context, rules []NetworkRule) (*P } return &resp, nil } + +// DeletePolicy removes egress rules matching the given targets from the current +// policy. Each target is a FQDN or wildcard domain. Targets not present in the +// policy are silently ignored (idempotent). The current defaultAction is +// preserved. +func (c *EgressClient) DeletePolicy(ctx context.Context, targets []string) (*PolicyStatusResponse, error) { + var resp PolicyStatusResponse + if err := c.doRequest(ctx, "DELETE", "/policy", targets, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/sdks/sandbox/go/opensandbox_test.go b/sdks/sandbox/go/opensandbox_test.go index 6a7a2ebbe..b45088058 100644 --- a/sdks/sandbox/go/opensandbox_test.go +++ b/sdks/sandbox/go/opensandbox_test.go @@ -578,6 +578,47 @@ func TestPatchPolicy(t *testing.T) { require.Len(t, got.Policy.Egress, 2) } +func TestDeletePolicy(t *testing.T) { + want := PolicyStatusResponse{ + Status: "ok", + Mode: "deny_all", + Policy: &NetworkPolicy{ + DefaultAction: "deny", + Egress: []NetworkRule{ + {Action: "allow", Target: "api.example.com"}, + }, + }, + } + + _, client := newEgressServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + assert.Fail(t, fmt.Sprintf("expected DELETE, got %s", r.Method)) + } + + var targets []string + if err := json.NewDecoder(r.Body).Decode(&targets); err != nil { + assert.Fail(t, fmt.Sprintf("decode body: %v", err)) + } + if len(targets) != 2 { + assert.Fail(t, fmt.Sprintf("expected 2 targets in request, got %d", len(targets))) + } + if targets[0] != "bad.example.com" || targets[1] != "*.blocked.org" { + assert.Fail(t, fmt.Sprintf("unexpected targets: %v", targets)) + } + + jsonResponse(w, http.StatusOK, want) + }) + + got, err := client.DeletePolicy(context.Background(), []string{ + "bad.example.com", + "*.blocked.org", + }) + require.NoErrorf(t, err, "DeletePolicy") + require.NotNil(t, got.Policy) + require.Len(t, got.Policy.Egress, 1) + require.Equal(t, "api.example.com", got.Policy.Egress[0].Target) +} + func TestPing(t *testing.T) { _, client := newExecdServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/sdks/sandbox/go/sandbox_egress.go b/sdks/sandbox/go/sandbox_egress.go index 471293688..baa32403d 100644 --- a/sdks/sandbox/go/sandbox_egress.go +++ b/sdks/sandbox/go/sandbox_egress.go @@ -31,3 +31,12 @@ func (s *Sandbox) PatchEgressRules(ctx context.Context, rules []NetworkRule) (*P } return s.egress.PatchPolicy(ctx, rules) } + +// DeleteEgressRules removes egress rules matching the given targets from the +// current egress policy. Targets not present in the policy are silently ignored. +func (s *Sandbox) DeleteEgressRules(ctx context.Context, targets []string) (*PolicyStatusResponse, error) { + if err := s.resolveEgress(ctx); err != nil { + return nil, err + } + return s.egress.DeletePolicy(ctx, targets) +} diff --git a/sdks/sandbox/javascript/src/adapters/egressAdapter.ts b/sdks/sandbox/javascript/src/adapters/egressAdapter.ts index 93aa90a8f..e2262ddae 100644 --- a/sdks/sandbox/javascript/src/adapters/egressAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/egressAdapter.ts @@ -22,6 +22,8 @@ type ApiGetPolicyOk = EgressPaths["/policy"]["get"]["responses"][200]["content"]["application/json"]; type ApiPatchRulesRequest = EgressPaths["/policy"]["patch"]["requestBody"]["content"]["application/json"]; +type ApiDeleteRulesRequest = + EgressPaths["/policy"]["delete"]["requestBody"]["content"]["application/json"]; export class EgressAdapter implements Egress { constructor(private readonly client: EgressClient) {} @@ -43,4 +45,12 @@ export class EgressAdapter implements Egress { }); throwOnOpenApiFetchError({ error, response }, "Patch sandbox egress rules failed"); } + + async deleteRules(targets: string[]): Promise { + const body: ApiDeleteRulesRequest = targets; + const { error, response } = await this.client.DELETE("/policy", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Delete sandbox egress rules failed"); + } } diff --git a/sdks/sandbox/javascript/src/api/egress.ts b/sdks/sandbox/javascript/src/api/egress.ts index 2cca3230a..934e869d7 100644 --- a/sdks/sandbox/javascript/src/api/egress.ts +++ b/sdks/sandbox/javascript/src/api/egress.ts @@ -54,7 +54,41 @@ export interface paths { }; put?: never; post?: never; - delete?: never; + /** + * Delete egress rules + * @description Remove specific egress rules from the currently enforced policy by target. + * + * - Accepts a list of target strings (FQDNs or wildcard domains). + * - Matching rules are removed; targets not found in the current policy + * are silently ignored (idempotent). + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string[]; + }; + }; + responses: { + /** @description Rules removed successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PolicyStatusResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; options?: never; head?: never; /** diff --git a/sdks/sandbox/javascript/src/sandbox.ts b/sdks/sandbox/javascript/src/sandbox.ts index 4315f7a44..6ad984322 100644 --- a/sdks/sandbox/javascript/src/sandbox.ts +++ b/sdks/sandbox/javascript/src/sandbox.ts @@ -570,6 +570,10 @@ export class Sandbox { await Sandbox._priv.get(this)!.egress.patchRules(rules); } + async deleteEgressRules(targets: string[]): Promise { + await Sandbox._priv.get(this)!.egress.deleteRules(targets); + } + /** * Get sandbox endpoint for a port (STRICT: no scheme), e.g. "localhost:44772" or "domain/route/.../44772". */ diff --git a/sdks/sandbox/javascript/src/services/egress.ts b/sdks/sandbox/javascript/src/services/egress.ts index 0a248d725..ff9efe3b7 100644 --- a/sdks/sandbox/javascript/src/services/egress.ts +++ b/sdks/sandbox/javascript/src/services/egress.ts @@ -24,4 +24,12 @@ export interface Egress { * the first rule for a target wins. The current defaultAction is preserved. */ patchRules(rules: NetworkRule[]): Promise; + /** + * Delete egress rules by target. + * + * Each entry is a FQDN or wildcard domain. Matching rules are removed from + * the currently enforced policy. Targets not present in the policy are + * silently ignored (idempotent). The current defaultAction is preserved. + */ + deleteRules(targets: string[]): Promise; } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt index 75cf9b66c..b490015c1 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt @@ -528,6 +528,19 @@ class Sandbox internal constructor( egressService.patchRules(rules) } + /** + * Deletes egress rules for this sandbox by target. + * + * Each entry is a FQDN or wildcard domain. Matching rules are removed from + * the currently enforced policy. Targets not present in the policy are + * silently ignored (idempotent). The current defaultAction is preserved. + * + * @throws SandboxException if operation fails + */ + fun deleteEgressRules(targets: List) { + egressService.deleteRules(targets) + } + /** * Pauses the sandbox while preserving its state. * diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt index 61aa78e5b..dc0b44b37 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt @@ -23,4 +23,6 @@ interface Egress { fun getPolicy(): NetworkPolicy fun patchRules(rules: List) + + fun deleteRules(targets: List) } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt index fc3b6192a..dc4460065 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt @@ -66,4 +66,13 @@ internal class EgressAdapter( throw e.toSandboxException() } } + + override fun deleteRules(targets: List) { + try { + api.policyDelete(targets) + } catch (e: Exception) { + logger.error("Failed to delete egress rules via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt index 9c0041368..295f92e15 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt @@ -218,6 +218,16 @@ class SandboxTest { verify { egressService.patchRules(rules) } } + @Test + fun `deleteEgressRules should delegate to egressService`() { + val targets = listOf("bad.example.com", "*.blocked.org") + every { egressService.deleteRules(targets) } just Runs + + sandbox.deleteEgressRules(targets) + + verify { egressService.deleteRules(targets) } + } + @Test fun `builder manualCleanup should clear timeout`() { val builder = diff --git a/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py index 99a00e9d4..7db99867a 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py @@ -110,3 +110,16 @@ async def patch_rules(self, rules: list[NetworkRule]) -> None: except Exception as e: logger.error("Failed to patch egress policy via endpoint %s", self.endpoint.endpoint, exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e + + async def delete_rules(self, targets: list[str]) -> None: + try: + from opensandbox.api.egress.api.policy import delete_policy + + response_obj = await delete_policy.asyncio_detailed( + client=self._client, + body=list(targets), + ) + handle_api_error(response_obj, "Delete egress rules") + except Exception as e: + logger.error("Failed to delete egress rules via endpoint %s", self.endpoint.endpoint, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/api/egress/api/policy/delete_policy.py b/sdks/sandbox/python/src/opensandbox/api/egress/api/policy/delete_policy.py new file mode 100644 index 000000000..cae7bb9a6 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/egress/api/policy/delete_policy.py @@ -0,0 +1,211 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.policy_status_response import PolicyStatusResponse +from ...types import Response + + +def _get_kwargs( + *, + body: list[str], +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/policy", + } + + _kwargs["json"] = body + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> PolicyStatusResponse | str | None: + if response.status_code == 200: + response_200 = PolicyStatusResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = response.text + return response_400 + + if response.status_code == 401: + response_401 = response.text + return response_401 + + if response.status_code == 500: + response_500 = response.text + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[PolicyStatusResponse | str]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: list[str], +) -> Response[PolicyStatusResponse | str]: + """Delete egress rules + + Remove specific egress rules from the currently enforced policy by target. + + - Accepts a list of target strings (FQDNs or wildcard domains). + - Matching rules are removed; targets not found in the current policy + are silently ignored (idempotent). + + Args: + body (list[str]): Example: ['bad.example.com', '*.blocked.org']. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PolicyStatusResponse | str] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: list[str], +) -> PolicyStatusResponse | str | None: + """Delete egress rules + + Remove specific egress rules from the currently enforced policy by target. + + - Accepts a list of target strings (FQDNs or wildcard domains). + - Matching rules are removed; targets not found in the current policy + are silently ignored (idempotent). + + Args: + body (list[str]): Example: ['bad.example.com', '*.blocked.org']. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PolicyStatusResponse | str + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: list[str], +) -> Response[PolicyStatusResponse | str]: + """Delete egress rules + + Remove specific egress rules from the currently enforced policy by target. + + - Accepts a list of target strings (FQDNs or wildcard domains). + - Matching rules are removed; targets not found in the current policy + are silently ignored (idempotent). + + Args: + body (list[str]): Example: ['bad.example.com', '*.blocked.org']. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PolicyStatusResponse | str] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: list[str], +) -> PolicyStatusResponse | str | None: + """Delete egress rules + + Remove specific egress rules from the currently enforced policy by target. + + - Accepts a list of target strings (FQDNs or wildcard domains). + - Matching rules are removed; targets not found in the current policy + are silently ignored (idempotent). + + Args: + body (list[str]): Example: ['bad.example.com', '*.blocked.org']. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PolicyStatusResponse | str + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/sdks/sandbox/python/src/opensandbox/sandbox.py b/sdks/sandbox/python/src/opensandbox/sandbox.py index ac6b06b24..90e997c77 100644 --- a/sdks/sandbox/python/src/opensandbox/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sandbox.py @@ -310,6 +310,17 @@ async def patch_egress_rules(self, rules: list[NetworkRule]) -> None: """ await self._egress_service.patch_rules(rules) + async def delete_egress_rules(self, targets: list[str]) -> None: + """ + Delete egress rules for this sandbox by target. + + Each entry is a FQDN or wildcard domain. Matching rules are removed + from the currently enforced policy. Targets not present in the policy + are silently ignored (idempotent). The current defaultAction is + preserved. + """ + await self._egress_service.delete_rules(targets) + async def pause(self) -> None: """ Pause the sandbox while preserving its state. diff --git a/sdks/sandbox/python/src/opensandbox/services/egress.py b/sdks/sandbox/python/src/opensandbox/services/egress.py index 89e8a162f..c863bd40f 100644 --- a/sdks/sandbox/python/src/opensandbox/services/egress.py +++ b/sdks/sandbox/python/src/opensandbox/services/egress.py @@ -50,3 +50,17 @@ async def patch_rules(self, rules: list[NetworkRule]) -> None: SandboxException: if the operation fails """ ... + + async def delete_rules(self, targets: list[str]) -> None: + """ + Delete egress rules by target via the sidecar policy API. + + Each entry is a FQDN or wildcard domain. Matching rules are removed + from the currently enforced policy. Targets not present in the policy + are silently ignored (idempotent). The current defaultAction is + preserved. + + Raises: + SandboxException: if the operation fails + """ + ... diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py index bf3e48714..56ffc1971 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py @@ -110,3 +110,16 @@ def patch_rules(self, rules: list[NetworkRule]) -> None: except Exception as e: logger.error("Failed to patch egress policy via endpoint %s", self.endpoint.endpoint, exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e + + def delete_rules(self, targets: list[str]) -> None: + try: + from opensandbox.api.egress.api.policy import delete_policy + + response_obj = delete_policy.sync_detailed( + client=self._client, + body=list(targets), + ) + handle_api_error(response_obj, "Delete egress rules") + except Exception as e: + logger.error("Failed to delete egress rules via endpoint %s", self.endpoint.endpoint, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py index c236067ff..83cce5919 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py @@ -318,6 +318,17 @@ def patch_egress_rules(self, rules: list[NetworkRule]) -> None: """ self._egress_service.patch_rules(rules) + def delete_egress_rules(self, targets: list[str]) -> None: + """ + Delete egress rules for this sandbox by target. + + Each entry is a FQDN or wildcard domain. Matching rules are removed + from the currently enforced policy. Targets not present in the policy + are silently ignored (idempotent). The current defaultAction is + preserved. + """ + self._egress_service.delete_rules(targets) + def pause(self) -> None: """ Pause the sandbox while preserving its state. diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/egress.py b/sdks/sandbox/python/src/opensandbox/sync/services/egress.py index c9d2ec730..71fb5977a 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/services/egress.py +++ b/sdks/sandbox/python/src/opensandbox/sync/services/egress.py @@ -38,3 +38,13 @@ def patch_rules(self, rules: list[NetworkRule]) -> None: preserved. """ ... + + def delete_rules(self, targets: list[str]) -> None: + """Delete egress rules by target via the sidecar policy API. + + Each entry is a FQDN or wildcard domain. Matching rules are removed + from the currently enforced policy. Targets not present in the policy + are silently ignored (idempotent). The current defaultAction is + preserved. + """ + ... diff --git a/specs/README.md b/specs/README.md index c56523504..1d04d34da 100644 --- a/specs/README.md +++ b/specs/README.md @@ -122,6 +122,7 @@ the sandbox endpoint for the egress port and then calling the sidecar endpoint d **Main Endpoints:** - `GET /policy` - Get the current egress policy - `PATCH /policy` - Merge new egress rules into the current policy +- `DELETE /policy` - Remove specific egress rules from the current policy by target ## Technical Features diff --git a/specs/README_zh.md b/specs/README_zh.md index f251cb0da..0f4ef1c6d 100644 --- a/specs/README_zh.md +++ b/specs/README_zh.md @@ -121,6 +121,7 @@ **主要端点:** - `GET /policy` - 获取当前 egress 策略 - `PATCH /policy` - 将新的 egress 规则合并到当前策略 +- `DELETE /policy` - 按 target 删除当前策略中的指定 egress 规则 ## 技术特性 diff --git a/specs/egress-api.yaml b/specs/egress-api.yaml index f7dce4ec5..fba36525f 100644 --- a/specs/egress-api.yaml +++ b/specs/egress-api.yaml @@ -109,6 +109,48 @@ paths: $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/InternalServerError' + delete: + tags: [Policy] + summary: Delete egress rules + description: | + Remove specific egress rules from the currently enforced policy by target. + + - Accepts a list of target strings (FQDNs or wildcard domains). + - Matching rules are removed; targets not found in the current policy + are silently ignored (idempotent). + requestBody: + required: true + content: + application/json: + schema: + type: array + minItems: 1 + items: + type: string + description: FQDN or wildcard domain to remove from the policy. + example: + - bad.example.com + - "*.blocked.org" + responses: + '200': + description: Rules removed successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyStatusResponse' + examples: + removed: + summary: Rules removed + value: + status: ok + mode: deny_all + enforcementMode: dns + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' components: responses: BadRequest: diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py index 0eb331d7c..3815ed74f 100644 --- a/tests/python/tests/test_sandbox_e2e.py +++ b/tests/python/tests/test_sandbox_e2e.py @@ -462,6 +462,82 @@ async def test_01aa_network_policy_get_and_patch(self): pass await sandbox.close() + @pytest.mark.timeout(180) + @pytest.mark.order(1) + async def test_01ac_network_policy_delete(self): + if is_kubernetes_runtime(): + pytest.skip("Network policy is not covered in the Kubernetes runtime suite") + + logger.info("=" * 80) + logger.info("TEST 1ac: networkPolicy delete (async)") + logger.info("=" * 80) + + cfg = create_connection_config() + sandbox = await Sandbox.create( + image=SandboxImageSpec(get_sandbox_image()), + resource=get_e2e_sandbox_resource(), + connection_config=cfg, + timeout=timedelta(minutes=5), + ready_timeout=timedelta(seconds=30), + network_policy=NetworkPolicy( + defaultAction="deny", + egress=[ + NetworkRule(action="allow", target="pypi.org"), + NetworkRule(action="allow", target="www.github.com"), + ], + ), + ) + try: + await asyncio.sleep(5) + + # Baseline: both targets reachable under deny-default policy. + initial_policy = await sandbox.get_egress_policy() + assert initial_policy.egress is not None + assert any(r.target == "pypi.org" and r.action == "allow" for r in initial_policy.egress) + assert any( + r.target == "www.github.com" and r.action == "allow" for r in initial_policy.egress + ) + pypi_ok = await sandbox.commands.run("curl -I https://pypi.org") + assert pypi_ok.error is None + github_ok = await sandbox.commands.run("curl -I https://www.github.com") + assert github_ok.error is None + + # Delete the github allow-rule. Include a non-existent target to + # confirm DELETE is idempotent (no error, silently ignored). + await sandbox.delete_egress_rules(["www.github.com", "nonexistent.example.com"]) + await asyncio.sleep(2) + + deleted_policy = await sandbox.get_egress_policy() + assert deleted_policy.egress is not None + assert not any( + r.target == "www.github.com" for r in deleted_policy.egress + ), "www.github.com rule should be removed" + assert any( + r.target == "pypi.org" and r.action == "allow" for r in deleted_policy.egress + ), "pypi.org rule should remain (other targets untouched)" + assert deleted_policy.default_action == "deny", "defaultAction must be preserved" + + # github now falls under default-deny; pypi still allowed. + github_blocked = await sandbox.commands.run("curl -I https://www.github.com") + assert github_blocked.error is not None + pypi_still_ok = await sandbox.commands.run("curl -I https://pypi.org") + assert pypi_still_ok.error is None + + # Second delete of the same target is a no-op. + await sandbox.delete_egress_rules(["www.github.com"]) + await asyncio.sleep(1) + unchanged_policy = await sandbox.get_egress_policy() + assert unchanged_policy.egress is not None + assert {r.target for r in unchanged_policy.egress} == { + r.target for r in deleted_policy.egress + } + finally: + try: + await sandbox.kill() + except Exception: + pass + await sandbox.close() + @pytest.mark.timeout(240) @pytest.mark.order(1) async def test_01ab_network_policy_get_and_patch_with_server_proxy(self):