extension

package
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

Documentation

Overview

Package extension defines the one model every runtime-authored capability conforms to. An Extension is a typed resource on the event-sourced foundation (admitted against a JSON schema, versioned, provenance-stamped, fleet-syncable like every other kind) that describes how to reach and use an external system: its base URL and protocol, its auth scheme, the capability tags that gate it, a safety envelope, and one or more typed surface blocks (an API integration, a tool, a hosting/ops provider, a scraper, an auth provider, an agent).

The design goal is a single engine. Integrations, plugins, hosting providers, and the agent's own self-authored tools are all the same kind of thing: a spec the store holds and a handler interprets. The spec is the default and carries no executable code; compiled Go enters only behind the optional-code port, referenced from the manifest by name (see CodeRef). New surface types are added by registering a handler (see Registry), never by editing this kind or the loader, so the agent can extend itself at runtime without a recompile.

Index

Constants

View Source
const (
	// GroupVersion is the Extension kind's API group and version. The `.ionagent.io`
	// suffix marks it unmistakably as ours, never a Kubernetes built-in.
	GroupVersion = "extension.ionagent.io/v1alpha1"
	// Kind is the resource kind name extensions are stored under.
	Kind = "Extension"
)
View Source
const (
	// SurfaceIntegration is an HTTP/JSON API surface driven by an endpoint contract.
	SurfaceIntegration = "integration"
	// SurfaceTool exposes one or more callable tools directly to the agent.
	SurfaceTool = "tool"
	// SurfaceOps is a hosting/operations provider (deploy, provision, supervise).
	SurfaceOps = "ops"
	// SurfaceScrape acquires a spec or data by scraping a documentation site.
	SurfaceScrape = "scrape"
	// SurfaceAuth contributes a named authentication provider other surfaces use.
	SurfaceAuth = "auth"
	// SurfaceAgent declares an agent archetype packaged with the extension.
	SurfaceAgent = "agent"
)

Well-known surface keys. A surface is one capability an extension exposes; the key routes its typed block to the handler registered for it (see Registry). These constants name the surfaces the core ships handlers for, but the set is open: a host or the agent may register a handler under any key and a spec may declare it, with no change to this kind. Decoding a surface's block is the handler's job, so the kind schema never constrains an individual block.

View Source
const (
	// GradeUnvalidated is a spec that has not yet passed admission.
	GradeUnvalidated = "unvalidated"
	// GradeSchemaValid is a spec admitted by the kind schema but not probed live.
	GradeSchemaValid = "schema-valid"
	// GradeProbed is a spec confirmed against the upstream by a live probe call.
	GradeProbed = "probed"
)

Validation grades for Status.Grade.

View Source
const (
	// SignerUnlockTool opens the signer's key and answers with its public half. The host
	// calls it once, at mount, with a passphrase the OPERATOR holds (in the vault). Until it
	// succeeds the signer holds nothing and can sign nothing.
	SignerUnlockTool = "signer_unlock"
	// SignerSignTool signs a payload, or refuses it. The signer applies its own policy here.
	SignerSignTool = "signer_sign"
)

signer tool names. They are RESERVED: an extension advertising them does not get them mounted, because they are the host's to call and nobody else's.

View Source
const SurfaceProcess = "process"

SurfaceProcess is the surface an out-of-process, code-backed extension declares: a compiled MCP tool-server binary flynn launches as a confined subprocess and consumes over its stdio. It is the concrete resolver for the spec's CodeRef port: where a pure-spec surface is data flynn interprets directly, a process surface is a separate program flynn runs behind the sandbox and the capability gate. The binary carries no authority of its own; every tool it advertises is mounted namespaced, default-deny, and governed at the dispatch waist, and the process is treated as potentially compromised even when it is first-party.

Variables

View Source
var DefaultOrigin = Origin{
	Repo: "ionalpha/flynn-extensions",
	Identity: sigstore.Identity{
		Workflow:   "https://github.com/ionalpha/go-ci/.github/workflows/monorepo-release.yml@refs/heads/main",
		Issuer:     "https://token.actions.githubusercontent.com",
		SourceRepo: "ionalpha/flynn-extensions",
	},
}

DefaultOrigin is the first-party extension catalog: extensions published by ionalpha/flynn-extensions, signed by the shared release workflow that builds them.

It is a var rather than a const so a test can point the resolver at a fixture server, and so an operator running a private catalog can replace it wholesale. It is not a per-extension setting: an extension does not get to say who vouches for it.

View Source
var KindDef = resource.Kind{
	APIVersion: GroupVersion,
	Name:       Kind,
	Schema:     specSchema,
	Singular:   "extension",
	Plural:     "extensions",
}

KindDef is the Extension kind definition registered with a resource registry so the store admits extensions.

Functions

func RegisterKind

func RegisterKind(reg *resource.Registry) error

RegisterKind registers the Extension kind with reg so a resource store admits extensions. It is idempotent: registering again replaces the definition.

Types

type AuthSpec

