security

package
v0.18.1 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: Apache-2.0 Imports: 22 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 APIDomainSources added in v0.18.1

func APIDomainSources(cfg types.APIConfig) map[string]string

APIDomainSources tags each API host with "api:<server-name>" for egress allowlist provenance (mirrors MCPDomainSources). First server name wins.

func APIDomains added in v0.18.1

func APIDomains(cfg types.APIConfig) []string

APIDomains extracts the outbound hosts that API servers in forge.yaml apis.servers[] must be reachable on. Mirrors MCPDomains: without this merge into the egress allowlist, an api-tool's outbound REST call would be silently blocked by the egress enforcer. Only the base_url host is needed (bearer/ static auth — no separate OAuth AS host). Deduped + sorted for stable output.

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, allowedPrivateCIDRs []*net.IPNet) bool

IsBlockedIP checks whether an IP is in a blocked CIDR range.

Semantics:

  • Always-blocked ranges (cloud metadata, loopback, "this" network) win unconditionally — no allowlist punches a hole in them.
  • If allowPrivate is true, RFC 1918 / link-local / CGNAT / IPv6 ULA are all permitted (container/K8s posture).
  • Otherwise, private ranges are blocked EXCEPT for IPs that fall inside one of allowedPrivateCIDRs. That lets an operator open a narrow slice of the private space (e.g. only 10.20.0.0/16) without opening RFC 1918 wholesale.

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, allowedPrivateCIDRs []*net.IPNet) *http.Transport

NewSafeTransport creates an http.Transport that uses SafeDialer for all connections. If resolver is nil, net.DefaultResolver is used. See NewSafeDialer for the semantics of allowedPrivateCIDRs.

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 ParsePrivateCIDRs added in v0.18.1

func ParsePrivateCIDRs(cidrs []string) ([]*net.IPNet, error)

ParsePrivateCIDRs parses a list of CIDR strings into net.IPNet values. Returns an error naming the first invalid entry. Entries must be canonical CIDR notation (e.g. "10.0.0.0/8"); bare IPs are rejected — the intent is range-level exemption, not per-host holes.

Non-canonical entries with host bits set (e.g. "10.20.0.5/16") are rejected too. `net.ParseCIDR` silently masks those to the network (10.20.0.0/16), which is the "wider than intended" direction — an operator who wrote "10.20.0.5/16" expecting a single host would instead get the whole /16 allowed. Failing loud here forces the operator to either write "10.20.0.5/32" (single host — explicit) or "10.20.0.0/16" (the range they really meant). #348 review nit 2.

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 DeniedCommandPattern added in v0.17.1

type DeniedCommandPattern struct {
	Pattern     string
	Message     string
	LayerSource string // "system" / "user" / "workspace"
	LayerPath   string
}

DeniedCommandPattern is one operator-authored command-deny pattern resolved from the layer stack, carrying its originating layer for runtime audit attribution (#238). Regex compilation happens at the enforcement site (forge-core/runtime), keeping this package free of a compiled-regex dependency in the policy schema.

func EffectiveDeniedCommandPatterns added in v0.17.1

func EffectiveDeniedCommandPatterns(layers []PolicyLayer) []DeniedCommandPattern

EffectiveDeniedCommandPatterns returns the union of every layer's denied_command_patterns in load order (system → user → workspace), deduped by pattern string. The FIRST layer to declare a pattern owns the attribution — so an audit block reports the broadest-scope layer that forbade the command, matching operator mental model (a corporate system policy "wins" the attribution over a workspace repeat).

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 EgressAttempt added in v0.18.1

type EgressAttempt struct {
	Domain        string
	Allowed       bool
	TaskID        string
	CorrelationID string
}

EgressAttempt describes a single egress decision for audit correlation. TaskID and CorrelationID are recovered from the Proxy-Authorization header the subprocess sends (see identityFromRequest) — the caller injects them as userinfo in the HTTP_PROXY URL, which HTTP clients replay as Basic proxy credentials on every request and CONNECT. They are empty when the client doesn't send credentials (arbitrary binaries), which degrades gracefully to the pre-#338 behaviour: a domain-only event with no task attribution.

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"`
	// AllowedPrivateCIDRs is the resolved list of CIDR strings. Callers
	// should run these through ParsePrivateCIDRs to obtain the []*net.IPNet
	// the SafeDialer/EgressProxy constructors expect.
	AllowedPrivateCIDRs []string `json:"allowed_private_cidrs,omitempty"`
	// AllowedTCP is the resolved raw-TCP allowlist (`host:port` entries).
	// Callers pass this to NewTCPMatcher when wiring the EgressProxy's
	// SOCKS5 listener. Nil / empty → no SOCKS5 listener is bound.
	AllowedTCP []string `json:"allowed_tcp,omitempty"`
}

