security

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 1, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package security provides egress security resolution for containerized agents.

Index

Constants

View Source
const (
	LayerSystem    = "system"
	LayerUser      = "user"
	LayerWorkspace = "workspace"

	// DefaultSystemPolicyPath is /etc/forge/policy.yaml. Override via
	// FORGE_SYSTEM_POLICY (test isolation; non-root install paths).
	DefaultSystemPolicyPath = "/etc/forge/policy.yaml"
)

Variables

View Source
var DefaultCapabilityBundles = map[string][]string{
	"slack":    {"slack.com", "wss-primary.slack.com", "api.slack.com", "files.slack.com"},
	"telegram": {"api.telegram.org"},

	"msteams": {"graph.microsoft.com", "login.microsoftonline.com"},
}

DefaultCapabilityBundles maps capability names to their required domain sets.

View Source
var DefaultToolDomains = map[string][]string{
	"web_search":        {"api.tavily.com", "api.perplexity.ai"},
	"web-search":        {"api.tavily.com", "api.perplexity.ai"},
	"http_request":      {},
	"slack_notify":      {"slack.com", "hooks.slack.com"},
	"github_api":        {"api.github.com", "github.com"},
	"openai_completion": {"api.openai.com"},
	"anthropic_api":     {"api.anthropic.com"},
	"huggingface_api":   {"api-inference.huggingface.co", "huggingface.co"},
	"google_vertex":     {"us-central1-aiplatform.googleapis.com"},
	"sendgrid_email":    {"api.sendgrid.com"},
	"twilio_sms":        {"api.twilio.com"},
	"aws_bedrock":       {"bedrock-runtime.us-east-1.amazonaws.com"},
	"azure_openai":      {"openai.azure.com"},
	"tavily_research":   {"api.tavily.com"},
	"tavily_search":     {"api.tavily.com"},
}

DefaultToolDomains maps tool names to their known required domains.

Functions

func AuthDomains

func AuthDomains(cfg types.AuthConfig) []string

AuthDomains extracts the outbound hosts that auth providers must be able to reach: OIDC issuers, http_verifier URLs, future Okta tenants, etc.

These domains are merged into the egress allowlist BEFORE the egress enforcer is constructed, so configuring an OIDC provider does not silently fail at runtime with a network-blocked JWKS fetch.

Returned hosts are deduplicated and sorted for stable test output. Empty/malformed URLs are skipped (validation happens elsewhere).

func EffectiveDeniedTools

func EffectiveDeniedTools(forgeDenied []string, layers []PolicyLayer) []string

EffectiveDeniedTools returns the union of forge.yaml's declared deny list (from the derived CLI config) and every layer's tool deny. Used by the runner to strip tools from the registry at startup. Dedupes; preserves forge.yaml ordering first, then appends each layer's denies in load order.

func EffectiveEgressAllowlist

func EffectiveEgressAllowlist(cfg *types.ForgeConfig, layers []PolicyLayer) []string

EffectiveEgressAllowlist returns forge.yaml's allowed_domains with any entry denied by ANY layer removed. The unioned deny list is what reaches the EgressEnforcer.

Returns the input unchanged when no layers are loaded.

func EffectiveToolCount

func EffectiveToolCount(cfg *types.ForgeConfig, layers []PolicyLayer) int

EffectiveToolCount returns how many tools the agent would register after every layer's deny strip. Used by the bound check above and the runner's startup log.

func EgressClientFromContext

func EgressClientFromContext(ctx context.Context) *http.Client

EgressClientFromContext retrieves the egress-enforced HTTP client from the context. Returns http.DefaultClient if none is set.

func EgressTransportFromContext

func EgressTransportFromContext(ctx context.Context) http.RoundTripper

EgressTransportFromContext retrieves the transport from the egress client in the context. Returns nil if no egress client is set (so that http.Client{Transport: nil} falls back to http.DefaultTransport).

func FormatViolations

func FormatViolations(violations []PolicyViolation) string

FormatViolations returns a multi-line, developer-friendly error message describing every violation. The runner uses this as the returned error from NewRunner when violations are present, so the CLI's error path surfaces every problem in one pass — developers fix the forge.yaml once and re-run rather than ping-ponging through one error at a time.