type AuthSpec struct {
	// Type is the auth scheme: "none", "basic", "bearer", "api_key", "oauth2", or the
	// name of a code-port auth provider for anything custom (SigV4, a service-account
	// JWT). Empty means "none".
	Type string `json:"type,omitempty"`
	// In and Name place an api_key credential: In is "header" or "query", Name is the
	// header or parameter name. Ignored for other types.
	In   string `json:"in,omitempty"`
	Name string `json:"name,omitempty"`
	// Scheme is the bearer prefix (default "Bearer"); ignored for other types.
	Scheme string `json:"scheme,omitempty"`
	// CredentialRef names the stored credential to resolve from the vault. The
	// multi-key, role-aware credential model is fleshed out by the auth overhaul; here
	// it is the reference the spec carries instead of a value.
	CredentialRef string `json:"credentialRef,omitempty"`
	// Roles enumerates the named credential roles this extension distinguishes (e.g. a
	// read role and a deploy role backed by different keys). Empty means a single
	// default credential.
	Roles []string `json:"roles,omitempty"`
	// OAuth2 carries the static parameters of an "oauth2" scheme (the token endpoint,
	// client id, grant, and scopes). The secret it exchanges (a client secret or a
	// refresh token) is the resolved credential, never carried here. Ignored for other
	// types.
	OAuth2 *OAuth2Spec `json:"oauth2,omitempty"`
}

AuthSpec declares how a surface authenticates without ever embedding a secret. The credential value lives in the vault and is resolved by reference at call time, so the stored spec is safe to inspect, diff, and sync across a fleet.

type CodeRef

type CodeRef struct {
	// Name is the registered code-port implementation to bind. Required when Code is
	// set.
	Name string `json:"name"`
}

CodeRef names a compiled implementation registered in-process under the optional-code port. It is the only way executable behaviour binds to an extension, and it binds by name: the manifest stays declarative data while the code lives in the binary (or a host plugin), resolved at load time. Resolving an unknown name fails closed, so a spec can never silently run the wrong code.

type Conn added in v0.1.3

type Conn interface {
	// Stdin is the process's standard input, for writing MCP requests.
	Stdin() io.WriteCloser
	// Stdout is the process's standard output, for reading MCP replies.
	Stdout() io.Reader
	// Stop ends the process and releases it. It is idempotent.
	Stop() error
}

Conn is a live duplex connection to a launched extension subprocess: its MCP stdio pipes and the means to stop it. The real launcher backs it with a sandbox session over anonymous pipes; a test backs it with an in-memory pair. Stop must kill the process and leave no orphan.

type DevResolver added in v0.1.3

type DevResolver struct {
	// Enabled turns dev mode on. It must be an explicit, deliberate opt-in; the zero value
	// refuses every source so a misconfiguration fails closed.
	Enabled bool
}

DevResolver resolves a dev (locally-built, unsigned) extension binary. It is the authoring inner loop: point flynn at a local build and mount it exactly like a released one, minus the download and signature. Because it runs unsigned code, it is gated by Enabled: unless dev mode is explicitly turned on, a dev source is refused, and a released source is always refused here (the signed-distribution resolver owns that path), so this resolver can never run remote or unverified code in a normal run.

func (DevResolver) Resolve added in v0.1.3

func (r DevResolver) Resolve(_ context.Context, extName string, block ProcessBlock) (string, []string, error)

Resolve returns the local dev binary path when dev mode is enabled and the block declares one. A released source, a missing dev path, or dev mode off all fail closed.

type DevSource added in v0.1.3

type DevSource struct {
	Path string `json:"path"`
}

DevSource is a locally-built extension binary referenced by absolute path.

type FetcherOption added in v0.1.3

type FetcherOption func(*httpFetcherConfig)

FetcherOption configures an HTTPHostFetcher.

func WithFetchContentType added in v0.1.3

func WithFetchContentType(s string) FetcherOption

WithFetchContentType sets the request's Content-Type. The default is application/json.

func WithFetchTimeout added in v0.1.3

func WithFetchTimeout(d time.Duration) FetcherOption

WithFetchTimeout bounds a single request. The default is 30s.

func WithMaxResponseBytes added in v0.1.3

func WithMaxResponseBytes(n int64) FetcherOption

WithMaxResponseBytes bounds the response body read from the endpoint, so an endpoint that answers with an unbounded stream cannot exhaust host memory. The default is 1 MiB.

func WithPrivateEndpoint added in v0.1.3

func WithPrivateEndpoint() FetcherOption

WithPrivateEndpoint permits an endpoint on a loopback or private address. It exists for a local service an operator deliberately runs (a test validator, a self-hosted node on the same box) and must be an explicit opt-in, because it turns off the anti-SSRF address rule that otherwise keeps a granted endpoint on the public internet. The default refuses a private endpoint, so a misconfiguration fails closed.

type HTTPHostFetcher added in v0.1.3

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

HTTPHostFetcher is the default HostFetcher: it POSTs the request body to one fixed endpoint over a netguard-policed HTTP client, and returns the response body bounded.

The endpoint is fixed at construction, so it is the operator's choice and not the extension's. The client's dial control re-checks the resolved address against the policy at connect time, so a DNS answer that swings to a private or loopback address after the name passed its check is still refused (anti-rebinding).

func NewHTTPHostFetcher added in v0.1.3

func NewHTTPHostFetcher(endpoint string, opts ...FetcherOption) (*HTTPHostFetcher, error)

NewHTTPHostFetcher returns a fetcher that POSTs to endpoint. It refuses an endpoint that is not an absolute http/https URL, and (unless WithPrivateEndpoint is given) one whose host is a literal private, loopback, or link-local address, so a grant cannot silently aim the host's network at its own internals or the cloud metadata endpoint.

func (*HTTPHostFetcher) Fetch added in v0.1.3

func (f *HTTPHostFetcher) Fetch(ctx context.Context, body []byte) ([]byte, error)

