sandbox: a manifest is pinned only when its image AND every feature are digest-addressed #270

Merged
dcharlot merged 1 commit from fix/manifest-pinned-means-pinned into main 2026-10-15 22:35:42 -04:00
4 changed files with 76 additions and 12 deletions

View file

@ -386,7 +386,7 @@ Long-horizon work outlives the sandbox that started it. A host that suspends and
## 14. The environment manifest
A durable run is only the same run if it resumes into the same toolchain — checkable by hash, not asserted by trust. The **environment manifest** is one canonical, content-addressed object standing for the declared environment: base image (pinned by digest where honest claims require it), tool features, environment variables and mounts. Declarations are ingested from the open `devcontainer.json` format — its **declarative subset only**: fields that execute commands or widen the sandbox (`build`, the lifecycle `*Command` hooks, `privileged`, …) are refused, and unknown fields are refused rather than silently dropped. Networking fields are excluded from identity: the network surface belongs to the grant (§5). The manifest is JCS-canonicalized, BLAKE3-addressed, Ed25519-signed (§7.3 convention); `SnapshotRef.manifest` (§13) records it at suspension and resumption **MUST** be refused on a drifted address. Conformance class **C7**. See [`spec/environment-manifest.md`](spec/environment-manifest.md); the reference `sandbox-attest::manifest` implements it.
A durable run is only the same run if it resumes into the same toolchain — checkable by hash, not asserted by trust. The **environment manifest** is one canonical, content-addressed object standing for the declared environment: base image and tool features (each pinned by digest where honest claims require it: a manifest is pinned only when both are), environment variables and mounts. Declarations are ingested from the open `devcontainer.json` format — its **declarative subset only**: fields that execute commands or widen the sandbox (`build`, the lifecycle `*Command` hooks, `privileged`, …) are refused, and unknown fields are refused rather than silently dropped. Networking fields are excluded from identity: the network surface belongs to the grant (§5). The manifest is JCS-canonicalized, BLAKE3-addressed, Ed25519-signed (§7.3 convention); `SnapshotRef.manifest` (§13) records it at suspension and resumption **MUST** be refused on a drifted address. Conformance class **C7**. See [`spec/environment-manifest.md`](spec/environment-manifest.md); the reference `sandbox-attest::manifest` implements it.
## 15. Warm pools

View file