EgressConfig holds the resolved egress configuration.

func Resolve

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

Resolve builds an EgressConfig from profile, mode, explicit domains, tool names, capabilities, and (optionally) the raw allowed_private_cidrs and allowed_tcp lists from forge.yaml. All list entries are validated here so a bad string fails at config-load time, not at first-dial. Pass nil for pre-CIDR / pre-TCP defaults.

type EgressEnforcer

type EgressEnforcer struct {
	AllowPrivateIPs     bool
	AllowedPrivateCIDRs []*net.IPNet
	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, allowedPrivateCIDRs []*net.IPNet) *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. allowedPrivateCIDRs narrows the SafeDialer's private-IP block; see NewSafeDialer.

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(EgressAttempt)
	// contains filtered or unexported fields
}

EgressProxy is a localhost-only forward proxy that validates outbound destinations before forwarding traffic. It runs TWO listeners on distinct ports:

  • An HTTP forward proxy (plain HTTP + HTTPS via CONNECT). Clients reach it via HTTP_PROXY / HTTPS_PROXY. This is the pre-existing surface.
  • A SOCKS5 CONNECT proxy for raw-TCP flows (databases, message brokers). Clients reach it via ALL_PROXY / SOCKS_PROXY. Started only when `allowed_tcp` is non-empty — one listener less to reason about at deploy time when raw-TCP isn't configured. See issue #337.

Both listeners share the same enforcement primitive (`ValidateAndDial`) so the allowlist policy and audit shape can't drift between HTTP and TCP.

func NewEgressProxy

func NewEgressProxy(matcher *DomainMatcher, allowPrivateIPs bool, allowedPrivateCIDRs []*net.IPNet) *EgressProxy

NewEgressProxy creates a new EgressProxy that validates domains using the given DomainMatcher. Call Start to bind and begin serving. allowedPrivateCIDRs narrows the private-IP block: when allowPrivateIPs is false, IPs inside any of the listed CIDRs bypass the private block. Pass nil for pre-CIDR defaults.

Raw-TCP allowlist entries live on the returned proxy via SetTCPMatcher — separating them from the constructor keeps the call sites that don't need SOCKS5 (browser capability, dev-open mode, tests) unchanged.

func (*EgressProxy) ProxyURL

func (p *EgressProxy) ProxyURL() string

ProxyURL returns the URL for HTTP_PROXY/HTTPS_PROXY env vars.

func (*EgressProxy) SOCKSURL added in v0.18.1

func (p *EgressProxy) SOCKSURL() string

SOCKSURL returns the URL for ALL_PROXY/SOCKS_PROXY env vars, or empty when raw-TCP egress isn't configured. Uses the `socks5h://` scheme so clients send the destination hostname (not a pre-resolved IP) — the proxy needs the hostname to record it in the audit hook.

func (*EgressProxy) SetTCPMatcher added in v0.18.1

func (p *EgressProxy) SetTCPMatcher(m *TCPMatcher)

SetTCPMatcher installs a port-aware allowlist for raw-TCP egress. When the matcher is non-nil and non-empty, Start also binds a SOCKS5 listener and SOCKSURL() returns a non-empty URL. Must be called before Start.

func (*EgressProxy) Start

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

Start binds to 127.0.0.1:0 (random ports) and begins serving. Returns the HTTP proxy URL (e.g., "http://127.0.0.1:54321"). The SOCKS5 listener, if TCPMatcher is non-empty, is started at the same time and its URL is available via SOCKSURL().

func (*EgressProxy) Stop

func (p *EgressProxy) Stop() error

Stop gracefully shuts down the proxy with a 5-second timeout. Both listeners are closed. Safe to call on a not-yet-Started proxy.

func (*EgressProxy) ValidateAndDial added in v0.18.1

func (p *EgressProxy) ValidateAndDial(ctx context.Context, host, port string) (net.Conn, error)

ValidateAndDial is the single gate for all outbound TCP through the proxy. Both `handleConnect` (HTTP-CONNECT path) and `handleSOCKS5` (raw-TCP path) call this — sharing the primitive prevents the two codepaths from drifting on either the allowlist policy or the audit shape.