Fetch POSTs body to the fixed endpoint and returns the response body, bounded. A non-2xx status is returned as an error carrying the status but not the body, so an endpoint's error page cannot become a channel into the extension. A transport failure is transient: the caller delivers it to the tool, whose own failure path decides how to unwind.

type HostFetcher added in v0.1.3

type HostFetcher interface {
	// Fetch sends body to the fetcher's endpoint and returns the response body.
	Fetch(ctx context.Context, body []byte) ([]byte, error)
}

HostFetcher sends a request body on behalf of a mounted extension tool and returns the response body. It is the network authority an extension borrows instead of holding: the extension process itself runs with egress fully denied, hands out opaque request bytes, and receives opaque response bytes back.

The destination is the fetcher's, never the extension's. Nothing in the request the extension hands out selects a host, a path, or a scheme, so a compromised extension cannot point the host's network at an address of its choosing (no SSRF, no exfiltration to a third party): the worst it can do is send garbage to the one endpoint the operator already granted it.

type HostSigner added in v0.1.3

type HostSigner interface {
	// Public is the public half of the signing key, as raw bytes.
	Public() []byte
	// Sign returns a detached signature over payload.
	Sign(ctx context.Context, payload []byte) ([]byte, error)
}

HostSigner produces detached signatures on behalf of a mounted extension tool, with a key the tool must not hold. The tool builds something that needs a signature; it hands out the bytes and gets a signature back. Public identifies the key so the tool can build against it; Sign returns a detached signature over the exact payload. What sits behind this port (a key held by another extension, a hardware token) is not the tool's concern, and the private key never crosses to the tool's process.

Public returns raw bytes rather than a key of any particular type: signers sit on different curves, and they all have to travel the same port. The host never interprets these bytes, so it does not need to know which curve produced them, and gains nothing by knowing.

Sign takes a context because the key need not be in this process: satisfying it may mean calling out to a signer extension, which can block and must be cancellable.

type LaunchRequest added in v0.1.3

type LaunchRequest struct {
	// Path is the trusted local binary to run. For a released extension it is the
	// cosign-verified artifact; for a dev extension it is the local build (dev mode only).
	Path string
	// Args are the fixed arguments to pass, verbatim.
	Args []string
	// EgressAllow is the effective outbound host allow-list (spec ∩ operator grant). Empty
	// means deny all egress: the extension reaches nothing.
	EgressAllow []string
}

LaunchRequest is what the handler hands the launcher to start one extension: the verified local binary path, the fixed arguments, and the already-computed effective egress allow-list. The handler has already intersected the spec's requested egress with the operator grant, so the launcher only enforces the result; it never reads egress from the spec directly.

type Launcher added in v0.1.3

type Launcher interface {
	Launch(ctx context.Context, req LaunchRequest) (Conn, error)
}

Launcher starts a verified binary as a confined, egress-locked subprocess and returns a duplex connection to its MCP stdio. It is the sandbox boundary of a process extension; the concrete SandboxLauncher enforces containment (refuse-rather-than-downgrade), deny-by-default egress, and a scrubbed environment, and a test supplies a fake so the mount and tool-bridge logic is exercised without a real process.

type Loader

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

Loader turns a stored Extension resource into live surfaces by routing each declared surface block to its registered handler. It is the bridge from data to behaviour: the resource store admits and versions the spec, and the loader mounts it. A load is all-or-nothing. If any surface fails to mount, the surfaces already mounted for that extension are unloaded before the error returns, so an extension is never left half-wired.

The loader is level-triggered: Load both mounts a new extension and replaces one already loaded (it unmounts the previous surfaces first), so re-applying the same spec is idempotent and applying a changed spec reconciles to it. It is safe for concurrent use.

func NewLoader

func NewLoader(reg *Registry) *Loader

NewLoader returns a loader that resolves handlers from reg.

func (*Loader) Load

func (l *Loader) Load(ctx context.Context, r resource.Resource) ([]string, error)

Load mounts every surface an extension declares. It decodes the spec, resolves a handler for each surface (fail-closed on an unknown surface), and calls OnLoad in a deterministic, sorted order so a load is reproducible. If the extension was already loaded, its previous surfaces are unmounted first. On any failure, the surfaces mounted during this call are rolled back and the original error is returned. It returns the sorted list of surface keys that ended up mounted.

func (*Loader) Mounted

func (l *Loader) Mounted(id string) []string

Mounted reports the sorted surface keys currently mounted for an extension id.

func (*Loader) Tools

func (l *Loader) Tools() []mission.Tool

Tools returns every tool contributed by the currently loaded extensions, in a deterministic order (by extension id, then by tool name), so the agent's tool surface is reproducible across runs. Authority to call a tool is enforced separately at the dispatch waist by the capability grant and credential check; presence here only makes a tool reachable, never automatically permitted.

func (*Loader) Unload

func (l *Loader) Unload(ctx context.Context, id string) error

Unload releases every surface mounted for an extension id. It is idempotent: unloading an extension that is not loaded is a no-op. Handler OnUnload errors are collected and returned joined, but every surface is still attempted so one stubborn handler cannot strand the others.

type Mount

type Mount struct {
	// ID is the extension resource's stable id, the key OnUnload is called with.
	ID string
	// Name is the extension's natural name (its slug).
	Name string
	// Spec is the full decoded extension spec, shared context for every surface.
	Spec Spec
	// Surface is the surface key this mount is for (one of the Surface* constants or a
	// host-registered key).
	Surface string
	// Block is the raw typed block for this surface, for the handler to decode.
	Block json.RawMessage
}

