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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 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).