effect

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package effect provides the filesystem and HTTP effect providers through which every agent-reachable side effect must pass.

The point is structural confinement. A tool that reads or writes a host file does not call os.Open; it calls a method on an FS the process handed it, and that FS is what decides whether the path is allowed. A tool that talks HTTP does not build an net/http.Client; it uses a Doer whose policy decides which hosts it may reach. Confinement therefore cannot be lost by omission: a new tool author has nothing to remember, because the unconfined primitive is not reachable from the tool at all.

Index

Constants

This section is empty.

Variables

View Source
var ErrDenied = errors.New("effect denied by policy")

ErrDenied is returned by a provider that refuses an effect outright, either because the effect is not configured for this process (see Deny) or because policy forbids it.

View Source
var ErrOutsideRoot = errors.New("path is outside the allowed root")

ErrOutsideRoot is returned by a confined FS for a path resolving outside its root.

Functions

func AllowHostOf

func AllowHostOf(rawURL string) []string

AllowHostOf returns rawURL's authority as a single-entry allowlist, for a client whose only legitimate destination is the upstream it was configured with. The port is kept when the URL carries one, so an upstream on localhost:3000 does not imply the rest of the loopback interface.

An empty or unparseable URL yields no entries, i.e. a client that can reach nothing — an unconfigured upstream should fail closed.

func NewHTTPClient

func NewHTTPClient(opts HTTPOptions) *http.Client

NewHTTPClient returns a client that can only reach destinations the policy allows. The check runs on the request, on each redirect, and on the address the dialer actually connects to.

Types

type Doer

type Doer interface {
	Do(req *http.Request) (*http.Response, error)
}

Doer performs HTTP requests. net/http.Client satisfies it, and so does a test fake: taking a Doer rather than building a client inline is what makes an HTTP-calling package testable without a live server.

type FS

type FS interface {
	Open(name string) (File, error)
	Create(name string) (File, error)
	ReadFile(name string) ([]byte, error)
	WriteFile(name string, data []byte, perm fs.FileMode) error
	Stat(name string) (fs.FileInfo, error)
	MkdirAll(name string, perm fs.FileMode) error
	Remove(name string) error
	RemoveAll(name string) error
	Rename(oldpath, newpath string) error

	// Resolve reports the host path name maps to, or an error if the provider
	// would refuse it. It exists so a caller can reject a bad path up front,
	// with a message naming it, rather than failing halfway through an async
	// job.
	//
	// It is NOT the gate, and a caller is never required to call it: the
	// operations above enforce the same policy themselves, and they enforce
	// more of it (Resolve cannot see a symlink that has yet to be traversed).
	// Treat it as a diagnostic, not a permission check.
	Resolve(name string) (string, error)
}

FS is the host filesystem provider. It mirrors the os functions the tools need, so a call site reads the same as the code it replaces, but every path is subject to the provider's policy.

Paths may be absolute or relative to the provider's root; a confined provider rejects any path that resolves outside it, symlinks included.

func Deny

func Deny(reason string) FS

Deny returns an FS that refuses every operation with ErrDenied, wrapped with reason. It is the right zero value for a process that must not touch host files at all (sandbox-mcp), and it keeps call sites free of nil checks.

func OS

func OS() FS

OS returns an unconfined FS backed by the os package.

It enforces nothing, so it belongs only where the paths are the operator's (startup config, CLI flags) or the test's — never where a path can reach an agent. Confine agent-reachable paths with Root.

func Root

func Root(dir string) FS

Root returns an FS confined to dir.

Every path is first resolved against dir lexically (an absolute path outside dir, or one climbing out with "..", is rejected with ErrOutsideRoot), and then executed through an os.Root, which additionally refuses to follow a symlink out of dir. The lexical check alone is not enough: it cannot see a symlink planted inside the root, which is how a "confined" write ends up on an arbitrary host path.

dir is created (0700) on first use rather than here, so a provider can be handed to a constructor that has nowhere to report an error. A dir that cannot be created turns every operation into that error.

Each operation opens and closes its own os.Root rather than caching one for the FS's lifetime: on Windows an open directory handle blocks removal of the directory (and its contents) out from under it, which would leak into any caller that expects dir to be removable once it is done, such as testing.T.TempDir's cleanup.

type File

type File interface {
	io.ReadWriteSeeker
	io.Closer
	Stat() (fs.FileInfo, error)
}

File is an open file handle from an FS.

type HTTPOptions

type HTTPOptions struct {
	// Policy is the egress allowlist. Its zero value allows nothing.
	Policy HTTPPolicy
	// Timeout bounds a whole request. Zero means no timeout, which is what a
	// streaming transport (SSE, streamable HTTP) needs.
	Timeout time.Duration
	// TLSClientConfig, if set, replaces the default TLS configuration (custom
	// CA bundle, client certificates).
	TLSClientConfig *tls.Config
}

HTTPOptions configures NewHTTPClient.

type HTTPPolicy

type HTTPPolicy struct {
	// AllowHosts is the destination allowlist. An entry is a hostname
	// ("grafana.internal"), a host:port ("grafana.internal:3000"), a subdomain
	// wildcard ("*.example.com"), or "*" for any host.
	//
	// An entry carrying a port pins the destination to that port, which is
	// what an upstream on localhost needs: without it, "localhost" would allow
	// every other service on the loopback interface, and an SSRF against a
	// Grafana on :3000 could walk the whole local port range.
	//
	// The zero value allows nothing. That is deliberate — a client is built
	// for a known upstream, so its allowlist is the upstream it was configured
	// with, and a request anywhere else is a bug or an attack.
	AllowHosts []string
	// AllowLinkLocal permits link-local destinations (169.254.0.0/16,
	// fe80::/10). These are blocked by default because that range holds the
	// cloud instance-metadata service (169.254.169.254), whose credentials are
	// the usual prize in an SSRF.
	AllowLinkLocal bool
}

HTTPPolicy decides which HTTP egress is allowed.

It is checked in three places, because any one of them alone is bypassable: on the outgoing request, on every redirect the server asks us to follow, and on the IP the hostname actually resolved to (an allowed name whose DNS answer points at 169.254.169.254 is not an allowed destination).

func (HTTPPolicy) CheckIP

func (p HTTPPolicy) CheckIP(ip net.IP) error

CheckIP reports whether ip is a destination this policy allows. It runs after DNS resolution, so a hostname cannot smuggle a blocked address in through its A record.

func (HTTPPolicy) CheckURL

func (p HTTPPolicy) CheckURL(u *url.URL) error

CheckURL reports whether u is a destination this policy allows.

Jump to

Keyboard shortcuts

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