Mount is what a handler receives when one surface of an extension is loaded. It carries the extension's identity and full spec (so the handler can read the base URL, auth, and safety envelope) alongside the raw block for the specific surface it handles. The handler decodes Block into its own type; nothing else does.

type OAuth2Spec

type OAuth2Spec struct {
	// TokenURL is the token endpoint the access token is obtained from.
	TokenURL string `json:"tokenURL,omitempty"`
	// ClientID is the OAuth2 client identifier (semi-public, carried inline).
	ClientID string `json:"clientID,omitempty"`
	// Grant selects the flow: "client_credentials" (default) or "refresh_token".
	Grant string `json:"grant,omitempty"`
	// Scopes are requested at the token endpoint.
	Scopes []string `json:"scopes,omitempty"`
}

OAuth2Spec is the static configuration of an "oauth2" auth scheme. The access token is obtained from TokenURL and refreshed automatically; the credential the integration holds supplies the client secret (the client_credentials grant) or the refresh token (the refresh_token grant), resolved from the vault at call time.

type Origin added in v0.1.3

type Origin struct {
	// Repo is the "owner/name" whose releases are installed from.
	Repo string

	// Identity is the signature the release must carry. Note that Identity.Workflow is
	// the *reusable* release workflow, which lives in a different repository than Repo;
	// that is what Sigstore binds the signature to, and pinning Repo instead would
	// reject every genuine release.
	Identity sigstore.Identity
}

Origin describes where released extensions come from and who is trusted to have built them. It is the trust root for every extension flynn will execute: change these fields and flynn runs somebody else's code.

type Point

type Point interface {
	// Capability is the surface key this handler serves (e.g. "integration").
	Capability() string
	// OnLoad wires one surface of an extension live.
	OnLoad(ctx context.Context, m Mount) error
	// OnUnload releases the surface previously loaded for the given extension id.
	OnUnload(ctx context.Context, id string) error
}

Point is the handler for one surface kind. Registering a Point under its Capability makes every spec that declares that surface loadable, which is how the engine gains new abilities without edits to the kind or the loader: a new surface is a new registration, not a new code path in the core.

OnLoad is called when an extension declaring this surface is loaded; it wires the surface live (registers tools, opens a provider) and returns an error to abort the load. OnUnload is called with the extension id when the extension is unloaded or replaced; it must release whatever OnLoad acquired and be idempotent, since a roll-back may unload a surface that never fully loaded. Implementations must be safe for concurrent use.

type ProcessBlock added in v0.1.3

type ProcessBlock struct {
	// Dev, when set, points at a locally-built binary by absolute path for the extension
	// authoring loop. It is unsigned by nature, so a launcher only honours it when dev mode
	// is explicitly enabled; in a normal run a dev source is refused.
	Dev *DevSource `json:"dev,omitempty"`
	// Release, when set, names a published, signed artifact the resolver downloads and
	// cosign-verifies against a pinned key before it is ever launched (the signed
	// distribution path). Exactly one of Dev or Release is used; Release wins if both are
	// present so a stray dev block cannot downgrade a released extension.
	Release *ReleaseSource `json:"release,omitempty"`
	// Args are fixed arguments appended to the resolved binary path, verbatim. They are
	// part of the spec, never model-influenced, so the launch command line is fully
	// determined before any model runs (closing the stdio-config injection class).
	Args []string `json:"args,omitempty"`
	// Tools, when non-empty, is an allow-list of the advertised tool names to mount; any
	// tool the server exposes that is not listed is ignored. Empty mounts every advertised
	// tool. It is a least-surface control: a spec can pin exactly the tools it vouches for.
	Tools []string `json:"tools,omitempty"`
}

ProcessBlock is the typed surface block for a process extension. It never carries the binary itself or any secret: it names how to obtain the (signed, verified) binary and how to run it, and the resolver turns that into a trusted local path. A stored spec is therefore safe to inspect and sync.

type ProcessHandler added in v0.1.3

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

ProcessHandler is the surface handler for out-of-process extensions. It launches an extension's verified binary in the sandbox, speaks MCP to it, and mounts its advertised tools as governed, namespaced, default-deny flynn tools. It is safe for concurrent use and holds one running subprocess per loaded extension id.

func NewProcessHandler added in v0.1.3

func NewProcessHandler(launcher Launcher, resolver Resolver, opts ...ProcessOption) *ProcessHandler

NewProcessHandler builds a process-surface handler that resolves binaries through resolver and launches them through launcher. Both are required; a nil launcher or resolver is a programming error the handler refuses to run without.

func (*ProcessHandler) Capability added in v0.1.3

func (h *ProcessHandler) Capability() string

Capability returns the surface key this handler serves.

func (*ProcessHandler) OnLoad added in v0.1.3

func (h *ProcessHandler) OnLoad(ctx context.Context, m Mount) error

OnLoad launches one extension's subprocess and mounts its tools. It resolves the binary (verified, or dev-only when the resolver allows it), computes the effective egress as the spec's requested hosts intersected with the operator grant, launches the process under the sandbox, performs the MCP handshake and lists its tools within the dial timeout, and wraps each advertised tool as a governed, namespaced, default-deny flynn tool. Any failure stops the subprocess before returning, so a failed load leaves no orphan and the loader's roll-back sees nothing mounted.

func (*ProcessHandler) OnUnload added in v0.1.3

func (h *ProcessHandler) OnUnload(_ context.Context, id string) error

OnUnload stops the extension's subprocess and releases its tools. It is idempotent: unloading an id that is not mounted is a no-op, and a Stop error is not surfaced because the process is being torn down regardless.

