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 파일 + 등재 문서) 밖이다 |