open-standards/sandbox/spec/capabilities.md
David Charlot 2ea5ad3e9c sandbox §5.3: an enforced grant field was outside the grant's content address
`grants_content_id` is the only thing that puts the grant set into a signed
attestation (`Attestation.grants_id`). It hand-builds a CBOR core of 27 keys
and covers `net_outbound` and `net_inbound` — and never
`g.network.credentials`.

Those credentials are enforced. `Enforcer::credential_for` returns the
credential the boundary injects on the unit's behalf for an authorised egress,
so *which* secret is injected is part of the capability surface. Swapping a
handle — `example/test-key` for `example/live-key` on an endpoint already in
`outbound` — changes nothing else about the grant, so the id was identical and
the mutation undetectable. Every attestation sealed under the old set went on
verifying while describing a surface no longer in force.

SPEC §1.2 requires the opposite in as many words: "a mutation to the granted
capability set MUST terminate the run and invalidate its attestation." The
repo already knew: a test beside this one is named
`io_ceiling_is_bound_into_grants_content_id` and opens "SPEC §1.2: an enforced
grant field MUST be part of the grant's content address, so loosening it
mid-run is detectable."

And the binding had been written. `EgressCredential::commitment()` documents
itself as "binds the `(endpoint, secret_ref, mode)` decision into an
attestation", and its only two callers in the tree were in its own test. The
claim in that doc was not true of the code. It is now — this gives it its
first real caller.

The commitment, never the credential, so the handle does not travel in the
grant id; the test asserts the handle is absent from it. `secret_ref` being a
name rather than a key is the whole safety property, and committing to the
credential itself would have spent it.

**Emitted only when the set is non-empty**, which is deliberate and not the
convention two keys above it. `opt_u64` always emits a presence-distinguishing
array because it must tell `None` from `Some(0)`; an empty credential set is
the same capability surface as a grant from before the field existed. So
omitting keeps every such grant's id byte-identical — including the
`b3:ONvIj2bsKw-2Kqe43mNV_3c0rrHTA3Df8xyM6D4ybnY` that `attestation.json` and
`negative.json` pin. All eight conformance classes pass with no skips. Only
grants that actually inject credentials get a new id, which is correct: theirs
was the id that was wrong.

SPEC §5.3 and spec/capabilities.md declared `NetworkGrant { outbound, inbound }`
— the enforced field was absent from the normative type as well as from the
address. Both now carry it, with the three rules: the handle is a name and
never a key, a credential's endpoint MUST also be in `outbound` (already
enforced by `injection_within_egress`), and the set MUST be in the content
address, by commitment.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-14 10:04:05 -04:00