func (*ProcessHandler) SignerChannelFor added in v0.1.3

func (h *ProcessHandler) SignerChannelFor(id string) (SignerChannel, error)

SignerChannelFor returns the host's private line to a mounted signer extension. It fails if the extension is not mounted, so a worker is never wired to a signer that is not running.

func (*ProcessHandler) Tools added in v0.1.3

func (h *ProcessHandler) Tools(id string) []mission.Tool

Tools returns the tools mounted for one extension id, satisfying ToolSource so the loader surfaces them to the agent. Authority to call any of them is still enforced separately at the dispatch waist by the capability grant; being returned here only makes a tool reachable, never permitted.

type ProcessOption added in v0.1.3

type ProcessOption func(*ProcessHandler)

ProcessOption configures a ProcessHandler.

func WithCallTimeout added in v0.1.3

func WithCallTimeout(d time.Duration) ProcessOption

WithCallTimeout bounds how long a single tool call may take before it is abandoned, so a hung extension cannot wedge a run. The default is 60s.

func WithDialTimeout added in v0.1.3

func WithDialTimeout(d time.Duration) ProcessOption

WithDialTimeout bounds the MCP handshake and tools/list at mount time, so an extension that never answers cannot wedge a load. The default is 30s.

func WithEgressGrant added in v0.1.3

func WithEgressGrant(hosts []string) ProcessOption

WithEgressGrant sets the operator's outbound host allow-list. The effective egress of any extension is its spec's requested hosts intersected with this grant, never the spec's hosts alone, so a spec can only narrow what the operator already permits, never widen it. Empty (the default) means the operator grants no hosts, so every extension is launched with egress fully denied.

func WithHostFetcher added in v0.1.3

func WithHostFetcher(fn func(extName, toolName string) HostFetcher) ProcessOption

WithHostFetcher binds a host-held endpoint to network-borrowing tools. fn is called for each mounted tool and returns the HostFetcher that tool may send requests through, or nil for a tool that borrows no network (the default for every tool). This is how an extension reaches a service without being given egress of its own: the extension process stays network-denied, hands out request bytes, and the host sends them to the endpoint IT holds. Because the extension never names a destination, a grant is an authority to reach exactly one place. A nil fn leaves every tool network-free.

func WithHostSigner added in v0.1.3

func WithHostSigner(fn func(extName, toolName string) HostSigner) ProcessOption

WithHostSigner binds host-held signing keys to signing-enabled tools. fn is called for each mounted tool and returns the HostSigner that tool may obtain signatures from, or nil for a tool that does not sign (the default for every tool). This is how the operator grants a specific extension tool the use of a key: the key stays in the host, and the tool only ever receives detached signatures over the bytes it hands out. A nil fn leaves every tool non-signing.

func WithMaxDescriptionBytes added in v0.1.3

func WithMaxDescriptionBytes(n int) ProcessOption

WithMaxDescriptionBytes bounds the size of a tool description surfaced to the model, so a hostile extension cannot pack instructions into an oversized description. The default is 4 KiB.

func WithMaxFetches added in v0.1.3

func WithMaxFetches(n int) ProcessOption

WithMaxFetches bounds how many requests one tool call may drive through the host-call handshake, so a hostile or broken extension cannot pump the host's network in a loop. The default is 256, which is generous for a tool that polls for an on-chain confirmation.

func WithMaxResultBytes added in v0.1.3

func WithMaxResultBytes(n int) ProcessOption

WithMaxResultBytes bounds the size of a tool result surfaced to the model, so a hostile extension cannot flood the model context. The default is 64 KiB.

func WithMaxSignatures added in v0.1.3

func WithMaxSignatures(n int) ProcessOption

WithMaxSignatures bounds how many signatures one tool call may request through the host-signing handshake, so a hostile or broken extension cannot drive an unbounded signing loop. The default is 32.

func WithReserved added in v0.1.3

func WithReserved(fn func(name string) bool) ProcessOption

WithReserved sets the predicate that reports whether a mounted tool name collides with a reserved or native name, so an extension cannot shadow a built-in tool (anti tool-poisoning). Namespacing already makes a native collision structurally impossible (a native tool name carries no dot, a mounted one is always "<ext>.<tool>"); this predicate is the belt-and-suspenders check a host wires to its own native-name and reserved-catalog set. The default reserves nothing, relying on namespacing alone.

type Registry

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

Registry resolves a Point by the surface key it serves. It is fail-closed: a surface with no registered handler is an error at load time, never a silent skip, so an extension is either fully wired or rejected. It is safe for concurrent use.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty handler registry.

func (*Registry) Capabilities

func (r *Registry) Capabilities() []string

Capabilities lists the registered surface keys in sorted order, for diagnostics and a kube-style listing of what surfaces this engine can serve.

func (*Registry) Has

func (r *Registry) Has(capability string) bool

Has reports whether a handler is registered for a surface key.

func (*Registry) Register

func (r *Registry) Register(h Point) error

Register adds a handler under its Capability. It refuses a nil handler, an empty capability, or a duplicate, so the registry can never hold two handlers for one surface (which would make routing ambiguous).

func (*Registry) Resolve

func (r *Registry) Resolve(capability string) (Point, error)

Resolve returns the handler for a surface key, or a Terminal error naming the available surfaces when none matches.

type ReleaseResolver added in v0.1.3

