Skip to content

Add capability-based restrictions to Builtin evaluation#5667

Open
StachuDotNet wants to merge 2 commits into
darklang:mainfrom
StachuDotNet:capabilities
Open

Add capability-based restrictions to Builtin evaluation#5667
StachuDotNet wants to merge 2 commits into
darklang:mainfrom
StachuDotNet:capabilities

Conversation

@StachuDotNet
Copy link
Copy Markdown
Member

@StachuDotNet StachuDotNet commented Jun 2, 2026

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 through dark caps. The
interactive instance is permissive until you configure a grant, then it respects it; dark run scripts
are 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
Capabilities record, a field per impurity. A grant covers a need field-by-field.

type Capabilities =
  { // simple on/off impurities
    stdout : bool
    stdin  : bool
    random : bool
    clock  : bool
    llm    : bool                  // the AI opt-in — denied by default

    // scoped / coupled impurities
    httpClient : List<HttpRule>    // (method × URL) rules — coupled, OR'd
    httpServer : Scope<int64>      // which ports may bind
    file : RW<string>              // read paths × write paths (prefix-scoped)
    env  : RW<string>              // read vars × write vars
    db   : RW<string>              // read tables × write tables
    exec : List<ExecRule> }        // run external programs — (program × args) rules

The pieces:

type Scope<'a> = Any | Only of Set<'a>                   // ⊤ "anywhere"  |  an allow-list (Only {} = nothing)
type RW<'a>    = { read: Scope<'a>; write: Scope<'a> }   // read-scope and write-scope can differ

type HttpRule = { methods: Scope<string>; url: UrlScope }
type ExecRule = { programs: Scope<string>; args: Scope<string> }

type HostMatch = AnyHost | ExactHost of string | Subdomain of string   // structured, NOT regex
type UrlScope  = { schemes : Scope<string>     // Only {"https"} | Any
                   hosts   : List<HostMatch>    // OR-list; [AnyHost] = any host
                   ports   : Scope<int64>
                   paths   : Scope<string> }    // path-prefix matched

Coupled rule-lists. Some domains must constrain dimensions together. httpClient couples
method × URL; exec couples program × args. Each is a list of rules — a request is allowed iff it
matches some rule (OR across rules, AND within one):

http-client * *://localhost:*    ┐  any method/scheme/port to your own services,
http-client GET                  ┘  but GET-only (https) to the outside world

exec git                         ┐  git with any args,
exec rm --dry-run                ┘  rm only with --dry-run

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:

http-client GET  https://*.github.com/api    # https + subdomains + path-prefix, GET only
http-client GET  api.x                        # = GET https://api.x:443, any path
http-client *    *://localhost:*              # any method/scheme/port to localhost

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 BuiltInFn carries a capabilities : Capabilities field —
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 deciding
needs by string-matching. (Marking needs by behaviour rather than name also fixed a latent bug: the
*Random/uuidGenerate builtins 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 touch
this 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 the
concrete target it already holds. The interpreter no longer pattern-matches fn.name.name against
"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 .dark code (Darklang.LanguageTools.Capabilities.parse / render). F# only
ever deals with the structured model. The boundary is a CapabilitiesToDarkTypes (C2DT) converter
mirroring ProgramTypesToDarkTypes, against a Dark mirror type LanguageTools.Capabilities; the caps
builtins 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 pmFnEffectiveCaps Builtin and surfaced as:

$ caps needed-for Darklang.Stdlib.HttpClient.get
Darklang.Stdlib.HttpClient.get needs:
  http-client   any method → anywhere

$ view Darklang.Stdlib.HttpClient.get
let get (uri: String) … = Stdlib.HttpClient.request "GET" uri headers Stdlib.Blob.empty
capabilities: http-client          ← dim badge

eval — runs under the host's capabilities

dark eval <expr> evaluates the expression under the instance's capabilities. With no grant configured
the host is permissive (allCaps), so eval is unrestricted by default; once you configure a grant, eval
respects it — an uncovered Builtin is denied. This is the common path and where the restriction matters
day-to-day.

dark run — secure-by-default sandbox

dark run <script> runs the script body with no capabilities, so any impurity raises unless you opt
into 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), and
pmFnEffectiveCaps (the analyzer) — each exchanging the structured Capabilities value across the
F#/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 caps command:

