Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/architecture/configuration-and-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ 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), 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,
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.
Expand Down
15 changes: 15 additions & 0 deletions go/api/config/crd/bases/api.kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ 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. 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-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
x-kubernetes-list-type: set
harness:
description: HarnessSpec defines a reusable runtime and its infrastructure
policy.
Expand Down
12 changes: 12 additions & 0 deletions go/api/v1alpha3/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ 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. 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-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"`
}

const (
Expand Down
15 changes: 15 additions & 0 deletions go/api/v1alpha3/configuration_crd_cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,21 @@ 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://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://*.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]")
})
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions go/api/v1alpha3/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions go/core/internal/egress/destination.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,3 +29,40 @@ 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, and a wildcard
// needs two labels under it: "*.com" would open a whole top-level domain.
func ValidHostPattern(host string) bool {
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
}
28 changes: 28 additions & 0 deletions go/core/internal/egress/destination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://*.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)
})
}
}
6 changes: 5 additions & 1 deletion go/core/internal/service/session/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://*.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)
Expand Down
35 changes: 15 additions & 20 deletions go/core/internal/substrate/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,26 +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)
}
if len(validation.IsDNS1123Subdomain(host)) != 0 {
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)
Expand Down Expand Up @@ -72,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 {
Expand Down
26 changes: 26 additions & 0 deletions go/core/internal/translator/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"`
Expand All @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions go/core/internal/translator/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -978,3 +978,70 @@ 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)
// 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)
}

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
}
15 changes: 15 additions & 0 deletions helm/kagent-crds/templates/api.kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ 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. 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-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
x-kubernetes-list-type: set
harness:
description: HarnessSpec defines a reusable runtime and its infrastructure
policy.
Expand Down
Loading
Loading