Order of operations:

  1. Localhost → dial directly, no matcher check. Matches the pre-existing CONNECT path exactly (localhost has always been an implicit exemption).
  2. Reject non-standard IP literals early via `ValidateHostIP` — the same octal / hex / packed-decimal guard the RoundTripper enforces.
  3. Match against BOTH the hostname matcher (`DomainMatcher`, HTTP-shared) AND the port-aware TCP matcher. A target passes if EITHER allows it. This means an HTTP-allowed hostname is reachable over CONNECT/SOCKS5 without a redundant `allowed_tcp` entry — the reverse of "allowlist duplicated across two config keys."
  4. Fire the audit hook exactly once with the (host, port) pair and the decision. Same shape for HTTP and SOCKS5 flows.
  5. On allow, dial via `SafeDialer` (SSRF + private-CIDR + strict-IP guard). On deny, return a policy error — no dial, no audit fanout.

The context is threaded through to `SafeDialContext` so the dial respects downstream cancellation (agent tool timeout, session shutdown).

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"`

	// DeniedCommandPatterns is an operator-authored, argument-level
	// command denylist applied to EVERY tool call by ANY skill the agent
	// uses (#238 / ASI02). It gives operators org-wide "keep cli_execute
	// but ban `rm -rf` / `git push --force` / `kubectl delete`" control
	// that today only skill authors can express via SKILL.md deny_commands.
	//
	// UNIQUE among PlatformPolicy fields: every other field is enforced
	// ONCE at startup (registry strip / allowlist diff / refuse-to-start).
	// This one is enforced PER INVOCATION — matched at BeforeToolExec on
	// each call's arguments with the same match target as skill
	// deny_commands (cli_execute → reconstructed command line; any other
	// tool → raw tool-input JSON). The tool is NOT stripped; only matching
	// calls are blocked, and a block emits a runtime guardrail_check audit
	// event tagged source: platform with first-denying-layer attribution.
	//
	// Unioned across layers like the other deny lists; a skill's own
	// deny_commands cannot relax an operator pattern (compose =
	// most-restrictive-wins). Patterns are compiled at startup and an
	// invalid regex fails closed (aborts startup), matching the loud-fail
	// posture of the other policy fields. Reuses agentspec.CommandFilter so
	// operators and skill authors author patterns identically and attach an
	// optional custom deny message.
	DeniedCommandPatterns []agentspec.CommandFilter `yaml:"denied_command_patterns,omitempty" json:"denied_command_patterns,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 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, allowedPrivateCIDRs []*net.IPNet) *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. allowedPrivateCIDRs is a narrower alternative: when allowPrivateIPs is false, IPs falling inside any of these CIDRs bypass the private-block (but always-blocked ranges — cloud metadata, loopback — still win unconditionally). Pass nil for the pre-CIDR default behavior.

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.

type TCPMatcher added in v0.18.1

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

TCPMatcher enforces port-aware allowlist entries for raw-TCP egress.

Entries have the shape `host:port` (or `host:*` for any-port on that host). Host portion supports the same exact + wildcard-suffix rules as DomainMatcher, so `*.brokers.internal:9092` matches `broker1.brokers.internal:9092` on the exact declared port.

The matcher is the port-carrying peer of DomainMatcher: DomainMatcher covers HTTP(S) traffic (where the transport carries no port granularity), and TCPMatcher covers raw-TCP flows through the SOCKS5 gate where the client negotiates a specific host:port pair. Both matchers are consulted at the dial gate — a target passes if either matcher allows it.

func NewTCPMatcher added in v0.18.1

func NewTCPMatcher(entries []string) (*TCPMatcher, error)

NewTCPMatcher parses the `allowed_tcp` config entries into a matcher. Entries must be `host:port` or `host:*` (bare host without port is rejected). Port `0` and ports outside 1–65535 are rejected. Returns an error naming the first invalid entry so bad config trips at load, not at first dial.

func (*TCPMatcher) Empty added in v0.18.1

func (m *TCPMatcher) Empty() bool

Empty reports whether the matcher has zero configured entries. Callers use this to skip starting a SOCKS5 listener when raw-TCP egress isn't configured — the listener is unnecessary and its port is one more thing to reason about at deploy time.

func (*TCPMatcher) IsAllowed added in v0.18.1

func (m *TCPMatcher) IsAllowed(host, port string) bool

IsAllowed returns true if the (host, port) pair matches any configured entry. Host is compared case-insensitively.

Directories

Path Synopsis
Package authgate implements the AARM R10 auth-required gate — the pause-and-resume primitive behind delegated MCP consent (#330).
Package authgate implements the AARM R10 auth-required gate — the pause-and-resume primitive behind delegated MCP consent (#330).
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.

Jump to

Keyboard shortcuts

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