caps                         Show the current grant
caps needed-for <fn>         What a fn (transitively) needs
caps grant-for-fn <fn>       Grant exactly what a fn needs
caps edit                    Interactive editor (TUI)

Adjust:  grant <spec> · revoke <domain>
Replace: set <spec>; <spec>… · clear · profile [name]

caps edit is a small TUI over a working copy of the grant — toggle flag impurities, edit coupled rules
through per-domain editors, apply a profile, w to write:

 capabilities   ● unsaved

   [x] random
   [ ] http-server
 › http-client   GET → *.github.com/api
   file          write → ~/.darklang/
   exec          rm  (--dry-run)

  ↑↓ move · space toggle · enter edit · a add · d delete · p profile · w write · esc exit

When a grant is missing

A denied call raises a structural error naming the resource and pointing at dark caps — the denial
fires before the impurity runs:

$ dark eval 'Stdlib.HttpClient.post "https://api.stripe.com/v1/charges" headers body'
capability denied: POST https://api.stripe.com/v1/charges needs http-client (a method/scheme/host/port/
path it doesn't allow), which this instance doesn't grant. Grant it with `dark caps`.

$ dark caps grant http-client POST api.stripe.com/v1
✓ granted  http-client POST api.stripe.com/v1

(The runtime message stays structural rather than printing a dark caps grant … spec, because F# never
renders 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

# simple on/off impurities
caps grant random
caps grant llm                          # the AI opt-in, denied by default

# http — coarse → nuanced
caps grant http-client                  # any method, anywhere
caps grant http-client GET              # GET anywhere (https)
caps grant http-client GET api.github.com           # GET https://api.github.com:443, any path
caps grant http-client POST https://api.stripe.com/v1   # POST, https, that host, under /v1 only
caps grant http-client GET *.github.com             # GET to github.com + any subdomain
caps grant http-client * *://localhost:*            # any method/scheme/port to localhost

# filesystem / env / db — direction × scope
caps grant file read                    # read anywhere
caps grant file write ~/.darklang/      # write only inside that dir
caps grant file read+write /tmp/work/
caps grant env read PATH
caps grant db read+write users

# run external programs — program × arg-allowlist
caps grant exec git                     # git with any args
caps grant exec rm --dry-run            # rm, only with --dry-run

# exactly what a function needs (walks its call graph)
caps grant-for-fn Darklang.Stdlib.HttpClient.get

Removing / replacing:

caps revoke http-client                 # drop every http-client rule
caps revoke file                        # drop file read AND write
caps clear                              # NONE — revoke everything

caps set 'http-client GET; file read ~/proj/; exec git; random'   # replace the whole posture at once
caps profile read-only                  # or a named posture: read-only · local-dev · trusted

Coupled rules just accumulate — "anything to localhost, but only GET-with-https outside" is two grants:

caps grant http-client * *://localhost:*
caps grant http-client GET

On disk

The grant lives in ~/.darklang/capabilities.bin — the Capabilities record serialized through the
repo'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 realistic
local 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:

$ caps set 'http-client GET; http-client POST api.openai.com/v1; http-client * *://localhost:*;
           file read; file write /tmp/; file write ~/.darklang/; exec git; exec rm --dry-run;
           env read PATH; stdout; stdin; random; clock'

$ caps
  http-client   GET → anywhere
  http-client   POST → api.openai.com/v1
  http-client   any → *://localhost:*
  file          read → anywhere
  file          write → /tmp/
  file          write → ~/.darklang/
  exec          git
  exec          rm  (--dry-run)
  env           read → PATH
  stdout
  stdin
  random
  clock

(One small future note: the F# Capabilities type currently lives in its own LibExecution.Capabilities
module with a hand-rolled binary serializer and a CapabilitiesToDarkTypes converter. 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.)

@StachuDotNet StachuDotNet force-pushed the capabilities branch 6 times, most recently from 51bba6a to 636351f Compare June 2, 2026 19:27
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>`.
@StachuDotNet StachuDotNet requested a review from OceanOak June 3, 2026 02:15
@StachuDotNet StachuDotNet changed the title Capabilities — resource-domain gating + static per-fn analysis Add capability-based restrictions to Builtin evaluation Jun 3, 2026
@StachuDotNet StachuDotNet marked this pull request as ready for review June 3, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant