From c70dd092bf1a4d2b1cbf42881506eb4318fddae8 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 03:15:27 +1100 Subject: [PATCH] feat(remote): read linked files from GitHub at the attested commit skilld.dev can now sign Skills larger than the 64 MiB Artifact. It lists the largest files as linked files, by path, mode, size and Git blob SHA, and only for a client that asks. skilld asks on every Resolution, reads each linked file from raw.githubusercontent.com at the attested commit, and installs the Skill only when every one matches its blob SHA. The installed digest covers the linked files too. --- Cargo.lock | 1 + GLOSSARY.md | 11 ++ crates/skilld-command/src/remote.rs | 103 ++++++++++++++++- crates/skilld-command/tests/remote.rs | 131 ++++++++++++++++++++-- crates/skilld-core/Cargo.toml | 2 + crates/skilld-core/src/lib.rs | 13 ++- crates/skilld-core/src/remote.rs | 115 ++++++++++++++++++- crates/skilld-core/tests/remote.rs | 153 +++++++++++++++++++++++++- 8 files changed, 508 insertions(+), 21 deletions(-) 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()); +}