- 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!( - 1659
"marketplace source registry schema {} is newer than supported schema {SOURCE_REGISTRY_SCHEMA}", - 1660
registry.schema - 1661
))); - 1662
} - 1663
Ok(registry) - 1664
} - 1665
- 1666
pub fn register_catalog_source( - 1667
&self, - 1668
root: &Path, - 1669
label: &str, - 1670
trust: MarketplaceTrust, - 1671
) -> Result<MarketplaceSource, PluginError> { - 1672
self.register_catalog_source_with_signature(root, label, trust, None) - 1673
} - 1674
- 1675
pub fn register_catalog_source_with_signature( - 1676
&self, - 1677
root: &Path, - 1678
label: &str, - 1679
trust: MarketplaceTrust, - 1680
signature: Option<SignatureEvidence>, - 1681
) -> Result<MarketplaceSource, PluginError> { - 1682
let inspection = inspect_catalog(root)?; - 1683
let signature = match signature { - 1684
Some(mut evidence) => { - 1685
if evidence.revoked - 1686
|| evidence.algorithm != "ed25519" - 1687
|| verify_ed25519_signature( - 1688
inspection.digest.as_bytes(), - 1689
&evidence.public_key, - 1690
&evidence.signature, - 1691
) - 1692
.is_err() - 1693
{ - 1694
return Err(PluginError::UnsafePackage( - 1695
"catalog signature evidence is not valid or is revoked".into(), - 1696
)); - 1697
} - 1698
evidence.verified = true; - 1699
Some(evidence) - 1700
} - 1701
None => None, - 1702
}; - 1703
let canonical = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - 1704
let id = format!("{}:{}", inspection.name, inspection.digest); - 1705
let source = MarketplaceSource { - 1706
id: id.clone(), - 1707
label: label.trim().to_string(), - 1708
root: canonical, - 1709
format: inspection.format, - 1710
catalog_digest: inspection.digest, - 1711
trace_id: inspection.trace_id, - 1712
trust, - 1713
enabled: false, - 1714
registered_at_unix: now_unix(), - 1715
signature, - 1716
}; - 1717
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1718
let mut registry = self.load_sources()?; - 1719
registry.schema = SOURCE_REGISTRY_SCHEMA; - 1720
registry.sources.insert(id, source.clone()); - 1721
self.save_sources(®istry)?; - 1722
Ok(source) - 1723
} - 1724
- 1725
pub fn set_key_revoked(&self, key_id: &str, revoked: bool) -> Result<(), PluginError> { - 1726
if key_id.trim().is_empty() { - 1727
return Err(PluginError::InvalidManifest( - 1728
"key id must not be empty".into(), - 1729
)); - 1730
} - 1731
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1732
let mut registry = self.load_sources()?; - 1733
if revoked { - 1734
registry.revoked_keys.insert(key_id.to_string()); - 1735
} else { - 1736
registry.revoked_keys.remove(key_id); - 1737
} - 1738
self.save_sources(®istry) - 1739
} - 1740
- 1741
pub fn list_sources(&self) -> Result<Vec<MarketplaceSource>, PluginError> { - 1742
Ok(self.load_sources()?.sources.into_values().collect()) - 1743
} - 1744
- 1745
pub fn set_source_enabled( - 1746
&self, - 1747
id: &str, - 1748
enabled: bool, - 1749
) -> Result<MarketplaceSource, PluginError> { - 1750
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1751
let mut registry = self.load_sources()?; - 1752
let source = registry - 1753
.sources - 1754
.get_mut(id) - 1755
.ok_or_else(|| PluginError::NotInstalled(id.to_string()))?; - 1756
let current = inspect_catalog(&source.root)?; - 1757
if current.digest != source.catalog_digest { - 1758
return Err(PluginError::UnsafePackage(format!( - 1759
"marketplace catalog changed since registration: {}", - 1760
source.root.display() - 1761
))); - 1762
} - 1763
if let Some(signature) = &source.signature { - 1764
if signature.revoked || registry.revoked_keys.contains(&signature.key_id) { - 1765
return Err(PluginError::UnsafePackage(format!( - 1766
"marketplace source key is revoked: {}", - 1767
signature.key_id - 1768
))); - 1769
} - 1770
if !signature.verified { - 1771
return Err(PluginError::UnsafePackage( - 1772
"marketplace source signature is not verified".into(), - 1773
)); - 1774
} - 1775
} - 1776
source.enabled = enabled; - 1777
let source = source.clone(); - 1778
self.save_sources(®istry)?; - 1779
Ok(source) - 1780
} - 1781
- 1782
fn save_sources(&self, registry: &MarketplaceSourceRegistry) -> Result<(), PluginError> { - 1783
let path = self.source_registry_path(); - 1784
if let Some(parent) = path.parent() { - 1785
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1786
} - 1787
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 1788
path: path.clone(), - 1789
source, - 1790
})?; - 1791
let temp = path.with_extension("json.tmp"); - 1792
let mut file = File::create(&temp).map_err(|error| io_error(&temp, error))?; - 1793
file.write_all(&bytes) - 1794
.map_err(|error| io_error(&temp, error))?; - 1795
file.sync_all().map_err(|error| io_error(&temp, error))?; - 1796
fs::rename(&temp, &path).map_err(|error| io_error(&path, error)) - 1797
} - 1798
- 1799
pub fn load(&self) -> Result<PluginRegistry, PluginError> { - 1800
let path = self.registry_path(); - 1801
if !path.exists() { - 1802
return Ok(PluginRegistry::default()); - 1803
} - 1804
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1805
let registry: PluginRegistry = - 1806
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1807
path: path.clone(), - 1808
source, - 1809
})?; - 1810
if registry.schema > REGISTRY_SCHEMA { - 1811
return Err(PluginError::InvalidManifest(format!( - 1812
"registry schema {} is newer than supported schema {REGISTRY_SCHEMA}", - 1813
registry.schema - 1814
))); - 1815
} - 1816
Ok(registry) - 1817
} - 1818
- 1819
pub fn list(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1820
Ok(self.load()?.plugins.into_values().collect()) - 1821
} - 1822
- 1823
pub fn enabled(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1824
Ok(self - 1825
.load()? - 1826
.plugins - 1827
.into_values() - 1828
.filter(|plugin| plugin.enabled) - 1829
.collect()) - 1830
} - 1831
- 1832
/// Scan installed (enabled or disabled) plugins for references to retired - 1833
/// tool names in their skill descriptions or bodies. Returns each - 1834
/// `(plugin_name, retired_tool_name)` pair found, so callers can remove - 1835
/// the offending plugin and warn the operator. - 1836
/// - 1837
/// A plugin is flagged when any of its `SKILL.md` files — either the - 1838
/// `description` frontmatter field or the body text — contains a - 1839
/// backtick-quoted reference to a retired tool name (e.g. - 1840
/// `` `python_eval` ``). - 1841
pub fn retired_plugins(&self) -> Result<Vec<(String, Vec<String>)>, PluginError> { - 1842
let retired_names: std::collections::HashSet<&str> = - 1843
vak_tools::retired::retired_names().into_iter().collect(); - 1844
let mut flagged: Vec<(String, Vec<String>)> = Vec::new(); - 1845
for plugin in self.list()? { - 1846
let mut hits: Vec<String> = Vec::new(); - 1847
// Walk all SKILL.md files in the package directory tree. - 1848
let walker = walkdir::WalkDir::new(&plugin.package_path) - 1849
.into_iter() - 1850
.filter_map(|e| e.ok()) - 1851
.filter(|e| e.file_name() == "SKILL.md"); - 1852
for entry in walker { - 1853
let Ok(text) = std::fs::read_to_string(entry.path()) else { - 1854
continue; - 1855
}; - 1856
let lower = text.to_lowercase(); - 1857
for retired in &retired_names { - 1858
let pattern = format!("`{}`", retired.to_lowercase()); - 1859
if lower.contains(&pattern) && !hits.iter().any(|h: &String| h == *retired) { - 1860
hits.push((*retired).to_string()); - 1861
} - 1862
} - 1863
} - 1864
if !hits.is_empty() { - 1865
flagged.push((plugin.name, hits)); - 1866
} - 1867
} - 1868
Ok(flagged) - 1869
} - 1870
- 1871
pub fn enabled_hooks(&self) -> Result<Vec<(InstalledPlugin, PluginHook)>, PluginError> { - 1872
let mut hooks = Vec::new(); - 1873
for plugin in self.enabled()? { - 1874
for relative in &plugin.capabilities.hooks { - 1875
let path = plugin.package_path.join(relative); - 1876
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1877
let value: serde_json::Value = - 1878
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1879
path: path.clone(), - 1880
source, - 1881
})?; - 1882
let entries = value.get("hooks").cloned().unwrap_or(value); - 1883
let entries = if let Some(array) = entries.as_array() { - 1884
array.clone() - 1885
} else { - 1886
vec![entries] - 1887
}; - 1888
for entry in entries { - 1889
let hook: PluginHook = serde_json::from_value(entry).map_err(|error| { - 1890
PluginError::InvalidManifest(format!( - 1891
"plugin '{}' hook {} is invalid: {error}", - 1892
plugin.name, relative - 1893
)) - 1894
})?; - 1895
if hook.command.trim().is_empty() { - 1896
return Err(PluginError::InvalidManifest(format!( - 1897
"plugin '{}' hook {} has an empty command", - 1898
plugin.name, relative - 1899
))); - 1900
} - 1901
hooks.push((plugin.clone(), hook)); - 1902
} - 1903
} - 1904
} - 1905
Ok(hooks) - 1906
} - 1907
- 1908
pub fn versions(&self, name: &str) -> Result<Vec<InstalledPlugin>, PluginError> { - 1909
let name = normalize_plugin_id(name) - 1910
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 1911
let registry = self.load()?; - 1912
if let Some(versions) = registry.versions.get(&name) { - 1913
return Ok(versions.clone()); - 1914
} - 1915
Ok(registry.plugins.get(&name).cloned().into_iter().collect()) - 1916
} - 1917
- 1918
pub fn install_local( - 1919
&self, - 1920
source: &Path, - 1921
options: InstallOptions, - 1922
) -> Result<InstalledPlugin, PluginError> { - 1923
let inspection = inspect_package(source)?; - 1924
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1925
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 1926
} - 1927
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1928
let mut registry = self.load()?; - 1929
if let Some(existing) = registry.plugins.get(&inspection.manifest.name) { - 1930
if existing.digest == inspection.digest { - 1931
return Ok(existing.clone()); - 1932
} - 1933
return Err(PluginError::AlreadyInstalled( - 1934
inspection.manifest.name.clone(), - 1935
)); - 1936
} - 1937
let package_path = self.package_path(&inspection); - 1938
if package_path.exists() { - 1939
let existing = inspect_package(&package_path)?; - 1940
if existing.digest != inspection.digest { - 1941
return Err(PluginError::UnsafePackage(format!( - 1942
"immutable package destination has unexpected content: {}", - 1943
package_path.display() - 1944
))); - 1945
} - 1946
} else { - 1947
self.copy_verified(&inspection, &package_path)?; - 1948
} - 1949
let installed_at_unix = now_unix(); - 1950
let source = inspection.root.display().to_string(); - 1951
let trace_id = format!("install:{}:{}", inspection.manifest.name, inspection.digest); - 1952
let installed = InstalledPlugin { - 1953
name: inspection.manifest.name.clone(), - 1954
version: inspection.manifest.version.clone(), - 1955
digest: inspection.digest.clone(), - 1956
description: inspection.manifest.description, - 1957
license: inspection.manifest.license, - 1958
publisher: inspection.manifest.publisher, - 1959
format: inspection.format, - 1960
source: source.clone(), - 1961
trace_id: trace_id.clone(), - 1962
package_path, - 1963
scope: options.scope, - 1964
enabled: false, - 1965
installed_at_unix, - 1966
capabilities: inspection.capabilities, - 1967
warnings: inspection.warnings, - 1968
}; - 1969
registry - 1970
.plugins - 1971
.insert(installed.name.clone(), installed.clone()); - 1972
registry - 1973
.versions - 1974
.entry(installed.name.clone()) - 1975
.or_default() - 1976
.push(installed.clone()); - 1977
registry.generation = registry.generation.saturating_add(1); - 1978
registry.audit.push(PluginAuditEvent { - 1979
generation: registry.generation, - 1980
at_unix: installed_at_unix, - 1981
action: PluginAuditAction::Installed, - 1982
plugin: installed.name.clone(), - 1983
version: installed.version.clone(), - 1984
digest: installed.digest.clone(), - 1985
trace_id, - 1986
source, - 1987
}); - 1988
self.save(®istry)?; - 1989
Ok(installed) - 1990
} - 1991
- 1992
pub fn update_local( - 1993
&self, - 1994
source: &Path, - 1995
options: InstallOptions, - 1996
) -> Result<InstalledPlugin, PluginError> { - 1997
let inspection = inspect_package(source)?; - 1998
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1999
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 2000
} - 2001
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2002
let mut registry = self.load()?; - 2003
let current = registry - 2004
.plugins - 2005
.get(&inspection.manifest.name) - 2006
.cloned() - 2007
.ok_or_else(|| PluginError::NotInstalled(inspection.manifest.name.clone()))?; - 2008
if current.digest == inspection.digest { - 2009
return Ok(current); - 2010
} - 2011
if registry - 2012
.versions - 2013
.get(&inspection.manifest.name) - 2014
.is_some_and(|items| items.iter().any(|item| item.digest == inspection.digest)) - 2015
{ - 2016
return Err(PluginError::AlreadyInstalled( - 2017
inspection.manifest.name.clone(), - 2018
)); - 2019
} - 2020
let package_path = self.package_path(&inspection); - 2021
if package_path.exists() { - 2022
let existing = inspect_package(&package_path)?; - 2023
if existing.digest != inspection.digest { - 2024
return Err(PluginError::UnsafePackage(format!( - 2025
"immutable package destination has unexpected content: {}", - 2026
package_path.display() - 2027
))); - 2028
} - 2029
} else { - 2030
self.copy_verified(&inspection, &package_path)?; - 2031
} - 2032
let installed_at_unix = now_unix(); - 2033
let source = inspection.root.display().to_string(); - 2034
let installed = InstalledPlugin { - 2035
name: inspection.manifest.name.clone(), - 2036
version: inspection.manifest.version.clone(), - 2037
digest: inspection.digest.clone(), - 2038
description: inspection.manifest.description, - 2039
license: inspection.manifest.license, - 2040
publisher: inspection.manifest.publisher, - 2041
format: inspection.format, - 2042
source: source.clone(), - 2043
trace_id: format!("update:{}:{}", inspection.manifest.name, inspection.digest), - 2044
package_path, - 2045
scope: options.scope, - 2046
enabled: false, - 2047
installed_at_unix, - 2048
capabilities: inspection.capabilities, - 2049
warnings: inspection.warnings, - 2050
}; - 2051
registry - 2052
.versions - 2053
.entry(installed.name.clone()) - 2054
.or_default() - 2055
.push(installed.clone()); - 2056
// Updating selects the new immutable generation, but it remains disabled - 2057
// until an operator explicitly enables the plugin. - 2058
registry - 2059
.plugins - 2060
.insert(installed.name.clone(), installed.clone()); - 2061
registry.generation = registry.generation.saturating_add(1); - 2062
registry.audit.push(PluginAuditEvent { - 2063
generation: registry.generation, - 2064
at_unix: installed_at_unix, - 2065
action: PluginAuditAction::Updated, - 2066
plugin: installed.name.clone(), - 2067
version: installed.version.clone(), - 2068
digest: installed.digest.clone(), - 2069
trace_id: installed.trace_id.clone(), - 2070
source, - 2071
}); - 2072
self.save(®istry)?; - 2073
Ok(installed) - 2074
} - 2075
- 2076
pub fn enable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2077
self.set_enabled(name, true) - 2078
} - 2079
- 2080
pub fn disable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2081
self.set_enabled(name, false) - 2082
} - 2083
- 2084
fn set_enabled(&self, name: &str, enabled: bool) -> Result<InstalledPlugin, PluginError> { - 2085
let name = normalize_plugin_id(name) - 2086
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2087
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2088
let mut registry = self.load()?; - 2089
let mut plugin = registry - 2090
.plugins - 2091
.get(&name) - 2092
.cloned() - 2093
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2094
if plugin.enabled == enabled { - 2095
return Ok(plugin); - 2096
} - 2097
plugin.enabled = enabled; - 2098
registry.plugins.insert(name.clone(), plugin.clone()); - 2099
registry.generation = registry.generation.saturating_add(1); - 2100
registry.audit.push(PluginAuditEvent { - 2101
generation: registry.generation, - 2102
at_unix: now_unix(), - 2103
action: if enabled { - 2104
PluginAuditAction::Enabled - 2105
} else { - 2106
PluginAuditAction::Disabled - 2107
}, - 2108
plugin: plugin.name.clone(), - 2109
version: plugin.version.clone(), - 2110
digest: plugin.digest.clone(), - 2111
trace_id: plugin.trace_id.clone(), - 2112
source: plugin.source.clone(), - 2113
}); - 2114
self.save(®istry)?; - 2115
Ok(plugin) - 2116
} - 2117
- 2118
pub fn rollback(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2119
let name = normalize_plugin_id(name) - 2120
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2121
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2122
let mut registry = self.load()?; - 2123
let current = registry - 2124
.plugins - 2125
.get(&name) - 2126
.cloned() - 2127
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2128
let previous = registry - 2129
.versions - 2130
.get(&name) - 2131
.and_then(|items| { - 2132
items - 2133
.iter() - 2134
.rev() - 2135
.find(|item| item.digest != current.digest) - 2136
}) - 2137
.cloned() - 2138
.ok_or_else(|| { - 2139
PluginError::InvalidManifest(format!("plugin {name:?} has no previous generation")) - 2140
})?; - 2141
let mut previous = previous; - 2142
previous.enabled = false; - 2143
registry.plugins.insert(name.clone(), previous.clone()); - 2144
registry.generation = registry.generation.saturating_add(1); - 2145
registry.audit.push(PluginAuditEvent { - 2146
generation: registry.generation, - 2147
at_unix: now_unix(), - 2148
action: PluginAuditAction::RolledBack, - 2149
plugin: previous.name.clone(), - 2150
version: previous.version.clone(), - 2151
digest: previous.digest.clone(), - 2152
trace_id: previous.trace_id.clone(), - 2153
source: previous.source.clone(), - 2154
}); - 2155
self.save(®istry)?; - 2156
Ok(previous) - 2157
} - 2158
- 2159
pub fn remove(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2160
let name = normalize_plugin_id(name) - 2161
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?;
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.