Documentation
¶
Overview ¶
Package security provides egress security resolution for containerized agents.
Index ¶
- Constants
- Variables
- func AuthDomains(cfg types.AuthConfig) []string
- func EffectiveDeniedTools(forgeDenied []string, layers []PolicyLayer) []string
- func EffectiveEgressAllowlist(cfg *types.ForgeConfig, layers []PolicyLayer) []string
- func EffectiveToolCount(cfg *types.ForgeConfig, layers []PolicyLayer) int
- func EgressClientFromContext(ctx context.Context) *http.Client
- func EgressTransportFromContext(ctx context.Context) http.RoundTripper
- func FormatViolations(violations []PolicyViolation) string
- func GenerateAllowlistJSON(cfg *EgressConfig) ([]byte, error)
- func GenerateK8sNetworkPolicy(agentID string, cfg *EgressConfig) ([]byte, error)
- func InContainer() bool
- func InferToolDomains(toolNames []string) []string
- func IsBlockedIP(ip net.IP, allowPrivate bool) bool
- func IsLocalhost(host string) bool
- func LLMProviderDomains(cfg *types.ForgeConfig) []string
- func LLMProviderEnvDomains(envVars map[string]string) []string
- func MCPDomainSources(cfg types.MCPConfig) map[string]string
- func MCPDomains(cfg types.MCPConfig) []string
- func NewSafeTransport(resolver Resolver, allowPrivateIPs bool) *http.Transport
- func OTelDomain(cfg types.TracingYAML) []string
- func ParseStrictIPv4(s string) net.IP
- func ResolveCapabilities(capabilities []string) []string
- func SafeRedirectPolicy(maxRedirects int) func(*http.Request, []*http.Request) error
- func SystemPolicyPath() string
- func UserPolicyPath() string
- func ValidateHostIP(host string) error
- func WithEgressClient(ctx context.Context, client *http.Client) context.Context
- func WorkspacePolicyPath() string
- type ChannelSkip
- type DomainMatcher
- type EgressConfig
- type EgressEnforcer
- type EgressMode
- type EgressProfile
- type EgressProxy
- type ModelMatcher
- type PlatformPolicy
- func (p PlatformPolicy) ChannelDenied(name string) bool
- func (p PlatformPolicy) EgressDomainDenied(domain string) bool
- func (p PlatformPolicy) IsZero() bool
- func (p PlatformPolicy) ModelForbidden(provider, name string) bool
- func (p PlatformPolicy) ToolDenied(name string) bool
- func (p PlatformPolicy) Validate() error
- type PolicyLayer
- func FirstLayerDenyingChannel(layers []PolicyLayer, name string) *PolicyLayer
- func FirstLayerDenyingEgress(layers []PolicyLayer, domain string) *PolicyLayer
- func FirstLayerDenyingTool(layers []PolicyLayer, name string) *PolicyLayer
- func FirstLayerForbiddingModel(layers []PolicyLayer, provider, name string) *PolicyLayer
- func LoadAllPolicyLayers() ([]PolicyLayer, error)
- func MostRestrictiveEgressMax(layers []PolicyLayer) (int, *PolicyLayer)
- func MostRestrictiveToolMax(layers []PolicyLayer) (int, *PolicyLayer)
- type PolicyViolation
- type PolicyViolationKind
- type Resolver
- type SafeDialer
Constants ¶
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 ¶
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.
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 ¶
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 ¶
InferToolDomains looks up known domains for the given tool names and returns a deduplicated list.
func IsBlockedIP ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
ResolveCapabilities returns a deduplicated list of domains for the given capability names.
func SafeRedirectPolicy ¶
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 ¶
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 ¶
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.
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.
type EgressMode ¶
type EgressMode string
EgressMode controls egress behavior.
const ( ModeDenyAll EgressMode = "deny-all" ModeAllowlist EgressMode = "allowlist" ModeDevOpen EgressMode = "dev-open" )
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"`
// Guardrails is the platform guardrails OVERLAY (#284) — a
// most-restrictive layer merged over the agent's guardrails.json. It
// uses the exact same schema as guardrails.json
// (guardrails.StructuredGuardrails: pii / security / customRules /
// gateConfig / …), authored here in YAML with the same camelCase
// field names.
//
// It is held as a raw subtree (map[string]any) rather than the typed
// struct so forge-core stays free of a dependency on the external
// guardrails module — forge-cli bridges this YAML subtree into the
// typed StructuredGuardrails (YAML → JSON → struct) and applies the
// one-way tighten merge. The platform can only tighten (force
// detections/gates on, raise actions, lower thresholds, union
// rule/denylist/blocked-skill sets); it can never loosen.
Guardrails map[string]any `yaml:"guardrails,omitempty" json:"guardrails,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 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 ¶
SafeDialContext resolves the address, validates all resulting IPs against blocked ranges, then dials the first safe IP directly to avoid TOCTOU re-resolution.
Source Files
¶
- allowlist.go
- auth_domains.go
- capabilities.go
- container.go
- domain_matcher.go
- egress_enforcer.go
- egress_proxy.go
- ip_validator.go
- llm_provider_domains.go
- mcp_domains.go
- network_policy.go
- otel_domains.go
- platform_policy.go
- platform_policy_enforce.go
- platform_policy_layers.go
- redirect.go
- resolver.go
- safe_dialer.go
- tool_domains.go
- types.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package defer implements governance R4c — the DEFER authorization decision.
|
Package defer implements governance R4c — the DEFER authorization decision. |
|
Package intent implements governance R3 — the intent-alignment policy check.
|
Package intent implements governance R3 — the intent-alignment policy check. |
|
Package stepup implements governance R4b — the STEP_UP authorization decision.
|
Package stepup implements governance R4b — the STEP_UP authorization decision. |