Add capability-based restrictions to Builtin evaluation#5667
Open
StachuDotNet wants to merge 2 commits into
Open
Add capability-based restrictions to Builtin evaluation#5667StachuDotNet wants to merge 2 commits into
StachuDotNet wants to merge 2 commits into
Conversation
51bba6a to
636351f
Compare
A per-instance capability grant — an explicit allow-list of which impurities (http, filesystem, subprocess, env, datastore, clock, randomness, llm) the code an instance runs may perform — enforced at the builtin call site, before the impurity happens. Local (never synced), nuanced, managed through `dark caps`, stored in `~/.darklang/capabilities.bin`. `dark eval` runs under the host grant (permissive until configured, then respected); `dark run` is deny-all by default. Default behaviour is unchanged until you start restricting. Design: - Each `BuiltInFn` declares its own `capabilities` need at its definition site — no central name-string registry, no `name.StartsWith "httpClient"` magic. - The call-site gate is one structural presence check (`coversStructurally grant fn.capabilities`); the nuanced per-call check (this URL / path / args) lives in each builtin's BODY via `CapabilityCheck`. The interpreter knows nothing about URLs, paths, or args. - A grant and a need are the same structured `Capabilities` record — a field per impurity, with scoped/coupled rules (method × URL for http, program × args for exec), read/write splits for file/env/db, and structured (non-regex) URL matching. - The grant-spec LANGUAGE (parse/render) lives entirely in Dark (`LanguageTools.Capabilities`); F# only ever deals with the structured model, crossing the F#/Dark boundary via `CapabilitiesToDarkTypes` (a C2DT converter mirroring PT2DT/RT2DT). - A fn's effective caps are the union of its builtins' needs folded over the call graph (content-hash cached), behind `caps needed-for <fn>`.
3d1188c to
443d94c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Darklang code performs every impurity through a Builtin — http, the filesystem, subprocesses, env vars,
datastores, the clock, randomness, the LLM. This PR adds a per-instance capability grant: an explicit
allow-list of which of those impurities are permitted, enforced at the Builtin call site — a call
whose need the grant doesn't cover is denied at runtime, before the impurity happens.
The grant is local to the instance (never synced, it's not package data), nuanced (not just
"http on/off" but "GET over https to
*.github.com"), and managed throughdark caps. Theinteractive instance is permissive until you configure a grant, then it respects it;
dark runscriptsare deny-all and opt in. The same per-domain need data also powers static "what can this function do?"
analysis. Default behaviour is unchanged until you start restricting.
The model
A grant (what the instance allows) and a need (what a call requires) are the same type — one
Capabilitiesrecord, a field per impurity. A grant covers a need field-by-field.The pieces:
Coupled rule-lists. Some domains must constrain dimensions together.
httpClientcouplesmethod × URL;
execcouples program × args. Each is a list of rules — a request is allowed iff itmatches some rule (OR across rules, AND within one):
Structured URLs. The URL is decomposed into scheme / host / port / path, each its own
Scope.Host matching is structured rather than regex (auditable, no host-bypass or ReDoS footguns). The grant
grammar is a URL pattern
[scheme://]host[:port][/path]with safe defaults — omitted scheme ⇒https,omitted port ⇒ the scheme's default, omitted path ⇒ any — and
*as the explicit per-part wildcard:Every dimension is explicit — there is no "empty implies any". An undeterminable value (e.g. a URL the
gate can't parse) becomes the maximal need, denied unless the grant is unrestricted: fail-closed and
deliberate, never a silent
Any.Implementation
Three structural choices keep this honest — no name-string magic, no central side-table, no parsing in F#:
Builtins declare their own needs. Every
BuiltInFncarries acapabilities : Capabilitiesfield —the need lives on the builtin, set at its definition site by what it actually does (mostly
Caps.pure;the ~70 effectful ones their real need). There is no
name.StartsWith "httpClient"registry decidingneeds by string-matching. (Marking needs by behaviour rather than name also fixed a latent bug: the
*Random/uuidGeneratebuiltins never matched the old"random"prefix and so declared no need.)Two-tier enforcement, no name dispatch in the interpreter. The call-site gate is one structural
presence check —
coversStructurally grant fn.capabilities— that asks only "may this instance touchthis domain at all?". The nuanced per-call check (this URL, this path, these args) lives in the
builtin's own body, which calls
CapabilityCheck.requireHttp/requireFile/requireExec/… with theconcrete target it already holds. The interpreter no longer pattern-matches
fn.name.nameagainst"httpClientRequest"to special-case URL parsing — it knows nothing about URLs, paths, or args.The grant LANGUAGE lives in Dark. All spec-string parsing and rendering (
http-client GET api.x⇄the structured record) is
.darkcode (Darklang.LanguageTools.Capabilities.parse/render). F# onlyever deals with the structured model. The boundary is a
CapabilitiesToDarkTypes(C2DT) convertermirroring
ProgramTypesToDarkTypes, against a Dark mirror typeLanguageTools.Capabilities; the capsbuiltins hand the structured value across, and the CLI parses/renders at the edge.
Builtins & CLI
Static analysis — "what can this function do?"
A function's effective capabilities are the union of its Builtins' needs, folded over its entire
(transitive) call graph. Because that's a pure function of the fn's content hash, it's computed once and
cached, so repeat lookups (and an always-on caps badge) cost an indexed read, not a graph walk. It's
exposed by the
pmFnEffectiveCapsBuiltin and surfaced as:eval— runs under the host's capabilitiesdark eval <expr>evaluates the expression under the instance's capabilities. With no grant configuredthe host is permissive (
allCaps), so eval is unrestricted by default; once you configure a grant, evalrespects it — an uncovered Builtin is denied. This is the common path and where the restriction matters
day-to-day.
dark run— secure-by-default sandboxdark run <script>runs the script body with no capabilities, so any impurity raises unless you optinto the host's grant with
--apply-host-caps. Untrusted scripts stay sandboxed; you grant deliberately.the grant itself
Three Builtins back the whole feature:
pmCapsGet(the grant),pmCapsSet(replace it), andpmFnEffectiveCaps(the analyzer) — each exchanging the structuredCapabilitiesvalue across theF#/Dark boundary, never spec strings. Every adjustment — grant one, revoke a domain, clear, apply a
profile — is a get-modify-set composed on top. The
capscommand:caps editis a small TUI over a working copy of the grant — toggle flag impurities, edit coupled rulesthrough per-domain editors, apply a profile,
wto write:When a grant is missing
A denied call raises a structural error naming the resource and pointing at
dark caps— the denialfires before the impurity runs:
(The runtime message stays structural rather than printing a
dark caps grant …spec, because F# neverrenders the grant language — see Implementation below. The CLI is the place to surface a one-shot
grant suggestion, rendered from the structured need on the Dark side.)
Adjusting grants
Removing / replacing:
Coupled rules just accumulate — "anything to localhost, but only GET-with-https outside" is two grants:
On disk
The grant lives in
~/.darklang/capabilities.bin— theCapabilitiesrecord serialized through therepo's reflection-free binary format (the release CLI is AOT-trimmed, so reflection-based serializers
like System.Text.Json are out). You manage it through
dark caps, not by editing the file. A realisticlocal posture — read the web, POST to one API + anything to localhost, write only a couple of dirs, run
a couple of tools — set in one go and viewed back:
(One small future note: the F#
Capabilitiestype currently lives in its ownLibExecution.Capabilitiesmodule with a hand-rolled binary serializer and a
CapabilitiesToDarkTypesconverter. It's a low-level,builtin-adjacent, should-be-stable shape, so it likely wants to live at the language level — PT or RT —
and share their serialization/hashing and DarkTypes conversion, rather than carrying its own.)