Documentation
¶
Overview ¶
Package security provides fine-grained authorization for Scheme runtime operations. It defines the Authorizer interface and context helpers that gate primitives like file I/O, code loading, and process control.
The package depends only on werr/ for error types and can be imported with minimal dependencies.
Index ¶
- Constants
- Variables
- func Check(ctx context.Context, req AccessRequest) errordeprecated
- func CheckWithAuthorizer(auth Authorizer, req AccessRequest) error
- func ConfinementRootOf(auth Authorizer) (string, bool)
- func WithAuthorizer(ctx context.Context, auth Authorizer) context.Context
- type AccessRequest
- type Authorizer
- func All(authorizers ...Authorizer) Authorizer
- func ConsoleAuthorizer() Authorizer
- func ConsoleWithLoadAllowingVirtualSources() Authorizer
- func ConsoleWithLoadAuthorizer() Authorizer
- func DenyAll() Authorizer
- func FilesystemRoot(root string) Authorizer
- func FilesystemRootWithVirtualSources(root string) Authorizer
- func FromContext(ctx context.Context) Authorizer
- func ReadOnly() Authorizer
- func ReadOnlyWithLoad() Authorizer
- func SandboxAuthorizer(envPrefix string) Authorizer
- type AuthorizerFunc
- type RootConfined
Constants ¶
const ( // ResourceFile covers every host path the program names, under the chmod // triple ActionRead / ActionWrite / ActionExec (plus ActionStat and // ActionDelete). Enforce them together: they are one resource with one // Target, so a single containment predicate decides all of them, and an // authorizer that confines reads and writes but not exec confines nothing -- // an executable outside the root is a general-purpose file accessor. // // A primitive whose argument denotes a host path MUST file it here, whatever // else it also asks. set-current-directory! files file:write on the // destination rather than an opaque process request, and process-spawn files // file:exec on the resolved binary, for exactly this reason: a path-confining // authorizer can only confine paths it is shown. ResourceFile = "file" ResourceCode = "code" ResourceEnv = "env" ResourceProcess = "process" // ResourceNamespace covers constructing a first-class environment whose // capability surface is not already the engine's. It is deliberately not // ResourceCode: "may run new code" and "may acquire primitives this engine // never registered" are different questions, and an authorizer that permits // eval under a confined root would otherwise also hand over gointerop. ResourceNamespace = "namespace" // ResourceStream covers the host process's standard streams, which the io // extension pre-opens as current-{input,output,error}-port. It is deliberately // not ResourceFile: the streams are handed to the engine at construction // rather than named by the program, so there is no path to confine, and an // authorizer that permits reads under a filesystem root is not thereby saying // the program may drain the host's stdin. Target is one of StreamStdin, // StreamStdout, StreamStderr; Action is ActionRead or ActionWrite. The gate // runs once per engine, when io.NewState builds the port parameters -- a // refusal means the port is never opened, not that each write is checked. ResourceStream = "stream" )
Well-known resource constants. Extensions may define additional resources without modifying this package.
const ( StreamStdin = "stdin" StreamStdout = "stdout" StreamStderr = "stderr" )
Well-known ResourceStream targets: the three host streams the io extension binds to current-{input,output,error}-port.
const ( ActionRead = "read" ActionWrite = "write" ActionDelete = "delete" ActionStat = "stat" ActionLoad = "load" // load+run code from a resolved file path ActionEval = "eval" // compile+run code from an in-memory datum (eval/compile) ActionExit = "exit" // ActionExec is the chmod x bit, and it asks a different question of each // resource. On ResourceProcess it is the CAPABILITY: may this program spawn a // subprocess at all (process-spawn). On ResourceFile it is the OBJECT: may it // run THIS binary, and -- POSIX x on a directory being traverse -- may a child // START in this directory. A spawn asks both, so a path-confining authorizer // sees the binary and the working directory it would otherwise never be shown. ActionExec = "exec" ActionExecShell = "exec-shell" // shell command execution (system) ActionCreate = "create" // construct a capability-bearing object (namespace) )
Well-known action constants. Extensions may define additional actions without modifying this package.
const SourceVirtualFS = "virtual-fs"
SourceVirtualFS is the TargetSource of a path served by a virtual filesystem (an embedder's WithSourceFS). It is the only named source: an fs.FS is an anonymous interface value with no name to report, and deriving one from its Go type would make the authorizer's answer depend on the embedder's type names. There is deliberately no wildcard — "any source" is the zero value's absence of a claim, not a value.
Variables ¶
var ErrAccessDenied = werr.NewStaticError("access denied")
ErrAccessDenied is the sentinel error returned when an Authorizer denies an operation. Use errors.Is to check for it. See the Authorizer doc for the deny-error wrapping convention.
Functions ¶
func Check
deprecated
func Check(ctx context.Context, req AccessRequest) error
Check authorizes req against the Authorizer in ctx.
Deprecated: The authorizer now lives on Namespace, not context. Production gate sites use CheckWithAuthorizer(mc.Authorizer(), req). This function remains for backward compatibility but will always find nil (open by default) unless the caller explicitly injects an authorizer via WithAuthorizer(ctx, auth).
New code should use CheckWithAuthorizer directly.
func CheckWithAuthorizer ¶
func CheckWithAuthorizer(auth Authorizer, req AccessRequest) error
CheckWithAuthorizer checks authorization using an explicit authorizer. Returns nil if auth is nil (open by default).
On denial it wraps the authorizer's error with the operation's action, resource, and target — supplying the context that lets authorizers return the bare ErrAccessDenied sentinel (see the Authorizer deny-error wrapping convention).
func ConfinementRootOf ¶
func ConfinementRootOf(auth Authorizer) (string, bool)
ConfinementRootOf reports the filesystem confinement root that applies to auth, if any. It unwraps All() composites, returning the first member that confines to a root.
This is a defense-in-depth bound, not the exact confinement. A composite is an intersection, so the true confinement is the intersection of every member's root, which is always a subset of the first member's root. The os.Root layer this drives is therefore never wider than that member's subtree, but for a multi-root composite it may be looser than the full intersection. That is safe: the policy layer (Authorize) enforces the full intersection and runs before any open, so returning the first root cannot widen access. (If multi-root composites ever need exact os.Root containment, return the deepest containing root for nested members and ok=false for disjoint ones — the profiles currently construct only single-root composites, so first-member-wins suffices.)
Returns ok=false when nothing confines auth to a root — callers then fall back to unconfined os operations, still gated by the policy layer.
func WithAuthorizer ¶
func WithAuthorizer(ctx context.Context, auth Authorizer) context.Context
WithAuthorizer returns a child context carrying the given Authorizer. Primitives retrieve it via FromContext or Check.
Types ¶
type AccessRequest ¶
type AccessRequest struct {
Resource string
Action string
Target string
// TargetSource names the namespace Target is drawn from. The zero value is
// the host OS filesystem — the only kind that existed before this field, so
// no built-in authorizer changes meaning for a request that omits it. A
// non-empty value (today only SourceVirtualFS) says Target is a path inside
// a virtual fs.FS supplied through WithSourceFS, and is therefore meaningless
// to OS path containment: "evil.scm" names a file in that fs.FS, while
// resolving it as an OS path silently reinterprets it against the process
// working directory.
TargetSource string
}
AccessRequest describes an operation that requires authorization. Resource and Action use well-known string constants defined below. Target is operation-specific (e.g., a file path, environment variable name, or library name).
type Authorizer ¶
type Authorizer interface {
Authorize(req AccessRequest) error
}
Authorizer decides whether an operation is allowed. Implementations must be safe for concurrent use.
Authorize returns nil to allow the operation, or an error wrapping ErrAccessDenied to deny it. Returning a non-nil error that does not wrap ErrAccessDenied is treated as a deny with an unexpected cause.
Deny-error wrapping convention: an Authorizer should return the bare ErrAccessDenied sentinel. CheckWithAuthorizer wraps every denial with the operation's action, resource, and target, so that context is always present without each authorizer repeating it. An Authorizer should add an inner reason (still wrapping ErrAccessDenied) only when the reason is not derivable from action+resource+target — for example FilesystemRoot reports the confining root ("path %q outside root %q"), which the operation fields alone cannot convey. errors.Is(err, ErrAccessDenied) matches either form.
func All ¶
func All(authorizers ...Authorizer) Authorizer
All returns an Authorizer that requires every authorizer in the list to allow the operation. The first denial short-circuits and its error is returned. An empty list allows everything.
func ConsoleAuthorizer ¶
func ConsoleAuthorizer() Authorizer
ConsoleAuthorizer returns an Authorizer for the Console profile. File operations are restricted to /tmp. Environment variable reads are allowed (the envvars primitive handles virtual-vs-OS routing). The host's standard streams are allowed — "stdin/stdout/stderr available" is what the Console profile is for (see wile.Console). Code loading and process execution are denied.
Containment is symlink-resolved (see containedInRoot), so a symlink staged inside /tmp that points outside /tmp does not escape the sandbox. The authorizer also reports /tmp as its ConfinementRoot, so file primitives open through os.Root for race-free containment.
func ConsoleWithLoadAllowingVirtualSources ¶ added in v1.20.0
func ConsoleWithLoadAllowingVirtualSources() Authorizer
ConsoleWithLoadAllowingVirtualSources returns a ConsoleWithLoad authorizer that additionally serves targets drawn from a virtual filesystem. It applies NO path confinement to those targets: the embedder is asserting that the fs.FS it supplied is itself the boundary. Host-filesystem targets stay confined to /tmp exactly as under ConsoleWithLoadAuthorizer.
func ConsoleWithLoadAuthorizer ¶
func ConsoleWithLoadAuthorizer() Authorizer
ConsoleWithLoadAuthorizer returns an Authorizer for the ConsoleWithLoad profile. File operations and code loading are both restricted to /tmp. Environment variable reads are allowed. Process execution is denied.
This is the security envelope wile-goast and similar embedders use to run sandboxed (eval ...) and (load ...) on Scheme files staged in /tmp.
Containment is symlink-resolved (see containedInRoot), so a symlink staged inside /tmp that points outside /tmp does not escape the sandbox. Dynamic code evaluation (code:eval, from (eval <datum>)/(compile <datum>)) has no path to restrict and is allowed here so the profile's documented sandboxed (eval ...) keeps working; the side effects of evaluated code remain gated at their own file/process/env sinks.
A target drawn from a virtual filesystem (AccessRequest.TargetSource set) is denied outright: its path names a file inside an embedder's fs.FS and has no relation to /tmp. Use ConsoleWithLoadAllowingVirtualSources to serve one — which is what an embedder that stages its Scheme sources in an fs.FS rather than under /tmp now needs.
func FilesystemRoot ¶
func FilesystemRoot(root string) Authorizer
FilesystemRoot returns an Authorizer that confines file and code operations to paths under root, and denies every other resource it does not model.
Containment is symlink-resolved (see containedInRoot): both root and target are canonicalised, so a symlink under root that points outside it is followed and rejected, while the root itself may legitimately be a symlink. Paths that do not exist yet (e.g. a file about to be created) are still admitted as long as their existing ancestry stays within root.
Deny-by-default is load-bearing. Until 2026-08-21 the default arm ALLOWED, which left this authorizer's one promise unenforceable through any door it did not model: (system "echo x > /outside") ran, because process:exec-shell fell through, and (environment '(wile kitchen-sink)) handed that same shell to an engine whose own profile excluded it, because namespace:create fell through too. A shell command line is not a path, so neither could be decided by containment; both are decided on the resource, like code:eval below.
ResourceStream is the one exemption, matching ReadOnly and SandboxAuthorizer: the host's standard streams are handed to the engine at construction rather than named by the program, so there is no path for a root to bound. It is also the only arm a denial could not be walked back from — All() is an intersection, so denying here would make "files confined to root, and the program may still print" unexpressible from the built-ins. Compose with DenyAll via All(...) to refuse the streams.
code:eval (dynamic (eval <datum>)/(compile <datum>)) is denied outright: its Target is a label, not a path, so there is nothing to confine. Use ConsoleWithLoadAuthorizer if sandboxed eval is required.
A target drawn from a virtual filesystem (AccessRequest.TargetSource set) is likewise denied outright. Use FilesystemRootWithVirtualSources to serve one.
func FilesystemRootWithVirtualSources ¶ added in v1.20.0
func FilesystemRootWithVirtualSources(root string) Authorizer
FilesystemRootWithVirtualSources returns a FilesystemRoot authorizer that additionally serves targets drawn from a virtual filesystem (an embedder's WithSourceFS). It applies NO path confinement to those targets: a virtual path has no relation to the host filesystem, so root cannot bound it. The embedder is asserting that the fs.FS it supplied is itself the boundary.
Host-filesystem targets are confined to root exactly as under FilesystemRoot.
func FromContext ¶
func FromContext(ctx context.Context) Authorizer
FromContext returns the Authorizer stored in ctx, or nil if none.
func ReadOnly ¶
func ReadOnly() Authorizer
ReadOnly returns an Authorizer that allows read and stat operations but denies everything else — including write, delete, exit, and code load.
ReadOnly does NOT permit ActionLoad: loading a file compiles and runs its contents, which is not a read-only operation. Callers that genuinely need to load code under an otherwise read-only policy should use ReadOnlyWithLoad.
ReadOnly applies NO path confinement — it allows reads of any path the host process can reach. Compose it with FilesystemRoot via All(...) to bound which paths may be read.
ResourceStream is exempt from the action test: read-only is a statement about the host's state, and a program's own stdout is its result channel, not state it mutates. Compose with DenyAll via All(...) to refuse the streams too.
func ReadOnlyWithLoad ¶
func ReadOnlyWithLoad() Authorizer
ReadOnlyWithLoad returns an Authorizer identical to ReadOnly but also permitting ActionLoad (loading and running code from a resolved file path).
Like ReadOnly it applies NO path confinement; compose it with FilesystemRoot via All(...) to bound which paths may be read or loaded.
func SandboxAuthorizer ¶
func SandboxAuthorizer(envPrefix string) Authorizer
SandboxAuthorizer returns an Authorizer that allows read-only file access, env reads with a prefix filter, and denies code loading and process execution.
The host's standard streams are allowed: this is the env-map modifier WithSandbox() installs on top of a profile, and taking away the profile's stdio is not what it is for. Compose with DenyAll (or a custom authorizer) via All() to refuse the streams.
Intended as a restrictive modifier that can be composed with a profile's built-in authorizer via All() to produce an intersection (most-restrictive-wins).
type AuthorizerFunc ¶
type AuthorizerFunc func(AccessRequest) error
AuthorizerFunc adapts a plain function to the Authorizer interface.
func (AuthorizerFunc) Authorize ¶
func (p AuthorizerFunc) Authorize(req AccessRequest) error
Authorize implements Authorizer.
type RootConfined ¶
type RootConfined interface {
// ConfinementRoot returns the directory file access is confined to, and
// ok=true, when this authorizer is root-confining. ok=false means the
// authorizer imposes no single-root file confinement.
ConfinementRoot() (root string, ok bool)
}
RootConfined is implemented by authorizers that confine filesystem access to a single directory subtree. When an authorizer reports a confinement root, file primitives open paths through os.Root, giving race-free, syscall-level containment that closes the TOCTOU gap between the policy check (Authorize) and the open.