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 ## 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 ## 15. Warm pools

View file

@ -29,12 +29,22 @@ use crate::jcs;
/// One tool feature (the devcontainer `features` table, normalized). /// One tool feature (the devcontainer `features` table, normalized).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Feature { 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, pub id: String,
/// The feature's options, JCS-canonicalized JSON text ("" when empty). /// The feature's options, JCS-canonicalized JSON text ("" when empty).
pub options: String, 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. /// One mount, normalized.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mount { pub struct Mount {
@ -190,9 +200,22 @@ impl EnvironmentManifest {
Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user }) Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user })
} }
/// Whether the environment is fully pinned (digest-addressed base image). /// Whether the toolchain is fully pinned: the base image AND every
/// Only a pinned manifest can honestly claim "same address ⇒ same bytes". /// 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 { 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() self.image_digest.is_some()
} }
@ -311,7 +334,9 @@ mod tests {
#[test] #[test]
fn ingest_canonicalize_address() { fn ingest_canonicalize_address() {
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap(); 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.env, vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
assert_eq!(m.features.len(), 2); assert_eq!(m.features.len(), 2);
assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1"); assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1");
@ -351,9 +376,33 @@ mod tests {
#[test] #[test]
fn unpinned_image_is_marked() { fn unpinned_image_is_marked() {
let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap(); let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap();
assert!(!m.image_pinned());
assert!(!m.is_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] #[test]
fn sign_verify_and_tamper() { fn sign_verify_and_tamper() {
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap(); let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();

View file

@ -286,7 +286,15 @@ fn c7() -> Result<(), String> {
"name": "cosmetic", "forwardPorts": [3000] "name": "cosmetic", "forwardPorts": [3000]
}"#; }"#;
let m = EnvironmentManifest::from_devcontainer(doc).map_err(|e| format!("{e:?}"))?; 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:?}"))?; let id = m.content_id().map_err(|e| format!("{e:?}"))?;
// Determinism: key order / whitespace must not change the address. // Determinism: key order / whitespace must not change the address.
let shuffled = doc.replace("\"B\": \"2\", \"A\": \"1\"", "\"A\":\"1\",\"B\":\"2\""); 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 | | accepted | meaning |
|---|---| |---|---|
| `image` | base image reference; a `@sha256:` digest marks the manifest **pinned** | | `image` | base image reference; a `@sha256:` digest pins the image |
| `features` | tool features, normalized to (id, JCS-canonical options), sorted | | `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 | | `containerEnv` | environment variables, sorted |
| `mounts` | string or object form, normalized to (source, target, kind), sorted by target | | `mounts` | string or object form, normalized to (source, target, kind), sorted by target |
| `remoteUser` | the user the unit runs as | | `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 the canonical bytes, mirroring the attestation convention (§7.3). Key order
and whitespace in the source document do not affect the address. and whitespace in the source document do not affect the address.
A manifest without an image digest is **unpinned**: its address identifies a A manifest is **pinned** only when its image and every feature are addressed
declaration, not bytes. Hosts SHOULD treat unpinned manifests the way the by digest. Otherwise it is **unpinned**: its address identifies a
energy receipt treats estimates — usable, but labelled, and never the basis declaration, not bytes — a tag such as `rust:1` resolves to whatever its
for a "same environment" claim across hosts. 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 ## Use in durable runs