type ReleaseResolver struct {
	// Origin is who is trusted. The zero value trusts nobody and refuses everything.
	Origin Origin

	// Dir is where verified extensions are installed, one directory per name and version.
	Dir string

	// Downloader performs the size-capped, digest-checked downloads.
	Downloader *fetch.Downloader

	// BaseURL overrides the release host (tests point this at a local server). Empty
	// means GitHub.
	BaseURL string

	// GOOS and GOARCH override the target platform. Empty means this machine's.
	GOOS, GOARCH string
	// contains filtered or unexported fields
}

ReleaseResolver resolves a released extension to a local binary it has proven the origin of, and refuses to produce a path for anything it cannot prove.

The chain of trust runs: a keyless signature over the release's checksums.txt, made by the pinned workflow identity; that file commits to every archive by SHA-256; the archive downloaded for this platform must match its digest; the binary extracted from it is hashed and the hash recorded. Nothing is executed on the strength of where it was downloaded from, only on the strength of what signed it.

func (ReleaseResolver) Resolve added in v0.1.3

func (r ReleaseResolver) Resolve(ctx context.Context, extName string, block ProcessBlock) (string, []string, error)

Resolve downloads, verifies and installs the extension named by the block's release source, returning the path to its binary. A dev source is not this resolver's business and is refused: honouring one here would let an unsigned local path stand in for a signed release, which is the one substitution the whole design exists to prevent.

type ReleaseSource added in v0.1.3

type ReleaseSource struct {
	Asset   string `json:"asset"`
	Version string `json:"version"`

	// Digests pins the exact bytes this flynn will run, keyed by "<goos>_<goarch>" and
	// holding the archive's SHA-256. It is what makes the pin structural rather than
	// nominal.
	//
	// Without it, the pin is a version STRING, and a git tag is mutable. Someone with write
	// access to the extensions repo could delete "token/v1.0.0", re-cut it against different
	// code, publish it through the very same trusted release workflow, and every flynn in
	// the world would fetch the new binary and verify its signature happily: the signature
	// only ever proved "the pinned workflow built this", never "this is the artifact we
	// reviewed". With the digest, that substitution is refused by a binary that was compiled
	// before the attack existed, because the hash it demands is baked into it.
	//
	// A platform absent from the map is unpinned and falls back to signature-only trust, so
	// a spec may pin the platforms it has hashes for without breaking the others.
	Digests map[string]string `json:"digests,omitempty"`
}

ReleaseSource names a published extension artifact by its asset name and version. The per-os/arch selection, download, and cosign verification are the resolver's job.

type Resolver added in v0.1.3

type Resolver interface {
	Resolve(ctx context.Context, extName string, block ProcessBlock) (path string, args []string, err error)
}

Resolver turns a process surface into a trusted local binary path plus its arguments. It is the trust boundary for the code itself: a released source is downloaded and cosign-verified against a pinned key, and a dev source is honoured only when dev mode is enabled. A resolver that cannot establish trust returns an error, and the handler then never launches anything, so unverified code never runs.

type RoutedSigner added in v0.1.3

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

RoutedSigner is a HostSigner whose key lives in ANOTHER extension: a signer extension, holding one chain's key and that chain's transaction parser.

This is the shape that lets the host stay ignorant. A host that holds a key cannot sign safely without understanding what it signs, so a host that holds a key must carry a parser for every format it might be asked to sign, and it stops being a general engine the moment it does. Move the key out and the parser goes with it: the host is left routing opaque bytes between two processes and reading none of them.

The separation is also what makes the check honest. The worker extension BUILDS the transaction; the signer HOLDS the key and independently decides whether to sign it. They are different artifacts, published and pinned separately. A worker compromised upstream gets no signature: it would have to compromise the signer too, and the signer is small enough to audit line by line and does nothing but parse, decide, and sign.

What must never happen is a worker that carries its own parser and vouches for its own payload. That is self-policing, and it buys exactly nothing.

The signer is reached over a SignerChannel, which is the host's private line to it, NOT the agent's tool surface. Its tools are never mounted, so the model can neither unlock the key nor ask for a signature behind the worker's back.

func NewRoutedSigner added in v0.1.3

func NewRoutedSigner(ctx context.Context, ch SignerChannel, passphrase secret.Text, keyPath string) (*RoutedSigner, error)

NewRoutedSigner unlocks a signer extension and returns a signer that routes signing requests to it. It fails if the signer cannot be reached, refuses the passphrase, or does not answer with a key, so a worker is never mounted against a signer that cannot sign: the failure lands at mount, where an operator sees it, rather than halfway through a mint.

The passphrase is the operator's, held in the host's vault. The host never learns the key it unlocks: what comes back is the public half.

keyPath names the sealed key on this machine. A released signer is launched from a catalog spec whose arguments were fixed before the machine existed, so it cannot have been told the path as a flag; the host names it here instead. The path is not a secret (the signer, not the host, opens the file), and a signer already launched with its own --key ignores it. Empty when the operator dev-linked a signer that carries its own key.

func (*RoutedSigner) PolicesPayloads added in v0.1.3

func (s *RoutedSigner) PolicesPayloads() bool

PolicesPayloads reports that the signer applies its own policy. It holds the key and it understands the format, so it is the only component positioned to judge the payload, and the host requires no policy of its own for it.

func (*RoutedSigner) Public added in v0.1.3

func (s *RoutedSigner) Public() []byte

Public returns the signer's public key, as the signer reported it at unlock.

func (*RoutedSigner) Sign added in v0.1.3

func (s *RoutedSigner) Sign(ctx context.Context, payload []byte) ([]byte, error)

