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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ version tags such as `v0.3.0`.

## Unreleased

- No unreleased changes.
- Group copied skills from the same verified v3 `well-known` installer source into one display-only Installed Source Collection, while keeping source matching strict and per-skill import safety unchanged.

## 0.9.1

Expand Down
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.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ A 30-second v0.9.0 overview of SkillBox: runtime-aware workspaces, review-before
- **Evidence-aware Calls, references, and operation history.** Count locally confirmed executions plus defensible structured invocations as Calls, keep lower-signal history references separate, and explain coverage without storing full chat transcripts.
- **Safe storage and deployment defaults.** Use ordered SQLite migrations, recovery backups, integrity checks, and ownership-checked symlinks instead of silently overwriting runtime content.
- **Git-backed local and GitHub collections.** Import Review groups skills from the same local Git worktree or reviewed GitHub repository snapshot into one collection card while keeping each child independently importable, deployable, and usage-tracked. The current project UI makes one shared User/Remote choice in the collection header, then selects or clears all eligible children as a group; unresolved or mixed type state blocks selection and apply until you choose explicitly. GitHub preview/apply fetches one bounded ref, shows the resolved SHA and child status, and never deploys automatically. Collection-level update/rollback remains planned for a later v0.9.x release.
- **Installed-source provenance.** Copied skills with valid v3 installer lockfile entries can appear under one normalized GitHub source collection even without a local Git worktree. This is display-only provenance: it does not invent a branch, HEAD, or update authority, and each child keeps the normal reviewed per-skill import path.
- **Installed-source provenance.** Copied skills with valid v3 installer lockfile entries can appear under one verified GitHub or HTTPS well-known source collection even without a local Git worktree. This is display-only provenance: it does not invent a branch, HEAD, or update authority, and each child keeps the normal reviewed per-skill import path.
- **Compatibility before deployment.** Rust-owned runtime profiles identify each workspace and report preserved frontmatter warnings or hard blockers before a confirmed symlink deployment.
- **Signed macOS distribution.** Install a notarized DMG or Homebrew cask and apply signed app updates only after confirmation.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ SkillBox 是一个 local-first 的 macOS 桌面应用,带 Rust core/CLI,用
- **按证据分类的 Calls、引用与操作历史。** Calls 只统计本机 confirmed execution 与可辩护的 structured invocation,低信号 history references 单独展示,并且不保存完整聊天 transcript。
- **安全的存储与部署默认值。** 使用顺序 SQLite migrations、恢复备份、完整性检查和 ownership-checked symlink,不静默覆盖 runtime 内容。
- **Git-backed 本地与 GitHub Skill Collections。** Import Review 会把同一 Git worktree 或经 review 的 GitHub repository snapshot 中的 skills 聚合为一个 collection 卡片,同时保留每个 child 独立的导入、部署和 usage 边界。当前项目 UI 在 collection header 统一选择一次 User/Remote,并可一次选中或清除全部 eligible children;类型未决或混合时,必须先显式选择,collection selection/apply 才会开放。GitHub preview/apply 对一个有界 ref 只 fetch 一次,展示 resolved SHA 与 child 状态,绝不自动部署。Collection 级更新/回滚仍计划在后续 v0.9.x 实现。
- **已安装来源 provenance。** 没有本地 Git worktree、但拥有有效 v3 installer lockfile 条目的复制 skill,也可以按规范化 GitHub source 聚合展示。这只是来源展示,不会伪造 branch、HEAD 或更新权限;每个 child 仍走原有的逐 skill review/import 流程。
- **已安装来源 provenance。** 没有本地 Git worktree、但拥有有效 v3 installer lockfile 条目的复制 skill,也可以按已验证的 GitHub 或 HTTPS well-known source 聚合展示。这只是来源展示,不会伪造 branch、HEAD 或更新权限;每个 child 仍走原有的逐 skill review/import 流程。
- **部署前检查 compatibility。** Rust-owned runtime profiles 标识 workspace,并在确认 symlink 部署前报告会原样保留的 frontmatter warnings 或 hard blockers。
- **签名的 macOS 分发。** 可安装已公证 DMG 或 Homebrew cask,app 更新也只在用户确认后应用。

