From 039f5e975d264338f49ceb5ca097560b7955561d Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Wed, 30 Sep 2026 10:17:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(api):=20Harness.spec.substrate.egress?= =?UTF-8?q?=20=E2=80=94=20hosts=20every=20agent=20on=20the=20Harness=20may?= =?UTF-8?q?=20reach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An actor's egress allowlist is exactly what its revision compiles, so no Harness can let its agents reach a package registry or a source host that no skill, plugin or MCP server names. The Harness now declares extra hosts, hostnames or leftmost-label wildcards, which the compiler appends to every runtime's destinations. Signed-off-by: QuentinBisson Signed-off-by: Jose Armesto --- .../configuration-and-compilation.md | 7 ++ .../crd/bases/api.kagent.dev_agents.yaml | 17 +++- .../crd/bases/api.kagent.dev_harnesses.yaml | 17 +++- go/api/v1alpha3/configuration_crd_cel_test.go | 23 +++++ go/api/v1alpha3/harness_types.go | 18 +++- go/api/v1alpha3/zz_generated.deepcopy.go | 23 ++++- .../internal/controller/collections_test.go | 18 ++-- .../internal/controller/preparation_test.go | 4 +- .../grpcserver/agenttemplate_harness_test.go | 4 +- go/core/internal/substrate/egress_test.go | 12 +++ go/core/internal/substrate/policy.go | 5 +- .../internal/translator/byo/compiler_test.go | 8 +- .../translator/claude/compiler_test.go | 2 +- .../translator/codex/compiler_test.go | 2 +- go/core/internal/translator/compiler.go | 20 +++++ go/core/internal/translator/compiler_test.go | 84 +++++++++++++++---- go/core/internal/translator/foundry_test.go | 4 +- go/core/test/e2e/sandbox_agent_test.go | 2 +- .../templates/api.kagent.dev_agents.yaml | 17 +++- .../templates/api.kagent.dev_harnesses.yaml | 17 +++- 20 files changed, 255 insertions(+), 49 deletions(-) diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index 406e4764da..0a36ca0f31 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -224,6 +224,13 @@ Harness runs. A summarizer `ModelConfig` other than the agent's own is resolved from the Harness namespace like the memory model and joins the revision's credentials, egress, and provenance. +`spec.substrate.egress` on the Harness lists hosts that every agent it runs may +reach besides the destinations its revision compiles: a hostname, or a +leftmost-label wildcard such as `*.githubusercontent.com`. No entry allows every +host. The compiler adds them after the runtime compiler has run, so every runtime +treats them the same, and they are part of the revision identity. SandboxTemplates +do not take this field. + ## Explicit Agent examples For a single agent, inline both specs: diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index d4bc7ef875..78b5e2679f 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -226,9 +226,22 @@ spec: rule: self.modelConfigRef.name.size() > 0 type: object substrate: - description: RuntimeSubstratePolicy contains the Substrate policy - shared by all runtime variants. + description: HarnessSubstratePolicy is the Substrate policy of + every agent that runs on the Harness. properties: + egress: + description: |- + Egress lists hosts every agent that runs on the Harness may reach besides + the destinations its revision compiles (the model, its MCP servers, its + skill and plugin sources, telemetry): a hostname, or a leftmost-label + wildcard such as "*.githubusercontent.com". Nothing allows every host. + items: + maxLength: 253 + pattern: ^(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set snapshotPolicy: description: SnapshotPolicy configures runtime snapshot storage. properties: diff --git a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml index 8d69d90664..2655e3271e 100644 --- a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml @@ -218,9 +218,22 @@ spec: rule: self.modelConfigRef.name.size() > 0 type: object substrate: - description: RuntimeSubstratePolicy contains the Substrate policy - shared by all runtime variants. + description: HarnessSubstratePolicy is the Substrate policy of every + agent that runs on the Harness. properties: + egress: + description: |- + Egress lists hosts every agent that runs on the Harness may reach besides + the destinations its revision compiles (the model, its MCP servers, its + skill and plugin sources, telemetry): a hostname, or a leftmost-label + wildcard such as "*.githubusercontent.com". Nothing allows every host. + items: + maxLength: 253 + pattern: ^(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set snapshotPolicy: description: SnapshotPolicy configures runtime snapshot storage. properties: diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index f28a7d037a..5dbad8acc5 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -95,6 +95,29 @@ func TestConfigurationCRDValidation(t *testing.T) { }), wantReject: "spec.workload.image", }, + { + name: "Harness accepts egress hostnames and leftmost-label wildcards", + object: validHarness(namespace, "harness-egress", HarnessSpec{ + Kagent: &KagentHarness{}, + Substrate: HarnessSubstratePolicy{Egress: []string{"proxy.golang.org", "*.githubusercontent.com"}}, + }), + }, + { + name: "Harness egress rejects a bare wildcard", + object: validHarness(namespace, "harness-egress-any", HarnessSpec{ + Kagent: &KagentHarness{}, + Substrate: HarnessSubstratePolicy{Egress: []string{"*"}}, + }), + wantReject: "spec.substrate.egress[0]", + }, + { + name: "Harness egress rejects an inner wildcard", + object: validHarness(namespace, "harness-egress-inner", HarnessSpec{ + Kagent: &KagentHarness{}, + Substrate: HarnessSubstratePolicy{Egress: []string{"a.*.example.com"}}, + }), + wantReject: "spec.substrate.egress[0]", + }, { name: "Harness env requires a value source", object: validHarness(namespace, "harness-empty-env", HarnessSpec{ diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index 3408165968..a04b3a2605 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -155,7 +155,23 @@ type HarnessSpec struct { Env []RuntimeEnvVar `json:"env,omitempty"` // +required - Substrate RuntimeSubstratePolicy `json:"substrate"` + Substrate HarnessSubstratePolicy `json:"substrate"` +} + +// HarnessSubstratePolicy is the Substrate policy of every agent that runs on the Harness. +type HarnessSubstratePolicy struct { + RuntimeSubstratePolicy `json:",inline"` + + // Egress lists hosts every agent that runs on the Harness may reach besides + // the destinations its revision compiles (the model, its MCP servers, its + // skill and plugin sources, telemetry): a hostname, or a leftmost-label + // wildcard such as "*.githubusercontent.com". Nothing allows every host. + // +optional + // +listType=set + // +kubebuilder:validation:MaxItems=256 + // +kubebuilder:validation:items:MaxLength=253 + // +kubebuilder:validation:items:Pattern=`^(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$` + Egress []string `json:"egress,omitempty"` } // HarnessCapabilities records behavior proven for a pinned adapter and runtime. diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 68c8f5b7f3..53983c5817 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -756,7 +756,7 @@ func (in *HarnessSpec) DeepCopyInto(out *HarnessSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - out.Substrate = in.Substrate + in.Substrate.DeepCopyInto(&out.Substrate) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessSpec. @@ -796,6 +796,27 @@ func (in *HarnessStatus) DeepCopy() *HarnessStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HarnessSubstratePolicy) DeepCopyInto(out *HarnessSubstratePolicy) { + *out = *in + out.RuntimeSubstratePolicy = in.RuntimeSubstratePolicy + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessSubstratePolicy. +func (in *HarnessSubstratePolicy) DeepCopy() *HarnessSubstratePolicy { + if in == nil { + return nil + } + out := new(HarnessSubstratePolicy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HarnessWorkload) DeepCopyInto(out *HarnessWorkload) { *out = *in diff --git a/go/core/internal/controller/collections_test.go b/go/core/internal/controller/collections_test.go index 0a3333fb09..bcfc0c7a93 100644 --- a/go/core/internal/controller/collections_test.go +++ b/go/core/internal/controller/collections_test.go @@ -33,10 +33,10 @@ func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { matchingHarness.UID = "harness-uid" matchingHarness.Spec.Kagent = &kagentv1alpha3.KagentHarness{} matchingHarness.Spec.Workload.Image = "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - matchingHarness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{ + matchingHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - } + }} modelConfigs := krt.NewStaticCollection(nil, []*kagentv1alpha3.ModelConfig{{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model"}, Spec: kagentv1alpha3.ModelConfigSpec{Provider: kagentv1alpha3.ModelProviderOpenAI, Model: "gpt-5"}}}, opts.WithName("ModelConfigs")...) templates := krt.NewStaticCollection[*kagentv1alpha3.AgentTemplate](nil, nil, opts.WithName("AgentTemplates")...) mock := krttest.NewMock(t, []any{ @@ -135,9 +135,9 @@ func TestReconciliationWorkerPoolSandboxClass(t *testing.T) { runtimeHarness := harness("team-a", string(harnessType), nil) runtimeHarness.UID = "harness-uid" runtimeHarness.Spec.Workload.Image = "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - runtimeHarness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{ + runtimeHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "selected"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - } + }} responses := kagentv1alpha3.OpenAIAPIFormatResponses model := &kagentv1alpha3.ModelConfig{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model", UID: "model-uid"}, @@ -285,9 +285,9 @@ func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { claudeHarness.UID = "harness-uid" claudeHarness.Spec.Claude = &kagentv1alpha3.ClaudeHarness{} claudeHarness.Spec.Workload.Image = "example.com/claude@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - claudeHarness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{ + claudeHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - } + }} model := &kagentv1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model", UID: "model-uid"}, Spec: kagentv1alpha3.ModelConfigSpec{ Provider: kagentv1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", }} @@ -340,9 +340,9 @@ func TestCodexReconciliationCompilesActorTemplate(t *testing.T) { codexHarness.UID = "harness-uid" codexHarness.Spec.Codex = &kagentv1alpha3.CodexHarness{} codexHarness.Spec.Workload.Image = "example.com/codex@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - codexHarness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{ + codexHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - } + }} model := &kagentv1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model", UID: "model-uid"}, Spec: kagentv1alpha3.ModelConfigSpec{ Provider: kagentv1alpha3.ModelProviderOpenAI, Model: "gpt-5.2-codex", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &kagentv1alpha3.OpenAIConfig{APIFormat: &responses}, @@ -403,7 +403,7 @@ func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { harness := harness("team-a", "kagent", map[string]string{"runtime": "python"}) harness.Spec.Kagent = &kagentv1alpha3.KagentHarness{} harness.Spec.Workload.Image = "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - harness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}} + harness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}} templates := krt.NewStaticCollection(nil, []*kagentv1alpha3.AgentTemplate{root, child}, opts.WithName("AgentTemplates")...) mock := krttest.NewMock(t, []any{ harness, diff --git a/go/core/internal/controller/preparation_test.go b/go/core/internal/controller/preparation_test.go index 8b5ddd6b2e..d5e7ee03b5 100644 --- a/go/core/internal/controller/preparation_test.go +++ b/go/core/internal/controller/preparation_test.go @@ -537,9 +537,9 @@ func newPreparationTestCollections(t *testing.T, workerPool string) (Collections runtimeHarness.Spec.BYO = &kagentv1alpha3.BYOHarness{} runtimeHarness.Spec.Workload.Image = "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" runtimeHarness.Spec.Workload.Command = []string{"/agent"} - runtimeHarness.Spec.Substrate = kagentv1alpha3.RuntimeSubstratePolicy{ + runtimeHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: kagentv1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: workerPool}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - } + }} harnesses := krt.NewStaticCollection(nil, []*kagentv1alpha3.Harness{runtimeHarness}, opts.WithName("Harnesses")...) mock := krttest.NewMock(t, []any{ template, diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index bedf29247c..98ddeef28e 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -89,10 +89,10 @@ func testHarness(namespace, name, workerPool string) *v1alpha3.Harness { Spec: v1alpha3.HarnessSpec{ Codex: &v1alpha3.CodexHarness{}, Workload: v1alpha3.HarnessWorkload{Image: testHarnessImage}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: workerPool}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "s3://snapshots"}, - }, + }}, }, } } diff --git a/go/core/internal/substrate/egress_test.go b/go/core/internal/substrate/egress_test.go index d4a0741f52..f03864a628 100644 --- a/go/core/internal/substrate/egress_test.go +++ b/go/core/internal/substrate/egress_test.go @@ -58,3 +58,15 @@ func (f *egressPolicyFake) GetActorEgressPolicy(_ context.Context, req *ateapipb f.actor = req.Actor return f.policy, f.getErr } + +func TestActorEgressPolicyAcceptsLeftmostLabelWildcards(t *testing.T) { + policy, err := ActorEgressPolicy("team-a", []string{"*.githubusercontent.com", "GitHub.com.", "192.0.2.1"}, nil) + require.NoError(t, err) + require.Len(t, policy.Rules, 2) + require.Equal(t, []string{"*.githubusercontent.com", "github.com"}, policy.Rules[0].GetHostnames().GetPatterns()) + require.Equal(t, []string{"192.0.2.1/32"}, policy.Rules[1].GetCidrs().GetCidrs()) + for _, invalid := range []string{"*", "*.", "*.*.example.com", "a.*.example.com", "*github.com", "-bad.example.com"} { + _, err := ActorEgressPolicy("team-a", []string{invalid}, nil) + require.Error(t, err, invalid) + } +} diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index caeb4d24e1..edd584419c 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -22,7 +22,10 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr continue } hostname := strings.TrimSuffix(strings.ToLower(destination), ".") - if len(validation.IsDNS1123Subdomain(hostname)) != 0 { + // A leftmost-label wildcard names every host under a domain, which is + // what Substrate's hostname rule matches; the domain itself must be a name. + name, _ := strings.CutPrefix(hostname, "*.") + if len(validation.IsDNS1123Subdomain(name)) != 0 { return nil, fmt.Errorf("invalid egress destination %q", destination) } hostnames = append(hostnames, hostname) diff --git a/go/core/internal/translator/byo/compiler_test.go b/go/core/internal/translator/byo/compiler_test.go index ed4ea45bc0..a084275e3e 100644 --- a/go/core/internal/translator/byo/compiler_test.go +++ b/go/core/internal/translator/byo/compiler_test.go @@ -19,9 +19,9 @@ func TestCompileOpaqueImage(t *testing.T) { BYO: &v1alpha3.BYOHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Command: []string{"/agent"}, Args: []string{"serve"}}, Env: []v1alpha3.RuntimeEnvVar{{Name: "MODE", Value: new("production")}}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }} template := &v2translator.TemplateConfiguration{Name: "custom-agent", Namespace: "test", Source: &metav1.ObjectMeta{Name: "custom-agent", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ Description: "custom A2A agent", SystemPrompt: "be helpful", @@ -58,9 +58,9 @@ func TestCompileOpaqueImageKeepsItsOwnTelemetry(t *testing.T) { {Name: "OTEL_SERVICE_NAME", Value: new("my-langgraph")}, {Name: "OTEL_EXPORTER_OTLP_ENDPOINT", Value: new("https://otlp.example.com")}, }, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }} template := &v2translator.TemplateConfiguration{Name: "custom-agent", Namespace: "test", Source: &metav1.ObjectMeta{Name: "custom-agent", Namespace: "test"}} diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index 8a46f3af4f..910d16cf68 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -612,7 +612,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ t.Helper() harness := &v2translator.HarnessConfiguration{Name: "claude", Namespace: "test", Source: &metav1.ObjectMeta{Name: "claude", Namespace: "test", UID: "harness-uid"}, Spec: v1alpha3.HarnessSpec{ Claude: &v1alpha3.ClaudeHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/claude@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}}, }} template := &v2translator.TemplateConfiguration{Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ ModelConfig: &corev1.LocalObjectReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", diff --git a/go/core/internal/translator/codex/compiler_test.go b/go/core/internal/translator/codex/compiler_test.go index 85d2e0804f..496f945089 100644 --- a/go/core/internal/translator/codex/compiler_test.go +++ b/go/core/internal/translator/codex/compiler_test.go @@ -304,7 +304,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ t.Helper() harness := &v2translator.HarnessConfiguration{Name: "codex", Namespace: "test", Source: &metav1.ObjectMeta{Name: "codex", Namespace: "test", UID: "harness"}, Spec: v1alpha3.HarnessSpec{ Codex: &v1alpha3.CodexHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/codex@sha256:" + strings.Repeat("a", 64)}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}}, }} template := &v2translator.TemplateConfiguration{Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "model"}, Description: "assistant"}} model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model"}, Spec: modelSpec} diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index 1de41f55da..599732936a 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "maps" + "slices" + "strings" "github.com/kagent-dev/kagent/go/api/v1alpha3" "istio.io/istio/pkg/kube/krt" @@ -165,9 +167,27 @@ func (c *Compiler) compileConfiguration(ctx context.Context, agentName string, h } result.AgentName = agentName result.SandboxClass = (*workerPool).Spec.SandboxClass + result.EgressDestinations = withHarnessEgress(result.EgressDestinations, harness.Spec.Substrate.Egress) return result, nil } +// withHarnessEgress adds the Harness's declared egress hosts to the +// destinations a runtime compiled for itself. Every runtime gets the same +// treatment, so no runtime compiler reads the Harness policy. +func withHarnessEgress(compiled, declared []string) []string { + if len(declared) == 0 { + return compiled + } + destinations := slices.Clone(compiled) + for _, host := range declared { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if host != "" && !slices.Contains(destinations, host) { + destinations = append(destinations, host) + } + } + return destinations +} + func harnessType(harness *HarnessConfiguration) HarnessType { switch { case harness.Spec.Kagent != nil: diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 5405f0c1ea..e4222d5f46 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -56,9 +56,9 @@ func TestCompileAgentPreservesWorkloadOverrides(t *testing.T) { Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Command: slices.Clone(tt.command), Args: slices.Clone(tt.args), }, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ @@ -121,9 +121,9 @@ func TestCompileAgentPinsAgentPluginSources(t *testing.T) { Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Command: []string{"kagent-adk", "static"}, Args: []string{"--host", "0.0.0.0"}, }, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ @@ -237,9 +237,9 @@ func TestCompileAgentResolvesWorkerPoolSandboxClass(t *testing.T) { Spec: v1alpha3.HarnessSpec{ Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "selected"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ @@ -342,7 +342,7 @@ func TestCompileAgentStructuredOutput(t *testing.T) { Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: corev1.LocalObjectReference{Name: "default-model"}, TTLDays: 7}, Compaction: &v1alpha3.KagentHarnessCompaction{CompactionInterval: new(5)}, }, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}}, }, } schema := `{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"],"additionalProperties":false}` @@ -452,7 +452,7 @@ func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "codex", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ Codex: &v1alpha3.CodexHarness{}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}}, }, } template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}}} @@ -511,7 +511,7 @@ func TestCompilerPermitsBYOWithoutModelConfig(t *testing.T) { adapter := &testHarnessCompiler{} harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ BYO: &v1alpha3.BYOHarness{}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}}, }} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}} @@ -551,10 +551,10 @@ func TestCompileAgentInjectsCredentialsAtGateway(t *testing.T) { Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ @@ -630,10 +630,10 @@ func TestCompileAgentForwardsOtelEnvironment(t *testing.T) { Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ @@ -678,7 +678,7 @@ func TestCompileAgentSharedADKConfig(t *testing.T) { Spec: v1alpha3.HarnessSpec{ Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}}, }, } child := &v1alpha3.AgentTemplate{ @@ -827,7 +827,7 @@ func TestCompileAgentInlineAndReferencedConfiguration(t *testing.T) { child := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "child"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "review security"}} harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "runtime", UID: "harness-uid"}, Spec: v1alpha3.HarnessSpec{ Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}}, }} for _, tt := range []struct { name string @@ -908,7 +908,7 @@ func TestCompileAgentRuntimeIdentity(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "shared-runtime"}, Spec: v1alpha3.HarnessSpec{ Workload: v1alpha3.HarnessWorkload{Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}}, }, } switch kind { @@ -972,3 +972,55 @@ func inlineAgent(harness *v1alpha3.Harness, template *v1alpha3.AgentTemplate) *v return &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "runnable-agent", Namespace: harness.Namespace}, Spec: v1alpha3.AgentSpec{Template: &template.Spec, Harness: &harness.Spec}} } + +func TestCompileAgentAddsTheHarnessEgress(t *testing.T) { + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.HarnessSubstratePolicy{ + RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + Egress: []string{"github.com", "*.githubusercontent.com", "Proxy.Golang.org.", "github.com"}, + }, + }, + } + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "agent", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + } + result, err := compiler(t, modelConfig()).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + for _, host := range []string{"github.com", "*.githubusercontent.com", "proxy.golang.org"} { + require.Contains(t, result.EgressDestinations, host) + } + require.Equal(t, 1, countOf(result.EgressDestinations, "github.com"), "a host is listed once") + withEgress, err := result.Digest() + require.NoError(t, err) + + harness.Spec.Substrate.Egress = nil + plain, err := compiler(t, modelConfig()).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + require.NotContains(t, plain.EgressDestinations, "github.com") + withoutEgress, err := plain.Digest() + require.NoError(t, err) + require.NotEqual(t, withoutEgress, withEgress, "the Harness egress is part of the revision identity") + + policy, err := substrate.ActorEgressPolicy("test", result.EgressDestinations, result.Credentials) + require.NoError(t, err) + var patterns []string + for _, rule := range policy.Rules { + patterns = append(patterns, rule.GetHostnames().GetPatterns()...) + } + require.Subset(t, patterns, []string{"github.com", "*.githubusercontent.com", "proxy.golang.org"}) +} + +func countOf(values []string, want string) int { + n := 0 + for _, value := range values { + if value == want { + n++ + } + } + return n +} diff --git a/go/core/internal/translator/foundry_test.go b/go/core/internal/translator/foundry_test.go index 7aac4a5200..e706fddb9d 100644 --- a/go/core/internal/translator/foundry_test.go +++ b/go/core/internal/translator/foundry_test.go @@ -31,9 +31,9 @@ func TestCompileFoundryEndpoint(t *testing.T) { Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent:latest"}, - Substrate: v1alpha3.RuntimeSubstratePolicy{ + Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: v1alpha3.RuntimeSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, - }, + }}, }, } template := &v1alpha3.AgentTemplate{ diff --git a/go/core/test/e2e/sandbox_agent_test.go b/go/core/test/e2e/sandbox_agent_test.go index 49bdb118b4..9667bbe84c 100644 --- a/go/core/test/e2e/sandbox_agent_test.go +++ b/go/core/test/e2e/sandbox_agent_test.go @@ -35,7 +35,7 @@ func TestSandboxAgentMCP(t *testing.T) { require.NoError(t, kube.Get(f.ctx, ctrlclient.ObjectKey{Namespace: f.template.Namespace, Name: f.template.Name}, source)) harness := &v1alpha3.Harness{ ObjectMeta: metav1.ObjectMeta{GenerateName: "scratch-agent-", Namespace: f.template.Namespace}, - Spec: v1alpha3.HarnessSpec{Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: image}, Substrate: source.Spec.Substrate, + Spec: v1alpha3.HarnessSpec{Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: image}, Substrate: v1alpha3.HarnessSubstratePolicy{RuntimeSubstratePolicy: source.Spec.Substrate}, Env: []v1alpha3.RuntimeEnvVar{{Name: "KAGENT_PROPAGATE_TOKEN", Value: new("true")}}}, } require.NoError(t, kube.Create(f.ctx, harness)) diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index d4bc7ef875..78b5e2679f 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -226,9 +226,22 @@ spec: rule: self.modelConfigRef.name.size() > 0 type: object substrate: - description: RuntimeSubstratePolicy contains the Substrate policy - shared by all runtime variants. + description: HarnessSubstratePolicy is the Substrate policy of + every agent that runs on the Harness. properties: + egress: + description: |- + Egress lists hosts every agent that runs on the Harness may reach besides + the destinations its revision compiles (the model, its MCP servers, its + skill and plugin sources, telemetry): a hostname, or a leftmost-label + wildcard such as "*.githubusercontent.com". Nothing allows every host. + items: + maxLength: 253 + pattern: ^(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set snapshotPolicy: description: SnapshotPolicy configures runtime snapshot storage. properties: diff --git a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml index 8d69d90664..2655e3271e 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml @@ -218,9 +218,22 @@ spec: rule: self.modelConfigRef.name.size() > 0 type: object substrate: - description: RuntimeSubstratePolicy contains the Substrate policy - shared by all runtime variants. + description: HarnessSubstratePolicy is the Substrate policy of every + agent that runs on the Harness. properties: + egress: + description: |- + Egress lists hosts every agent that runs on the Harness may reach besides + the destinations its revision compiles (the model, its MCP servers, its + skill and plugin sources, telemetry): a hostname, or a leftmost-label + wildcard such as "*.githubusercontent.com". Nothing allows every host. + items: + maxLength: 253 + pattern: ^(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set snapshotPolicy: description: SnapshotPolicy configures runtime snapshot storage. properties: From 26b145404396f0188674b8bd39f2ae061cd0df76 Mon Sep 17 00:00:00 2001 From: Jose Armesto Date: Thu, 1 Oct 2026 18:09:01 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(api):=20Agent.spec.egress=20=E2=80=94?= =?UTF-8?q?=20HTTP(S)=20origins=20an=20Agent=20may=20reach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosts an agent needs are a property of the workload where it runs (a module proxy, an internal git host, a registry mirror), not of the runtime and not of the portable template. The Agent now declares extra HTTP(S) origins, with a leftmost-label wildcard allowed, and the compiler adds them in canonical form to the revision destinations of every runtime. The actor egress policy accepts the wildcard host pattern that Substrate matches. The UI Agent form keeps the field on an edit. Signed-off-by: Jose Armesto --- .../configuration-and-compilation.md | 11 +++ .../crd/bases/api.kagent.dev_agents.yaml | 14 ++++ go/api/v1alpha3/agent_types.go | 11 +++ go/api/v1alpha3/configuration_crd_cel_test.go | 11 +++ go/api/v1alpha3/zz_generated.deepcopy.go | 5 ++ go/core/internal/egress/destination.go | 38 ++++++++++ go/core/internal/egress/destination_test.go | 28 ++++++++ .../internal/service/session/workflow_test.go | 6 +- go/core/internal/substrate/policy.go | 5 +- go/core/internal/translator/compiler.go | 26 +++++++ go/core/internal/translator/compiler_test.go | 70 +++++++++++++++++++ .../templates/api.kagent.dev_agents.yaml | 14 ++++ ui/src/api/domain/agents.ts | 5 +- ui/src/pages/agents/AgentFormPage.tsx | 2 + 14 files changed, 242 insertions(+), 4 deletions(-) diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index b4da4053ae..11409f1ce7 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -20,6 +20,17 @@ with `tools[].subAgent.templateRef` and compile under the parent Agent's Harness Each subagent requires `templateRef` and shares the parent's runtime and Harness. Dedicated `agentRef` bindings are deferred and are not part of the served API. +`Agent.spec.egress` lists HTTP(S) origins the Agent may reach besides the +destinations its revision compiles, such as `https://proxy.golang.org`. A host +may have `*` as its leftmost label (`https://*.githubusercontent.com` matches +one label); nothing allows every host. The field is on the Agent, not the +template or the Harness: the hosts are a property of the workload in the place +where it runs (an internal git host, a registry mirror), so a template stays +portable and a Harness stays about the runtime. The compiler adds the origins, +in canonical form, after the runtime compiler has run, so every runtime and +every Shared subagent treats them the same, and they are part of the revision +identity. + All three are `api.kagent.dev/v1alpha3` Kubernetes resources. Infrastructure-derived values such as runtime addresses and inferred egress do not belong in the public API. diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index 84023a3624..4ccbf76284 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -51,6 +51,20 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. Nothing allows every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. diff --git a/go/api/v1alpha3/agent_types.go b/go/api/v1alpha3/agent_types.go index 95fd057d5a..96d4827915 100644 --- a/go/api/v1alpha3/agent_types.go +++ b/go/api/v1alpha3/agent_types.go @@ -37,6 +37,17 @@ type AgentSpec struct { // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="harnessRef.name must not be empty" // +optional HarnessRef *corev1.LocalObjectReference `json:"harnessRef,omitempty"` + // Egress lists HTTP(S) origins the Agent may reach besides the destinations + // its revision compiles (the model, its MCP servers, its skill and plugin + // sources, telemetry), such as "https://proxy.golang.org". The host may + // have "*" as its leftmost label: "https://*.githubusercontent.com" matches + // one label, such as raw.githubusercontent.com. Nothing allows every host. + // +optional + // +listType=set + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MaxLength=270 + // +kubebuilder:validation:items:Pattern=`^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$` + Egress []string `json:"egress,omitempty"` } const ( diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index be23e3a308..3597d75267 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -341,6 +341,17 @@ func TestConfigurationCRDValidation(t *testing.T) { } require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "empty-" + strings.ToLower(field)}, Spec: spec}), field+".name must not be empty") } + t.Run("Agent egress accepts HTTP(S) origins and leftmost-label wildcards", func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, + Egress: []string{"https://proxy.golang.org", "http://mirror.internal:8080", "https://*.githubusercontent.com"}} + require.NoError(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "egress"}, Spec: spec})) + }) + for i, origin := range []string{"proxy.golang.org", "ftp://proxy.golang.org", "https://*", "https://a.*.example.com", "https://192.0.2.1", "https://proxy.golang.org/path", "https://Proxy.Golang.org"} { + t.Run("Agent egress rejects "+origin, func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, Egress: []string{origin}} + require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: fmt.Sprintf("egress-invalid-%d", i)}, Spec: spec}), "spec.egress[0]") + }) + } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 59a3d9ddf2..d5426ad471 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -109,6 +109,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. diff --git a/go/core/internal/egress/destination.go b/go/core/internal/egress/destination.go index ade6f203ed..200533026f 100644 --- a/go/core/internal/egress/destination.go +++ b/go/core/internal/egress/destination.go @@ -1,9 +1,14 @@ package egress import ( + "fmt" "net" + "net/netip" "net/url" + "strconv" "strings" + + "k8s.io/apimachinery/pkg/util/validation" ) // Origin retains the protocol and destination port needed by Substrate's @@ -24,3 +29,36 @@ func Origin(u *url.URL) string { } return (&url.URL{Scheme: u.Scheme, Host: host}).String() } + +// ParseOrigin validates a declared HTTP(S) origin, such as +// "https://proxy.golang.org" or "https://*.githubusercontent.com", and returns +// its canonical form. Unlike Origin, it refuses anything beyond scheme, host +// and port, because a declared path or credential would silently widen to the +// whole origin. +func ParseOrigin(value string) (string, error) { + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Opaque != "" || u.User != nil || u.Hostname() == "" || + u.Path != "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("invalid egress origin %q: expected http(s)://host[:port]", value) + } + if !ValidHostPattern(strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")) { + return "", fmt.Errorf("invalid egress origin %q: the host must be a DNS name, optionally with \"*\" as its leftmost label", value) + } + if port := u.Port(); port != "" { + if number, err := strconv.ParseUint(port, 10, 16); err != nil || number == 0 { + return "", fmt.Errorf("invalid egress origin %q: invalid port", value) + } + } + return Origin(u), nil +} + +// ValidHostPattern reports whether a normalized host is a DNS name, or a DNS +// name with "*" as its leftmost label, which is what Substrate's hostname +// rules match. An IP address and "*" alone are not patterns. +func ValidHostPattern(host string) bool { + name, _ := strings.CutPrefix(host, "*.") + if _, err := netip.ParseAddr(name); err == nil { + return false + } + return len(validation.IsDNS1123Subdomain(name)) == 0 +} diff --git a/go/core/internal/egress/destination_test.go b/go/core/internal/egress/destination_test.go index dc9fa6d812..237ee160e3 100644 --- a/go/core/internal/egress/destination_test.go +++ b/go/core/internal/egress/destination_test.go @@ -22,3 +22,31 @@ func TestOrigin(t *testing.T) { }) } } + +func TestParseOrigin(t *testing.T) { + for _, tt := range []struct{ raw, want string }{ + {"https://proxy.golang.org", "https://proxy.golang.org:443"}, + {"https://Proxy.Golang.org.", "https://proxy.golang.org:443"}, + {"http://mirror.internal", "http://mirror.internal:80"}, + {"https://git.internal:8443", "https://git.internal:8443"}, + {"https://*.githubusercontent.com", "https://*.githubusercontent.com:443"}, + } { + t.Run(tt.raw, func(t *testing.T) { + got, err := ParseOrigin(tt.raw) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + for _, raw := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://", "https://*", "https://*.", + "https://a.*.example.com", "https://*.*.example.com", "https://*github.com", "https://-bad.example.com", + "https://192.0.2.1", "https://[2001:db8::1]", "https://proxy.golang.org/", "https://proxy.golang.org/path", + "https://proxy.golang.org?x=1", "https://proxy.golang.org?", "https://proxy.golang.org#f", + "https://user@proxy.golang.org", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { + t.Run(raw, func(t *testing.T) { + _, err := ParseOrigin(raw) + require.Error(t, err) + }) + } +} diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 69e7cca446..120c54901b 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -363,7 +363,11 @@ func TestActorEgressPolicy(t *testing.T) { policy, err = substrate.ActorEgressPolicy("team-a", nil, nil) require.NoError(t, err) require.Empty(t, policy.Rules, "no destinations must deny all egress") - for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", "http://*.example.com"} { + policy, err = substrate.ActorEgressPolicy("team-a", []string{"https://*.example.com:443"}, nil) + require.NoError(t, err, "a leftmost-label wildcard is a Substrate hostname pattern") + require.Equal(t, []string{"*.example.com"}, policy.Rules[0].GetHttps().GetHostnames()) + for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", + "https://*", "https://a.*.example.com", "https://*.*.example.com", "https://*example.com"} { t.Run(destination, func(t *testing.T) { _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) require.Error(t, err) diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index 64bcb848fa..d28d7d09ad 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -11,7 +11,6 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/core/internal/egress" - "k8s.io/apimachinery/pkg/util/validation" ) // ActorEgressPolicy compiles HTTP(S) origins into an actor's default allowlist. @@ -33,7 +32,9 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr if _, err := netip.ParseAddr(host); err == nil { return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) } - if len(validation.IsDNS1123Subdomain(host)) != 0 { + // A leftmost-label wildcard is a Substrate hostname pattern; the name + // under it must still be a DNS name. + if !egress.ValidHostPattern(host) { return nil, fmt.Errorf("invalid egress destination %q", destination) } origin := egress.Origin(u) diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index c64977cb6f..bb33788d98 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "maps" + "slices" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "istio.io/istio/pkg/kube/krt" "k8s.io/apimachinery/pkg/types" ) @@ -126,6 +128,10 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return nil, err } result.AgentUID = string(agent.UID) + result.EgressDestinations, err = withAgentEgress(result.EgressDestinations, agent.Spec.Egress) + if err != nil { + return nil, err + } result.Provenance, err = json.Marshal(struct { AgentName string `json:"agentName"` AgentUID string `json:"agentUID"` @@ -138,6 +144,26 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return result, nil } +// withAgentEgress adds the Agent's declared origins to the destinations its +// runtime compiled, in canonical form and once each. Every runtime gets the +// same treatment, so no runtime compiler reads the Agent's egress. +func withAgentEgress(compiled, declared []string) ([]string, error) { + if len(declared) == 0 { + return compiled, nil + } + destinations := slices.Clone(compiled) + for _, value := range declared { + origin, err := egress.ParseOrigin(value) + if err != nil { + return nil, NewValidationError("Agent egress: %v", err) + } + if !slices.Contains(destinations, origin) { + destinations = append(destinations, origin) + } + } + return destinations, nil +} + // compileConfiguration compiles resolved configuration for the named Agent. // The Agent name owns runtime identity; template and Harness names are provenance. func (c *Compiler) compileConfiguration(ctx context.Context, agentName string, harness *HarnessConfiguration, template *TemplateConfiguration) (*CompileResult, error) { diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 00469ab8e2..e70bbcb411 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -978,3 +978,73 @@ func TestResolveModelConfigMistral(t *testing.T) { resolved = mockCollections(t, model).ResolvedModelConfigs.List()[0] require.Equal(t, "APIKeySecretNotFound", resolved.Failure().Reason) } + +func TestCompileAgentAddsTheAgentEgress(t *testing.T) { + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "go-coder", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + } + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + }, + } + declared := []string{"https://proxy.golang.org", "https://*.githubusercontent.com", "https://Proxy.Golang.org.", "https://git.internal:8443"} + want := []string{"https://proxy.golang.org:443", "https://*.githubusercontent.com:443", "https://git.internal:8443"} + c := compiler(t, modelConfig(), template, harness) + + referenced := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "coder", Namespace: "test"}, Spec: v1alpha3.AgentSpec{ + TemplateRef: &corev1.LocalObjectReference{Name: template.Name}, HarnessRef: &corev1.LocalObjectReference{Name: harness.Name}, Egress: declared, + }} + inline := inlineAgent(harness, template) + inline.Spec.Egress = declared + for name, agent := range map[string]*v1alpha3.Agent{"references": referenced, "inline": inline} { + t.Run(name, func(t *testing.T) { + result, err := c.CompileAgent(t.Context(), agent) + require.NoError(t, err) + require.Subset(t, result.EgressDestinations, want) + require.Equal(t, 1, countOf(result.EgressDestinations, "https://proxy.golang.org:443"), "an origin is listed once") + policy, err := substrate.ActorEgressPolicy("test", result.EgressDestinations, result.Credentials) + require.NoError(t, err) + var hostnames []string + for _, rule := range policy.Rules { + hostnames = append(hostnames, rule.GetHttps().GetHostnames()...) + } + require.Subset(t, hostnames, []string{"proxy.golang.org", "*.githubusercontent.com", "git.internal"}) + }) + } + + withEgress, err := c.CompileAgent(t.Context(), referenced) + require.NoError(t, err) + plain := referenced.DeepCopy() + plain.Spec.Egress = nil + withoutEgress, err := c.CompileAgent(t.Context(), plain) + require.NoError(t, err) + for _, origin := range want { + require.NotContains(t, withoutEgress.EgressDestinations, origin) + } + withDigest, err := withEgress.Digest() + require.NoError(t, err) + withoutDigest, err := withoutEgress.Digest() + require.NoError(t, err) + require.NotEqual(t, withoutDigest, withDigest, "the Agent egress is part of the revision identity") + + invalid := referenced.DeepCopy() + invalid.Spec.Egress = []string{"https://proxy.golang.org/path"} + _, err = c.CompileAgent(t.Context(), invalid) + var validation *v2translator.ValidationError + require.ErrorAs(t, err, &validation) +} + +func countOf(values []string, want string) int { + n := 0 + for _, value := range values { + if value == want { + n++ + } + } + return n +} diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 84023a3624..4ccbf76284 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -51,6 +51,20 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. Nothing allows every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. diff --git a/ui/src/api/domain/agents.ts b/ui/src/api/domain/agents.ts index 537ff3af5c..ceb5c7c8b6 100644 --- a/ui/src/api/domain/agents.ts +++ b/ui/src/api/domain/agents.ts @@ -5,7 +5,10 @@ import type { HarnessSpec } from "./harnesses"; /** Exactly one of each pair; refs resolve in the Agent's namespace. */ export type AgentSpec = ({ template: AgentTemplateSpec; templateRef?: never } | { templateRef: { name: string }; template?: never }) & - ({ harness: HarnessSpec; harnessRef?: never } | { harnessRef: { name: string }; harness?: never }); + ({ harness: HarnessSpec; harnessRef?: never } | { harnessRef: { name: string }; harness?: never }) & { + /** HTTP(S) origins the Agent may reach besides what its revision compiles. */ + egress?: string[]; + }; /** One condition the controller recorded for an Agent. */ export interface AgentCondition { type: string; diff --git a/ui/src/pages/agents/AgentFormPage.tsx b/ui/src/pages/agents/AgentFormPage.tsx index 75693eb0e3..d1d615229a 100644 --- a/ui/src/pages/agents/AgentFormPage.tsx +++ b/ui/src/pages/agents/AgentFormPage.tsx @@ -137,6 +137,8 @@ function AgentForm({ agent }: { agent?: Agent }) { ...(harnessSource === "reference" ? { harnessRef: { name: harnessRef! } } : { harness: harnessSpecFromDraft(harnessDraft, spec?.harness) }), + // The form does not author egress; an edit keeps what the Agent declares. + ...(spec?.egress ? { egress: spec.egress } : {}), } as AgentSpec; const input = { namespace, From 5d5ca194db88ad7b294c3baa41f3ac6ac1f0c574 Mon Sep 17 00:00:00 2001 From: Jose Armesto Date: Thu, 1 Oct 2026 18:17:46 +0200 Subject: [PATCH 3/3] fix(api): tighten Agent egress validation and share it with the policy The CRD now accepts a port from 1 to 65535 only, as the compiler does, and a wildcard needs two labels under it, so "*.com" cannot open a whole top-level domain. The actor egress policy validates through egress.ParseOrigin, so the compiler and the policy cannot drift, and its comment states why a wildcard rule cannot bypass credential replacement. Signed-off-by: Jose Armesto --- .../configuration-and-compilation.md | 3 +- .../crd/bases/api.kagent.dev_agents.yaml | 5 +-- go/api/v1alpha3/agent_types.go | 5 +-- go/api/v1alpha3/configuration_crd_cel_test.go | 8 +++-- go/core/internal/egress/destination.go | 8 +++-- go/core/internal/egress/destination_test.go | 2 +- .../internal/service/session/workflow_test.go | 2 +- go/core/internal/substrate/policy.go | 36 ++++++++----------- go/core/internal/translator/compiler_test.go | 7 ++-- .../templates/api.kagent.dev_agents.yaml | 5 +-- ui/playwright/DEFERRED.md | 9 +++++ 11 files changed, 51 insertions(+), 39 deletions(-) diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index 11409f1ce7..f8225fd125 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -23,7 +23,8 @@ Dedicated `agentRef` bindings are deferred and are not part of the served API. `Agent.spec.egress` lists HTTP(S) origins the Agent may reach besides the destinations its revision compiles, such as `https://proxy.golang.org`. A host may have `*` as its leftmost label (`https://*.githubusercontent.com` matches -one label); nothing allows every host. The field is on the Agent, not the +one label), with at least two labels under it, so nothing opens a top-level +domain or every host. The field is on the Agent, not the template or the Harness: the hosts are a property of the workload in the place where it runs (an internal git host, a registry mirror), so a template stays portable and a Harness stays about the runtime. The compiler adds the origins, diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index 4ccbf76284..3a4845f7b5 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -57,10 +57,11 @@ spec: its revision compiles (the model, its MCP servers, its skill and plugin sources, telemetry), such as "https://proxy.golang.org". The host may have "*" as its leftmost label: "https://*.githubusercontent.com" matches - one label, such as raw.githubusercontent.com. Nothing allows every host. + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. items: maxLength: 270 - pattern: ^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$ + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ type: string maxItems: 64 type: array diff --git a/go/api/v1alpha3/agent_types.go b/go/api/v1alpha3/agent_types.go index 96d4827915..20e9879e9c 100644 --- a/go/api/v1alpha3/agent_types.go +++ b/go/api/v1alpha3/agent_types.go @@ -41,12 +41,13 @@ type AgentSpec struct { // its revision compiles (the model, its MCP servers, its skill and plugin // sources, telemetry), such as "https://proxy.golang.org". The host may // have "*" as its leftmost label: "https://*.githubusercontent.com" matches - // one label, such as raw.githubusercontent.com. Nothing allows every host. + // one label, such as raw.githubusercontent.com. A wildcard needs two labels + // under it, so nothing opens a whole top-level domain or every host. // +optional // +listType=set // +kubebuilder:validation:MaxItems=64 // +kubebuilder:validation:items:MaxLength=270 - // +kubebuilder:validation:items:Pattern=`^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$` + // +kubebuilder:validation:items:Pattern=`^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$` Egress []string `json:"egress,omitempty"` } diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index 3597d75267..204602b844 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -343,10 +343,14 @@ func TestConfigurationCRDValidation(t *testing.T) { } t.Run("Agent egress accepts HTTP(S) origins and leftmost-label wildcards", func(t *testing.T) { spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, - Egress: []string{"https://proxy.golang.org", "http://mirror.internal:8080", "https://*.githubusercontent.com"}} + Egress: []string{"https://proxy.golang.org", "http://mirror.internal:8080", "https://git.internal:65535", "https://*.githubusercontent.com"}} require.NoError(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "egress"}, Spec: spec})) }) - for i, origin := range []string{"proxy.golang.org", "ftp://proxy.golang.org", "https://*", "https://a.*.example.com", "https://192.0.2.1", "https://proxy.golang.org/path", "https://Proxy.Golang.org"} { + for i, origin := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://*", "https://*.com", "https://a.*.example.com", "https://192.0.2.1", "https://[2001:db8::1]", + "https://proxy.golang.org/path", "https://Proxy.Golang.org", "https://user@proxy.golang.org", "https://proxy.golang.org?x=1", + "https://proxy.golang.org#f", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { t.Run("Agent egress rejects "+origin, func(t *testing.T) { spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, Egress: []string{origin}} require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: fmt.Sprintf("egress-invalid-%d", i)}, Spec: spec}), "spec.egress[0]") diff --git a/go/core/internal/egress/destination.go b/go/core/internal/egress/destination.go index 200533026f..6171155f25 100644 --- a/go/core/internal/egress/destination.go +++ b/go/core/internal/egress/destination.go @@ -54,11 +54,15 @@ func ParseOrigin(value string) (string, error) { // ValidHostPattern reports whether a normalized host is a DNS name, or a DNS // name with "*" as its leftmost label, which is what Substrate's hostname -// rules match. An IP address and "*" alone are not patterns. +// rules match. An IP address and "*" alone are not patterns, and a wildcard +// needs two labels under it: "*.com" would open a whole top-level domain. func ValidHostPattern(host string) bool { - name, _ := strings.CutPrefix(host, "*.") + name, wildcard := strings.CutPrefix(host, "*.") if _, err := netip.ParseAddr(name); err == nil { return false } + if wildcard && !strings.Contains(name, ".") { + return false + } return len(validation.IsDNS1123Subdomain(name)) == 0 } diff --git a/go/core/internal/egress/destination_test.go b/go/core/internal/egress/destination_test.go index 237ee160e3..1bcebcd606 100644 --- a/go/core/internal/egress/destination_test.go +++ b/go/core/internal/egress/destination_test.go @@ -39,7 +39,7 @@ func TestParseOrigin(t *testing.T) { } for _, raw := range []string{ "proxy.golang.org", "ftp://proxy.golang.org", "https://", "https://*", "https://*.", - "https://a.*.example.com", "https://*.*.example.com", "https://*github.com", "https://-bad.example.com", + "https://a.*.example.com", "https://*.*.example.com", "https://*github.com", "https://*.com", "https://-bad.example.com", "https://192.0.2.1", "https://[2001:db8::1]", "https://proxy.golang.org/", "https://proxy.golang.org/path", "https://proxy.golang.org?x=1", "https://proxy.golang.org?", "https://proxy.golang.org#f", "https://user@proxy.golang.org", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 120c54901b..21a8dbb2db 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -367,7 +367,7 @@ func TestActorEgressPolicy(t *testing.T) { require.NoError(t, err, "a leftmost-label wildcard is a Substrate hostname pattern") require.Equal(t, []string{"*.example.com"}, policy.Rules[0].GetHttps().GetHostnames()) for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", - "https://*", "https://a.*.example.com", "https://*.*.example.com", "https://*example.com"} { + "https://*", "https://*.com", "https://a.*.example.com", "https://*.*.example.com", "https://*example.com"} { t.Run(destination, func(t *testing.T) { _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) require.Error(t, err) diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index d28d7d09ad..67dd233f8d 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -24,28 +24,20 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr protocols := map[string]string{} hosts := map[string]bool{} for _, destination := range destinations { - u, err := url.Parse(destination) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Hostname() == "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { - return nil, fmt.Errorf("invalid egress destination %q: expected an HTTP(S) origin", destination) + if u, err := url.Parse(destination); err == nil { + if _, err := netip.ParseAddr(strings.TrimSuffix(u.Hostname(), ".")); err == nil { + return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) + } } - host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") - if _, err := netip.ParseAddr(host); err == nil { - return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) - } - // A leftmost-label wildcard is a Substrate hostname pattern; the name - // under it must still be a DNS name. - if !egress.ValidHostPattern(host) { - return nil, fmt.Errorf("invalid egress destination %q", destination) - } - origin := egress.Origin(u) - u, err = url.Parse(origin) + origin, err := egress.ParseOrigin(destination) if err != nil { - return nil, fmt.Errorf("invalid egress destination %q: %w", destination, err) - } - port, err := strconv.ParseUint(u.Port(), 10, 16) - if err != nil || port == 0 { - return nil, fmt.Errorf("invalid egress port in %q", destination) + return nil, fmt.Errorf("invalid egress destination: %w", err) } + // ParseOrigin returns scheme://host:port with a valid port; the port is + // re-encoded so "0443" and "443" are one origin. + u, _ := url.Parse(origin) + host := u.Hostname() + port, _ := strconv.ParseUint(u.Port(), 10, 16) u.Host = net.JoinHostPort(host, strconv.FormatUint(port, 10)) if protocol, found := protocols[u.Host]; found && protocol != u.Scheme { return nil, fmt.Errorf("egress destination %q uses both HTTP and HTTPS on the same port", u.Host) @@ -73,8 +65,10 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr for _, origin := range names { target := origins[origin] ports := &ateapipb.Ports{Numbers: []int32{target.port}} - // Keep exactly one rule per host and port so an overlapping rule cannot - // bypass credential replacement. + // Keep exactly one rule per host pattern and port. A wildcard rule can + // overlap an exact one, but Substrate decides on the exact hostname + // first (EgressPolicy in proto/ateapi.proto), and credentials bind only + // to exact hostnames, so a wildcard cannot bypass credential replacement. if target.scheme == "https" { policy.Rules = append(policy.Rules, &ateapipb.EgressRule{Https: &ateapipb.HTTPSRule{Hostnames: []string{target.hostname}, Ports: ports, Effects: effects[target.hostname]}}) } else { diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index e70bbcb411..ff2d6e2906 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -1023,14 +1023,11 @@ func TestCompileAgentAddsTheAgentEgress(t *testing.T) { plain.Spec.Egress = nil withoutEgress, err := c.CompileAgent(t.Context(), plain) require.NoError(t, err) + // The destinations are a digest input, so the revision identity follows them. + require.Subset(t, withEgress.EgressDestinations, want) for _, origin := range want { require.NotContains(t, withoutEgress.EgressDestinations, origin) } - withDigest, err := withEgress.Digest() - require.NoError(t, err) - withoutDigest, err := withoutEgress.Digest() - require.NoError(t, err) - require.NotEqual(t, withoutDigest, withDigest, "the Agent egress is part of the revision identity") invalid := referenced.DeepCopy() invalid.Spec.Egress = []string{"https://proxy.golang.org/path"} diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 4ccbf76284..3a4845f7b5 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -57,10 +57,11 @@ spec: its revision compiles (the model, its MCP servers, its skill and plugin sources, telemetry), such as "https://proxy.golang.org". The host may have "*" as its leftmost label: "https://*.githubusercontent.com" matches - one label, such as raw.githubusercontent.com. Nothing allows every host. + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. items: maxLength: 270 - pattern: ^https?://(\*\.)?([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:[0-9]{1,5})?$ + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ type: string maxItems: 64 type: array diff --git a/ui/playwright/DEFERRED.md b/ui/playwright/DEFERRED.md index be664c4f12..53e148bc42 100644 --- a/ui/playwright/DEFERRED.md +++ b/ui/playwright/DEFERRED.md @@ -332,3 +332,12 @@ navigation had just reset. What actually removes the coupling is a second `test` same file — one recording for the lifecycle, one for the states, neither able to abort the other. That costs `conventions.test.ts` its "one spec, one test" rule, so it is an amendment to the convention rather than a reshuffle, and belongs in its own change. + +## An Agent edit keeps the egress the form does not show + +`AgentFormPage` does not author `spec.egress`, and the update sends the whole spec, so +the save carries the Agent's existing `egress` over. Nothing asserts it: no spec opens +an Agent that declares egress, saves it, and reads the request back. The case it guards +is silent — a save that dropped the line would widen nothing and narrow the Agent's +egress to what its revision compiles, with no message on the page. It belongs in the +agent lifecycle spec, with a fixture Agent whose `egress` matches the CRD shape.