Sign hands the payload to the signer extension and returns the detached signature it produced. The signer may refuse, in which case its refusal is returned as-is: it names the rule the payload broke, and that reason belongs to the operator reading the error, not to this host, which cannot check the claim and does not try.

type SafetySpec

type SafetySpec struct {
	// EgressAllow lists additional hostnames the extension may reach beyond the
	// BaseURL host. Empty confines it to the BaseURL host alone.
	EgressAllow []string `json:"egressAllow,omitempty"`
	// ReadOnly marks an extension whose surfaces never mutate upstream state, so a
	// run granted only read authority may still use it.
	ReadOnly bool `json:"readOnly,omitempty"`
	// RateLimitPerMinute caps requests per minute across the extension's surfaces. 0
	// means the shared transport's default.
	RateLimitPerMinute int `json:"rateLimitPerMinute,omitempty"`
}

SafetySpec is the declared safety envelope for an extension. It is intent the runtime enforces: the egress waist confines network reach to EgressAllow (plus the BaseURL host), the capability gate refuses ungranted surfaces, and the transport honours the rate limit. A spec cannot weaken these by omission; an empty envelope is the most restrictive reading (base-URL host only).

type SandboxLauncher added in v0.1.3

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

SandboxLauncher is the production Launcher. It runs an extension binary inside a fresh sandbox at the kernel-confined tier: a read-only host, the syscall filter, a scrubbed deny-by-default environment (no secret ever reaches the process), memory and process-count caps, and deny-by-default egress restricted to exactly the effective allow-list. It requires confinement: on a host or platform that cannot contain the process, the launch is refused rather than downgraded, so an extension that cannot be contained never runs.

func NewSandboxLauncher added in v0.1.3

func NewSandboxLauncher(workRoot string) *SandboxLauncher

NewSandboxLauncher returns a launcher that creates each extension's scratch working directory under workRoot. The directory is a fresh, empty jail per launch and is removed when the connection is stopped.

func (*SandboxLauncher) Launch added in v0.1.3

func (l *SandboxLauncher) Launch(ctx context.Context, req LaunchRequest) (Conn, error)

Launch starts req.Path in a confined, egress-locked sandbox and returns a duplex connection to its MCP stdio. The egress policy is deny-by-default: with no effective hosts the process reaches nothing; with hosts it may reach only those names and only when they resolve to public addresses (private, loopback, and the cloud-metadata range stay denied, anti-SSRF). Confinement is required, so a host that cannot enforce the read-only filesystem and syscall filter fails the launch. The launch command is the fixed verified path plus fixed args, never model-influenced.

type SelfPolicing added in v0.1.3

type SelfPolicing interface {
	// PolicesPayloads reports that this signer applies its own policy before signing.
	PolicesPayloads() bool
}

SelfPolicing is implemented by a HostSigner that decides for itself whether a payload may be signed, because it holds the key AND understands the payload's format. A signer extension is the case that matters: it parses the transaction and refuses an unsafe one at the point the key is used, which is the only point where the question can honestly be asked.

The host REQUIRES this of any signer it uses (see serviceSign). It is not a convenience. The host holds no parser for any format, so a signer that does not judge its own payloads leaves nobody judging them, and signing bytes nobody has read is how the largest theft in this industry happened: a published, correctly-signed component was compromised upstream, and everyone who trusted its output signed what it asked them to.

Verifying an extension's signature proves which binary is running. It proves nothing about what that binary asks to be signed. Those are different questions, and only the second one is asked at the moment the key is used, by whoever holds the key.

type SignerChannel added in v0.1.3

type SignerChannel interface {
	// Unlock opens the signer's key and returns its public half and the curve it sits on. keyPath
	// names the sealed key for a signer that was not launched with its own --key (a released one);
	// it is empty for a signer that carries its own key, and the signer prefers its flag over it.
	Unlock(ctx context.Context, passphrase secret.Text, keyPath string) (pub []byte, curve string, err error)
	// SignPayload asks the signer to sign payload. The signer parses it and may refuse.
	SignPayload(ctx context.Context, payload []byte) ([]byte, error)
}

SignerChannel is the host's private line to a signer extension. It is deliberately NOT the agent's tool surface.

A mounted tool is a tool the MODEL can call. If a signer's tools were mounted, the model could unlock the key itself, or call the signing tool directly and skip the worker that was supposed to build the transaction. Neither is a thing an agent should be able to do, and neither is stopped by a capability grant on the worker, because the model would not be going through the worker at all.

So the signer's tools are never mounted (see reservedSignerTool). The host reaches them over this channel, which nothing model-facing can get at, and the only thing that ever crosses it is a passphrase the operator granted and a payload the worker built.

type SourceResolver added in v0.1.3

type SourceResolver struct {
	Release ReleaseResolver
	Dev     DevResolver
}

SourceResolver routes an extension to the resolver its source kind demands: a published release goes through signature verification, a dev binary through the explicit dev-mode opt-in.

A release wins whenever both are declared. That is the point of the ordering: a spec carrying a stray dev block, whether from an author's machine or from an attacker who managed to edit a stored spec, must not be able to substitute an unsigned local binary for the signed release the operator asked for. Downgrade is the attack; refusing to downgrade is the defence.

func (SourceResolver) Resolve added in v0.1.3

func (s SourceResolver) Resolve(ctx context.Context, extName string, block ProcessBlock) (string, []string, error)

Resolve dispatches on the source the spec declares.

type Spec

