From d8b711aa559d6520c0e115307d18338fcc820620 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Tue, 16 Jun 2026 09:16:27 +0800 Subject: [PATCH 1/2] fix(helm): give kb-index Job a dedicated egress NetworkPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #601 kb-index CronJob carried the agent selectorLabels, so it was covered by the agent NetworkPolicy. That policy's anti-SSRF egress (default-deny private ranges via a publicHttps `except` list) blocks the Job from cloning the git host on a private/mesh range, and an extraEgress allow does not reliably override the `except` on all CNIs (observed: the covered pod times out connecting to the git host even with an explicit /32:443 allow rule present). Fix: relabel the index Job pod (app.kubernetes.io/name "-kb-index", component "kb-index") so it is NOT selected by the agent policy, and add a dedicated NetworkPolicy that restricts the Job to exactly DNS + tcp/443 (forgejo private clone + embedding API public) — no `except`, no other ports. The Job runs a fixed trusted pipeline (git clone + kb index), not untrusted tool/LLM calls, so it does not need the agent's full lockdown, but it stays scoped rather than fully unrestricted. Co-Authored-By: Claude Fable 5 --- .../helm/templates/cronjob-kb-index.yaml | 12 +++- .../templates/networkpolicy-kb-index.yaml | 46 ++++++++++++++ coding-agent/tests/deploy/test_helm_chart.py | 62 ++++++++++++++++++- 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 coding-agent/helm/templates/networkpolicy-kb-index.yaml diff --git a/coding-agent/helm/templates/cronjob-kb-index.yaml b/coding-agent/helm/templates/cronjob-kb-index.yaml index 8cd37546e..ff33adf3d 100644 --- a/coding-agent/helm/templates/cronjob-kb-index.yaml +++ b/coding-agent/helm/templates/cronjob-kb-index.yaml @@ -13,7 +13,17 @@ spec: template: metadata: labels: - {{- include "coding-agent.selectorLabels" . | nindent 12 }} + # Deliberately NOT the agent's selectorLabels: this EXCLUDES the + # index Job from the agent NetworkPolicy (whose podSelector IS the + # agent selectorLabels), so the Job can reach the private/mesh git + # host to clone corpora. The agent's default-deny-private egress + # (anti-SSRF) blocks that, and an extraEgress allow does not reliably + # override the publicHttps `except` on all CNIs. The index Job runs a + # fixed, trusted pipeline (git clone + kb index), not untrusted + # tool/LLM calls, so it does not need the agent's egress lockdown. + app.kubernetes.io/name: {{ include "coding-agent.name" . }}-kb-index + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: kb-index spec: serviceAccountName: {{ include "coding-agent.serviceAccountName" . }} automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} diff --git a/coding-agent/helm/templates/networkpolicy-kb-index.yaml b/coding-agent/helm/templates/networkpolicy-kb-index.yaml new file mode 100644 index 000000000..2f4b7e46d --- /dev/null +++ b/coding-agent/helm/templates/networkpolicy-kb-index.yaml @@ -0,0 +1,46 @@ +{{- if and .Values.kb.enabled .Values.kb.index.enabled .Values.networkPolicy.enabled .Values.networkPolicy.egress.enabled }} +# Dedicated egress policy for the KB index Job. The Job is deliberately NOT +# covered by the main agent NetworkPolicy (it carries app.kubernetes.io/name +# "-kb-index", not the agent selectorLabels) because the agent policy's +# default-deny-private egress (anti-SSRF, with a publicHttps `except` list) +# blocks the Job from cloning the git host on a private/mesh range, and an +# extraEgress allow does not reliably override that `except` on all CNIs. +# This policy keeps the Job restricted to exactly what it needs: DNS + tcp/443 +# (forgejo private clone + embedding API public), nothing else. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "coding-agent.fullname" . }}-kb-index + labels: + {{- include "coding-agent.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {{ include "coding-agent.name" . }}-kb-index + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: kb-index + policyTypes: + - Egress + egress: + {{- if .Values.networkPolicy.egress.dns.enabled }} + - to: + - namespaceSelector: + {{- toYaml .Values.networkPolicy.egress.dns.namespaceSelector | nindent 12 }} + podSelector: + {{- toYaml .Values.networkPolicy.egress.dns.podSelector | nindent 12 }} + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- end }} + # HTTPS to anywhere. No `except`: the index Job must reach the in-cluster / + # mesh git host (private range) to clone corpora, as well as the public + # embedding API. Restricted to tcp/443 only. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - port: 443 + protocol: TCP +{{- end }} diff --git a/coding-agent/tests/deploy/test_helm_chart.py b/coding-agent/tests/deploy/test_helm_chart.py index 171e18a66..f7818a16b 100644 --- a/coding-agent/tests/deploy/test_helm_chart.py +++ b/coding-agent/tests/deploy/test_helm_chart.py @@ -610,7 +610,7 @@ def test_helm_kb_index_disabled_does_not_render_cronjob() -> None: assert not _objects_of_kind(docs, "CronJob") -def test_helm_kb_index_cronjob_renders_pvc_env_and_netpol_labels() -> None: +def test_helm_kb_index_cronjob_excluded_from_netpol() -> None: docs = _render( "--set", "kb.enabled=true", @@ -628,10 +628,19 @@ def test_helm_kb_index_cronjob_renders_pvc_env_and_netpol_labels() -> None: container = pod_spec["containers"][0] assert job["spec"]["schedule"] == "0 * * * *" - assert job["spec"]["jobTemplate"]["spec"]["template"]["metadata"]["labels"] == { - "app.kubernetes.io/name": "coding-agent", + # The index Job must be EXCLUDED from the agent NetworkPolicy so it can reach + # the private/mesh git host. Its pod labels must therefore NOT satisfy the + # netpol podSelector (which is the agent selectorLabels). + job_labels = job["spec"]["jobTemplate"]["spec"]["template"]["metadata"]["labels"] + assert job_labels == { + "app.kubernetes.io/name": "coding-agent-kb-index", "app.kubernetes.io/instance": "coding-agent", + "app.kubernetes.io/component": "kb-index", } + netpol_selector = _network_policy(docs)["spec"]["podSelector"]["matchLabels"] + assert not (netpol_selector.items() <= job_labels.items()), ( + "kb-index Job must not match the agent NetworkPolicy podSelector" + ) assert pod_spec["securityContext"]["fsGroup"] == 10001 assert container["securityContext"] == { "runAsGroup": 10001, @@ -683,6 +692,53 @@ def test_helm_kb_index_cronjob_renders_pvc_env_and_netpol_labels() -> None: assert "--corpus \"sre\"" in script +def test_helm_kb_index_dedicated_netpol_allows_dns_and_https_only() -> None: + docs = _render( + "--set", + "kb.enabled=true", + "--set", + "kb.index.enabled=true", + "--set-json", + 'kb.index.repos=[{"name":"sre","corpus":"sre","url":"https://x/sre.git"}]', + ) + netpol = _object_named(docs, "NetworkPolicy", "-kb-index") + assert netpol["spec"]["policyTypes"] == ["Egress"] + + # The dedicated policy must select exactly the kb-index Job pods. + sel = netpol["spec"]["podSelector"]["matchLabels"] + job = _cronjob(docs, "-kb-index") + job_labels = job["spec"]["jobTemplate"]["spec"]["template"]["metadata"]["labels"] + assert sel.items() <= job_labels.items() + assert sel["app.kubernetes.io/component"] == "kb-index" + + egress = netpol["spec"]["egress"] + # DNS rule present. + assert any( + any(p.get("port") == 53 for p in rule.get("ports", [])) for rule in egress + ) + # HTTPS-to-anywhere rule: 443 to 0.0.0.0/0 with NO `except` (so it can reach + # the private/mesh git host), and no other ports opened. + https = [ + rule + for rule in egress + if any(p.get("port") == 443 for p in rule.get("ports", [])) + ] + assert len(https) == 1 + block = https[0]["to"][0]["ipBlock"] + assert block["cidr"] == "0.0.0.0/0" + assert "except" not in block + opened_ports = {p.get("port") for rule in egress for p in rule.get("ports", [])} + assert opened_ports == {53, 443} + + +def test_helm_kb_index_dedicated_netpol_absent_when_index_disabled() -> None: + docs = _render("--set", "kb.enabled=true", "--set", "kb.index.enabled=false") + assert not any( + doc.get("metadata", {}).get("name", "").endswith("-kb-index") + for doc in _objects_of_kind(docs, "NetworkPolicy") + ) + + def test_helm_chart_ignores_legacy_sandbox_sidecar_values() -> None: docs = _render("--set", "sandbox.sidecar.enabled=true") assert all( From 77296179cc838578067310146b336801b4a45310 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Tue, 16 Jun 2026 09:38:45 +0800 Subject: [PATCH 2/2] fix(helm): run kb-index Job unrestricted (drop unworkable dedicated netpol) Co-Authored-By: Claude Fable 5 --- .../helm/templates/cronjob-kb-index.yaml | 10 ++-- .../templates/networkpolicy-kb-index.yaml | 46 ------------------ coding-agent/tests/deploy/test_helm_chart.py | 47 ------------------- 3 files changed, 4 insertions(+), 99 deletions(-) delete mode 100644 coding-agent/helm/templates/networkpolicy-kb-index.yaml diff --git a/coding-agent/helm/templates/cronjob-kb-index.yaml b/coding-agent/helm/templates/cronjob-kb-index.yaml index ff33adf3d..04b078ad3 100644 --- a/coding-agent/helm/templates/cronjob-kb-index.yaml +++ b/coding-agent/helm/templates/cronjob-kb-index.yaml @@ -15,12 +15,10 @@ spec: labels: # Deliberately NOT the agent's selectorLabels: this EXCLUDES the # index Job from the agent NetworkPolicy (whose podSelector IS the - # agent selectorLabels), so the Job can reach the private/mesh git - # host to clone corpora. The agent's default-deny-private egress - # (anti-SSRF) blocks that, and an extraEgress allow does not reliably - # override the publicHttps `except` on all CNIs. The index Job runs a - # fixed, trusted pipeline (git clone + kb index), not untrusted - # tool/LLM calls, so it does not need the agent's egress lockdown. + # agent selectorLabels). Like the github->forgejo sync job, it runs + # with unrestricted egress because it executes a fixed, trusted + # pipeline (git clone + kb index), not untrusted tool/LLM calls, and + # the CNI cannot honor an ipBlock allow to the in-cluster git host. app.kubernetes.io/name: {{ include "coding-agent.name" . }}-kb-index app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/component: kb-index diff --git a/coding-agent/helm/templates/networkpolicy-kb-index.yaml b/coding-agent/helm/templates/networkpolicy-kb-index.yaml deleted file mode 100644 index 2f4b7e46d..000000000 --- a/coding-agent/helm/templates/networkpolicy-kb-index.yaml +++ /dev/null @@ -1,46 +0,0 @@ -{{- if and .Values.kb.enabled .Values.kb.index.enabled .Values.networkPolicy.enabled .Values.networkPolicy.egress.enabled }} -# Dedicated egress policy for the KB index Job. The Job is deliberately NOT -# covered by the main agent NetworkPolicy (it carries app.kubernetes.io/name -# "-kb-index", not the agent selectorLabels) because the agent policy's -# default-deny-private egress (anti-SSRF, with a publicHttps `except` list) -# blocks the Job from cloning the git host on a private/mesh range, and an -# extraEgress allow does not reliably override that `except` on all CNIs. -# This policy keeps the Job restricted to exactly what it needs: DNS + tcp/443 -# (forgejo private clone + embedding API public), nothing else. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: {{ include "coding-agent.fullname" . }}-kb-index - labels: - {{- include "coding-agent.labels" . | nindent 4 }} -spec: - podSelector: - matchLabels: - app.kubernetes.io/name: {{ include "coding-agent.name" . }}-kb-index - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/component: kb-index - policyTypes: - - Egress - egress: - {{- if .Values.networkPolicy.egress.dns.enabled }} - - to: - - namespaceSelector: - {{- toYaml .Values.networkPolicy.egress.dns.namespaceSelector | nindent 12 }} - podSelector: - {{- toYaml .Values.networkPolicy.egress.dns.podSelector | nindent 12 }} - ports: - - port: 53 - protocol: UDP - - port: 53 - protocol: TCP - {{- end }} - # HTTPS to anywhere. No `except`: the index Job must reach the in-cluster / - # mesh git host (private range) to clone corpora, as well as the public - # embedding API. Restricted to tcp/443 only. - - to: - - ipBlock: - cidr: 0.0.0.0/0 - ports: - - port: 443 - protocol: TCP -{{- end }} diff --git a/coding-agent/tests/deploy/test_helm_chart.py b/coding-agent/tests/deploy/test_helm_chart.py index f7818a16b..e11257c59 100644 --- a/coding-agent/tests/deploy/test_helm_chart.py +++ b/coding-agent/tests/deploy/test_helm_chart.py @@ -692,53 +692,6 @@ def test_helm_kb_index_cronjob_excluded_from_netpol() -> None: assert "--corpus \"sre\"" in script -def test_helm_kb_index_dedicated_netpol_allows_dns_and_https_only() -> None: - docs = _render( - "--set", - "kb.enabled=true", - "--set", - "kb.index.enabled=true", - "--set-json", - 'kb.index.repos=[{"name":"sre","corpus":"sre","url":"https://x/sre.git"}]', - ) - netpol = _object_named(docs, "NetworkPolicy", "-kb-index") - assert netpol["spec"]["policyTypes"] == ["Egress"] - - # The dedicated policy must select exactly the kb-index Job pods. - sel = netpol["spec"]["podSelector"]["matchLabels"] - job = _cronjob(docs, "-kb-index") - job_labels = job["spec"]["jobTemplate"]["spec"]["template"]["metadata"]["labels"] - assert sel.items() <= job_labels.items() - assert sel["app.kubernetes.io/component"] == "kb-index" - - egress = netpol["spec"]["egress"] - # DNS rule present. - assert any( - any(p.get("port") == 53 for p in rule.get("ports", [])) for rule in egress - ) - # HTTPS-to-anywhere rule: 443 to 0.0.0.0/0 with NO `except` (so it can reach - # the private/mesh git host), and no other ports opened. - https = [ - rule - for rule in egress - if any(p.get("port") == 443 for p in rule.get("ports", [])) - ] - assert len(https) == 1 - block = https[0]["to"][0]["ipBlock"] - assert block["cidr"] == "0.0.0.0/0" - assert "except" not in block - opened_ports = {p.get("port") for rule in egress for p in rule.get("ports", [])} - assert opened_ports == {53, 443} - - -def test_helm_kb_index_dedicated_netpol_absent_when_index_disabled() -> None: - docs = _render("--set", "kb.enabled=true", "--set", "kb.index.enabled=false") - assert not any( - doc.get("metadata", {}).get("name", "").endswith("-kb-index") - for doc in _objects_of_kind(docs, "NetworkPolicy") - ) - - def test_helm_chart_ignores_legacy_sandbox_sidecar_values() -> None: docs = _render("--set", "sandbox.sidecar.enabled=true") assert all(