Skip to content

Commit d2ac48d

Browse files
author
am
committed
fix: reject unsafe marketplace skill slugs
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
1 parent 39469b3 commit d2ac48d

6 files changed

Lines changed: 313 additions & 26 deletions

File tree

bb-cli/src/bb/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ pub mod skills_config;
1616
pub mod skills_doctor;
1717
pub mod skills_install;
1818
pub mod skills_models;
19+
pub mod skills_slug;
1920
pub mod skills_targets;
2021
pub mod workspace;

bb-cli/src/bb/skills_install.rs

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use super::skills_models::{
1717
InstallOperation, InstallPlanResponse, InstalledSkillMetadata, InstalledSkillRequest,
1818
SkillDetail, Warning,
1919
};
20+
use super::skills_slug::{confined_skill_path, ensure_confined_skill_path, validate_slug};
2021
use super::skills_targets::{
2122
backup_unmanaged_path, copy_dir_recursive, finish_link, iso8601_utc, link_into_target,
2223
remove_any, rollback_link, BackupOutcome, LinkOutcome, ResolvedTarget, Scope,
@@ -215,7 +216,12 @@ impl PackageReplacement {
215216
}
216217
}
217218

218-
pub fn replace_managed_dir(staging: &Path, final_dir: &Path) -> Result<PackageReplacement> {
219+
pub fn replace_managed_dir(
220+
root: &Path,
221+
staging: &Path,
222+
final_dir: &Path,
223+
) -> Result<PackageReplacement> {
224+
ensure_confined_skill_path(root, final_dir)?;
219225
let final_metadata = fs::symlink_metadata(final_dir).ok();
220226
let final_exists = final_metadata.is_some();
221227
let is_bb_owned = final_metadata.is_some_and(|metadata| metadata.is_dir())
@@ -344,6 +350,18 @@ pub fn execute_plan(
344350
plan: InstallPlanResponse,
345351
options: &ExecuteOptions,
346352
) -> Result<PlanExecution> {
353+
// Validate the entire untrusted plan before the first operation can fetch
354+
// an artifact or mutate the filesystem. Deserialization already applies
355+
// this contract; this second gate protects programmatic future callers.
356+
for operation in &plan.operations {
357+
validate_slug(&operation.skill.slug).with_context(|| {
358+
format!(
359+
"invalid skill slug in install plan operation `{}`",
360+
operation.action
361+
)
362+
})?;
363+
}
364+
347365
let mut execution = PlanExecution {
348366
plan_id: plan.plan_id,
349367
warnings: plan.warnings,
@@ -395,7 +413,7 @@ fn execute_install_operation(
395413
.as_ref()
396414
.context("install operation did not include artifact metadata")?;
397415

398-
let final_dir = canonical_dir(config, options.scope, slug);
416+
let final_dir = confined_skill_path(&canonical_root(config, options.scope), slug)?;
399417

400418
// Source provenance comes from the catalog detail; failures downgrade to
401419
// missing provenance rather than blocking the install.
@@ -466,7 +484,13 @@ fn persist_download(config: &SkillsConfig, slug: &str, version_id: &str, bytes:
466484
if fs::create_dir_all(&downloads).is_err() {
467485
return;
468486
}
469-
let _ = fs::write(downloads.join(format!("{slug}-{version_id}.zip")), bytes);
487+
let version_key = sha256_hex(version_id.as_bytes());
488+
let Ok(download_path) = confined_skill_path(&downloads, slug)
489+
.map(|path| path.with_file_name(format!("{slug}-{version_key}.zip")))
490+
else {
491+
return;
492+
};
493+
let _ = fs::write(download_path, bytes);
470494
}
471495

472496
fn write_package(
@@ -478,6 +502,7 @@ fn write_package(
478502
let parent = final_dir
479503
.parent()
480504
.context("package directory has no parent")?;
505+
ensure_confined_skill_path(parent, final_dir)?;
481506
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
482507

483508
let staging = parent.join(format!(".{}.tmp-{}", metadata.slug, unique_suffix()));
@@ -499,7 +524,7 @@ fn write_package(
499524
serde_json::to_vec_pretty(metadata).context("serialize install metadata")?,
500525
)
501526
.context("write install metadata")?;
502-
replace_managed_dir(&staging, final_dir)
527+
replace_managed_dir(parent, &staging, final_dir)
503528
})();
504529
if result.is_err() && staging.exists() {
505530
let _ = fs::remove_dir_all(&staging);
@@ -513,6 +538,7 @@ pub fn link_targets(
513538
targets: &[ResolvedTarget],
514539
slug: &str,
515540
) -> Result<Vec<LinkOutcome>> {
541+
validate_slug(slug)?;
516542
let mut links = Vec::new();
517543
for target in targets {
518544
for base_dir in &target.base_dirs {
@@ -653,6 +679,7 @@ pub fn install_local_path(
653679
let parent = final_dir
654680
.parent()
655681
.context("package directory has no parent")?;
682+
ensure_confined_skill_path(parent, &final_dir)?;
656683
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
657684
let staging = parent.join(format!(".{slug}.tmp-{}", unique_suffix()));
658685
if staging.exists() {
@@ -685,7 +712,7 @@ pub fn install_local_path(
685712
serde_json::to_vec_pretty(&metadata).context("serialize install metadata")?,
686713
)
687714
.context("write install metadata")?;
688-
let package_replacement = replace_managed_dir(&staging, &final_dir)?;
715+
let package_replacement = replace_managed_dir(parent, &staging, &final_dir)?;
689716
let links = match link_targets(&final_dir, targets, &slug) {
690717
Ok(links) => links,
691718
Err(error) => {
@@ -717,18 +744,6 @@ pub fn install_local_path(
717744
})
718745
}
719746

720-
fn validate_slug(slug: &str) -> Result<()> {
721-
let valid = !slug.is_empty()
722-
&& slug
723-
.chars()
724-
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
725-
if valid {
726-
Ok(())
727-
} else {
728-
anyhow::bail!("invalid skill name `{slug}`; use lowercase letters, digits, `-`, and `_`")
729-
}
730-
}
731-
732747
/// Deterministic content hash over a directory's files (path + bytes).
733748
fn hash_directory(dir: &Path) -> Result<String> {
734749
let mut entries = Vec::new();
@@ -803,7 +818,7 @@ pub fn remove_skill(
803818
) -> Result<RemovalReport> {
804819
use super::skills_targets::{inspect_link, remove_any, LinkState, TargetRegistry};
805820

806-
let final_dir = canonical_dir(config, scope, slug);
821+
let final_dir = confined_skill_path(&canonical_root(config, scope), slug)?;
807822
let metadata = read_metadata(&final_dir).ok();
808823
if metadata.is_none() && !(include_unmanaged && force) {
809824
return Err(failure(
@@ -840,7 +855,7 @@ pub fn remove_skill(
840855
let canonical_root = final_dir.parent();
841856
for target in targets {
842857
for base_dir in &target.base_dirs {
843-
let link_path = base_dir.join(slug);
858+
let link_path = confined_skill_path(base_dir, slug)?;
844859
// The agents target's directory is the canonical packages root
845860
// itself (skills live there; other targets link to it), so its
846861
// entry is never a link to remove — package removal below
@@ -875,7 +890,8 @@ pub fn remove_skill(
875890
}
876891
}
877892
// Clean up legacy Phase 1 copies under <skills_home>/targets/.
878-
let legacy = config.legacy_target_dir(&target.name).join(slug);
893+
let legacy_root = config.legacy_target_dir(&target.name);
894+
let legacy = confined_skill_path(&legacy_root, slug)?;
879895
if legacy.exists() {
880896
if legacy.join(META_FILE_NAME).is_file() || (include_unmanaged && force) {
881897
remove_any(&legacy)?;
@@ -999,7 +1015,7 @@ mod tests {
9991015
fs::create_dir_all(&staging).expect("create staging skill");
10001016
fs::write(staging.join("SKILL.md"), "new").expect("write new skill");
10011017

1002-
let backup = replace_managed_dir(&staging, &final_dir)
1018+
let backup = replace_managed_dir(&temp, &staging, &final_dir)
10031019
.expect("replace managed skill")
10041020
.finish()
10051021
.expect("finish replacement");
@@ -1029,7 +1045,7 @@ mod tests {
10291045
fs::create_dir_all(&staging).expect("create staging skill");
10301046
fs::write(staging.join("SKILL.md"), "new").expect("write new skill");
10311047

1032-
let recovery = replace_managed_dir(&staging, &final_dir)
1048+
let recovery = replace_managed_dir(&temp, &staging, &final_dir)
10331049
.expect("replace managed skill")
10341050
.restore(&final_dir)
10351051
.expect("restore previous package");
@@ -1052,7 +1068,7 @@ mod tests {
10521068
fs::create_dir_all(&staging).expect("create staging skill");
10531069
fs::write(staging.join("SKILL.md"), "new").expect("write new skill");
10541070

1055-
let recovery = replace_managed_dir(&staging, &final_dir)
1071+
let recovery = replace_managed_dir(&temp, &staging, &final_dir)
10561072
.expect("replace unmanaged skill")
10571073
.restore(&final_dir)
10581074
.expect("restore unmanaged skill");
@@ -1079,7 +1095,7 @@ mod tests {
10791095
fs::create_dir_all(&staging).expect("create staging skill");
10801096
fs::write(staging.join("SKILL.md"), "new").expect("write new skill");
10811097

1082-
replace_managed_dir(&staging, &final_dir)
1098+
replace_managed_dir(&temp, &staging, &final_dir)
10831099
.expect("replace manual symlink")
10841100
.restore(&final_dir)
10851101
.expect("restore manual symlink");

bb-cli/src/bb/skills_models.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use std::collections::BTreeMap;
44

5-
use serde::{Deserialize, Serialize};
5+
use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
66
use serde_json::Value;
77

88
pub use builderbot_auth::preferences::{
@@ -168,11 +168,21 @@ pub struct InstallOperation {
168168

169169
#[derive(Debug, Deserialize)]
170170
pub struct PlanSkill {
171+
#[serde(deserialize_with = "deserialize_skill_slug")]
171172
pub slug: String,
172173
pub version_id: String,
173174
pub content_sha256: String,
174175
}
175176

177+
fn deserialize_skill_slug<'de, D>(deserializer: D) -> Result<String, D::Error>
178+
where
179+
D: Deserializer<'de>,
180+
{
181+
let slug = String::deserialize(deserializer)?;
182+
super::skills_slug::validate_slug(&slug).map_err(D::Error::custom)?;
183+
Ok(slug)
184+
}
185+
176186
#[derive(Debug, Deserialize)]
177187
pub struct PlanArtifact {
178188
pub id: String,
@@ -207,3 +217,70 @@ pub struct InstalledSkillMetadata {
207217
pub local_source: bool,
208218
pub pinned: bool,
209219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::*;
224+
use serde_json::json;
225+
226+
#[test]
227+
fn install_plan_rejects_unsafe_slugs_for_every_action() {
228+
let oversized = "a".repeat(super::super::skills_slug::MAX_SKILL_SLUG_BYTES + 1);
229+
let unsafe_slugs = [
230+
"",
231+
".",
232+
"..",
233+
"../escape",
234+
"foo/bar",
235+
r"foo\bar",
236+
"/absolute",
237+
r"C:\absolute",
238+
r"C:relative",
239+
r"\\server\share",
240+
oversized.as_str(),
241+
];
242+
243+
for action in ["install", "update", "remove", "noop", "future"] {
244+
for slug in unsafe_slugs {
245+
let plan = json!({
246+
"plan_id": "malicious",
247+
"operations": [{
248+
"action": action,
249+
"skill": {
250+
"slug": slug,
251+
"version_id": "version-1",
252+
"content_sha256": "content-sha"
253+
},
254+
"artifact": null,
255+
"installed_via": "explicit"
256+
}],
257+
"warnings": []
258+
});
259+
let error = serde_json::from_value::<InstallPlanResponse>(plan)
260+
.expect_err("unsafe slug must reject the entire plan");
261+
assert!(error.to_string().contains("invalid skill name"));
262+
}
263+
}
264+
}
265+
266+
#[test]
267+
fn install_plan_accepts_valid_marketplace_slug() {
268+
let plan = json!({
269+
"plan_id": "valid",
270+
"operations": [{
271+
"action": "noop",
272+
"skill": {
273+
"slug": "builderbot-tools",
274+
"version_id": "version-1",
275+
"content_sha256": "content-sha"
276+
},
277+
"artifact": null,
278+
"installed_via": "explicit"
279+
}],
280+
"warnings": []
281+
});
282+
283+
let plan = serde_json::from_value::<InstallPlanResponse>(plan).expect("valid plan");
284+
assert_eq!(plan.operations[0].skill.slug, "builderbot-tools");
285+
}
286+
}

0 commit comments

Comments
 (0)