@ -29,12 +29,22 @@ use crate::jcs;
/// One tool feature (the devcontainer `features` table, normalized).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Feature {
/// The feature reference as declared (e.g. `ghcr.io/devcontainers/features/rust:1`).
/// The feature reference as declared. A reference with a digest
/// (`ghcr.io/devcontainers/features/rust@sha256:…`) names fixed bytes; a
/// tag (`ghcr.io/devcontainers/features/rust:1`) floats, and resolves to
/// whatever its publisher last pushed under it.
pub id: String,
/// The feature's options, JCS-canonicalized JSON text ("" when empty).
pub options: String,
}
impl Feature {
/// Whether the reference is addressed by digest, and so names fixed bytes.
pub fn is_pinned(&self) -> bool {
self.id.contains("@sha256:")
}
}
/// One mount, normalized.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mount {
@ -190,9 +200,22 @@ impl EnvironmentManifest {
Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user })
}
/// Whether the environment is fully pinned (digest-addressed base image).
/// Only a pinned manifest can honestly claim "same address ⇒ same bytes".
/// Whether the toolchain is fully pinned: the base image AND every
/// feature are addressed by digest. Only then does "same address ⇒ same
/// bytes" hold. A pinned image with a feature declared by tag (`rust:1`)
/// is not pinned: the tag floats, so two hosts can resolve one address to
/// different toolchains.
///
/// Mounted content is data, not toolchain, and is never part of the
/// address: a pinned manifest fixes what the unit runs with, not what its
/// mounts hold.
pub fn is_pinned(&self) -> bool {
self.image_pinned() && self.features.iter().all(Feature::is_pinned)
}
/// Whether the base image alone is addressed by digest. Necessary for
/// [`EnvironmentManifest::is_pinned`], not sufficient.
pub fn image_pinned(&self) -> bool {
self.image_digest.is_some()
}
@ -311,7 +334,9 @@ mod tests {
#[test]
fn ingest_canonicalize_address() {
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();
assert!(m.is_pinned());
// The image is digest-addressed, but `rust:1` and `node:1` float.
assert!(m.image_pinned());
assert!(!m.is_pinned());
assert_eq!(m.env, vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
assert_eq!(m.features.len(), 2);
assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1");
@ -351,9 +376,33 @@ mod tests {
#[test]
fn unpinned_image_is_marked() {
let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap();
assert!(!m.image_pinned());
assert!(!m.is_pinned());
}
/// Pinned means the whole toolchain: a digest image with digest features
/// is pinned; one feature by tag beside them is not; and a mount, whose
/// content is never addressed, does not change either answer.
#[test]
fn pinned_means_every_feature_is_digest_addressed() {
let doc = |rust: &str| {
format!(
r#"{{"image": "ghcr.io/acme/builder@sha256:0011aabb",
"features": {{"{rust}": {{}},
"ghcr.io/devcontainers/features/node@sha256:ccdd": {{}}}},
"mounts": ["source=cache,target=/cache,type=volume"]}}"#
)
};
let pinned = EnvironmentManifest::from_devcontainer(&doc("ghcr.io/devcontainers/features/rust@sha256:eeff")).unwrap();
assert!(pinned.features.iter().all(Feature::is_pinned));
assert!(pinned.is_pinned());
let floating = EnvironmentManifest::from_devcontainer(&doc("ghcr.io/devcontainers/features/rust:1")).unwrap();
assert!(floating.image_pinned());
assert!(!floating.is_pinned(), "a feature by tag leaves the toolchain unpinned");
let no_features = EnvironmentManifest::from_devcontainer(r#"{"image": "ghcr.io/acme/builder@sha256:0011aabb"}"#).unwrap();
assert!(no_features.is_pinned());
}
#[test]
fn sign_verify_and_tamper() {
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();

View file

@ -286,7 +286,15 @@ fn c7() -> Result<(), String> {
"name": "cosmetic", "forwardPorts": [3000]
}"#;
let m = EnvironmentManifest::from_devcontainer(doc).map_err(|e| format!("{e:?}"))?;
want(m.is_pinned(), "digest-addressed image is pinned")?;
want(m.image_pinned(), "digest-addressed image is pinned")?;
// The image is pinned, but `rust:1` floats: the toolchain is not.
want(!m.is_pinned(), "a feature declared by tag leaves the manifest unpinned")?;
let pinned_doc = doc.replace(
"ghcr.io/devcontainers/features/rust:1",
"ghcr.io/devcontainers/features/rust@sha256:eeff",
);
let pinned = EnvironmentManifest::from_devcontainer(&pinned_doc).map_err(|e| format!("{e:?}"))?;
want(pinned.is_pinned(), "digest image and digest features are pinned")?;
let id = m.content_id().map_err(|e| format!("{e:?}"))?;
// Determinism: key order / whitespace must not change the address.
let shuffled = doc.replace("\"B\": \"2\", \"A\": \"1\"", "\"A\":\"1\",\"B\":\"2\"");

View file

@ -18,8 +18,8 @@ industry. This standard ingests its **declarative subset**:
| accepted | meaning |
|---|---|
| `image` | base image reference; a `@sha256:` digest marks the manifest **pinned** |
| `features` | tool features, normalized to (id, JCS-canonical options), sorted |
| `image` | base image reference; a `@sha256:` digest pins the image |
| `features` | tool features, normalized to (id, JCS-canonical options), sorted; a reference with a `@sha256:` digest is pinned, one by tag (`:1`) floats |
| `containerEnv` | environment variables, sorted |
| `mounts` | string or object form, normalized to (source, target, kind), sorted by target |
| `remoteUser` | the user the unit runs as |
@ -45,10 +45,17 @@ its BLAKE3 address is the environment's identity; it is signed Ed25519 over
the canonical bytes, mirroring the attestation convention (§7.3). Key order
and whitespace in the source document do not affect the address.
A manifest without an image digest is **unpinned**: its address identifies a
declaration, not bytes. Hosts SHOULD treat unpinned manifests the way the
energy receipt treats estimates — usable, but labelled, and never the basis
for a "same environment" claim across hosts.
A manifest is **pinned** only when its image and every feature are addressed
by digest. Otherwise it is **unpinned**: its address identifies a
declaration, not bytes — a tag such as `rust:1` resolves to whatever its
publisher last pushed, so a pinned image beside a feature by tag does not fix
the toolchain. Hosts SHOULD treat unpinned manifests the way the energy
receipt treats estimates — usable, but labelled, and never the basis for a
"same environment" claim across hosts.
Mounted content is data, not toolchain, and is never part of the address. A
pinned manifest fixes what a unit runs with; it says nothing about what its
mounts hold.
## Use in durable runs