func GenerateAllowlistJSON

func GenerateAllowlistJSON(cfg *EgressConfig) ([]byte, error)

GenerateAllowlistJSON produces the JSON output for egress_allowlist.json.

func GenerateK8sNetworkPolicy

func GenerateK8sNetworkPolicy(agentID string, cfg *EgressConfig) ([]byte, error)

GenerateK8sNetworkPolicy produces a K8s NetworkPolicy YAML for the given agent and egress config.

func InContainer

func InContainer() bool

InContainer returns true when the process runs inside Docker or Kubernetes. Used to skip the local egress proxy (NetworkPolicy enforces egress there).

func InferToolDomains

func InferToolDomains(toolNames []string) []string

InferToolDomains looks up known domains for the given tool names and returns a deduplicated list.

func IsBlockedIP

func IsBlockedIP(ip net.IP, allowPrivate bool) bool

IsBlockedIP checks whether an IP is in a blocked CIDR range. When allowPrivate is true, RFC 1918 and link-local ranges are permitted (for container/K8s environments), but cloud metadata and loopback are always blocked. Returns true (blocked) for nil IPs (fail closed).

func IsLocalhost

func IsLocalhost(host string) bool

IsLocalhost returns true for loopback addresses. It uses strict IPv4 parsing to prevent octal/hex bypass (e.g. 0177.0.0.1).

func LLMProviderDomains

func LLMProviderDomains(cfg *types.ForgeConfig) []string

LLMProviderDomains returns the hostnames of every custom base URL declared on the agent's primary model and its fallbacks. Used by the build pipeline (forge-cli/build/egress_stage.go) and the runner (forge-cli/runtime/runner.go) to auto-merge LLM provider hosts into the egress allowlist alongside AuthDomains, MCPDomains, and OTelDomain.

