- 659
pub fn inspect_package_with_limits( - 660
root: &Path, - 661
limits: InspectLimits, - 662
) -> Result<PackageInspection, PluginError> { - 663
if !root.exists() { - 664
return Err(PluginError::NotFound(root.to_path_buf())); - 665
} - 666
if !root.is_dir() { - 667
return Err(PluginError::UnsafePackage(format!( - 668
"package root must be a directory: {}", - 669
root.display() - 670
))); - 671
} - 672
let root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - 673
let (mut manifest, format, manifest_warnings) = load_manifest(&root)?; - 674
normalize_and_validate_manifest(&mut manifest, &root)?; - 675
let files = collect_files(&root, limits)?; - 676
let total_bytes = files.iter().map(|file| file.bytes).sum(); - 677
let digest = digest_files(&root, &files)?; - 678
let capabilities = inventory_capabilities(&root, &manifest.components, &files)?; - 679
let mut warnings = manifest_warnings; - 680
if manifest.license.is_none() { - 681
warnings.push( - 682
"no license declared; marketplace availability does not grant redistribution rights" - 683
.into(), - 684
); - 685
} - 686
if manifest.publisher.is_none() { - 687
warnings.push("no publisher identity declared".into()); - 688
} - 689
Ok(PackageInspection { - 690
manifest, - 691
format, - 692
root, - 693
digest, - 694
file_count: files.len(), - 695
total_bytes, - 696
files, - 697
capabilities, - 698
warnings, - 699
}) - 700
} - 701
- 702
fn load_manifest( - 703
root: &Path, - 704
) -> Result<(PluginManifest, ManifestFormat, Vec<String>), PluginError> { - 705
let native_path = root.join("vak-plugin.json"); - 706
if native_path.is_file() { - 707
let bytes = fs::read(&native_path).map_err(|error| io_error(&native_path, error))?; - 708
let manifest = serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 709
path: native_path, - 710
source, - 711
})?; - 712
return Ok((manifest, ManifestFormat::Vak, Vec::new())); - 713
} - 714
- 715
let codex_path = root.join(".codex-plugin/plugin.json"); - 716
if codex_path.is_file() { - 717
let bytes = fs::read(&codex_path).map_err(|error| io_error(&codex_path, error))?; - 718
let value: serde_json::Value = - 719
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 720
path: codex_path, - 721
source, - 722
})?; - 723
return normalize_client_manifest( - 724
root, - 725
&value, - 726
ManifestFormat::Codex, - 727
".codex-plugin/plugin.json", - 728
); - 729
} - 730
- 731
for (relative, format) in [ - 732
(".claude-plugin/plugin.json", ManifestFormat::Claude), - 733
(".cursor-plugin/plugin.json", ManifestFormat::Cursor), - 734
(".github/plugin/plugin.json", ManifestFormat::Copilot), - 735
(".plugin/plugin.json", ManifestFormat::Copilot), - 736
] { - 737
let path = root.join(relative); - 738
if path.is_file() { - 739
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 740
let value = serde_json::from_slice(&bytes) - 741
.map_err(|source| PluginError::Json { path, source })?; - 742
return normalize_client_manifest(root, &value, format, relative); - 743
} - 744
} - 745
- 746
let agent_path = root.join("plugin.json"); - 747
if agent_path.is_file() { - 748
let bytes = fs::read(&agent_path).map_err(|error| io_error(&agent_path, error))?; - 749
let value: serde_json::Value = - 750
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 751
path: agent_path, - 752
source, - 753
})?; - 754
let format = if value.get("$schema").and_then(serde_json::Value::as_str) - 755
== Some("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") - 756
{ - 757
ManifestFormat::AgentPlugin - 758
} else { - 759
ManifestFormat::Copilot - 760
}; - 761
return normalize_client_manifest(root, &value, format, "plugin.json"); - 762
} - 763
- 764
let gemini_path = root.join("gemini-extension.json"); - 765
if gemini_path.is_file() { - 766
let bytes = fs::read(&gemini_path).map_err(|error| io_error(&gemini_path, error))?; - 767
let value = serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 768
path: gemini_path, - 769
source, - 770
})?; - 771
return normalize_client_manifest( - 772
root, - 773
&value, - 774
ManifestFormat::Gemini, - 775
"gemini-extension.json", - 776
); - 777
} - 778
- 779
let skill_path = root.join("SKILL.md"); - 780
if skill_path.is_file() { - 781
let (name, description) = parse_skill_header(&skill_path)?; - 782
return Ok(( - 783
PluginManifest { - 784
schema: REGISTRY_SCHEMA, - 785
name, - 786
version: "0.0.0+local".into(), - 787
description, - 788
license: None, - 789
publisher: None, - 790
homepage: None, - 791
components: Components { - 792
skills: vec![".".into()], - 793
..Components::default() - 794
}, - 795
}, - 796
ManifestFormat::AgentSkill, - 797
vec!["standalone Agent Skill normalized as a local skills-only plugin".into()], - 798
)); - 799
} - 800
- 801
Err(PluginError::ManifestMissing(root.to_path_buf())) - 802
} - 803
- 804
fn normalize_client_manifest( - 805
root: &Path, - 806
value: &serde_json::Value, - 807
format: ManifestFormat, - 808
manifest_path: &str, - 809
) -> Result<(PluginManifest, ManifestFormat, Vec<String>), PluginError> { - 810
let object = value - 811
.as_object() - 812
.ok_or_else(|| PluginError::InvalidManifest("plugin manifest must be an object".into()))?; - 813
if format == ManifestFormat::AgentPlugin { - 814
let schema = object.get("$schema").and_then(serde_json::Value::as_str); - 815
if schema != Some("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") { - 816
return Err(PluginError::InvalidManifest( - 817
"Agent Plugins 1.0 requires its canonical $schema".into(), - 818
)); - 819
} - 820
} - 821
let name = required_string(object, "name")?; - 822
let source_version = object - 823
.get("version") - 824
.and_then(serde_json::Value::as_str) - 825
.unwrap_or("0.0.0+local") - 826
.to_string(); - 827
let (version, version_warning) = normalize_external_version(&source_version); - 828
let description = object - 829
.get("description") - 830
.and_then(serde_json::Value::as_str) - 831
.unwrap_or("") - 832
.to_string(); - 833
let license = object - 834
.get("license") - 835
.and_then(serde_json::Value::as_str) - 836
.map(str::to_string); - 837
let homepage = object - 838
.get("homepage") - 839
.and_then(serde_json::Value::as_str) - 840
.map(str::to_string); - 841
let publisher = normalize_publisher(object.get("publisher").or_else(|| object.get("author")))?; - 842
let mut components = Components { - 843
skills: string_or_strings(object.get("skills"), "skills")?, - 844
commands: string_or_strings(object.get("commands"), "commands")?, - 845
mcp: string_or_strings(object.get("mcp"), "mcp")?, - 846
hooks: paths_or_inline(object.get("hooks"), "hooks", manifest_path)?, - 847
agents: string_or_strings(object.get("agents"), "agents")?, - 848
rules: string_or_strings(object.get("rules"), "rules")?, - 849
lsp: paths_or_inline(object.get("lspServers"), "lspServers", manifest_path)?, - 850
policies: string_or_strings(object.get("policies"), "policies")?, - 851
themes: paths_or_inline(object.get("themes"), "themes", manifest_path)?, - 852
presentation: string_or_strings(object.get("presentation"), "presentation")?, - 853
assets: string_or_strings(object.get("assets"), "assets")?, - 854
}; - 855
if object.get("mcpServers").is_some() { - 856
components.mcp.push(manifest_path.to_string()); - 857
} - 858
if format == ManifestFormat::AgentPlugin { - 859
components = Components::default(); - 860
add_conventional_component(root, "skills", &mut components.skills); - 861
add_conventional_component(root, "mcp.json", &mut components.mcp); - 862
} - 863
add_conventional_component(root, "skills", &mut components.skills); - 864
add_conventional_component(root, "commands", &mut components.commands); - 865
add_conventional_component(root, ".mcp.json", &mut components.mcp); - 866
add_conventional_component(root, ".github/mcp.json", &mut components.mcp); - 867
add_conventional_component(root, "mcp.json", &mut components.mcp); - 868
add_conventional_component(root, "hooks.json", &mut components.hooks); - 869
add_conventional_component(root, "hooks/hooks.json", &mut components.hooks); - 870
add_conventional_component(root, "agents", &mut components.agents); - 871
add_conventional_component(root, "rules", &mut components.rules); - 872
add_conventional_component(root, "lsp.json", &mut components.lsp); - 873
add_conventional_component(root, ".github/lsp.json", &mut components.lsp); - 874
add_conventional_component(root, "policies", &mut components.policies); - 875
add_conventional_component(root, "themes", &mut components.themes); - 876
add_conventional_component(root, "presentation", &mut components.presentation); - 877
add_conventional_component(root, "assets", &mut components.assets); - 878
let known: BTreeSet<&str> = [ - 879
"name", - 880
"version", - 881
"description", - 882
"license", - 883
"publisher", - 884
"author", - 885
"homepage", - 886
"skills", - 887
"commands", - 888
"mcp", - 889
"mcpServers", - 890
"hooks", - 891
"agents", - 892
"rules", - 893
"lspServers", - 894
"policies", - 895
"themes", - 896
"presentation", - 897
"assets", - 898
"apps", - 899
"$schema", - 900
"extensions", - 901
"category", - 902
"tags", - 903
"variables", - 904
"settings", - 905
"contextFileName", - 906
"excludeTools", - 907
"migratedTo", - 908
"plan", - 909
] - 910
.into_iter() - 911
.collect(); - 912
let unknown = object - 913
.keys() - 914
.filter(|key| !known.contains(key.as_str())) - 915
.cloned() - 916
.collect::<Vec<_>>(); - 917
let mut warnings = if unknown.is_empty() { - 918
Vec::new() - 919
} else { - 920
vec![format!( - 921
"ignored unsupported declarative plugin manifest keys: {}", - 922
unknown.join(", ") - 923
)] - 924
}; - 925
if let Some(warning) = version_warning { - 926
warnings.push(warning); - 927
} - 928
Ok(( - 929
PluginManifest { - 930
schema: REGISTRY_SCHEMA, - 931
name, - 932
version, - 933
description, - 934
license, - 935
publisher, - 936
homepage, - 937
components, - 938
}, - 939
format, - 940
warnings, - 941
)) - 942
} - 943
- 944
fn normalize_external_version(source: &str) -> (String, Option<String>) { - 945
if Version::parse(source).is_ok() { - 946
return (source.to_string(), None); - 947
} - 948
let digest = Sha256::digest(source.as_bytes()); - 949
let normalized = format!("0.0.0+source.{}", &hex::encode(digest)[..12]); - 950
( - 951
normalized.clone(), - 952
Some(format!( - 953
"source version {source:?} is not semantic; normalized internally as {normalized}" - 954
)), - 955
) - 956
} - 957
- 958
fn required_string( - 959
object: &serde_json::Map<String, serde_json::Value>, - 960
field: &str, - 961
) -> Result<String, PluginError> { - 962
object - 963
.get(field) - 964
.and_then(serde_json::Value::as_str) - 965
.filter(|value| !value.trim().is_empty()) - 966
.map(str::to_string) - 967
.ok_or_else(|| PluginError::InvalidManifest(format!("missing non-empty '{field}'"))) - 968
} - 969
- 970
fn normalize_publisher( - 971
value: Option<&serde_json::Value>, - 972
) -> Result<Option<Publisher>, PluginError> { - 973
let Some(value) = value else { - 974
return Ok(None); - 975
}; - 976
if let Some(name) = value.as_str() { - 977
let id = normalize_id(name).ok_or_else(|| { - 978
PluginError::InvalidManifest("publisher string cannot form a stable id".into()) - 979
})?; - 980
return Ok(Some(Publisher { - 981
id, - 982
name: name.to_string(), - 983
url: None, - 984
})); - 985
} - 986
let object = value.as_object().ok_or_else(|| { - 987
PluginError::InvalidManifest("publisher must be a string or object".into()) - 988
})?; - 989
let name = required_string(object, "name")?; - 990
let id = object - 991
.get("id") - 992
.and_then(serde_json::Value::as_str) - 993
.and_then(normalize_id) - 994
.or_else(|| normalize_id(&name)) - 995
.ok_or_else(|| PluginError::InvalidManifest("publisher needs a valid id".into()))?; - 996
let url = object - 997
.get("url") - 998
.and_then(serde_json::Value::as_str) - 999
.map(str::to_string); - 1000
Ok(Some(Publisher { id, name, url })) - 1001
} - 1002
- 1003
fn string_or_strings( - 1004
value: Option<&serde_json::Value>, - 1005
field: &str, - 1006
) -> Result<Vec<String>, PluginError> { - 1007
let Some(value) = value else { - 1008
return Ok(Vec::new()); - 1009
}; - 1010
if let Some(value) = value.as_str() { - 1011
return Ok(vec![value.to_string()]); - 1012
} - 1013
value - 1014
.as_array() - 1015
.ok_or_else(|| { - 1016
PluginError::InvalidManifest(format!("'{field}' must be a string or string array")) - 1017
})? - 1018
.iter() - 1019
.map(|item| { - 1020
item.as_str().map(str::to_string).ok_or_else(|| { - 1021
PluginError::InvalidManifest(format!("'{field}' contains a non-string path")) - 1022
}) - 1023
}) - 1024
.collect() - 1025
} - 1026
- 1027
fn paths_or_inline( - 1028
value: Option<&serde_json::Value>, - 1029
field: &str, - 1030
manifest_path: &str, - 1031
) -> Result<Vec<String>, PluginError> { - 1032
let Some(value) = value else { - 1033
return Ok(Vec::new()); - 1034
}; - 1035
if value.is_object() - 1036
|| value - 1037
.as_array() - 1038
.is_some_and(|items| items.iter().any(|item| !item.is_string())) - 1039
{ - 1040
return Ok(vec![manifest_path.to_string()]); - 1041
} - 1042
string_or_strings(Some(value), field) - 1043
} - 1044
- 1045
fn add_conventional_component(root: &Path, relative: &str, values: &mut Vec<String>) { - 1046
if root.join(relative).exists() && !values.iter().any(|value| value == relative) { - 1047
values.push(relative.to_string()); - 1048
} - 1049
} - 1050
- 1051
fn normalize_and_validate_manifest( - 1052
manifest: &mut PluginManifest, - 1053
root: &Path, - 1054
) -> Result<(), PluginError> { - 1055
manifest.name = normalize_plugin_id(&manifest.name).ok_or_else(|| { - 1056
PluginError::InvalidManifest( - 1057
"name must use lowercase letters, digits, dashes, or non-repeated dots".into(), - 1058
) - 1059
})?; - 1060
Version::parse(&manifest.version).map_err(|error| { - 1061
PluginError::InvalidManifest(format!( - 1062
"version '{}' is not semver: {error}", - 1063
manifest.version - 1064
)) - 1065
})?; - 1066
if manifest.schema > REGISTRY_SCHEMA { - 1067
return Err(PluginError::InvalidManifest(format!( - 1068
"schema {} is newer than supported schema {REGISTRY_SCHEMA}", - 1069
manifest.schema - 1070
))); - 1071
} - 1072
for (kind, declared) in manifest.components.declared_paths() { - 1073
let relative = validate_relative_path(declared)?; - 1074
let path = root.join(relative); - 1075
if !path.exists() { - 1076
return Err(PluginError::InvalidManifest(format!( - 1077
"declared {kind} path does not exist: {declared}" - 1078
))); - 1079
} - 1080
} - 1081
Ok(()) - 1082
} - 1083
- 1084
fn normalize_id(value: &str) -> Option<String> { - 1085
let value = value.trim(); - 1086
if value.is_empty() - 1087
|| value.starts_with('-') - 1088
|| value.ends_with('-') - 1089
|| !value.chars().all(|character| { - 1090
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - 1091
}) - 1092
{ - 1093
return None; - 1094
} - 1095
Some(value.to_string()) - 1096
} - 1097
- 1098
fn normalize_plugin_id(value: &str) -> Option<String> { - 1099
let value = value.trim(); - 1100
if value.is_empty() - 1101
|| value.len() > 64 - 1102
|| !value - 1103
.chars() - 1104
.next() - 1105
.is_some_and(|character| character.is_ascii_alphanumeric()) - 1106
|| !value - 1107
.chars() - 1108
.last() - 1109
.is_some_and(|character| character.is_ascii_alphanumeric()) - 1110
|| value.contains("--") - 1111
|| value.contains("..") - 1112
|| !value.chars().all(|character| { - 1113
character.is_ascii_lowercase() - 1114
|| character.is_ascii_digit() - 1115
|| character == '-' - 1116
|| character == '.' - 1117
}) - 1118
{ - 1119
return None; - 1120
} - 1121
Some(value.to_string()) - 1122
} - 1123
- 1124
fn validate_relative_path(value: &str) -> Result<&Path, PluginError> { - 1125
let path = Path::new(value); - 1126
if value.is_empty() || path.is_absolute() { - 1127
return Err(PluginError::UnsafePackage(format!( - 1128
"component path must be relative: {value:?}" - 1129
))); - 1130
} - 1131
for component in path.components() { - 1132
if !matches!(component, Component::Normal(_) | Component::CurDir) { - 1133
return Err(PluginError::UnsafePackage(format!( - 1134
"component path escapes package root: {value:?}" - 1135
))); - 1136
} - 1137
} - 1138
Ok(path) - 1139
} - 1140
- 1141
fn collect_files(root: &Path, limits: InspectLimits) -> Result<Vec<PackageFile>, PluginError> { - 1142
let mut files = Vec::new(); - 1143
let mut total_bytes = 0_u64; - 1144
for entry in WalkDir::new(root).follow_links(false) { - 1145
let entry = entry.map_err(|error| { - 1146
PluginError::UnsafePackage(format!("could not walk package: {error}")) - 1147
})?; - 1148
let depth = entry.depth(); - 1149
if depth > limits.max_depth { - 1150
return Err(PluginError::LimitExceeded(format!( - 1151
"path depth {depth} exceeds {} at {}", - 1152
limits.max_depth, - 1153
entry.path().display() - 1154
))); - 1155
} - 1156
if depth == 0 { - 1157
continue; - 1158
} - 1159
let metadata = - 1160
fs::symlink_metadata(entry.path()).map_err(|error| io_error(entry.path(), error))?; - 1161
if metadata.file_type().is_symlink() { - 1162
return Err(PluginError::UnsafePackage(format!( - 1163
"symbolic links are not allowed: {}", - 1164
entry.path().display() - 1165
))); - 1166
} - 1167
if metadata.is_dir() { - 1168
continue; - 1169
} - 1170
if !metadata.is_file() { - 1171
return Err(PluginError::UnsafePackage(format!( - 1172
"special files are not allowed: {}", - 1173
entry.path().display() - 1174
))); - 1175
} - 1176
reject_hard_link(entry.path(), &metadata)?; - 1177
if metadata.len() > limits.max_file_bytes { - 1178
return Err(PluginError::LimitExceeded(format!( - 1179
"file {} is {} bytes; maximum is {}", - 1180
entry.path().display(), - 1181
metadata.len(), - 1182
limits.max_file_bytes - 1183
))); - 1184
} - 1185
total_bytes = total_bytes - 1186
.checked_add(metadata.len()) - 1187
.ok_or_else(|| PluginError::LimitExceeded("expanded size overflow".into()))?; - 1188
if total_bytes > limits.max_total_bytes { - 1189
return Err(PluginError::LimitExceeded(format!( - 1190
"expanded size {total_bytes} exceeds {} bytes", - 1191
limits.max_total_bytes - 1192
))); - 1193
} - 1194
if files.len() >= limits.max_files { - 1195
return Err(PluginError::LimitExceeded(format!( - 1196
"file count exceeds {}", - 1197
limits.max_files - 1198
))); - 1199
} - 1200
let relative = entry.path().strip_prefix(root).map_err(|_| { - 1201
PluginError::UnsafePackage(format!( - 1202
"walked path escaped package root: {}", - 1203
entry.path().display() - 1204
)) - 1205
})?; - 1206
files.push(PackageFile { - 1207
path: portable_path(relative)?, - 1208
bytes: metadata.len(), - 1209
executable: is_executable(&metadata), - 1210
}); - 1211
} - 1212
files.sort_by(|left, right| left.path.cmp(&right.path)); - 1213
if files.is_empty() { - 1214
return Err(PluginError::InvalidManifest( - 1215
"package contains no files".into(), - 1216
)); - 1217
} - 1218
Ok(files) - 1219
} - 1220
- 1221
#[cfg(unix)] - 1222
fn reject_hard_link(path: &Path, metadata: &fs::Metadata) -> Result<(), PluginError> { - 1223
use std::os::unix::fs::MetadataExt as _; - 1224
if metadata.nlink() > 1 { - 1225
return Err(PluginError::UnsafePackage(format!( - 1226
"hard-linked files are not allowed: {}", - 1227
path.display() - 1228
))); - 1229
} - 1230
Ok(()) - 1231
} - 1232
- 1233
#[cfg(not(unix))] - 1234
fn reject_hard_link(_path: &Path, _metadata: &fs::Metadata) -> Result<(), PluginError> { - 1235
Ok(()) - 1236
} - 1237
- 1238
#[cfg(unix)] - 1239
fn is_executable(metadata: &fs::Metadata) -> bool { - 1240
use std::os::unix::fs::PermissionsExt as _; - 1241
metadata.permissions().mode() & 0o111 != 0 - 1242
} - 1243
- 1244
#[cfg(not(unix))] - 1245
fn is_executable(_metadata: &fs::Metadata) -> bool { - 1246
false - 1247
} - 1248
- 1249
fn portable_path(path: &Path) -> Result<String, PluginError> { - 1250
let mut parts = Vec::new(); - 1251
for component in path.components() { - 1252
match component { - 1253
Component::Normal(value) => parts.push(value.to_string_lossy().into_owned()), - 1254
Component::CurDir => {} - 1255
_ => { - 1256
return Err(PluginError::UnsafePackage(format!( - 1257
"invalid relative path: {}", - 1258
path.display() - 1259
))); - 1260
} - 1261
} - 1262
} - 1263
Ok(parts.join("/")) - 1264
} - 1265
- 1266
fn digest_files(root: &Path, files: &[PackageFile]) -> Result<String, PluginError> { - 1267
let mut digest = Sha256::new(); - 1268
for file in files { - 1269
let path_bytes = file.path.as_bytes(); - 1270
digest.update((path_bytes.len() as u64).to_le_bytes()); - 1271
digest.update(path_bytes); - 1272
digest.update(file.bytes.to_le_bytes()); - 1273
let path = root.join(path_from_portable(&file.path)); - 1274
let mut input = File::open(&path).map_err(|error| io_error(&path, error))?; - 1275
let mut buffer = [0_u8; 64 * 1024]; - 1276
loop { - 1277
let read = input - 1278
.read(&mut buffer) - 1279
.map_err(|error| io_error(&path, error))?; - 1280
if read == 0 { - 1281
break; - 1282
} - 1283
digest.update(&buffer[..read]); - 1284
} - 1285
} - 1286
Ok(hex::encode(digest.finalize())) - 1287
} - 1288
- 1289
fn path_from_portable(path: &str) -> PathBuf { - 1290
path.split('/').collect() - 1291
} - 1292
- 1293
fn inventory_capabilities( - 1294
root: &Path, - 1295
components: &Components, - 1296
files: &[PackageFile], - 1297
) -> Result<CapabilityInventory, PluginError> { - 1298
let mut inventory = CapabilityInventory::default(); - 1299
for skill_root in &components.skills { - 1300
let relative = validate_relative_path(skill_root)?; - 1301
let absolute = root.join(relative); - 1302
if absolute.join("SKILL.md").is_file() { - 1303
let (name, _) = parse_skill_header(&absolute.join("SKILL.md"))?; - 1304
inventory.skills.push(name); - 1305
} else if absolute.is_dir() { - 1306
let mut names = fs::read_dir(&absolute) - 1307
.map_err(|error| io_error(&absolute, error))? - 1308
.filter_map(Result::ok) - 1309
.filter(|entry| entry.path().join("SKILL.md").is_file()) - 1310
.map(|entry| { - 1311
parse_skill_header(&entry.path().join("SKILL.md")).map(|header| header.0) - 1312
}) - 1313
.collect::<Result<Vec<_>, _>>()?; - 1314
inventory.skills.append(&mut names); - 1315
} - 1316
} - 1317
inventory.skills.sort(); - 1318
inventory.skills.dedup(); - 1319
inventory.commands = component_files(root, &components.commands, Some("md"))?; - 1320
inventory.mcp_manifests = component_files(root, &components.mcp, None)?; - 1321
inventory.hooks = component_files(root, &components.hooks, None)?; - 1322
inventory.agents = component_files(root, &components.agents, Some("md"))?; - 1323
inventory.rules = component_files(root, &components.rules, None)?; - 1324
inventory.lsp_manifests = component_files(root, &components.lsp, None)?; - 1325
inventory.policies = component_files(root, &components.policies, Some("toml"))?; - 1326
inventory.themes = component_files(root, &components.themes, None)?; - 1327
inventory.presentation = component_files(root, &components.presentation, None)?; - 1328
inventory.assets = component_files(root, &components.assets, None)?; - 1329
for file in files { - 1330
let path = Path::new(&file.path); - 1331
if path - 1332
.components() - 1333
.any(|component| matches!(component, Component::Normal(value) if value == "scripts")) - 1334
{ - 1335
inventory.scripts.push(file.path.clone()); - 1336
} - 1337
if file.executable { - 1338
inventory.executables.push(file.path.clone()); - 1339
} - 1340
} - 1341
Ok(inventory) - 1342
} - 1343
- 1344
fn component_files( - 1345
root: &Path, - 1346
declared: &[String], - 1347
extension: Option<&str>, - 1348
) -> Result<Vec<String>, PluginError> { - 1349
let mut output = Vec::new(); - 1350
for value in declared { - 1351
let path = root.join(validate_relative_path(value)?); - 1352
if path.is_file() { - 1353
output.push(portable_path(path.strip_prefix(root).map_err(|_| { - 1354
PluginError::UnsafePackage(format!("component escaped root: {}", path.display())) - 1355
})?)?); - 1356
continue; - 1357
} - 1358
for entry in WalkDir::new(&path).follow_links(false) { - 1359
let entry = entry.map_err(|error| { - 1360
PluginError::UnsafePackage(format!("could not inventory component: {error}")) - 1361
})?; - 1362
if !entry.file_type().is_file() - 1363
|| extension.is_some_and(|expected| { - 1364
entry - 1365
.path() - 1366
.extension() - 1367
.is_none_or(|actual| actual != expected) - 1368
}) - 1369
{ - 1370
continue; - 1371
} - 1372
output.push(portable_path(entry.path().strip_prefix(root).map_err( - 1373
|_| { - 1374
PluginError::UnsafePackage(format!( - 1375
"component escaped root: {}", - 1376
entry.path().display() - 1377
)) - 1378
}, - 1379
)?)?); - 1380
} - 1381
} - 1382
output.sort(); - 1383
output.dedup(); - 1384
Ok(output) - 1385
} - 1386
- 1387
fn parse_skill_header(path: &Path) -> Result<(String, String), PluginError> { - 1388
let text = fs::read_to_string(path).map_err(|error| io_error(path, error))?; - 1389
let rest = text.strip_prefix("---").ok_or_else(|| { - 1390
PluginError::InvalidManifest(format!("skill has no YAML frontmatter: {}", path.display())) - 1391
})?; - 1392
let (frontmatter, _) = rest.split_once("---").ok_or_else(|| { - 1393
PluginError::InvalidManifest(format!( - 1394
"skill frontmatter is not closed: {}", - 1395
path.display() - 1396
)) - 1397
})?; - 1398
let mut name = None; - 1399
let mut description = String::new(); - 1400
for line in frontmatter.lines() { - 1401
let line = line.trim(); - 1402
if let Some(value) = line.strip_prefix("name:") { - 1403
name = Some(value.trim().trim_matches(['\'', '"']).to_string()); - 1404
} else if let Some(value) = line.strip_prefix("description:") { - 1405
description = value.trim().trim_matches(['\'', '"']).to_string(); - 1406
} - 1407
} - 1408
let name = name - 1409
.or_else(|| { - 1410
path.parent() - 1411
.and_then(Path::file_name) - 1412
.map(|value| value.to_string_lossy().into_owned()) - 1413
}) - 1414
.and_then(|value| normalize_id(&value)) - 1415
.ok_or_else(|| { - 1416
PluginError::InvalidManifest(format!( - 1417
"skill needs a valid kebab-case name: {}", - 1418
path.display() - 1419
)) - 1420
})?; - 1421
Ok((name, description)) - 1422
} - 1423
- 1424
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 1425
#[serde(rename_all = "kebab-case")] - 1426
pub enum InstallScope { - 1427
User, - 1428
Workspace, - 1429
} - 1430
- 1431
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1432
pub struct InstalledPlugin { - 1433
pub name: String, - 1434
pub version: String, - 1435
pub digest: String, - 1436
pub description: String, - 1437
pub license: Option<String>, - 1438
pub publisher: Option<Publisher>, - 1439
pub format: ManifestFormat, - 1440
pub source: String, - 1441
#[serde(default)] - 1442
pub trace_id: String, - 1443
pub package_path: PathBuf, - 1444
pub scope: InstallScope, - 1445
pub enabled: bool, - 1446
pub installed_at_unix: u64, - 1447
pub capabilities: CapabilityInventory, - 1448
pub warnings: Vec<String>, - 1449
} - 1450
- 1451
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1452
pub struct PluginRegistry { - 1453
pub schema: u32, - 1454
pub generation: u64, - 1455
pub plugins: BTreeMap<String, InstalledPlugin>, - 1456
#[serde(default)] - 1457
pub versions: BTreeMap<String, Vec<InstalledPlugin>>, - 1458
#[serde(default)] - 1459
pub audit: Vec<PluginAuditEvent>, - 1460
} - 1461
- 1462
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1463
#[serde(rename_all = "kebab-case")] - 1464
pub enum PluginAuditAction { - 1465
Installed, - 1466
Updated, - 1467
Enabled, - 1468
Disabled, - 1469
RolledBack, - 1470
Removed, - 1471
} - 1472
- 1473
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1474
pub struct PluginAuditEvent { - 1475
pub generation: u64, - 1476
pub at_unix: u64, - 1477
pub action: PluginAuditAction, - 1478
pub plugin: String, - 1479
pub version: String, - 1480
pub digest: String, - 1481
pub trace_id: String, - 1482
pub source: String, - 1483
} - 1484
- 1485
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1486
pub struct PluginInvocationEvent { - 1487
pub at_unix: u64, - 1488
pub trace_id: String, - 1489
pub plugin: String, - 1490
pub capability: String, - 1491
pub success: bool, - 1492
} - 1493
- 1494
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1495
pub struct PluginHook { - 1496
pub event: String, - 1497
#[serde(rename = "match", default)] - 1498
pub matcher: Option<String>, - 1499
pub command: String, - 1500
#[serde(default)] - 1501
pub timeout_ms: Option<u64>, - 1502
} - 1503
- 1504
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1505
pub struct SignatureEvidence { - 1506
pub algorithm: String, - 1507
pub key_id: String, - 1508
pub public_key: String, - 1509
pub signature: String, - 1510
pub verified: bool, - 1511
pub revoked: bool, - 1512
} - 1513
- 1514
/// Verify detached Ed25519 evidence over an exact byte sequence. Key trust - 1515
/// and revocation policy remain caller-owned; a valid signature alone never - 1516
/// grants runtime capabilities. - 1517
pub fn verify_ed25519_signature( - 1518
message: &[u8], - 1519
public_key_base64: &str, - 1520
signature_base64: &str, - 1521
) -> Result<(), PluginError> { - 1522
let key = base64::engine::general_purpose::STANDARD - 1523
.decode(public_key_base64) - 1524
.map_err(|error| { - 1525
PluginError::UnsafePackage(format!("invalid Ed25519 public key encoding: {error}")) - 1526
})?; - 1527
let signature = base64::engine::general_purpose::STANDARD - 1528
.decode(signature_base64) - 1529
.map_err(|error| { - 1530
PluginError::UnsafePackage(format!("invalid Ed25519 signature encoding: {error}")) - 1531
})?; - 1532
let verifier = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key); - 1533
verifier - 1534
.verify(message, &signature) - 1535
.map_err(|_| PluginError::UnsafePackage("Ed25519 signature verification failed".into())) - 1536
} - 1537
- 1538
impl Default for PluginRegistry { - 1539
fn default() -> Self { - 1540
Self { - 1541
schema: REGISTRY_SCHEMA, - 1542
generation: 0, - 1543
plugins: BTreeMap::new(), - 1544
versions: BTreeMap::new(), - 1545
audit: Vec::new(), - 1546
} - 1547
} - 1548
} - 1549
- 1550
#[derive(Debug, Clone, Copy)] - 1551
pub struct InstallOptions { - 1552
pub scope: InstallScope, - 1553
pub allow_unlicensed: bool, - 1554
} - 1555
- 1556
impl Default for InstallOptions { - 1557
fn default() -> Self { - 1558
Self { - 1559
scope: InstallScope::User, - 1560
allow_unlicensed: false, - 1561
} - 1562
} - 1563
} - 1564
- 1565
#[derive(Clone)] - 1566
pub struct PluginStore { - 1567
home: PathBuf, - 1568
} - 1569
- 1570
impl PluginStore { - 1571
pub fn new(home: impl Into<PathBuf>) -> Self { - 1572
Self { home: home.into() } - 1573
} - 1574
- 1575
pub fn registry_path(&self) -> PathBuf { - 1576
self.home.join("plugins/registry.json") - 1577
} - 1578
- 1579
pub fn packages_root(&self) -> PathBuf { - 1580
self.home.join("plugins/packages") - 1581
} - 1582
- 1583
pub fn source_registry_path(&self) -> PathBuf { - 1584
self.home.join("plugins/sources.json") - 1585
} - 1586
- 1587
pub fn invocation_log_path(&self) -> PathBuf { - 1588
self.home.join("plugins/invocations.jsonl") - 1589
} - 1590
- 1591
pub fn record_invocation( - 1592
&self, - 1593
trace_id: &str, - 1594
plugin: &str, - 1595
capability: &str, - 1596
success: bool, - 1597
) -> Result<(), PluginError> { - 1598
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1599
let path = self.invocation_log_path(); - 1600
if let Some(parent) = path.parent() { - 1601
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1602
} - 1603
let event = PluginInvocationEvent { - 1604
at_unix: now_unix(), - 1605
trace_id: trace_id.to_string(), - 1606
plugin: plugin.to_string(), - 1607
capability: capability.to_string(), - 1608
success, - 1609
}; - 1610
let bytes = serde_json::to_vec(&event).map_err(|source| PluginError::Json { - 1611
path: path.clone(), - 1612
source, - 1613
})?; - 1614
let mut file = OpenOptions::new() - 1615
.create(true) - 1616
.append(true) - 1617
.open(&path) - 1618
.map_err(|error| io_error(&path, error))?; - 1619
file.write_all(&bytes) - 1620
.map_err(|error| io_error(&path, error))?; - 1621
file.write_all(b"\n") - 1622
.map_err(|error| io_error(&path, error))?; - 1623
file.sync_data().map_err(|error| io_error(&path, error)) - 1624
} - 1625
- 1626
pub fn invocations(&self) -> Result<Vec<PluginInvocationEvent>, PluginError> { - 1627
let path = self.invocation_log_path(); - 1628
if !path.exists() { - 1629
return Ok(Vec::new()); - 1630
} - 1631
let text = fs::read_to_string(&path).map_err(|error| io_error(&path, error))?; - 1632
text.lines() - 1633
.filter(|line| !line.trim().is_empty()) - 1634
.map(|line| { - 1635
serde_json::from_str(line).map_err(|source| PluginError::Json { - 1636
path: path.clone(), - 1637
source, - 1638
}) - 1639
}) - 1640
.collect() - 1641
} - 1642
- 1643
pub fn load_sources(&self) -> Result<MarketplaceSourceRegistry, PluginError> { - 1644
let path = self.source_registry_path(); - 1645
if !path.exists() { - 1646
return Ok(MarketplaceSourceRegistry { - 1647
schema: SOURCE_REGISTRY_SCHEMA, - 1648
..MarketplaceSourceRegistry::default() - 1649
}); - 1650
} - 1651
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1652
let registry: MarketplaceSourceRegistry = - 1653
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1654
path: path.clone(), - 1655
source, - 1656
})?; - 1657
if registry.schema > SOURCE_REGISTRY_SCHEMA { - 1658
return Err(PluginError::InvalidManifest(format!(
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.