From 5c7802ce4385b3dd547e0d19797ad9145abf183a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:10:55 +0900 Subject: [PATCH] =?UTF-8?q?test(e2e):=20approval-gated=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EB=A1=9C=EB=93=9C=EC=9D=98=20egress=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=A7=91=ED=96=89=EC=9D=84=20=EB=8B=A8=EC=96=B8?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(AC-F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind v0.24.0 의 kindnet 이 NetworkPolicy 를 집행하므로(#176·#180), 지금껏 「정책 집행 CNI 선행」으로 유예하던 시나리오 2 를 Go 대체 검증으로 산다. e2e_f2_egress_boundary_test.go — 세션 둘(S1·S2)을 띄워 S1 워크로드 파드 안에서: (a) kube-dns 조회 성공, (b)(c) 자기 헬퍼 MCP·프록시 포트 연결, (d) 다른 네임스페이스 서비스·fetch-origin·공개 IP 차단, (e) 공급자 origin 차단, (f) 승인 게이트웨이 차단, (g) S2 헬퍼 차단, (h) 정책 쌍이 세션 삭제와 함께 사라짐. 차단마다 제한받지 않는 헬퍼 파드에서 같은 목적지로 붙는 대조군을 먼저 통과시킨다. 공개 IP 갈래는 러너에 공개 egress 가 없으면 건너뛴다. docs/test/e2e.md — 유예 표 시나리오 2 행을 형제 행(1·7·8)과 같은 「kube 관측 의존 · 현행 대체 검증 = Go 파일」로 바꾸고, 남은 미검증 분기의 f6 차단 두 행을 걷었다(시나리오 2 가 소유). 집계 불변. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Xb2pE6EtTVPjXUZqzS2Kk --- .../test/e2e_f2_egress_boundary_test.go | 146 ++++++++++++++++++ docs/test/e2e.md | 10 +- 2 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 control-plane/test/e2e_f2_egress_boundary_test.go diff --git a/control-plane/test/e2e_f2_egress_boundary_test.go b/control-plane/test/e2e_f2_egress_boundary_test.go new file mode 100644 index 0000000..d3c7266 --- /dev/null +++ b/control-plane/test/e2e_f2_egress_boundary_test.go @@ -0,0 +1,146 @@ +//go:build e2e + +// 검증 시나리오: approval-gated-workload.md#시나리오 2 +// +// AC-F2's network boundary, enforced — not just present — on the deployed SUT. +// kind's kindnet enforces NetworkPolicy since v0.24.0, so a refused connection +// here is the policy at work. Every refusal is paired with a control: the same +// destination dialled from a pod the policy does not confine (the session's +// helper pod) must connect, or the refusal would prove nothing beyond "that +// address is dead in this cluster". +package e2e_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// $1 host, $2 port. Prints exactly "open" or "closed". bash's /dev/tcp dials +// without speaking any protocol, so the verdict is about the connection alone. +const f2DialProbe = `if timeout 4 bash -c 'exec 3<>"/dev/tcp/$1/$2"' _ "$1" "$2" 2>/dev/null; then echo open; else echo closed; fi` + +func (f f6Session) dial(t *testing.T, pod, container, host, port string) string { + t.Helper() + return strings.TrimSpace(f.sh(t, pod, container, f2DialProbe, host, port)) +} + +// The control must connect from the unconfined pod first; only then does the +// confined pod's refusal say anything. +func (f f6Session) mustBeBlockedFromWorkload(t *testing.T, controlContainer, host, port string) { + t.Helper() + if got := f.dial(t, f.helper.Name, controlContainer, host, port); got != "open" { + t.Fatalf("control: %s:%s from helper %s/%s = %s, want open — the workload-side refusal below would be vacuous", + host, port, f.helper.Name, controlContainer, got) + } + if got := f.dial(t, f.work.Name, workloadContainer, host, port); got != "closed" { + t.Fatalf("workload pod %s reached %s:%s (%s) — AC-F2's egress allowlist admits only kube-dns and its own helper", + f.work.Name, host, port, got) + } +} + +func (f f6Session) sessionPolicies(t *testing.T) []string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + list, err := f.cs.NetworkingV1().NetworkPolicies(f.ns).List(ctx, metav1.ListOptions{ + LabelSelector: "session-id=" + f.session.ID, + }) + if err != nil { + t.Fatalf("list network policies for session %s: %v", f.session.ID, err) + } + names := make([]string, 0, len(list.Items)) + for _, p := range list.Items { + names = append(names, p.Name) + } + return names +} + +func TestApprovalGatedEgressBoundary_IsEnforcedOnTheDeployedSUT(t *testing.T) { + s1, ok := newF6Session(t, map[string]any{"name": uniqueName(t), "workloadType": "approval-gated"}) + if !ok { + t.Skip("no kube API access (kubeconfig/in-cluster) — enforcement is observed from inside the pods") + } + s2, _ := newF6Session(t, map[string]any{"name": uniqueName(t) + "-b", "workloadType": "approval-gated"}) + + s1Helper := s1.helper.Status.PodIP + s2Helper := s2.helper.Status.PodIP + if s1Helper == "" || s2Helper == "" { + t.Fatalf("helper pod IPs not assigned (s1=%q s2=%q)", s1Helper, s2Helper) + } + ns := s1.ns + + t.Run("a_kube_dns_resolves", func(t *testing.T) { + out := strings.TrimSpace(s1.sh(t, s1.work.Name, workloadContainer, + `getent hosts kubernetes.default.svc.cluster.local >/dev/null && echo resolved || echo failed`)) + if out != "resolved" { + t.Fatalf("workload pod could not resolve a cluster name (%s) — the DNS leg of the allowlist is closed", out) + } + }) + + t.Run("b_c_own_helper_ports_connect", func(t *testing.T) { + for _, port := range []string{sessionMCPPortText, helperProxyPort} { + if got := s1.dial(t, s1.work.Name, workloadContainer, s1Helper, port); got != "open" { + t.Errorf("workload → own helper %s:%s = %s, want open", s1Helper, port, got) + } + } + }) + + t.Run("d_other_destinations_refused", func(t *testing.T) { + s1.mustBeBlockedFromWorkload(t, sessionMCPContainer, "oidc-mock.oidc-mock.svc.cluster.local", "5556") + s1.mustBeBlockedFromWorkload(t, sessionMCPContainer, "fetch-origin."+ns+".svc.cluster.local", "8080") + }) + + t.Run("d_public_ip_refused", func(t *testing.T) { + if got := s1.dial(t, s1.helper.Name, sessionMCPContainer, "1.1.1.1", "443"); got != "open" { + t.Skipf("this runner has no public egress (control from the helper = %s); nothing to refuse", got) + } + if got := s1.dial(t, s1.work.Name, workloadContainer, "1.1.1.1", "443"); got != "closed" { + t.Fatalf("workload pod reached a public address (%s)", got) + } + }) + + t.Run("e_provider_origin_refused", func(t *testing.T) { + s1.mustBeBlockedFromWorkload(t, helperCredProxyContainer, "anthropic-fake."+ns+".svc.cluster.local", "8443") + }) + + t.Run("f_approval_gateway_refused", func(t *testing.T) { + s1.mustBeBlockedFromWorkload(t, sessionMCPContainer, "pocket-aide.pocket-aide.svc.cluster.local", "8080") + }) + + t.Run("g_other_sessions_helper_refused", func(t *testing.T) { + for _, port := range []string{sessionMCPPortText, helperProxyPort} { + if got := s2.dial(t, s2.work.Name, workloadContainer, s2Helper, port); got != "open" { + t.Fatalf("control: S2 workload → S2 helper %s:%s = %s, want open", s2Helper, port, got) + } + if got := s1.dial(t, s1.work.Name, workloadContainer, s2Helper, port); got != "closed" { + t.Errorf("S1 workload reached S2's helper %s:%s (%s) — one session could borrow another's proxy or MCP", + s2Helper, port, got) + } + } + }) + + t.Run("h_policies_live_and_die_with_the_session", func(t *testing.T) { + if got := s1.sessionPolicies(t); len(got) != 2 { + t.Fatalf("session %s carries %d network policies %v, want the egress/ingress pair", s1.session.ID, len(got), got) + } + if resp, raw := do(t, http.MethodDelete, "/api/v1/sessions/"+s1.session.ID, nil); resp.StatusCode >= 400 { + t.Fatalf("delete %s: status=%d body=%s", s1.session.ID, resp.StatusCode, raw) + } + deadline := time.Now().Add(90 * time.Second) + for { + left := s1.sessionPolicies(t) + if len(left) == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("network policies %v outlived session %s", left, s1.session.ID) + } + time.Sleep(2 * time.Second) + } + }) +} diff --git a/docs/test/e2e.md b/docs/test/e2e.md index 86994b0..c78a063 100644 --- a/docs/test/e2e.md +++ b/docs/test/e2e.md @@ -510,7 +510,7 @@ e2e 자동 검증이 곤란해 전용 파일을 두지 않는 시나리오. 등 | `state-api.md#시나리오 4` | **Playwright 이관 미완료.** 매칭 공간 축소로 Go 파일이 매칭 단위에서 빠졌다. 검증 자체는 그 파일에 그대로 있다. | `control-plane/test/e2e_c4_session_switch_test.go` | 이 모델 | `web/e2e/` 최상위 전용 spec 착지 + **같은 PR 에서 `control-plane/test/e2e_c4_session_switch_test.go` 제거**. 두 스위트가 같은 시나리오를 동시에 들고 있는 창을 남기지 않는다. | control-plane HTTP API 만 쓰므로 Playwright `request` 픽스처로 이관 가능하다. 승격 재료가 이미 있다 — `web/e2e/journeys/j3-multi-session-switch.spec.ts` 가 이 시나리오의 화면을 훑는다(규칙 2 를 맞춰 시나리오 단위로 쪼개 최상위로 올린다). | | `state-api.md#시나리오 5` | **Playwright 이관 미완료.** 매칭 공간 축소로 Go 파일이 매칭 단위에서 빠졌다. 검증 자체는 그 파일에 그대로 있다. | `control-plane/test/e2e_state_api_5_wire_validation_test.go` | 이 모델 | `web/e2e/` 최상위 전용 spec 착지 + **같은 PR 에서 `control-plane/test/e2e_state_api_5_wire_validation_test.go` 제거**. 두 스위트가 같은 시나리오를 동시에 들고 있는 창을 남기지 않는다. | control-plane HTTP API 만 쓰므로 Playwright `request` 픽스처로 이관 가능하다. | | `state-api.md#시나리오 6` | **Playwright 이관 미완료.** 매칭 공간 축소로 Go 파일이 매칭 단위에서 빠졌다. 검증 자체는 그 파일에 그대로 있다. | `control-plane/test/e2e_state_api_6_stream_contract_test.go` | 이 모델 | `web/e2e/` 최상위 전용 spec 착지 + **같은 PR 에서 `control-plane/test/e2e_state_api_6_stream_contract_test.go` 제거**. 두 스위트가 같은 시나리오를 동시에 들고 있는 창을 남기지 않는다. | control-plane HTTP API 만 쓰므로 Playwright `request` 픽스처로 이관 가능하다. | -| `approval-gated-workload.md#시나리오 2` | **전용 spec 미저작.** 기능은 착지했고 예외 사유도 없는데 전용 파일이 아직 없다(개정 전 「저작 대기」). 개정 전 예정 스위트는 `go` 였으나 매칭 공간이 Playwright 단독이 되어 **최상위 spec 으로 저작한다**. | 없음 | 하네스(정책 집행 CNI) | **정책 집행 CNI.** kind SUT는 kindnet이라 NetworkPolicy를 집행하지 않는다(`deploy/kind-config.yaml`에 `disableDefaultCNI`가 없다). calico 등을 overlay에 세우거나 실클러스터 잡을 더해야 차단을 관측할 수 있다 — 하네스 소관. | 정책 오브젝트는 실재한다 — `control-plane/internal/adapter/k8s/network_policy.go`가 세션마다 egress·ingress를 만들고, `control-plane/test/approval_gated_network_policy_test.go`(태그 `integration`)가 셀렉터·헬퍼 파드 소유권·타입별 유무·복원 라운드를 fake clientset으로 산다. e2e가 배타적으로 살 것은 **집행**이다: (a)(b)(c) 성공과 (d)(e)(f)(g) 차단, 그리고 (h) 세션 삭제 시 정책 오브젝트 동반 소멸. | +| `approval-gated-workload.md#시나리오 2` | **Playwright 이관 미완료 — kube 관측 의존.** 이 시나리오의 기대 결과는 워크로드 파드 **안에서** 건 연결의 성패라 `remotecommand` exec 로만 관측되고, 브라우저로도 control-plane HTTP API 로도 보이지 않는다. 규칙 4의 **예외 전환 후보**이며, 어느 쪽인지는 이 행을 집는 슬라이스가 판정한다. *2026-11-01: 옛 선행 「정책 집행 CNI」는 거짓 전제였다 — kind v0.24.0 의 kindnet 이 NetworkPolicy 를 집행한다(§「남은 미검증 분기」 (3) 정정) — 그래서 집행을 Go 대체 검증으로 바로 샀다.* | `control-plane/test/e2e_f2_egress_boundary_test.go` | 이 모델 | `web/e2e/` 최상위 전용 spec 착지 **또는** 규칙 4 예외로 전환(대체 검증 수단 = `control-plane/test/e2e_f2_egress_boundary_test.go`). 어느 쪽이든 이 행을 같은 PR 에서 제거한다. 이관을 택하면 관측 경로를 최소 테스트 계측으로 내야 하고, 그것이 제품 동작을 바꾸면 경계를 넘은 것이라 예외로 간다. | `control-plane/test/e2e_f2_egress_boundary_test.go` 가 지금 이 시나리오의 (a)~(h) 를 전부 산다 — (a)(b)(c) 성공, (d)(e)(f)(g) 차단(차단마다 **제한받지 않는 헬퍼 파드에서 같은 목적지로 붙는 대조군**을 먼저 통과시킨다 — 없으면 거부는 「그 주소가 이 클러스터에서 죽어 있다」 이상을 말하지 않는다), (h) 세션 삭제 시 정책 쌍 동반 소멸. 셀렉터·소유권·타입별 유무·복원 라운드는 여전히 `control-plane/test/approval_gated_network_policy_test.go`(태그 `integration`)의 몫이다. 공개 IP 갈래는 러너에 공개 egress 가 없으면(대조군 실패) 그 하위 테스트만 건너뛴다. | | `approval-gated-workload.md#시나리오 2-1` | **전용 spec 미저작.** 기능은 착지했고 예외 사유도 없는데 전용 파일이 아직 없다(개정 전 「저작 대기」). 개정 전 예정 스위트는 `go` 였으나 매칭 공간이 Playwright 단독이 되어 **최상위 spec 으로 저작한다**. | 없음 | 하네스(정책 집행 CNI) | 시나리오 2와 **같은 CNI 선행** — (c) 우회 차단 갈래에만 걸린다. (a)(b)는 지금도 관측 가능하므로 두 시나리오를 한 슬라이스로 묶는 편이 싸다. | 프록시 단일 통로는 실물이다 — `data-plane/cmd/agent/credential_proxy.go`와 대역 `deploy/e2e-anthropic-fake.yaml`(bearer를 검사해 틀리면 401). `control-plane/test/e2e_provider_reachability_test.go`(비-시나리오 등재)가 이미 「헬퍼를 거쳐 도달하고 호출자가 붙인 Authorization이 플랫폼 토큰으로 대체된다」를 산다. 이 파일이 더할 것: **출발 파드의 배타성**(upstream에 닿은 연결이 전부 헬퍼 출발)과 (b) 프록시 중단 시 실행만 실패하고 승인 경로는 계속 도는 것. | | `approval-gated-workload.md#시나리오 4` | **전용 spec 미저작.** 기능은 착지했고 예외 사유도 없는데 전용 파일이 아직 없다(개정 전 「저작 대기」). 개정 전 예정 스위트는 `go` 였으나 매칭 공간이 Playwright 단독이 되어 **최상위 spec 으로 저작한다**. | 없음 | data-plane 구현(타임아웃 주입 훅) | **폴링 타임아웃 주입** — (c) 갈래만. `data-plane/cmd/agent/approval_gateway.go`의 `defaultApprovalTimeout`이 10분 상수이고 `newApprovalGateway`가 `timeout` 필드를 env로 받지 않아, 지금 (c)를 사려면 CI가 10분을 기다려야 한다. 축소값 주입은 사용자 가시 동작을 바꾸지 않는 테스트 훅이다 — data-plane 소관. (a) 거절은 실물 PocketAide 의 사용자 API(`/api/approvals/{id}/reject`)로 **선행 없이** 관측 가능하다. (b) 만료는 2026-10-24 부터 (c)와 **같은 선행**이다 — 게이트웨이가 대역에서 실물 PocketAide 로 바뀌어 결정을 임의로 `EXPIRED` 로 놓을 운영자 라우트가 없고, 클라이언트는 `timeoutSeconds` 를 자기 대기(10분)와 맞춰 보내므로 PocketAide 의 만료도 10분 뒤다. 같은 타임아웃 훅이 두 갈래를 함께 연다. | 세 갈래의 처리 코드는 실재한다 — 같은 파일이 APPROVED/REJECTED/EXPIRED를 정착 결정으로 가르고 자체 `TIMEOUT`을 별도로 발급하며(비-2xx는 결정이 아니라 실패로 구분한다), in-band 통지는 `session_mcp_notices.go`가 만든다. 이 파일이 살 것: 세 경우 모두 **외부 호출 0** · 실패가 in-band 마커로 남음 · 세션이 `active` 유지 · 큐 미폐색 · (d) 후속 write 정상 처리. | | `approval-gated-workload.md#시나리오 6` | **전용 spec 미저작.** 기능은 착지했고 예외 사유도 없는데 전용 파일이 아직 없다(개정 전 「저작 대기」). 개정 전 예정 스위트는 `go` 였으나 매칭 공간이 Playwright 단독이 되어 **최상위 spec 으로 저작한다**. | 없음 | 이 모델 | **없음** (2026-09-12 에 풀렸다). 선행이던 「시나리오 3 의 승인 경로 파일」은 `control-plane/test/e2e_approval_gated_3_approval_path_test.go` 가 세웠고, 공유 볼륨에 파일을 만드는 수단도 실재한다 — `session_mcp_tools.go` 의 `spillBody` 가 `maxInlineBodyBytes`(100 000) 초과 응답을 `bodyPath`/`bodyBytes` 로 볼륨에 떨군다(AC-F5 전반, #101). ~~⚠️ **(c) 복원 갈래만은 아직 못 산다**~~ — *2026-11-01 해소*: 이 타입에 AC-F5 아카이브 전략이 등록됐다(공유 볼륨 내용을 담는 파일시스템 아카이브, 게이트 `APPROVAL_GATED_ARCHIVE_ENABLED` — kind 오버레이에서 on). 이전까지는 `POST /snapshot` 이 503 `checkpoint strategy is disabled`(`ErrCheckpointDisabled`)라 동결 자체가 없었다. 이제 (c) 갈래도 선행 없이 저작할 수 있다 — 동결은 두 파드를 회수하고 복원은 새 헬퍼 파드와 그 라운드의 새 공유 볼륨을 세운 뒤 아카이브의 공유 루트를 그 볼륨에 전개하며, 그 배선은 `e2e_f4_helper_pod_test.go` 의 `TestApprovalGatedHelperPod_FreezeReclaimsBothPodsAndRestoreRewiresAFreshPair` 가 이미 단언한다(파일 내용의 왕복은 아직 이 시나리오 전용 spec 의 몫). | RWX 볼륨은 #98·#100으로 착지했다 — `control-plane/internal/adapter/k8s/shared_volume.go`의 `ReadWriteMany` 클레임, `deploy/shared-volume-provisioner.yaml`(local-path `sharedFileSystemPath`), `deploy/kind-config.yaml`의 전 노드 동일 마운트, `control-plane/test/shared_volume_test.go`·`approval_gated_shared_volume_test.go`. **「RWX가 미구현이라 막혔다」는 더 이상 참이 아니다.** 이 파일이 살 것: (a) 워크로드와 MCP 컨테이너가 같은 파일을 보고 프록시에는 마운트되지 않음 (b) **동결을 건너뛴** 다음 실행도 그 파일을 봄 (c) 복원 후 잔존 (d) `cursorBefore` 델타와 `offset=0` 전체 (e) 다른 세션은 마운트 불가. | @@ -749,7 +749,7 @@ e2e 자동 검증이 곤란해 전용 파일을 두지 않는 시나리오. 등 > 없고, 승인 컨텍스트는 승인 왕복(AC-F3)이 선행이라 진입 자체가 없으며, 뒤의 둘은 **이 SUT에서 > 그 표면이 비어 있다** — one-shot 타입이라 프롬프트 전엔 scrollback이 없고, 요청 로거가 `Debug`인데 > 핸들러가 기본 `Info`라 세션 수명 동안 로그가 한 줄도 나오지 않는다. -> *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* 차단 둘의 「관측 대상 자체가 없다」는 그래서 더 이상 성립하지 않는다. +> *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* 차단 둘의 「관측 대상 자체가 없다」는 그래서 더 이상 성립하지 않는다. 그 둘은 같은 날 `approval-gated-workload.md#시나리오 2` 의 대체 검증 `e2e_f2_egress_boundary_test.go` 가 (e)(f)(g) 로 샀다 — 시나리오 2 가 소유하는 갈래라 f6 에 다시 담으면 규칙 1 의 중복이다. > > 뒤의 둘은 **추측이 아니라 CI가 알려 준 것**이다 — 이 파일의 첫 실행에서 「로그가 비어 있으면 > 침묵은 아무것도 증명하지 않는다」는 **자기 대조군이 발화해 빨개졌다**. 규범이 금지하는 것은 @@ -775,7 +775,7 @@ e2e 자동 검증이 곤란해 전용 파일을 두지 않는 시나리오. 등 > kindnet으로 뜨고, kindnet은 **NetworkPolicy를 집행하지 않는다**. 따라서 정책 오브젝트의 존재·셀렉터· > 동반 삭제까지는 Go e2e로 단언할 수 있어도 "실제로 막히는가"는 정책 집행 CNI가 있는 오버레이나 실 > 클러스터가 선결이다(`approval-gated-workload.md` 시나리오 2의 사전 조건이 같은 말을 한다). -> *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* 따라서 F2 의 「실제로 막히는가」는 이 SUT 에서 관측 가능하고, 남은 것은 그 단언의 저작이다. F4는 +> *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* 따라서 F2 의 「실제로 막히는가」는 이 SUT 에서 관측 가능하고, 같은 날 `control-plane/test/e2e_f2_egress_boundary_test.go` 가 그 단언을 샀다(유예 표의 시나리오 2 행). F4는 > 두 파드의 동반 회수라 `e2e_a3_pod_reclaim_test.go`와 같은 모양으로 자동화 가능하므로, **예외가 아니라 > 전용 파일**이 기본값이고 실클러스터 항목(PID 네임스페이스 분리 등)만 남으면 그때 갈라 등재한다. @@ -808,7 +808,7 @@ e2e 자동 검증이 곤란해 전용 파일을 두지 않는 시나리오. 등 상태에 도달할 방법이 없다(AC-B1의 트리거 정책과 함께 풀린다), (2) `approval-gated` 타입에 스냅샷 전략이 등록돼 있지 않다(AC-F5의 아카이브 전략과 함께 풀린다 — *2026-11-01 풀려 해당 두 행은 표에서 나갔다*), (3) SUT의 CNI가 NetworkPolicy를 **집행하지 않아** 차단 갈래에 관측 대상 자체가 없다(kindnet — `deploy/`에 -`disableDefaultCNI` 0파일), *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* (3) 에 걸린 두 행은 막힌 이유가 사라졌고 저작만 남았다, (4) **표면이 이 SUT에서 비어 있다** — 찾을 텍스트가 없으므로 음성 +`disableDefaultCNI` 0파일), *(2026-11-01 정정 — 이 전제는 거짓이었다: kind v0.24.0 부터 kindnet 이 kube-network-policies 로 NetworkPolicy 를 **집행한다**. CI 실측: #176 의 정적 정책 `control-plane-ingress` 가 호스트 → NodePort 헬스체크를 떨어뜨려 deploy SUT 가 멈췄고, `deploy/` 오버레이에서 그 정책을 지우자 통과했다.)* (3) 에 걸렸던 두 행(워크로드 → 게이트웨이·공급자 직접 연결 차단, 다른 세션 → 헬퍼 ingress 차단)은 `e2e_f2_egress_boundary_test.go` 가 사게 되어 같은 날 표에서 나갔다 — 지금 (3) 에 걸린 행은 없다, (4) **표면이 이 SUT에서 비어 있다** — 찾을 텍스트가 없으므로 음성 단언이 vacuous하다. (4)는 실측으로만 알 수 있고 실제로 CI가 먼저 알려 줬다: AC-F6 파일의 첫 실행이 자기 대조군에 걸려 빨개졌다. (5) **대역이 그 산출물을 만들 수 없다** — 공급자 대역의 응답은 프롬프트와 무관한 상수 한 줄이라 「이어진 대화」를 가리키는 문장도, 다중바이트 경계도 나오지 않는다 @@ -821,8 +821,6 @@ in-flight 위상은 전이와 경주해야 보이고, 운으로 이긴 관측은 | write `idle->active->write` | `e2e_c3_write_branches_test.go` | 〃 | | switch의 idle 대상 승격 | `e2e_c4_session_switch_test.go` | 〃 | | stream의 `idle` 갈래(「active/idle은 기존 pod에서만 stream한다」의 idle 절반) | `e2e_state_api_6_stream_contract_test.go` | 〃 — 위 세 행과 같은 선행이다. active 절반은 소유 파일이 사고, 「상태 승격이 없다」도 active 세션에서 산다(왕복 전후로 상태·pod·`lastAccess`가 불변). idle에서만 갈리는 것은 「승격시키지 않는다」의 대상이 idle이라는 것 하나이고, 그 진입 트리거가 없다 | -| 워크로드 파드 → 게이트웨이 주소·공급자 origin **직접 연결 차단** | `e2e_f6_credential_split_test.go` | **2026-11-01: 막힌 이유가 거짓으로 판명 — kindnet 은 정책을 집행한다(위 (3) 정정). 남은 것은 저작이다.** 옛 사유: SUT의 kindnet이 NetworkPolicy를 집행하지 않아 **거부가 일어나지 않는다** — 여기서 성공하는 연결은 제품 결함이 아니고 실패해도 경계를 산 것이 아니므로 단언이 vacuous하다. 선행은 정책 집행 CNI(`deploy/kind-config.yaml`의 `disableDefaultCNI` + CNI 설치)이고 소관은 **AC-F2**다. 배치 쪽(자격 증명이 어느 컨테이너에 있는가)은 소유 파일이 전부 산다 | -| 다른 세션의 워크로드 파드 → 이 헬퍼 파드 **ingress 차단** | 〃 | 〃 (같은 선행) | | 승인 컨텍스트에 자격 증명·게이트웨이 키가 실리지 않음 | 〃 | **선행은 2026-09-12 에 풀렸다** — 「승인 왕복 자체가 아직 없다」가 이 행의 막힌 이유였는데, `e2e_approval_gated_3_approval_path_test.go` 가 그 왕복을 세워 게이트웨이에 실제 요청 레코드가 생기고, 2026-10-24 부터는 그 레코드의 `context` 를 실물 PocketAide 의 사용자 API(`/api/approvals`)로 승인자가 보는 그대로 읽을 수 있다(하네스 `harness_pocket_aide_test.go`). 남은 것은 **저작**이며 소유 파일은 그대로 f6 이다(승인 컨텍스트는 자격 증명 배치의 조항이지 승인 경로의 조항이 아니다 — 두 파일에 담으면 규칙 1 의 중복이 된다). 토큰이 **조회 응답·목록 응답·`read` 응답·control-plane 로그**에 나타나지 않는 것은 소유 파일이 이미 산다 | | `read`·SSE **출력 본문(scrollback)** 에 토큰 부재 | 〃 | `approval-gated`는 `claude-code`와 같은 one-shot 실행 모델이라 상주 셸이 없다 — 프롬프트를 한 번 돌리기 전까지 scrollback이 **비어 있어** 「토큰이 출력에 없다」가 vacuous하다. 소유 파일이 사는 것은 read **응답(엔벨로프)** 이고, 그 응답이 자기 세션 id를 담는다는 대조군을 먼저 통과시킨다. 선행은 이 타입의 프롬프트 왕복(공급자 대역 + 세션 MCP)을 e2e가 구동하는 것이고 그 구동 기구는 **AC-E2·E3**의 것이다. ⚠️ 같은 반쪽이 `e2e_e6_credential_placement_test.go`의 `NeitherTokenIsEchoedByThePublicAPI`에도 있다(`claude-code`도 one-shot이라 그 `session output` 표면이 빈 문자열이다) — 그쪽은 대조군이 없어 조용히 통과하므로, 다음 감지가 함께 볼 것 | | control-plane **요청 단위 로그**에 토큰 부재 | 〃 | 요청 로거(`withLogging`)가 `Debug`로 적고 핸들러가 기본 `Info`라 **세션 수명 동안 한 줄도 나오지 않는다**(CI 실측 — 이 파일의 첫 실행이 그 대조군에 걸렸다). 소유 파일이 사는 것은 **프로세스 시작부터의 전체 로그**이고, 그 로그가 Secret에서 해소한 값(optional `model`)을 실제로 echo한다는 것을 두 번째 대조군으로 둔다. 요청 단위 로그를 켜는 것은 **제품 결정**(로그 수준 또는 `Info` 요청 로그)이라 이 원장의 산출물(e2e 파일 + 등재 문서) 밖이다 |