diff --git a/Cargo.lock b/Cargo.lock index 705ea329..4bed4813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1980,6 +1980,7 @@ dependencies = [ "ed25519-dalek", "serde", "serde_json", + "sha1", "sha2", "time", ] diff --git a/GLOSSARY.md b/GLOSSARY.md index 59480f83..621d25fd 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -21,6 +21,7 @@ Every public export, command, error, route, and document uses these terms. | Artifact | `skilld.dev/api/v1` | published protocol | skilld CLI | Artifact | | Artifact attestation | `skilld.dev/api/v1` | published protocol | skilld CLI | attestation | | Check result | `skilld.dev/api/v1` | published protocol | skilld CLI, developer | check result | +| Linked file | `skilld.dev/api/v1` | published protocol | skilld CLI | linked file | | behavior | `packages/protocol/rules/skill-behaviors.json`, `skilld run` | published value | Agent, developer | behavior | | Source status | lockfile and protocol | published value | skilld CLI, CI | source status | | Skill page | `skilld.dev` and skilld CLI output | published value | Agent, developer | Skill page | @@ -277,6 +278,16 @@ The digest reports changes in watched Repositories. **Casing:** `Check result` in headings, `check result` in sentences. +### Linked file + +**Is:** a Skill file an Artifact attestation lists by path, mode, size, and Git blob SHA without packing its bytes. The skilld CLI reads it from `raw.githubusercontent.com` at the attested commit and installs the Skill only when every linked file matches. + +**Use for:** Skill files that would push an Artifact past its size limit. The skilld CLI sends `skilld-capabilities: linked-files` on each Resolution request, and skilld.dev lists linked files only for a client that does. + +**Never:** remote asset, external file, download, attachment. + +**Casing:** `Linked file` in headings, `linked file` in sentences, `linkedFiles` in JSON. + ### behavior **Is:** one thing a Skill's files ask an Agent to do, found by a fixed text pattern in `packages/protocol/rules/skill-behaviors.json`. diff --git a/crates/skilld-command/src/remote.rs b/crates/skilld-command/src/remote.rs index 4c995c46..d5b12359 100644 --- a/crates/skilld-command/src/remote.rs +++ b/crates/skilld-command/src/remote.rs @@ -14,9 +14,10 @@ use skilld_core::{ ArtifactAttestation, CheckOutcome, CommitAuthor, CommitSha, CommitSummary, ListedOrigin, ListedSkill, LockedSource, MultiSkillRef, PreparedFile, RemoteError, RemoteSelector, RepositoryVisibility, SearchResponse, SkillListing, SkillName, SourceRef, SourceRequest, - SourceSelector, SourceStatus, TrustedRoot, TrustedRootPin, VerifiedTrustedRoot, - declared_skill_name, parse_search_response, prepare_unverified_files, verify_artifact, - verify_attestation, verify_trusted_root, + SourceSelector, SourceStatus, TrustedRoot, TrustedRootPin, VerifiedArtifact, + VerifiedTrustedRoot, declared_skill_name, parse_search_response, prepare_unverified_files, + verify_artifact, verify_attestation, verify_linked_file, verify_trusted_root, + with_linked_files, }; use skilld_ui::text::is_unsafe_terminal; use url::Url; @@ -1021,7 +1022,9 @@ impl SkilldRemote { } if !(200..300).contains(&response.status) { return Err(match allowed { - AllowedOrigin::Github | AllowedOrigin::GithubTarball => github_error(&response), + AllowedOrigin::Github + | AllowedOrigin::GithubTarball + | AllowedOrigin::GithubFile => github_error(&response), AllowedOrigin::Service(_) | AllowedOrigin::Artifact(_) => { problem_error(&response) } @@ -1242,6 +1245,7 @@ impl SkilldRemote { let mut headers = self.authenticated_headers()?; headers.extend(json_headers()); headers.push(idempotency_header()); + headers.push(capabilities_header()); let request = HttpRequest { method: HttpMethod::Post, url: self.service_url("/api/v1/resolutions")?.into(), @@ -1500,6 +1504,55 @@ impl SkilldRemote { .map(|response| response.body) } + /// Read each linked file the attestation lists from GitHub at the attested + /// commit, and add it once its size and Git blob SHA match. A file that + /// does not match stops the whole Skill: nothing is installed. + fn read_linked_files( + &self, + verified: VerifiedArtifact, + ) -> Result { + if verified.attestation.linked_files.is_empty() { + return Ok(verified); + } + self.progress + .stage(RemoteProgressStage::DownloadingArtifact); + let source = &verified.attestation.source; + let mut read = Vec::with_capacity(verified.attestation.linked_files.len()); + for declaration in &verified.attestation.linked_files { + let limit = usize::try_from(declaration.size) + .ok() + .and_then(|value| value.checked_add(1)) + .ok_or_else(|| { + RemoteError::new("RESPONSE_TOO_LARGE", "a linked file exceeds the size limit") + })?; + let request = HttpRequest { + method: HttpMethod::Get, + url: linked_file_url( + &source.owner, + &source.repository, + &source.commit_sha, + &source.skill_path, + &declaration.path, + )?, + headers: vec![HttpHeader { + name: "user-agent".to_owned(), + value: HeaderValue::Public("skilld".to_owned()), + }], + body: vec![], + response_limit: limit, + }; + let response = self.execute_bounded( + request, + AllowedOrigin::GithubFile, + None, + Some(download_timeout(declaration.size)), + MAX_RETRIES, + )?; + read.push(verify_linked_file(declaration, response.body)?); + } + with_linked_files(verified, read) + } + fn github_json Deserialize<'de>>( &self, url: &str, @@ -2978,6 +3031,7 @@ impl RemoteProvider for SkilldRemote { Err(error) => return Err(unverifiable(error)), } }; + let verified = self.read_linked_files(verified)?; if matches!( &selector.source().selector, SourceSelector::NamedSkill { name } if name != verified.name.as_str() @@ -3557,6 +3611,9 @@ enum AllowedOrigin { /// follows one redirect. Every other GitHub request keeps /// `AllowedOrigin::Github`, which accepts `api.github.com` alone. GithubTarball, + /// One file of a Repository at one commit, from `raw.githubusercontent.com` + /// and nowhere else. A linked file comes from here. + GithubFile, } fn same_origin(url: &Url, base: &Url) -> bool { @@ -3605,6 +3662,11 @@ fn validate_url(url: &Url, allowed: &AllowedOrigin) -> Result<(), RemoteError> { ) && url.port().is_none() } + AllowedOrigin::GithubFile => { + url.scheme() == "https" + && url.host_str() == Some("raw.githubusercontent.com") + && url.port().is_none() + } }; if allowed { Ok(()) @@ -3629,6 +3691,39 @@ fn json_headers() -> Vec { ] } +/// Names the Artifact features this CLI reads. skilld.dev lists a Skill file +/// as a linked file only for a client that names `linked-files`. +fn capabilities_header() -> HttpHeader { + HttpHeader { + name: "skilld-capabilities".to_owned(), + value: HeaderValue::Public("linked-files".to_owned()), + } +} + +/// The URL of one Skill file at one commit, each path segment percent-encoded. +fn linked_file_url( + owner: &str, + repository: &str, + commit_sha: &str, + skill_path: &str, + path: &str, +) -> Result { + let mut url = Url::parse("https://raw.githubusercontent.com/") + .expect("the GitHub file host is a valid URL"); + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemoteError::new("INVALID_REMOTE_URL", "a linked file URL is invalid"))?; + segments.clear(); + segments.push(owner).push(repository).push(commit_sha); + if skill_path != "." { + segments.extend(skill_path.split('/')); + } + segments.extend(path.split('/')); + } + Ok(url.into()) +} + fn idempotency_header() -> HttpHeader { let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); let time = SystemTime::now() diff --git a/crates/skilld-command/tests/remote.rs b/crates/skilld-command/tests/remote.rs index 87a3f816..c7ec1ba7 100644 --- a/crates/skilld-command/tests/remote.rs +++ b/crates/skilld-command/tests/remote.rs @@ -21,10 +21,10 @@ use skilld_command::{ use skilld_core::{ AgentTargetId, ArtifactAttestation, ArtifactFile, AttestationSignature, CheckOutcome, CheckResult, CommitAuthor, CommitSha, CommitSummary, InstallMode, InstallOperation, - InstallRequest, InstallScope, InstallSource, ListedOrigin, ListedSkill, LockedSource, - MultiSkillRef, PreparedFile, RemoteError, RemoteSelector, RepositoryVisibility, ResolvedSource, - SearchResponse, SignatureAlgorithm, SourceProvider, SourceStatus, TrustedRootPin, - UpdatePlanItem, UpdatePlanV1, UpdateRelation, + InstallRequest, InstallScope, InstallSource, LinkedFile, ListedOrigin, ListedSkill, + LockedSource, MultiSkillRef, PreparedFile, RemoteError, RemoteSelector, RepositoryVisibility, + ResolvedSource, SearchResponse, SignatureAlgorithm, SourceProvider, SourceStatus, + TrustedRootPin, UpdatePlanItem, UpdatePlanV1, UpdateRelation, }; const ROOT_DOMAIN: &[u8] = b"skilld-trusted-key-v1\0"; @@ -278,8 +278,27 @@ fn verified_remote_responses_for(skill: &[u8]) -> (TrustedRootPin, Vec, +) -> (TrustedRootPin, Vec) { + verified_remote_responses_linking(skill, checks, &[]) +} + +/// A ready Resolution whose attestation lists these linked files. A response +/// for each one follows the Artifact, in order. +fn verified_remote_responses_linking( + skill: &[u8], + checks: Vec, + linked: &[(&str, &[u8])], ) -> (TrustedRootPin, Vec) { let archive = tar_skill(skill); + let linked_files = linked + .iter() + .map(|(path, bytes)| LinkedFile { + path: (*path).to_owned(), + mode: 0o755, + size: bytes.len() as u64, + git_blob_sha: git_blob_sha(bytes), + }) + .collect::>(); let root_key = SigningKey::from_bytes(&[7_u8; 32]); let signing_key = SigningKey::from_bytes(&[9_u8; 32]); let root_public_key = URL_SAFE_NO_PAD.encode(root_key.verifying_key().to_bytes()); @@ -314,7 +333,7 @@ fn verified_remote_responses_with( }; let content_sha256 = hex(&Sha256::digest(&archive)); let artifact_id = format!("sha256:{content_sha256}"); - let statement = serde_json::to_vec(&json!({ + let mut statement = json!({ "version": 1, "artifactId": artifact_id, "createdAt": "2026-08-20T00:00:00.000Z", @@ -326,8 +345,11 @@ fn verified_remote_responses_with( "policyVersion": "2026-08-20", "files": [file.clone()], "checkResults": checks - })) - .unwrap(); + }); + if !linked_files.is_empty() { + statement["linkedFiles"] = json!(linked_files); + } + let statement = serde_json::to_vec(&statement).unwrap(); let signature = signing_key.sign(&signed_message(ATTESTATION_DOMAIN, &statement)); let attestation = ArtifactAttestation { version: 1, @@ -341,6 +363,7 @@ fn verified_remote_responses_with( policy_version: "2026-08-20".to_owned(), files: vec![file], check_results: checks, + linked_files, statement: URL_SAFE_NO_PAD.encode(statement), signature: AttestationSignature { algorithm: SignatureAlgorithm::Ed25519, @@ -390,7 +413,14 @@ fn verified_remote_responses_with( response(200, serde_json::to_vec(&root).unwrap()), response(200, serde_json::to_vec(&grant).unwrap()), response(200, archive), - ], + ] + .into_iter() + .chain( + linked + .iter() + .map(|(_, bytes)| response(200, bytes.to_vec())), + ) + .collect(), ) } @@ -2715,6 +2745,91 @@ fn a_verified_remote_install_uses_resolution_root_grant_and_content_in_order() { assert!(requests[3].url.ends_with("/content")); } +fn linking_remote( + responses: Vec, + pin: TrustedRootPin, +) -> (Arc, SkilldRemote) { + let http = Arc::new(FakeHttp::with(responses)); + let remote = SkilldRemote::new( + http.clone(), + Arc::new(NoTokenProvider), + NativeRemoteConfig::Pinned(pin), + ) + .with_endpoint("http://127.0.0.1:8787") + .unwrap() + .with_sleeper(Arc::new(NoSleep)); + (http, remote) +} + +#[test] +fn a_resolution_request_says_this_cli_reads_linked_files() { + let (pin, responses) = verified_remote_responses(); + let (http, remote) = linking_remote(responses, pin); + + remote + .prepare( + &RemoteSelector::parse("skilld-dev/skills/example").unwrap(), + false, + ) + .unwrap(); + + let requests = http.requests.lock().unwrap(); + assert!(requests[0].headers.iter().any(|header| { + header.name == "skilld-capabilities" && header.value.expose() == "linked-files" + })); +} + +#[test] +fn a_linked_file_is_read_from_github_at_the_attested_commit_and_installed() { + let tool = b"\x7fELF a binary too large to pack".as_slice(); + let (pin, responses) = verified_remote_responses_linking( + b"---\nname: example\ndescription: verified\n---\n", + passing_checks(), + &[("scripts/my tool#1", tool)], + ); + let (http, remote) = linking_remote(responses, pin); + + let prepared = remote + .prepare( + &RemoteSelector::parse("skilld-dev/skills/example").unwrap(), + false, + ) + .unwrap(); + + let installed = prepared + .files + .iter() + .find(|file| file.path == "scripts/my tool#1") + .unwrap(); + assert_eq!(installed.bytes, tool); + assert_eq!(installed.mode, 0o755); + let requests = http.requests.lock().unwrap(); + assert_eq!( + requests[4].url, + "https://raw.githubusercontent.com/skilld-dev/skills/0123456789abcdef0123456789abcdef01234567/skills/example/scripts/my%20tool%231" + ); +} + +#[test] +fn a_linked_file_with_other_bytes_installs_nothing() { + let (pin, mut responses) = verified_remote_responses_linking( + b"---\nname: example\ndescription: verified\n---\n", + passing_checks(), + &[("assets/track.mp3", b"the attested bytes")], + ); + *responses.last_mut().unwrap() = response(200, b"the attested bytez".to_vec()); + let (_, remote) = linking_remote(responses, pin); + + let error = remote + .prepare( + &RemoteSelector::parse("skilld-dev/skills/example").unwrap(), + false, + ) + .unwrap_err(); + + assert_eq!(error.code, "LINKED_FILE_DIGEST_MISMATCH"); +} + fn prepared_with_page_header(header: Option<&str>) -> Option { let (pin, mut responses) = verified_remote_responses(); if let Some(value) = header { diff --git a/crates/skilld-core/Cargo.toml b/crates/skilld-core/Cargo.toml index a2544326..e556c841 100644 --- a/crates/skilld-core/Cargo.toml +++ b/crates/skilld-core/Cargo.toml @@ -25,6 +25,8 @@ serde.workspace = true serde_json.workspace = true +sha1 = "0.11.0" + sha2.workspace = true time = { version = "=0.3.55", features = [ "parsing" ] } diff --git a/crates/skilld-core/src/lib.rs b/crates/skilld-core/src/lib.rs index 7f26693c..3c0148d8 100644 --- a/crates/skilld-core/src/lib.rs +++ b/crates/skilld-core/src/lib.rs @@ -24,12 +24,13 @@ pub use release::{ ReleasePin, VerifiedRelease, is_release_version, verify_release_asset, verify_release_manifest, }; pub use remote::{ - ArtifactAttestation, ArtifactFile, AttestationSignature, CheckOutcome, CheckResult, - PreparedFile, RemoteError, RemoteSelector, RepositoryVisibility, ResolvedSource, - SearchResponse, SearchResult, SignatureAlgorithm, SourceProvider, SourceRef, SourceRequest, - SourceSelector, TrustedKey, TrustedKeyStatus, TrustedRoot, TrustedRootPin, VerifiedArtifact, - VerifiedTrustedRoot, declared_skill_name, parse_search_response, prepare_unverified_files, - skill_identity, verify_artifact, verify_attestation, verify_trusted_root, + ArtifactAttestation, ArtifactFile, AttestationSignature, CheckOutcome, CheckResult, LinkedFile, + MAX_LINKED_BYTES, PreparedFile, RemoteError, RemoteSelector, RepositoryVisibility, + ResolvedSource, SearchResponse, SearchResult, SignatureAlgorithm, SourceProvider, SourceRef, + SourceRequest, SourceSelector, TrustedKey, TrustedKeyStatus, TrustedRoot, TrustedRootPin, + VerifiedArtifact, VerifiedTrustedRoot, declared_skill_name, parse_search_response, + prepare_unverified_files, skill_identity, verify_artifact, verify_attestation, + verify_linked_file, verify_trusted_root, with_linked_files, }; use serde::{Deserialize, Serialize}; pub use target::{ diff --git a/crates/skilld-core/src/remote.rs b/crates/skilld-core/src/remote.rs index aa2817b1..2efd438f 100644 --- a/crates/skilld-core/src/remote.rs +++ b/crates/skilld-core/src/remote.rs @@ -15,6 +15,10 @@ const TRUSTED_KEY_DOMAIN: &[u8] = b"skilld-trusted-key-v1\0"; const MAX_STATEMENT_BYTES: usize = 6 * 1024 * 1024; const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024; const MAX_FILES: usize = 2_000; +/// The bytes all linked files of one Skill may hold. skilld holds every Skill +/// file in memory before it writes one, and GitHub refuses a file over 100 MiB +/// in a Repository. skilld.dev applies the same limit (its ADR-0013). +pub const MAX_LINKED_BYTES: u64 = 256 * 1024 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -463,6 +467,21 @@ pub struct ArtifactFile { pub sha256: String, } +/// A Skill file the attestation lists without packing it in the Artifact. +/// +/// skilld reads it from GitHub at the attested commit and installs it only +/// when its size and Git blob SHA match. skilld.dev lists linked files only +/// for a client that sends `skilld-capabilities: linked-files`. +// Strict on purpose: skilld must understand every signed claim it verifies. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LinkedFile { + pub path: String, + pub mode: u32, + pub size: u64, + pub git_blob_sha: String, +} + // Strict on purpose: skilld must understand every signed claim it verifies. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -492,6 +511,9 @@ pub struct ArtifactAttestation { pub policy_version: String, pub files: Vec, pub check_results: Vec, + /// Absent from every statement that links no file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub linked_files: Vec, pub statement: String, pub signature: AttestationSignature, } @@ -511,6 +533,8 @@ struct SignedStatement { policy_version: String, files: Vec, check_results: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + linked_files: Vec, } impl ArtifactAttestation { @@ -527,6 +551,7 @@ impl ArtifactAttestation { policy_version: self.policy_version.clone(), files: self.files.clone(), check_results: self.check_results.clone(), + linked_files: self.linked_files.clone(), } } } @@ -676,6 +701,71 @@ pub fn verify_artifact( }) } +/// Check one linked file against its declaration: the exact size and the Git +/// blob SHA the attestation signs. Only the attestation is trusted, never the +/// host that served the bytes. +pub fn verify_linked_file( + declaration: &LinkedFile, + bytes: Vec, +) -> Result { + if bytes.len() as u64 != declaration.size { + return Err(RemoteError::new( + "LINKED_FILE_SIZE_MISMATCH", + "a linked file does not have its attested size", + )); + } + if git_blob_sha1(&bytes) != declaration.git_blob_sha { + return Err(RemoteError::new( + "LINKED_FILE_DIGEST_MISMATCH", + "a linked file does not match its attested Git blob", + )); + } + Ok(PreparedFile { + path: declaration.path.clone(), + mode: declaration.mode, + bytes, + }) +} + +/// The verified Artifact with its linked files added, once every one is read. +/// +/// The installed digest then covers the linked files too, so the lockfile +/// names every byte skilld wrote. +pub fn with_linked_files( + mut artifact: VerifiedArtifact, + linked: Vec, +) -> Result { + let declared = artifact + .attestation + .linked_files + .iter() + .map(|file| (file.path.as_str(), file.mode)) + .collect::>(); + let read = linked + .iter() + .map(|file| (file.path.as_str(), file.mode)) + .collect::>(); + if linked.len() != declared.len() || declared != read { + return Err(RemoteError::new( + "LINKED_FILES_MISMATCH", + "the linked files do not match the attestation", + )); + } + artifact.files.extend(linked); + artifact + .files + .sort_by(|left, right| left.path.cmp(&right.path)); + artifact.installed_sha256 = installed_digest(&artifact.files); + Ok(artifact) +} + +fn git_blob_sha1(bytes: &[u8]) -> String { + let mut hasher = sha1::Sha1::new(); + hasher.update(format!("blob {}\0", bytes.len()).as_bytes()); + hasher.update(bytes); + hex(&hasher.finalize()) +} + pub fn verify_attestation( attestation: &ArtifactAttestation, root: &VerifiedTrustedRoot, @@ -784,7 +874,7 @@ fn validate_attestation_shape(attestation: &ArtifactAttestation) -> Result<(), R || !is_sha256(&attestation.content_sha256) || attestation.artifact_id != format!("sha256:{}", attestation.content_sha256) || attestation.files.is_empty() - || attestation.files.len() > MAX_FILES + || attestation.files.len() + attestation.linked_files.len() > MAX_FILES || attestation.check_results.is_empty() || attestation.check_results.len() > 100 || attestation.content_bytes == 0 @@ -832,6 +922,29 @@ fn validate_attestation_shape(attestation: &ArtifactAttestation) -> Result<(), R RemoteError::new("ATTESTATION_INVALID", "the Artifact file sizes overflowed") })?; } + let mut linked_size = 0_u64; + for file in &attestation.linked_files { + validate_relative_path(&file.path, 1024)?; + if !paths.insert(file.path.clone()) + || !folded_paths.insert(file.path.to_ascii_lowercase()) + || !matches!(file.mode, 0o644 | 0o755) + || !is_commit_sha(&file.git_blob_sha) + { + return Err(RemoteError::new( + "ATTESTATION_INVALID", + "a linked file declaration is invalid", + )); + } + linked_size = linked_size + .checked_add(file.size) + .filter(|size| *size <= MAX_LINKED_BYTES) + .ok_or_else(|| { + RemoteError::new( + "ATTESTATION_INVALID", + "the linked files exceed the size limit", + ) + })?; + } if content_size > attestation.content_bytes || paths.iter().any(|path| { paths diff --git a/crates/skilld-core/tests/remote.rs b/crates/skilld-core/tests/remote.rs index 9889e2fb..6a3b8465 100644 --- a/crates/skilld-core/tests/remote.rs +++ b/crates/skilld-core/tests/remote.rs @@ -3,10 +3,11 @@ use ed25519_dalek::{Signer as _, SigningKey}; use serde::Serialize; use sha2::{Digest, Sha256}; use skilld_core::{ - ArtifactAttestation, ArtifactFile, AttestationSignature, CheckOutcome, CheckResult, + ArtifactAttestation, ArtifactFile, AttestationSignature, CheckOutcome, CheckResult, LinkedFile, PreparedFile, RemoteSelector, RepositoryVisibility, ResolvedSource, SignatureAlgorithm, SourceProvider, TrustedKey, TrustedKeyStatus, TrustedRoot, TrustedRootPin, declared_skill_name, - prepare_unverified_files, skill_identity, verify_artifact, verify_trusted_root, + prepare_unverified_files, skill_identity, verify_artifact, verify_linked_file, + verify_trusted_root, with_linked_files, }; const ROOT_DOMAIN: &[u8] = b"skilld-trusted-key-v1\0"; @@ -77,6 +78,8 @@ struct ArtifactStatement<'a> { policy_version: &'a str, files: &'a [ArtifactFile], check_results: &'a [CheckResult], + #[serde(skip_serializing_if = "<[LinkedFile]>::is_empty")] + linked_files: &'a [LinkedFile], } fn signed_message(domain: &[u8], statement: &[u8]) -> Vec { @@ -184,6 +187,16 @@ fn attestation_at_path( files: Vec, signing_key: &SigningKey, skill_path: &str, +) -> ArtifactAttestation { + attestation_with_linked(archive, files, signing_key, skill_path, vec![]) +} + +fn attestation_with_linked( + archive: &[u8], + files: Vec, + signing_key: &SigningKey, + skill_path: &str, + linked_files: Vec, ) -> ArtifactAttestation { let content_sha256 = hex(&Sha256::digest(archive)); let artifact_id = format!("sha256:{content_sha256}"); @@ -217,6 +230,7 @@ fn attestation_at_path( policy_version: "2026-08-20", files: &files, check_results: &checks, + linked_files: &linked_files, }) .unwrap(); let signature = signing_key.sign(&signed_message(ATTESTATION_DOMAIN, &statement)); @@ -232,6 +246,7 @@ fn attestation_at_path( policy_version: "2026-08-20".to_owned(), files, check_results: checks, + linked_files, statement: URL_SAFE_NO_PAD.encode(statement), signature: AttestationSignature { algorithm: SignatureAlgorithm::Ed25519, @@ -630,3 +645,137 @@ fn rejects_a_root_that_differs_from_the_compile_time_pin() { assert_eq!(error.code, "TRUSTED_ROOT_MISMATCH"); } + +fn linked(path: &str, bytes: &[u8]) -> LinkedFile { + LinkedFile { + path: path.to_owned(), + mode: 0o644, + size: bytes.len() as u64, + git_blob_sha: git_blob_sha(bytes), + } +} + +fn git_blob_sha(bytes: &[u8]) -> String { + use sha1::{Digest as _, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(format!("blob {}\0", bytes.len()).as_bytes()); + hasher.update(bytes); + hex(&hasher.finalize()) +} + +#[test] +fn installs_a_linked_file_beside_the_archive_files_once_its_git_blob_matches() { + let skill = b"---\nname: example\ndescription: fixture\n---\n"; + let binary = b"\x7fELF a binary too large to pack"; + let archive = archive(&[("SKILL.md", 0o644, skill, b'0')]); + let (root, pin, signing_key) = trusted_root(); + let root = verify_trusted_root(root, &pin).unwrap(); + let attestation = attestation_with_linked( + &archive, + vec![file("SKILL.md", 0o644, skill)], + &signing_key, + "skills/example", + vec![linked("scripts/tool", binary)], + ); + + let verified = verify_artifact(attestation, &root, &archive).unwrap(); + let declaration = verified.attestation.linked_files[0].clone(); + let packed_only = verified.installed_sha256.clone(); + let tool = verify_linked_file(&declaration, binary.to_vec()).unwrap(); + let installed = with_linked_files(verified, vec![tool]).unwrap(); + + assert_eq!( + installed + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(), + ["SKILL.md", "scripts/tool"] + ); + assert_ne!(installed.installed_sha256, packed_only); +} + +#[test] +fn refuses_a_linked_file_whose_bytes_fail_its_git_blob_sha() { + let declaration = linked("scripts/tool", b"the attested bytes"); + + let other = verify_linked_file(&declaration, b"the attested bytez".to_vec()).unwrap_err(); + let short = verify_linked_file(&declaration, b"short".to_vec()).unwrap_err(); + + assert_eq!(other.code, "LINKED_FILE_DIGEST_MISMATCH"); + assert_eq!(short.code, "LINKED_FILE_SIZE_MISMATCH"); +} + +#[test] +fn refuses_to_install_until_every_linked_file_is_read() { + let skill = b"---\nname: example\ndescription: fixture\n---\n"; + let archive = archive(&[("SKILL.md", 0o644, skill, b'0')]); + let (root, pin, signing_key) = trusted_root(); + let root = verify_trusted_root(root, &pin).unwrap(); + let attestation = attestation_with_linked( + &archive, + vec![file("SKILL.md", 0o644, skill)], + &signing_key, + "skills/example", + vec![linked("assets/a.mp3", b"a"), linked("assets/b.mp3", b"b")], + ); + let verified = verify_artifact(attestation, &root, &archive).unwrap(); + let first = verify_linked_file(&verified.attestation.linked_files[0], b"a".to_vec()).unwrap(); + + let error = with_linked_files(verified, vec![first]).unwrap_err(); + + assert_eq!(error.code, "LINKED_FILES_MISMATCH"); +} + +#[test] +fn rejects_a_linked_file_that_shares_a_path_with_a_packed_file() { + let skill = b"---\nname: example\ndescription: fixture\n---\n"; + let archive = archive(&[("SKILL.md", 0o644, skill, b'0')]); + let (root, pin, signing_key) = trusted_root(); + let root = verify_trusted_root(root, &pin).unwrap(); + + for path in ["SKILL.md", "skill.md", "SKILL.md/inner"] { + let attestation = attestation_with_linked( + &archive, + vec![file("SKILL.md", 0o644, skill)], + &signing_key, + "skills/example", + vec![linked(path, b"x")], + ); + let error = verify_artifact(attestation, &root, &archive).unwrap_err(); + assert_eq!(error.code, "ATTESTATION_INVALID", "{path}"); + } +} + +#[test] +fn rejects_linked_files_past_the_byte_limit() { + let skill = b"---\nname: example\ndescription: fixture\n---\n"; + let archive = archive(&[("SKILL.md", 0o644, skill, b'0')]); + let (root, pin, signing_key) = trusted_root(); + let root = verify_trusted_root(root, &pin).unwrap(); + let mut huge = linked("assets/film.mp4", b"x"); + huge.size = 256 * 1024 * 1024 + 1; + let attestation = attestation_with_linked( + &archive, + vec![file("SKILL.md", 0o644, skill)], + &signing_key, + "skills/example", + vec![huge], + ); + + let error = verify_artifact(attestation, &root, &archive).unwrap_err(); + + assert_eq!(error.code, "ATTESTATION_INVALID"); +} + +#[test] +fn an_attestation_without_linked_files_serializes_without_the_field() { + let skill = b"---\nname: example\ndescription: fixture\n---\n"; + let archive = archive(&[("SKILL.md", 0o644, skill, b'0')]); + let (_, _, signing_key) = trusted_root(); + let attestation = attestation(&archive, vec![file("SKILL.md", 0o644, skill)], &signing_key); + + let value = serde_json::to_value(&attestation).unwrap(); + + assert!(value.get("linkedFiles").is_none()); +}