118 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The capability-grant model
Normative companion to [`SPEC.md`](../SPEC.md) §5. RFC 2119 keywords apply.
A **grant set** is the complete, explicit description of what a unit may touch. The model is **deny-by-default**: any access to a resource not named by a grant **MUST** be denied. The grant set is content-addressed before the run (its `grants_id`, SPEC §3) and **MUST NOT** widen during the run (SPEC §1.2).
```
Grants {
compute : ComputeGrant,
storage : StorageGrant,
network : NetworkGrant,
kv : KvGrant,
host : HostGrant,
}
```
The **default grant set is empty**: no devices beyond `Cpu`, no storage, no network, no kv, no host services. Every capability is opt-in.
## ComputeGrant — the heterogeneity clause
```
ComputeGrant {
devices : Set<DeviceClass>, // Cpu | Simd | Gpu | Npu | Tpu | Lpu | Photonic | Quantum
energy_budget_uj : u64,
cpu_time_ms : u64,
memory_bytes : u64,
}
```
Rules:
1. **Dispatch is gated by class.** A unit **MUST NOT** dispatch work to a device class absent from `devices`; the host **MUST** deny it. `Cpu` is implicitly present.
2. **The host must isolate AND meter every granted class.** A host **MUST** refuse the grant (decline the run) if `devices` names a class it cannot both confine at the tier in force **and** attribute energy to in the receipt (SPEC §6.2). A class the host can run but cannot meter is not grantable — metering is not optional.
3. **Class minimums bind the tier.** Any class beyond `Cpu`/`Simd` requires ≥ S2; direct device assignment requires ≥ S5; confidential compute requires ≥ S6 (see [`tiers.md`](tiers.md)).
4. **Budgets are hard.** On exceeding `energy_budget_uj`, `cpu_time_ms`, or `memory_bytes`, the host **MUST** terminate the unit and record the breached bound as `outcome = Terminated{ breach }` in the attestation (SPEC §7.1).
The energy budget is denominated in the same unit as the receipt — integer microjoules — so a unit's grant and its account are directly comparable.
## StorageGrant
```
StorageGrant {
read : [Glob], write : [Glob], delete : [Glob],
max_iops : u64?, // optional sustained-I/O ceiling: ops/sec
max_read_bps : u64?, // optional sustained-I/O ceiling: bytes/sec
}
```
A path operation **MUST** match at least one allow-glob for its mode (`read`/`write`/`delete`) or be denied. Globs use `*` (single segment) and `**`/`/*` (prefix) matching; the matching algorithm is fixed in the reference `sandbox-capability` crate and vectored. Write and delete do not imply read. The empty lists deny all storage.
### I/O ceilings — the contention lever
A grant **MAY** carry sustained-I/O ceilings. A request to sustain a rate is permitted **iff** it stays within every ceiling that is set; an unset ceiling (`null`) does not bound that dimension. This is a deny-by-default rate control, and it is the standard's lever against **storage-contention side channels** (see `SECURITY.md`): a unit whose I/O is capped cannot drive the high-rate, page-cache-busting random-read pattern such a channel needs to read co-tenant activity off shared-device latency. A deployment that colocates a contention-sensitive workload at S0–S2 (shared physical I/O) **MUST** set a ceiling on untrusted neighbors, or raise the floor to **S3**, where device I/O is partitioned rather than shared.
The ceilings serialize **only when set** (`skip_serializing_if`): a grant that omits them is byte-identical to one written before they existed, so its content id and the published conformance vectors are unchanged. The reference enforces them in `sandbox-capability` (`Enforcer::may_sustain_io`, `io_ceilings`); a ceiling is a property of the **grant**, so it is bound into `grants_content_id` and attested.
## NetworkGrant
```
NetworkGrant { outbound : [Endpoint],
inbound : bool,
credentials : [EgressCredential] } // SPEC §5.3
EgressCredential { endpoint : Endpoint, secret_ref : Handle, inject : Injection }
```
`outbound` is an allowlist of `host:port` strings; an egress to any endpoint not on the list **MUST** be denied. `inbound = false` denies all inbound connections. The empty grant denies all network. Per SPEC §1.4, the host's own operation never depends on network; a denied egress is a unit-level denial, not a host failure.
## KvGrant
```
KvGrant { read : bool, write : bool, prefixes : [Bytes] }
```
Key-value access is gated by direction (`read`/`write`) and by key `prefixes`: a key operation **MUST** match a granted prefix and direction or be denied. Empty `prefixes` with `read`/`write` true grants the whole keyspace; the conservative default is all-false.
## HostGrant
```
HostGrant { logging : bool, clock : bool, random : bool, inference : bool }
```
- `logging` — the unit may emit log lines to the host sink.
- `clock` — the unit may read wall-clock time. When false, time calls return a normalized constant (anti-evasion, SPEC §7.5).
- `random` — the unit may draw host randomness. When a replay journal is recorded (SPEC §8), draws are journaled.
- `inference` — the unit may call an on-host model endpoint. When granted, the energy of each inference **MUST** be metered to the device class that served it (SPEC §6.2) and **MUST NOT** be hidden inside an opaque host-call cost.
## ProcessGrant — shell and code-interpreter
```
ProcessGrant { exec : bool, programs : [String], max_processes : u32 }
```
The substrate for **shell access** and **code-interpreter** capabilities. A unit **MUST NOT** spawn a subprocess unless `exec` is true and the program is on the `programs` allowlist — deny-by-default, so an empty allowlist permits nothing even with `exec` true. `max_processes` bounds concurrency. Granting `exec` lifts the required tier to at least **S2** ([`tiers.md`](tiers.md)): subprocess execution crosses a brokered host boundary. The reference WASM executor brokers this as a `sandbox::exec` host call that consults the `Enforcer` before spawning and journals the subprocess output (a nondeterministic input, SPEC §8).
## BrowserGrant — browser and GUI agents
```
BrowserGrant { enabled : bool, origins : [Origin], headless : bool }
```
The substrate for **browser-automation** and **GUI-agent** capabilities. A navigation **MUST** be denied unless `enabled` is true and the target origin (`scheme://host[:port]`) is on the `origins` allowlist. `headless` forbids on-screen rendering. Granting `enabled` lifts the required tier to at least **S2**.
## DisplayGrant — desktop and VNC
```
DisplayGrant { enabled : bool, vnc : bool, max_width : u32, max_height : u32 }
```
The substrate for **desktop** capabilities. Framebuffer access **MUST** be denied unless `enabled` is true; a VNC export **MUST** additionally require `vnc`; the surface is bounded by `max_width × max_height`. Granting `enabled` lifts the required tier to at least **S2**.
The **filesystem** capability is `storage`; a **code-interpreter** is `process` + `storage`; an **editor/IDE** is `display` + `process` + `storage`. The standard models the underlying resources, never the composed product — so a new agent surface is expressed as a combination of grants, not a new primitive.
## Enforcement contract
The reference `sandbox-capability` crate exposes an `Enforcer` that, given a `Grants` set, answers each access query (`may_dispatch(class)`, `may_path(mode, path)`, `may_egress(endpoint)`, `may_kv(dir, key)`, `may_host(service)`, `may_exec(program)`, `may_navigate(origin)`, `may_display()`, `may_vnc()`) with allow/deny. The enforcer is **pure and deterministic**: the same grant set and query always yield the same decision, with no float and no ambient state. Conformance vector class **C2** (SPEC §10, [`conformance.md`](conformance.md)) fixes the decisions for a canonical grant set, including the deny-by-default negatives.
At tier S2 the enforcement is **live**, not advisory: the reference WASM executor (`sandbox-exec-wasm`) brokers every capability the guest exercises through a host-call boundary — `kv_get`/`kv_put`, `storage_read`/`storage_write`, `egress`, `clock_now`, `random_u64`, `log` — and each call consults the `Enforcer` before acting. A denied call returns an error code to the guest (denial is not a crash), so capability enforcement is exercised through real execution. Each nondeterministic input the guest receives (a kv or storage read, the clock, a random draw, an outbound call) is recorded in the run's replay journal (SPEC §8), so the attestation carries a `journal_root`.