Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.
Expand Down
103 changes: 99 additions & 4 deletions crates/skilld-command/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<VerifiedArtifact, RemoteError> {
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<T: for<'de> Deserialize<'de>>(
&self,
url: &str,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
Expand All @@ -3629,6 +3691,39 @@ fn json_headers() -> Vec<HttpHeader> {
]
}

/// 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<String, RemoteError> {
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()
Expand Down
131 changes: 123 additions & 8 deletions crates/skilld-command/tests/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -278,8 +278,27 @@ fn verified_remote_responses_for(skill: &[u8]) -> (TrustedRootPin, Vec<HttpRespo
fn verified_remote_responses_with(
skill: &[u8],
checks: Vec<CheckResult>,
) -> (TrustedRootPin, Vec<HttpResponse>) {
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<CheckResult>,
linked: &[(&str, &[u8])],
) -> (TrustedRootPin, Vec<HttpResponse>) {
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::<Vec<_>>();
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());
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
)
}

Expand Down Expand Up @@ -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<HttpResponse>,
pin: TrustedRootPin,
) -> (Arc<FakeHttp>, 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<String> {
let (pin, mut responses) = verified_remote_responses();
if let Some(value) = header {
Expand Down
2 changes: 2 additions & 0 deletions crates/skilld-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ] }
Expand Down
Loading