// Package policy loads and enforces the agent authorisation surface. // // The policy is embedded rather than read from disk: a security boundary that lives in an editable // file can be silently widened on one host, and "that box was configured differently" is the kind // of finding that only surfaces after an incident. The build vendors contracts/agent-policy.json // into this directory, and TestVendoredCopyMatchesContracts fails if the vendored copy drifts. package policy import ( "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "net" "regexp" "strings" _ "embed" ) // The directive must sit immediately above the variable; a blank line here silently detaches it. // Vendoring is a make step so the copy under this directory is never hand-edited. // //go:embed agent-policy.json var embedded []byte type Egress struct { Mode string `json:"mode"` PortOnly bool `json:"portOnly"` DialedHosts []string `json:"dialedHosts"` } type AuthPaths struct { Pattern string `json:"pattern"` Proxied bool `json:"proxied"` } type Limits struct { DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"` MaxRequestBytes int `json:"maxRequestBytes"` MaxResponseBytes int `json:"maxResponseBytes"` RequestTTLSeconds int `json:"requestTtlSeconds"` ClockSkewSeconds int `json:"clockSkewSeconds"` ReplayCacheSize int `json:"replayCacheSize"` MailboxDepth int `json:"mailboxDepth"` } type Policy struct { SchemaVersion int `json:"schemaVersion"` Name string `json:"name"` Egress Egress `json:"egress"` AuthPaths AuthPaths `json:"authPaths"` Read []string `json:"read"` Write map[string][]string `json:"write"` Dangerous []string `json:"dangerous"` Limits Limits `json:"limits"` authRe *regexp.Regexp read map[string]bool write map[string]map[string]bool dangerous map[string]bool digest string } // Load returns the embedded policy. It never falls back to a permissive default: if the embedded // bytes fail validation the agent must not start. func Load() (*Policy, error) { return Parse(embedded) } func Parse(raw []byte) (*Policy, error) { var p Policy if err := json.Unmarshal(raw, &p); err != nil { return nil, fmt.Errorf("policy: decode: %w", err) } if p.SchemaVersion != 1 { return nil, fmt.Errorf("policy: unsupported schemaVersion %d", p.SchemaVersion) } if p.Egress.Mode != "loopback-only" || !p.Egress.PortOnly { return nil, errors.New("policy: egress must be loopback-only and port-only") } if len(p.Egress.DialedHosts) == 0 { return nil, errors.New("policy: egress.dialedHosts is empty") } for _, host := range p.Egress.DialedHosts { ip := net.ParseIP(host) if ip == nil || !ip.IsLoopback() { return nil, fmt.Errorf("policy: egress.dialedHosts entry %q is not a literal loopback address", host) } } if p.AuthPaths.Proxied { return nil, errors.New("policy: authPaths must not be proxied") } re, err := regexp.Compile(p.AuthPaths.Pattern) if err != nil { return nil, fmt.Errorf("policy: authPaths.pattern: %w", err) } p.authRe = re p.read = make(map[string]bool, len(p.Read)) for _, path := range p.Read { if err := p.checkPath(path); err != nil { return nil, err } if re.MatchString(path) { return nil, fmt.Errorf("policy: read path %q matches the auth pattern", path) } if p.read[path] { return nil, fmt.Errorf("policy: duplicate read path %q", path) } p.read[path] = true } p.write = make(map[string]map[string]bool, len(p.Write)) p.dangerous = make(map[string]bool, len(p.Dangerous)) for _, path := range p.Dangerous { p.dangerous[path] = true } for path, methods := range p.Write { if err := p.checkPath(path); err != nil { return nil, err } if re.MatchString(path) { return nil, fmt.Errorf("policy: write path %q matches the auth pattern", path) } if !p.dangerous[path] { return nil, fmt.Errorf("policy: write path %q is not declared dangerous, so it would run without a confirmation intent", path) } set := make(map[string]bool, len(methods)) for _, method := range methods { if method == "GET" || method == "HEAD" { return nil, fmt.Errorf("policy: write path %q must not allow %s", path, method) } if set[method] { return nil, fmt.Errorf("policy: write path %q duplicates method %s", path, method) } set[method] = true } if len(set) == 0 { return nil, fmt.Errorf("policy: write path %q has no methods", path) } p.write[path] = set } for path := range p.dangerous { if _, ok := p.write[path]; !ok { return nil, fmt.Errorf("policy: %q is declared dangerous but is not writable", path) } } for name, value := range map[string]int{ "dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute, "maxRequestBytes": p.Limits.MaxRequestBytes, "maxResponseBytes": p.Limits.MaxResponseBytes, "requestTtlSeconds": p.Limits.RequestTTLSeconds, "clockSkewSeconds": p.Limits.ClockSkewSeconds, "replayCacheSize": p.Limits.ReplayCacheSize, "mailboxDepth": p.Limits.MailboxDepth, } { if value <= 0 { return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value) } } if p.Limits.ClockSkewSeconds > 300 { return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay") } if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds { return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds and would never be enforced") } if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth { return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued") } sum := sha256.Sum256(raw) p.digest = hex.EncodeToString(sum[:]) return &p, nil } func (p *Policy) checkPath(path string) error { if !strings.HasPrefix(path, "/api/") { return fmt.Errorf("policy: path %q is not under /api/", path) } if strings.Contains(path, "..") || strings.ContainsAny(path, "?#\\") { return fmt.Errorf("policy: path %q contains traversal, query, fragment or backslash", path) } return nil } // SHA256 is the digest of the exact bytes the agent enforced. It is logged at startup so an operator // can confirm a fleet is running the intended policy without diffing files. func (p *Policy) SHA256() string { return p.digest } // Decision reports whether a request may be executed, and whether it needs a confirmation intent. func (p *Policy) Decision(method, path string) (allowed bool, dangerous bool, reason string) { clean := path if i := strings.IndexAny(clean, "?#"); i >= 0 { clean = clean[:i] } if err := p.checkPath(clean); err != nil { return false, false, "path rejected" } // Constant-time on purpose is not needed for a public allowlist, but the auth check must come // first so a path that is both auth-shaped and allowlisted cannot win by ordering. if p.authRe.MatchString(clean) { return false, false, "authentication endpoints are not reachable through the relay" } if method == "GET" { if p.read[clean] { return true, false, "" } return false, false, "path is not on the read allowlist" } methods, ok := p.write[clean] if !ok { if p.read[clean] { return false, false, "path is read-only" } return false, false, "path is not on the write allowlist" } if !methods[method] { return false, false, "method is not allowed for this path" } return true, true, "" } // RequiresConfirm reports whether a dangerous write carries a matching intent. The comparison is // constant time so the agent does not leak the expected digest byte by byte on a wrong guess. func RequiresConfirm(got, want []byte) bool { return subtle.ConstantTimeCompare(got, want) == 1 } // Raw returns the embedded bytes for the drift test. func Raw() []byte { return embedded }