Expand Down
1 change: 1 addition & 0 deletions crates/skillbox-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ serde_yaml_ng = "0.10"
sha2 = "0.11"
skillbox-git = { path = "../skillbox-git" }
skillbox-github = { path = "../skillbox-github" }
url = "2"
183 changes: 160 additions & 23 deletions crates/skillbox-core/src/installed_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use url::Url;

const MAX_LOCKFILE_BYTES: u64 = 5 * 1024 * 1024;
const MAX_LOCKFILE_ENTRIES: usize = 10_000;
Expand All @@ -23,10 +24,27 @@ pub(crate) struct InstalledSourceDiscoveryStats {
struct LockfileEntry {
root: PathBuf,
skill_name: String,
source_url: String,
source_kind: LockfileSourceKind,
collection_url: String,
skill_source_url: String,
skill_path: String,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum LockfileSourceKind {
Github,
WellKnown,
}

impl LockfileSourceKind {
fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::WellKnown => "well-known",
}
}
}

pub(crate) fn discover_installed_source_collections(
roots: &[PathBuf],
candidates: &[ImportCandidate],
Expand Down Expand Up @@ -71,8 +89,8 @@ pub(crate) fn discover_installed_source_collections(
.map(|group| (group.name.to_ascii_lowercase(), group))
.collect::<HashMap<_, _>>();
let mut collection_children =
BTreeMap::<String, BTreeMap<String, ImportCandidateCollectionChild>>::new();
let mut seen = HashSet::<(String, String, String)>::new();
BTreeMap::<(String, String), BTreeMap<String, ImportCandidateCollectionChild>>::new();
let mut seen = HashSet::<(String, String, String, String)>::new();

for candidate in candidates {
let name = candidate.name.to_ascii_lowercase();
Expand All @@ -99,7 +117,8 @@ pub(crate) fn discover_installed_source_collections(
continue;
};
let key = (
entry.source_url.clone(),
entry.source_kind.as_str().to_string(),
entry.collection_url.clone(),
group.id.clone(),
variant.id.clone(),
);
Expand All @@ -109,15 +128,18 @@ pub(crate) fn discover_installed_source_collections(
stats.lockfile_matches += 1;
let child = installed_source_child(entry, group, variant);
collection_children
.entry(entry.source_url.clone())
.entry((
entry.source_kind.as_str().to_string(),
entry.collection_url.clone(),
))
.or_default()
.insert(child.id.clone(), child);
}
}

let collections = collection_children
.into_iter()
.filter_map(|(source_url, children)| {
.filter_map(|((source_kind, source_url), children)| {
let children = children.into_values().collect::<Vec<_>>();
// A single installer-provenance match is not enough to establish
// a useful collection. Keep it in the normal standalone group so
Expand All @@ -140,11 +162,12 @@ pub(crate) fn discover_installed_source_collections(
})
.collect::<Vec<_>>()
.join("\n");
let identity = format!("installed-source-v1\n{source_url}\n{child_seed}");
let display_name = source_url
.strip_prefix("https://github.com/")
.unwrap_or(&source_url)
.to_string();
let identity = if source_kind == LockfileSourceKind::Github.as_str() {
format!("installed-source-v1\n{source_url}\n{child_seed}")
} else {
format!("installed-source-v2\n{source_kind}\n{source_url}\n{child_seed}")
};
let display_name = installed_source_display_name(&source_url);
stats.installed_source_collections += 1;
Some(ImportCandidateCollection {
id: format!("installed-source-{}", &sha256(&identity)[..16]),
Expand Down Expand Up @@ -245,13 +268,38 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option<Lockfi
}
let object = value.as_object()?;
let source_type = bounded_string(object.get("sourceType")?)?;
if source_type != "github" {
return None;
}
let source_url = bounded_string(object.get("sourceUrl")?)?;
let source_url = skillbox_github::normalize_github_repo_url(&source_url).ok()?;
let skill_path = bounded_string(object.get("skillPath")?)?;
validate_lockfile_skill_path(&skill_path, name)?;
let (source_kind, collection_url, skill_source_url, skill_path) = match source_type.as_str() {
"github" => {
let source_url = bounded_string(object.get("sourceUrl")?)?;
let source_url = skillbox_github::normalize_github_repo_url(&source_url).ok()?;
let skill_path = bounded_string(object.get("skillPath")?)?;
validate_lockfile_skill_path(&skill_path, name)?;
(
LockfileSourceKind::Github,
source_url.clone(),
source_url,
skill_path,
)
}
"well-known" => {
let source_base_url = bounded_string(object.get("sourceBaseUrl")?)?;
let source_url = bounded_string(object.get("sourceUrl")?)?;
let (source_base_url, source_url) =
validate_well_known_source(&source_base_url, &source_url, name)?;
let digest = bounded_string(object.get("wellKnownDigest")?)?;
validate_sha256_digest(&digest)?;
if let Some(skill_folder_hash) = object.get("skillFolderHash") {
bounded_string(skill_folder_hash)?;
}
(
LockfileSourceKind::WellKnown,
source_base_url,
source_url,
format!("skills/{name}/SKILL.md"),
)
}
_ => return None,
};
if let Some(plugin_name) = object.get("pluginName") {
// The installer may use this optional field for a package or collection
// identifier (for example, several skills can belong to one plugin).
Expand All @@ -262,7 +310,9 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option<Lockfi
Some(LockfileEntry {
root: root.to_path_buf(),
skill_name: name.to_string(),
source_url,
source_kind,
collection_url,
skill_source_url,
skill_path,
})
}
Expand Down Expand Up @@ -293,6 +343,82 @@ fn lockfile_skill_name(value: &str) -> Option<&str> {
(!name.is_empty()).then_some(name)
}

fn validate_well_known_source(
source_base_url: &str,
source_url: &str,
entry_name: &str,
) -> Option<(String, String)> {
validate_lockfile_skill_path(&format!("skills/{entry_name}/SKILL.md"), entry_name)?;
let base = parse_safe_https_url(source_base_url)?;
let source = parse_safe_https_url(source_url)?;
let normalized_base = base.as_str().trim_end_matches('/').to_string();
let expected = Url::parse(&format!(
"{normalized_base}/.well-known/skills/{entry_name}/SKILL.md"
))
.ok()?;
(source == expected).then(|| (normalized_base, expected.to_string()))
}

fn parse_safe_https_url(value: &str) -> Option<Url> {
if value.is_empty()
|| value.len() > MAX_LOCKFILE_STRING_BYTES
|| value.trim() != value
|| value.contains('\\')
|| value.chars().any(char::is_control)
{
return None;
}
let url = Url::parse(value).ok()?;
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| url.host_str().is_none()
|| url.cannot_be_a_base()
{
return None;
}
let safe_path = url
.path_segments()?
.filter(|segment| !segment.is_empty())
.all(|segment| {
segment != "."
&& segment != ".."
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~'))
});
safe_path.then_some(url)
}

fn validate_sha256_digest(value: &str) -> Option<()> {
let digest = value.strip_prefix("sha256:")?;
(digest.len() == 64 && digest.chars().all(|ch| ch.is_ascii_hexdigit())).then_some(())
}

fn installed_source_display_name(source_url: &str) -> String {
if let Some(value) = source_url.strip_prefix("https://github.com/") {
return value.to_string();
}
let Ok(url) = Url::parse(source_url) else {
return source_url.to_string();
};
let Some(host) = url.host_str() else {
return source_url.to_string();
};
let host = match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let path = url.path().trim_matches('/');
if path.is_empty() {
host
} else {
format!("{host}/{path}")
}
}

fn candidate_matches_lock_entry(candidate: &ImportCandidate, entry: &LockfileEntry) -> bool {
if !same_path(candidate.source_root.as_deref(), Some(&entry.root)) {
return false;
Expand Down Expand Up @@ -338,10 +464,21 @@ fn installed_source_child(
.strip_suffix("/SKILL.md")
.unwrap_or(&entry.skill_path)
.to_string();
let identity = format!(
"{}\n{}\n{}\n{}",
entry.source_url, group.id, variant.id, relative_path
);
let identity = match entry.source_kind {
LockfileSourceKind::Github => format!(
"{}\n{}\n{}\n{}",
entry.collection_url, group.id, variant.id, relative_path
),
LockfileSourceKind::WellKnown => format!(
"installed-source-child-v2\n{}\n{}\n{}\n{}\n{}\n{}",
entry.source_kind.as_str(),
entry.collection_url,
entry.skill_source_url,
group.id,
variant.id,
relative_path
),
};
ImportCandidateCollectionChild {
id: format!("child-{}", &sha256(&identity)[..16]),
group_id: group.id.clone(),
Expand Down
Loading