type Spec struct {
	// DisplayName is a human label; empty falls back to the resource name.
	DisplayName string `json:"displayName,omitempty"`
	// Version is the extension's own published version, used for upgrade ordering and
	// provenance. It is distinct from the resource revision the store assigns: one
	// tracks what the author shipped, the other tracks how many times this record was
	// written.
	Version string `json:"version,omitempty"`
	// Provider groups extensions that share an upstream (e.g. "cloudflare"), so
	// several surfaces and credential roles can be reasoned about as one provider.
	Provider string `json:"provider,omitempty"`

	// BaseURL is the root the surfaces address. Surface blocks carry paths relative to
	// it so the host is declared once.
	BaseURL string `json:"baseURL,omitempty"`
	// Protocol names how the surfaces talk to the upstream. Empty means "https". A
	// protocol the declarative interpreter does not understand (a binary or streaming
	// protocol) is served through the optional-code port; see Code.
	Protocol string `json:"protocol,omitempty"`

	// Auth describes how a request authenticates. It never carries a secret value:
	// the credential is referenced by name and resolved from the vault at call time,
	// so a stored spec is safe to sync and inspect.
	Auth AuthSpec `json:"auth,omitempty"`

	// Capabilities are the tags that gate this extension. A surface is mounted and
	// surfaced as a tool only when its capability is granted to the run and the
	// referenced credential is configured, the same gating every other context is
	// retrieved under.
	Capabilities []string `json:"capabilities,omitempty"`

	// Safety bounds what the extension may do at runtime (egress allow-list, read-only
	// marking, rate limit). The egress waist and capability gate enforce it; the spec
	// only declares the intent.
	Safety SafetySpec `json:"safety,omitempty"`

	// Surfaces are the capability blocks this extension exposes, keyed by surface
	// (see the Surface* constants). Each value is the typed block for that surface,
	// left as raw JSON here because only the surface's handler knows its shape. This
	// is the open extension point: registering a handler under a new key makes specs
	// that declare it loadable, with no change to this kind.
	Surfaces map[string]json.RawMessage `json:"surfaces,omitempty"`

	// Code, when set, routes this extension's surfaces through a compiled
	// implementation registered in-process under the named code port, instead of the
	// declarative interpreter. The manifest carries only the name, never Go, so the
	// spec stays pure data the store can admit, version, and sync. This is the
	// single, explicit boundary at which compiled code enters; pure-spec is the
	// default.
	Code *CodeRef `json:"code,omitempty"`
}

Spec is an Extension's desired shape: everything needed to reach and use an external system, as pure data. Every field is optional so a minimal Extension is just a name and one surface; the zero Spec is an inert extension that exposes nothing. The extension's natural name (its slug) is the resource Name, so the spec carries no separate id.

func DecodeSpec

func DecodeSpec(r resource.Resource) (Spec, error)

DecodeSpec reads the typed spec from a resource. An empty Spec decodes to the zero Spec, so a bare extension is valid.

func (Spec) Encode

func (s Spec) Encode() (json.RawMessage, error)

Encode renders the spec for storage as canonical JSON.

func (Spec) Surface

func (s Spec) Surface(key string) (json.RawMessage, bool)

Surface returns the raw block for a surface key and whether the extension declares it.

type Status

type Status struct {
	// ObservedGeneration is the spec generation the status reflects.
	ObservedGeneration int64 `json:"observedGeneration,omitempty"`
	// Grade is how far the spec has been validated: GradeUnvalidated, GradeSchemaValid
	// (admitted by the kind schema), or GradeProbed (a live probe call succeeded in the
	// sandbox). Acquisition and runtime authoring promote the grade.
	Grade string `json:"grade,omitempty"`
	// LastProbe is the RFC3339 timestamp of the last probe, supplied by the caller's
	// clock (never read from the wall clock here so status stays replay-equivalent).
	// Empty means never probed.
	LastProbe string `json:"lastProbe,omitempty"`
	// Enabled reports whether the extension is currently loaded and its surfaces
	// mounted.
	Enabled bool `json:"enabled,omitempty"`
	// MountedSurfaces lists the surface keys the loader has mounted, in sorted order.
	MountedSurfaces []string `json:"mountedSurfaces,omitempty"`
}

Status is an Extension's observed state, set by the loader and reconcilers rather than admitted against the spec schema. It records how far the extension has been validated, whether it is enabled, and which surfaces are currently mounted.

func DecodeStatus

func DecodeStatus(r resource.Resource) (Status, error)

DecodeStatus reads the typed status from a resource.

func (Status) Encode

func (s Status) Encode() (json.RawMessage, error)

Encode renders the status for storage.

type ToolSource

type ToolSource interface {
	Tools(id string) []mission.Tool
}

ToolSource is the optional interface a Point implements when its surface contributes callable tools to the agent. Not every surface does (an auth provider contributes credentials, not tools), so it is separate from Point: the loader type-asserts for it and collects tools only from handlers that expose them. This is the tool-bridge boundary. The actual tools a surface builds (an API call per endpoint, a deploy action) are the surface handler's concern; the keystone only defines how they reach the agent.

Tools returns the live tools mounted for one extension id, or nil if that extension contributes none. It is called after OnLoad has mounted the surface.

Directories

Path Synopsis
Package catalog ships a curated set of official Extension specs inside the binary and syncs them into the resource store, so a freshly installed Flynn already knows how to reach common services.
Package catalog ships a curated set of official Extension specs inside the binary and syncs them into the resource store, so a freshly installed Flynn already knows how to reach common services.

Jump to

Keyboard shortcuts

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