Capabilities and security
How Sakura Lang separates effects from authority, limits root handlers with sakura.toml, narrows authority for dependencies, and blocks injection and secret leaks.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
Most supply-chain attacks and injection bugs rely on code doing more than it should: a dependency that reads files it was never meant to see, a string from a request that reaches a SQL query, a token that ends up in a log. Sakura Lang uses effect checking, scoped handlers and explicit trust boundaries to make authority reviewable. These are language-level checks, not an OS sandbox for trusted FFI.
Effects are requirements, handlers are authority
Fs in a signature says "this function may perform filesystem operations". It does not say which files. The installed handler decides that (design §6.1).
A handler for Fs is built from a Dir capability value. Every path is resolved relative to that directory, and symlink escapes are rejected. Authority lives in capability values that handlers close over, not in effect labels.
The effect row still matters: a function can only perform the effects in its row, and a pure function cannot touch the outside world at all. But the row is what a function needs. What it may actually reach is set by whoever installs the handler.
Root authority comes from the host
The entry point is pub fn main() -> Unit ! {…}. The host supplies root handlers for exactly the std effects main declares, and nothing else. Only std effects may appear in main's row (V6010), because only they have host root handlers.
Root handlers are limited by the [permissions] table in the project manifest:
[permissions]
fs.read = ["./data", "./templates"]
fs.write = ["./data/out"]
net = ["api.anthropic.com:443", "localhost:5432"]
env = ["DATABASE_URL", "ANTHROPIC_API_KEY"]
process = [] # no subprocessessakura run passes this table as JSON in SAKURA_PERMISSIONS; the host filesystem, HTTP/serving and environment adapters enforce it. Running emitted JS directly without that variable does not establish this ceiling. Trusted FFI is outside this language-level boundary. There is no separate Net effect: the net list limits the network effects Http and Serve (Sql.postgres is not implemented), and env limits which variables Env can read. A main that declares an effect whose permission list is empty, such as Http with an empty net list, fails at startup, not at first use, and exits with status 75 (permission denied).
Narrowing authority
Dir and File have no public constructors. Fs.root() supplies a directory capability, and Dir.subdir narrows it. Http.scoped(hosts: List<String>) and Env.only(keys) attenuate outer handlers by data; NetScope, EnvScope, and SecretStore are not current APIs.
Attenuating handlers are ordinary std functions: Fs.rooted(dir), Http.scoped(hosts), and Env.only(keys).
This test narrows a scripted HTTP handler to one host, without using the network:
import std/http.{Any, Http}
import std/request.{Method, Request}
import std/response.{Response}
import std/url.{Url}
test "only the allowed HTTP host is forwarded" {
with Http.scripted([(Any, Response.status(200, "hello"))]),
Http.scoped(["example.com:443"]) {
let req = Request.of(Method.Get, Url.from("https://example.com/"))
expect_eq(Http.send(req).status, 200)
let denied = Request.of(Method.Get, Url.from("https://other.example/"))
expect((try { Http.send(denied) }).is_err())
}
}The same attenuation wraps a host HTTP handler, which additionally enforces manifest permissions.
Forged capabilities are rejected
A user @external cannot declare a parameter or result containing a capability from another package, or any Handler<…> (V6001, design §17.91). The check follows records, collections, options and function types; another package's @resource type also counts. An FFI package may declare externals over its own external types.
This declaration is intentionally rejected with V6001; no JavaScript is executed:
import std/fs.{Dir}
@external(js, "./forge.js", "root")
fn forge() -> DirScoped escape rules
An external type, a handler, an @resource type, or a value transitively containing one is scoped. Closures also carry the scoped locals they capture (design §6.3). Scoped values may be passed down the call stack, stored in scoped records, and returned from ordinary functions. They may not flow into:
- a
Task.spawnclosure whoseScopeoutlives thewiththat produced the value (there are no detached tasks in v0), - a top-level
let, - a value returned from the
withbody that introduced the scope.
A violation is V6002, including an explicit return out of the with. Calls returning function-containing values conservatively retain scoped argument captures.
V6003 rejects a resource escaping the callback that borrows it, or being stored in handler var state. The callback rule also applies to wrapper functions and to a callback parameter whose type contains a resource, such as a record with a File field (design §17.89). This example is intentionally rejected:
import std/fs.{File, Fs, FsError, Mode}
import std/path.{Path}
fn keep_open(path: Path) -> File ! {Fs, Fail<FsError>} {
Fs.with_file(path, Mode.Read, fn(file) { file })
}Return ordinary data read inside the callback instead. Files are finalized on callback exit. v0 does not check affine use counts; runtime file operations still reject access after closure.
Dependency authority (design; package tooling planned)
The design calls for computed package authority and sakura deps capabilities; that command and multi-package tooling are not implemented. Imports do not grant authority: callers install handlers around dependency calls. Adding an effect to a dependency's signature requires its callers to account for that effect.
The implemented build manifest inventories user externals alongside other trusted components. It is not yet the planned dependency authority graph.
Untrusted data
of, check, trust, len and refined-target validate work today. A validation target must be a known refinement of the wrapped base; otherwise the compiler reports V5003. Using Untrusted.validate as a function value is also rejected. See contracts.
Untrusted<T> wraps data from outside the deployment: raw request bodies, file contents read as text (Fs.read_text), response text (Response.text), and free-text LLM output (Llm.generate_text). Environment variables are not in that list: they are set by the operator and gated by the manifest's env list, so Env.get(key) returns a plain Option<String>. Untrusted<T> supports:
| Operation | Result |
|---|---|
.validate() | Result<V, Violation> for any refined type V, named by annotation: let e: Email = Untrusted.validate(u)?. |
.check(clause, pred) | Result<T, Violation>. |
.len() | The length, without trusting the content. |
.trust(@trust("reason")) | T. An explicit, greppable escape hatch. |
Untrusted.of(t) | Wraps a value, for tests. |
Validating against a refinement is the normal path:
type Email = String where String.len(it) <= 254 && String.contains(it, "@")
error InvalidEmail
fn read_email(raw: Untrusted<String>) -> Email ! {Fail<InvalidEmail>} {
match raw.validate() { // V = Email, inferred from the return type
Ok(email) => email,
Err(_) => fail InvalidEmail,
}
}Untrusted<T> is Show, so it can be logged. Schema-decoded values (Request.json, Json.decode, Llm.generate) are already validated against the target type's refinements and come back as T, not Untrusted<T>.
Tagged templates never splice
sql"…", html"…" and url"…" are implemented tagged templates; shell is reserved but has no std module. The design uses tagged templates (design §4.11). A tagged string desugars to a call to the tag module's template function, with the literal parts and the interpolated values passed separately:
sql"select * from users where email = {email}"
==> sql.template(["select * from users where email = ", ""], [SqlValue.from(email)])Interpolated values are never spliced raw. Each one is type checked against the tag's parameter type. For sql that is SqlValue, which becomes a bound parameter. html escapes text by construction. This query binds a string as a parameter:
import std/sql.{Sql, SqlError}
type Post = { id: Int, title: String }
fn search(q: String) -> List<Post> ! {Sql, Fail<SqlError>} {
Sql.query(sql"select id, title from posts where title = {q}")
}q reaches the database as a parameter value, never as SQL text. Direct Untrusted<String> SQL interpolation currently fails with V2032 because SqlValue.from requires Schema; validate the input into a refined string before passing it here. The source index describes broader support than this binary accepts.
HTML interpolations have element type Html. Existing Html values compose as markup; other Show values convert through Html.from and are escaped text (design §17.77). Untrusted<String> interpolated directly is escaped in its shown wrapper form; Html.untrusted displays just the escaped contents:
import std/html.{Html}
test "HTML composition keeps text escaped" {
let heading = html"<h1>{"Tom & Jerry"}</h1>"
let input = Untrusted.of("<script>")
let page = html"<main>{heading}{Html.untrusted(input)}</main>"
expect_eq(Html.to_string(page), "<main><h1>Tom & Jerry</h1><script></main>")
expect_eq(Html.to_string(Html.from(3)), "3")
}Html.raw(markup, @trust("reason")) is the explicit unescaped entry point; there is no TrustedHtml type. Its reason is included in the trust audit.
This holds because the template's result types are pub opaque types. Query<R> and Html are exported by name only: other modules can pass them around, but constructing one outside its declaring module is V1006, so callers must use the module APIs. Html.raw makes bypassing escaping an explicit trust decision.
Secrets
Secret<T> wraps credentials. Env.secret("KEY") returns Option<Secret<String>>. A secret is not Show, Eq, Ord, or Schema, so it cannot be interpolated, logged, snapshotted, or serialized. Interpolating one is V2031. Only std sinks accept it, such as Http.bearer:
import std/http.{Http}
import std/request.{Request}
import std/response.{HttpError}
import std/log.{Level}
import std/url.{Url}
import std/decimal.{Decimal}
pub fn call_billing(amount: Decimal) -> Unit ! {Env, Http, Log, Fail<HttpError>} {
let token = Env.secret("BILLING_TOKEN").expect("BILLING_TOKEN")
let req = Http.bearer(Request.post_json(Url.from("https://billing/charge"), amount), token)
Log.event(Level.Info, "charging {amount}", []) // ok: Decimal is Show
// Log.event(Level.Info, "token {token}", []) // V2031: Secret<String> is not Show
let _ = Http.send(req)
}Adapters that genuinely need the raw value use Secret.expose(s, @trust("reason")). sakura audit lists every user @trust site with its reason. This prevents accidental disclosure, not deliberate leakage.
The trusted computing base
Some things are assumed, and Sakura Lang says so. The trusted computing base is the compiler, the runtime, the std root handlers, FFI bindings marked @external, every @trust site, the host platform, and later the SMT solver (design §6.6). sakura build --manifest inventories trusted compiler/runtime/std bindings and user trust sites, including a hash of the emitted runtime. It does not hash every trusted component. Each item has the trusted status in the guarantee model.
Compiler fixes never widen authority silently. A fix that would change authority carries impact: authority_change and is never applied by sakura fix automatically. See Diagnostics.
What the compiler checks
- A function performs only the effects in its row. Every performed operation has an installed handler.
maindeclares only std effects (V6010). Root handlers are limited by[permissions]; a denied permission stops the program at startup (exit75).- Prelude
Trustis compiler-known.@trust("reason")is accepted only directly in a trusted sink argument; other uses reportV6001. - Scoped capability/resource escape checks (
V6002/V6003) and foreign-capability FFI checks (V6001) are implemented. The broader M3 gates and maintainer security review remain separate requirements. Secret<T>is never shown, logged, snapshotted, or serialized (V2031).- Tagged templates receive interpolations as values, never raw text.
Not in v0
- Information-flow control.
Secret<T>stops accidents, not a determined leak (design §1.1). - Declared capability ceilings for packages. The design calls for computed package authority; package tooling is not implemented. Mandatory ceilings in
sakura.tomlfor published packages are planned for v1 (design §16).
Next
Memory model: value semantics, reference counting, and in-place reuse.