Why this exists (issue #139):

Without this, an agent configured against an OpenAI-compatible
provider (Together.ai, OpenRouter, Groq, Fireworks, Anyscale,
vLLM, llama.cpp's server) ships a NetworkPolicy that blocks the
provider's hostname — the build pipeline only sees what's in
forge.yaml, and the operator's env-driven OPENAI_BASE_URL doesn't
flow through. Same trap Phase 6 of OTel Tracing v1 (#107) fixed
for the OTLP collector; this is the symmetric fix for the LLM
provider.

Returns nil when neither the primary nor any fallback declares a base URL — backward-compatible with deployments that rely on the vendor's default host (api.openai.com, api.anthropic.com, etc.) already covered by the operator's explicit egress.allowed_domains.

Malformed URLs are silently skipped (same posture as AuthDomains / MCPDomains / OTelDomain): the build pipeline must never block a deployment over LLM config; the runtime config resolver is the single place that fails loudly on bad URLs.

Port stripping follows the cross-package contract documented on hostFromURL — every egress matcher callsite strips the port before checking the allowlist, so a hostname-only entry suffices.

func LLMProviderEnvDomains

func LLMProviderEnvDomains(envVars map[string]string) []string

LLMProviderEnvDomains returns the hostnames extracted from the four canonical SDK base-URL env vars when present in the supplied env map. Used by the runner to auto-merge env-driven LLM provider hosts into the egress allowlist for deployments that haven't yet migrated to the new ModelRef.BaseURL field.

Why two helpers, not one:

LLMProviderDomains is the build-time signal (read from forge.yaml,
the only source the build pipeline can see). LLMProviderEnvDomains
is the runtime safety-net (read from the resolved env). Most
operators will populate forge.yaml going forward; the env-based
helper rescues existing deployments that point at a custom
provider via OPENAI_BASE_URL only.

The env vars consulted are the standard SDK conventions every OpenAI/Anthropic/Ollama/Gemini-compatible provider documents. Forge does not invent any Forge-specific FORGE_*_BASE_URL variant.

Returns nil when none are set. Malformed URLs are silently skipped (same posture as the cfg-side helper).

func MCPDomainSources

func MCPDomainSources(cfg types.MCPConfig) map[string]string

MCPDomainSources returns a per-domain source tag suitable for embedding in egress_allowlist.json provenance. Tags look like "mcp:<server-name>" and let operators trace why a given domain is in the allowlist (vs. tool-derived or operator-supplied).

When the same host appears via multiple servers (e.g., a shared OAuth authorization server across two MCP services), the tag is the lexicographically first server name — deterministic.

func MCPDomains

func MCPDomains(cfg types.MCPConfig) []string

MCPDomains extracts the outbound hosts that MCP servers in forge.yaml mcp.servers[] must be reachable on. Mirrors AuthDomains for the same reason: without this merge into the egress allowlist, an HTTP MCP call would be silently blocked by the egress enforcer.

Hosts are deduplicated and sorted for stable test output. Empty/malformed URLs are skipped — validation happens in validate.ValidateMCPConfig.

Phase 1: HTTP transport only, so the only outbound is the server's URL host. Future OAuth-discovery work may add an authorization- server host once we land RFC 9728 discovery.

func NewSafeTransport

func NewSafeTransport(resolver Resolver, allowPrivateIPs bool) *http.Transport

NewSafeTransport creates an http.Transport that uses SafeDialer for all connections. If resolver is nil, net.DefaultResolver is used.

func OTelDomain

func OTelDomain(cfg types.TracingYAML) []string

OTelDomain returns the hostname of the OTLP collector configured in observability.tracing.endpoint, as a single-element slice ready to be merged into the egress allowlist alongside AuthDomains and MCPDomains.

Why this exists (Phase 6 of OTel Tracing v1, #107 / #108):

Without this entry, a deployment with tracing enabled in
forge.yaml ships a NetworkPolicy that blocks the OTLP exporter's
outbound traffic — spans accumulate in the BatchSpanProcessor
queue and silently drop on shutdown timeout. The operator sees a
working `forge run` locally and an inexplicably empty trace
backend in cluster. The build pipeline must inject the collector
host into the allowlist automatically so "tracing on in
forge.yaml" implies "tracing reaches the backend" without a
second egress edit.

The function returns nil when tracing is disabled, when no endpoint is configured, or when the endpoint is unparseable — every "skip" case yields an empty slice the caller appends as a no-op. A malformed endpoint is NOT a build-time error here; the cli's tracing resolver (Phase 2) is the single place that fails loudly on bad configuration, and Phase 6 is intentionally tolerant so the build stage can never block a deployment over telemetry config.

Port stripping is handled by hostFromURL (see auth_domains.go for the cross-package contract — every egress matcher callsite strips the port from the OUTBOUND host before checking the allowlist, so a hostname-only entry suffices for any port).

Source tagging: this helper returns bare hostnames matching the AuthDomains / MCPDomains convention. The egress_allowlist.json shape does not currently carry per-domain source provenance — a future allowlist schema upgrade could introduce a "source: otel" tag, mirroring MCPDomainSources, without changing this helper.

func ParseStrictIPv4

func ParseStrictIPv4(s string) net.IP

ParseStrictIPv4 parses an IPv4 address in strict dotted-decimal notation. It rejects octal (0177.0.0.1), hex (0x7f.0.0.1), packed decimal (2130706433), and leading-zero forms (127.0.0.01). Returns nil if the input is not a valid strict IPv4 address.

func ResolveCapabilities

func ResolveCapabilities(capabilities []string) []string

ResolveCapabilities returns a deduplicated list of domains for the given capability names.

func SafeRedirectPolicy

func SafeRedirectPolicy(maxRedirects int) func(*http.Request, []*http.Request) error

SafeRedirectPolicy returns a CheckRedirect function that strips sensitive credentials (Authorization, Cookie, etc.) when a redirect crosses origin boundaries (different scheme, host, or port from the original request).

func SystemPolicyPath

func SystemPolicyPath() string

SystemPolicyPath returns the on-disk system policy path with env override taken into account. Exposed so the CLI's --system flag writes to the same path the runtime reads from.

func UserPolicyPath

func UserPolicyPath() string

UserPolicyPath returns ~/.forge/policy.yaml. Empty when the user has no home directory (rare; typically a chroot or test sandbox); callers treat empty as "no user layer."

func ValidateHostIP

func ValidateHostIP(host string) error

ValidateHostIP validates that a hostname is not using a non-standard IP format that could bypass security checks. It rejects octal, hex, packed decimal, and leading-zero IP representations.

func WithEgressClient

func WithEgressClient(ctx context.Context, client *http.Client) context.Context

WithEgressClient stores an egress-enforced HTTP client in the context.

func WorkspacePolicyPath

func WorkspacePolicyPath() string

WorkspacePolicyPath returns the path from FORGE_PLATFORM_POLICY (the FWS-5 env var, unchanged). Empty when not set — runtime treats empty as "no workspace layer."

Types

type ChannelSkip

type ChannelSkip struct {
	Channel   string
	Layer     string // "system" / "user" / "workspace"
	LayerPath string // on-disk path for fields.source
}

ChannelSkip records a channel that was skipped at startup and which layer's deny list named it. The runner emits one channel_denied_by_policy audit event per skip with the layer name attached. The security package stays free of the runtime dependency (audit lives in forge-core/runtime); the caller walks the slice and emits.

See issue #90 / FWS-6.

func EffectiveChannels

func EffectiveChannels(declared []string, layers []PolicyLayer) (effective []string, skipped []ChannelSkip)

EffectiveChannels returns the channel list that should actually be started (after policy filtering) along with one ChannelSkip per filtered entry. The caller iterates the effective list to start adapters and the skip list to emit audit events.

Attribution: when a channel is denied by multiple layers, the system layer wins (first match in layer load order: system → user → workspace). System-layer denies are the most visible in the audit pipeline.

See issue #90 / FWS-6.

type DomainMatcher

type DomainMatcher struct {
	// contains filtered or unexported fields
}

DomainMatcher checks hostnames against an exact+wildcard allowlist. It is used by both EgressEnforcer (Go HTTP) and EgressProxy (subprocess HTTP).

func NewDomainMatcher

func NewDomainMatcher(mode EgressMode, domains []string) *DomainMatcher

NewDomainMatcher creates a new DomainMatcher for the given mode and domain list. Domains may include wildcard prefixes (e.g. "*.github.com") which match any subdomain.

func (*DomainMatcher) IsAllowed

func (m *DomainMatcher) IsAllowed(host string) bool

IsAllowed checks if a host is permitted under the current mode. Exact match is checked first, then wildcard suffix, then mode fallback.

func (*DomainMatcher) Mode

func (m *DomainMatcher) Mode() EgressMode

Mode returns the egress mode of this matcher.

type EgressConfig

type EgressConfig struct {
	Profile         EgressProfile `json:"profile"`
	Mode            EgressMode    `json:"mode"`
	AllowedDomains  []string      `json:"allowed_domains,omitempty"` // explicit user domains
	ToolDomains     []string      `json:"tool_domains,omitempty"`    // inferred from tools
	AllDomains      []string      `json:"all_domains,omitempty"`     // deduplicated union
	AllowPrivateIPs bool          `json:"allow_private_ips,omitempty"`
}

EgressConfig holds the resolved egress configuration.

func Resolve

func Resolve(profile, mode string, explicitDomains, toolNames, capabilities []string) (*EgressConfig, error)

Resolve builds an EgressConfig from profile, mode, explicit domains, tool names, and capabilities.

type EgressEnforcer

type EgressEnforcer struct {
	AllowPrivateIPs bool
	OnAttempt       func(ctx context.Context, domain string, allowed bool)
	// contains filtered or unexported fields
}

EgressEnforcer is an http.RoundTripper that validates outbound requests against a domain allowlist before forwarding them to the base transport.

func NewEgressEnforcer

func NewEgressEnforcer(base http.RoundTripper, mode EgressMode, domains []string, allowPrivateIPs bool) *EgressEnforcer

NewEgressEnforcer creates a new EgressEnforcer wrapping the given base transport. If base is nil, a SafeTransport is used instead of http.DefaultTransport. Domains may include wildcard prefixes (e.g. "*.github.com") which match any subdomain.

func (*EgressEnforcer) RoundTrip

func (e *EgressEnforcer) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper. It checks the request hostname against the allowlist and fires the OnAttempt callback.

type EgressMode

type EgressMode string

EgressMode controls egress behavior.

const (
	ModeDenyAll   EgressMode = "deny-all"
	ModeAllowlist EgressMode = "allowlist"
	ModeDevOpen   EgressMode = "dev-open"
)

func DefaultMode

func DefaultMode() EgressMode

DefaultMode returns the default egress mode.

type EgressProfile

type EgressProfile string

EgressProfile controls the overall security posture.

const (
	ProfileStrict     EgressProfile = "strict"
	ProfileStandard   EgressProfile = "standard"
	ProfilePermissive EgressProfile = "permissive"
)

func DefaultProfile

func DefaultProfile() EgressProfile

DefaultProfile returns the default egress profile.

type EgressProxy

type EgressProxy struct {
	OnAttempt func(domain string, allowed bool)
	// contains filtered or unexported fields
}

EgressProxy is a localhost-only HTTP/HTTPS forward proxy that validates outbound domains against a DomainMatcher before forwarding requests. It is used to enforce egress rules on subprocesses (skill scripts) that cannot use the Go-level EgressEnforcer RoundTripper.

func NewEgressProxy

func NewEgressProxy(matcher *DomainMatcher, allowPrivateIPs bool) *EgressProxy

NewEgressProxy creates a new EgressProxy that validates domains using the given DomainMatcher. Call Start to bind and begin serving.

func (*EgressProxy) ProxyURL

func (p *EgressProxy) ProxyURL() string

ProxyURL returns the URL for HTTP_PROXY/HTTPS_PROXY env vars.

func (*EgressProxy) Start

func (p *EgressProxy) Start(ctx context.Context) (string, error)

Start binds to 127.0.0.1:0 (random port) and begins serving. Returns the proxy URL (e.g., "http://127.0.0.1:54321").

func (*EgressProxy) Stop

func (p *EgressProxy) Stop() error

Stop gracefully shuts down the proxy with a 5-second timeout.

type ModelMatcher

type ModelMatcher struct {
	Provider string `yaml:"provider" json:"provider"`
	Name     string `yaml:"name" json:"name"`
}

ModelMatcher identifies one forbidden model. Both fields are required; "match any model from provider X" intentionally requires listing every model name — operators must be explicit. Loose patterns ("anthropic/*") are a footgun (provider adds a new model, nobody updates the policy, model leaks through).

func (ModelMatcher) String

func (m ModelMatcher) String() string

String returns "provider/name" for log + error message use.

type PlatformPolicy

type PlatformPolicy struct {
	// DeniedEgressDomains is the workspace-level deny list applied on
	// top of forge.yaml's egress.allowed_domains. At startup, the
	// effective allowlist is the set-difference (forge.yaml allowed
	// MINUS this list). If forge.yaml's allow list contains any domain
	// in this set, the agent refuses to start with a clear error and
	// emits policy_violation_at_build_time — operators see the
	// conflict in their audit pipeline, developers see the error.
	//
	// Domain matching is exact-host (no wildcards in this list).
	// Wildcard semantics belong in forge.yaml; the platform deny list
	// is operator-supplied and intentionally simple.
	DeniedEgressDomains []string `yaml:"denied_egress_domains,omitempty" json:"denied_egress_domains,omitempty"`

	// DeniedTools is the union with forge.yaml's denied tools. Tool
	// names match the registry name (e.g. "cli_execute", "http_request",
	// MCP-namespaced "linear__create_issue"). A tool denied here is
	// stripped from the agent's registry at startup — same code path
	// as forge.yaml's deny list.
	DeniedTools []string `yaml:"denied_tools,omitempty" json:"denied_tools,omitempty"`

	// ForbiddenModels lists provider/name pairs the agent must NOT
	// use. If forge.yaml's model OR any model in model.fallbacks
	// matches an entry here, the agent refuses to start. Use cases:
	// cost ceilings ("no Opus in this workspace"), data-residency
	// requirements ("no third-party providers for tenant X").
	ForbiddenModels []ModelMatcher `yaml:"forbidden_models,omitempty" json:"forbidden_models,omitempty"`

	// MaxEgressAllowlistSize caps the number of entries forge.yaml's
	// egress.allowed_domains may declare. Defense against allowlist
	// bloat — a developer adding 200 third-party domains to their
	// allowlist is almost certainly doing something they shouldn't.
	// Zero means no cap (today's behavior).
	MaxEgressAllowlistSize int `yaml:"max_egress_allowlist_size,omitempty" json:"max_egress_allowlist_size,omitempty"`

	// MaxToolCount caps the number of tools the agent may register
	// (after intersection/union math). Same rationale as
	// MaxEgressAllowlistSize. Zero means no cap.
	MaxToolCount int `yaml:"max_tool_count,omitempty" json:"max_tool_count,omitempty"`

	// DeniedChannels reserved for FWS-6 (#90) — channel-policy
	// injection. Not consumed by the runtime in v1; the field is on
	// the schema so operators write one policy document, not two.
	DeniedChannels []string `yaml:"denied_channels,omitempty" json:"denied_channels,omitempty"`
}

PlatformPolicy is the workspace-level runtime safety net the platform (initializ Command, custom deployers, GitOps controllers) injects at deploy time to bound what a forge.yaml is allowed to declare. The agent's forge.yaml is what it *claims* to do; the platform policy is the *ceiling* — the agent refuses to start if its declaration exceeds the ceiling. See issue #89 / FWS-5.

Absence of a policy file is the normal case for self-managed deployments and `forge run`: when FORGE_PLATFORM_POLICY is unset (or points at a missing file), the loader returns a zero-value policy that constrains nothing — fully backward compatible with pre-FWS-5 behavior.

The policy is read **once at startup**. Live reload is deliberately out of scope for v1: policy changes require a redeploy. This keeps the trust boundary predictable — the agent's running state always reflects the policy file that was present at boot.

Schema sharing with FWS-6 (#90): the DeniedChannels slot is reserved here so operators read the policy as a single document. Until FWS-6 ships, the field exists but is not consumed by the runtime.

func LoadPlatformPolicy

func LoadPlatformPolicy(path string) (PlatformPolicy, error)

LoadPlatformPolicy reads + parses a platform policy file from disk. An empty path or a missing file returns the zero-value policy with no error — both map to "no platform policy applied" by design. Parse errors and schema-validation errors are returned so the caller can fail startup loudly (a malformed policy file is an operator mistake that must NOT default to "no policy" — that's the opposite of safe).

func ParsePlatformPolicy

func ParsePlatformPolicy(data []byte) (PlatformPolicy, error)

ParsePlatformPolicy parses a YAML byte slice into a PlatformPolicy and validates the result. Exposed separately from LoadPlatformPolicy so `forge validate --platform-policy` can lint a policy without touching the filesystem twice and so tests don't need a tempdir.

func (PlatformPolicy) ChannelDenied

func (p PlatformPolicy) ChannelDenied(name string) bool

ChannelDenied reports whether the given channel name is on the platform deny list. Match is case-sensitive — channel names are registry identifiers (e.g. "slack", "telegram", "msteams"), same convention as tool names. Used by the runtime at channel adapter init to skip denied channels and emit a channel_denied_by_policy audit event. See issue #90 / FWS-6.

func (PlatformPolicy) EgressDomainDenied

func (p PlatformPolicy) EgressDomainDenied(domain string) bool

EgressDomainDenied reports whether the given domain is on the platform deny list. Case-insensitive exact match — domain values in forge.yaml are normalized at parse time, but the platform deny list comes straight from an operator's YAML and we don't want a case-difference to slip something through.

func (PlatformPolicy) IsZero

func (p PlatformPolicy) IsZero() bool

IsZero reports whether the policy applies any constraint. A zero-value policy is what callers get when no FORGE_PLATFORM_POLICY is set; IsZero lets the runtime skip enforcement entirely (and skip emitting the policy_loaded audit event) for the common "no platform policy" case.

func (PlatformPolicy) ModelForbidden

func (p PlatformPolicy) ModelForbidden(provider, name string) bool

ModelForbidden reports whether the given provider/name pair matches any entry in ForbiddenModels. Used to check both the primary model and every fallback.

func (PlatformPolicy) ToolDenied

func (p PlatformPolicy) ToolDenied(name string) bool

ToolDenied reports whether the given tool name is on the platform deny list. Tool names are case-sensitive in the registry, so this match is case-sensitive too — "cli_execute" and "CLI_Execute" are different identifiers.

func (PlatformPolicy) Validate

func (p PlatformPolicy) Validate() error

Validate reports schema-level errors in the policy document itself (separate from "forge.yaml conflicts with policy" which is the runtime's enforcement check). Used by `forge validate --platform-policy` and by the loader.

type PolicyLayer

type PolicyLayer struct {
	// Source is the layer identifier ("system" / "user" / "workspace").
	// Audit events use this verbatim as the fields.layer value.
	Source string
	// Path is the on-disk location of the policy file. Audit events
	// use this as fields.source so a downstream consumer can fetch the
	// document directly.
	Path string
	// Policy is the parsed policy. Zero policy means the layer's file
	// was absent or empty — the loader includes only non-zero layers
	// in the returned slice, so callers don't need to check IsZero.
	Policy PlatformPolicy
}

PolicyLayer is one of the three sources the runtime reads PlatformPolicy from. Each layer's policy is loaded independently; at enforcement time the runner walks all loaded layers and unions their denies (for *_egress_domains / _tools / _channels / forbidden_models) or takes the most restrictive max value (for bound caps).

Layers, in order of broadening scope:

  • LayerSystem: /etc/forge/policy.yaml (or FORGE_SYSTEM_POLICY). Set by a sysadmin pushing corporate-laptop policy. Most users can't write to this path; the runtime simply reads it (root not required to read a world-readable policy file).

  • LayerUser: ~/.forge/policy.yaml. Set by the developer via `forge channel disable …` or the GUI's chip toggle. Applies to every agent this user runs on this machine.

  • LayerWorkspace: file at FORGE_PLATFORM_POLICY env var. Set by the workspace operator (initializ Command, custom controller, GitOps tooling) at deploy time. Applies to the specific deployed agent. Unchanged from FWS-5.

See issue #90 / FWS-6.

func FirstLayerDenyingChannel

func FirstLayerDenyingChannel(layers []PolicyLayer, name string) *PolicyLayer

FirstLayerDenyingChannel — see FirstLayerDenyingEgress.

func FirstLayerDenyingEgress

func FirstLayerDenyingEgress(layers []PolicyLayer, domain string) *PolicyLayer

FirstLayerDenyingEgress returns the first layer whose deny list contains the given domain (system → user → workspace order). Returns nil when no layer denies. Used by the runner to attribute a policy_violation_at_build_time audit event to the deciding layer.

func FirstLayerDenyingTool

func FirstLayerDenyingTool(layers []PolicyLayer, name string) *PolicyLayer

FirstLayerDenyingTool — see FirstLayerDenyingEgress.

func FirstLayerForbiddingModel

func FirstLayerForbiddingModel(layers []PolicyLayer, provider, name string) *PolicyLayer

FirstLayerForbiddingModel — see FirstLayerDenyingEgress.

func LoadAllPolicyLayers

func LoadAllPolicyLayers() ([]PolicyLayer, error)

LoadAllPolicyLayers reads each of the three layers and returns the non-zero ones in source order (system, then user, then workspace). Each absent or empty layer is silently omitted; this is the backward-compat path for pre-FWS-6 deployments that had only the FORGE_PLATFORM_POLICY env (or none of the three).

A malformed policy at ANY layer is an error — operator (or sysadmin, or developer) mistake that must fail loudly. Silently dropping a broken layer would let a typo bypass intended bounds.

Layer-ordering rule for audit attribution: when an offending value (e.g. a denied domain) is on multiple layers' deny lists, the FIRST loaded layer that contains it takes credit. The load order is system → user → workspace, so a system-layer deny "wins" attribution over a user-layer or workspace-layer deny on the same value. This makes the most-restrictive layer the most visible in the audit pipeline.

func MostRestrictiveEgressMax

func MostRestrictiveEgressMax(layers []PolicyLayer) (int, *PolicyLayer)

MostRestrictiveEgressMax walks the layers and returns the smallest non-zero MaxEgressAllowlistSize plus the layer it came from. When no layer sets a non-zero max, returns 0 and nil — caller treats as "no cap." See issue #90 / FWS-6.

func MostRestrictiveToolMax

func MostRestrictiveToolMax(layers []PolicyLayer) (int, *PolicyLayer)

MostRestrictiveToolMax — see MostRestrictiveEgressMax.

type PolicyViolation

type PolicyViolation struct {
	// Kind classifies the violation. The runner audit emitter uses
	// this as the violation_kind field; consumers (cost / compliance
	// dashboards) group by kind.
	Kind PolicyViolationKind

	// OffendingValue is what forge.yaml declared that the policy
	// forbids — a domain, tool name, model identifier, or a numeric
	// count for size-bound violations.
	OffendingValue string

	// ForgeYAMLField is the dotted path into forge.yaml where the
	// offending value lives (e.g. "egress.allowed_domains",
	// "model.name"). Lets the developer's error message point at the
	// exact field to edit.
	ForgeYAMLField string

	// Layer is the policy source that enforced this rule
	// ("system" / "user" / "workspace"). System-layer violations
	// signal a sysadmin-set bound; user-layer signals the local
	// developer's own policy; workspace signals the deploy-time
	// operator policy. See issue #90 / FWS-6.
	Layer string

	// LayerPath is the on-disk location of the enforcing policy file.
	// Surfaced in audit events so consumers can fetch the document
	// directly and in the formatted error so developers know which
	// file to look at.
	LayerPath string
}

PolicyViolation describes a single conflict between forge.yaml's declaration and a policy layer. Multiple violations may be reported from one enforcement pass — the runner emits all of them to audit, then aborts with a single combined error so the developer sees every problem in one go instead of fixing them one at a time.

func EnforcePolicy

func EnforcePolicy(cfg *types.ForgeConfig, layers []PolicyLayer) []PolicyViolation

EnforcePolicy compares a forge.yaml-derived ForgeConfig against the loaded platform policy and reports every violation. An empty violation slice means the configuration is acceptable — the runner should also compute the effective allowlist via EffectiveEgressAllowlist and the effective deny list via EffectiveDeniedTools, both of which apply the intersection / union math separately from violation detection.

The split is intentional: forbidden_model and denied_egress are HARD errors that abort startup (the developer's declaration is explicitly forbidden), while the bound checks are also hard errors but classified separately so the audit pipeline can distinguish "policy-forbidden-value" from "policy-over-budget" — they need different operator responses.

See issue #89 / FWS-5, issue #90 / FWS-6.

type PolicyViolationKind

type PolicyViolationKind string

PolicyViolationKind enumerates the conflict categories. New values are additive — audit consumers that don't recognize a kind string should pass it through rather than reject the event.

const (
	ViolationDeniedEgress        PolicyViolationKind = "denied_egress"
	ViolationDeniedTool          PolicyViolationKind = "denied_tool"
	ViolationForbiddenModel      PolicyViolationKind = "forbidden_model"
	ViolationEgressBoundExceeded PolicyViolationKind = "egress_bound_exceeded"
	ViolationToolBoundExceeded   PolicyViolationKind = "tool_bound_exceeded"
)

type Resolver

type Resolver interface {
	LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}

Resolver abstracts DNS resolution for testability.

type SafeDialer

type SafeDialer struct {
	// contains filtered or unexported fields
}

SafeDialer validates resolved IPs before establishing connections, preventing DNS rebinding and SSRF via post-resolution checks.

func NewSafeDialer

func NewSafeDialer(resolver Resolver, allowPrivateIPs bool) *SafeDialer

NewSafeDialer creates a SafeDialer. If resolver is nil, net.DefaultResolver is used. Set allowPrivateIPs to true in container environments where RFC 1918 addresses are used for inter-service communication.

func (*SafeDialer) SafeDialContext

func (s *SafeDialer) SafeDialContext(ctx context.Context, network, addr string) (net.Conn, error)

SafeDialContext resolves the address, validates all resulting IPs against blocked ranges, then dials the first safe IP directly to avoid TOCTOU re-resolution.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL