- 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()))?; - 2162
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2163
let mut registry = self.load()?; - 2164
let installed = registry - 2165
.plugins - 2166
.remove(&name) - 2167
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2168
let packages_root = fs::canonicalize(self.packages_root()) - 2169
.map_err(|error| io_error(self.packages_root(), error))?; - 2170
let package_paths = registry - 2171
.versions - 2172
.remove(&name) - 2173
.unwrap_or_else(|| vec![installed.clone()]) - 2174
.into_iter() - 2175
.map(|item| item.package_path) - 2176
.collect::<Vec<_>>(); - 2177
for package_path in &package_paths { - 2178
let package_path = - 2179
fs::canonicalize(package_path).map_err(|error| io_error(package_path, error))?; - 2180
if !package_path.starts_with(&packages_root) || package_path == packages_root { - 2181
return Err(PluginError::UnsafePackage(format!( - 2182
"registry package path is outside the managed store: {}", - 2183
package_path.display() - 2184
))); - 2185
} - 2186
} - 2187
registry.generation = registry.generation.saturating_add(1); - 2188
registry.audit.push(PluginAuditEvent { - 2189
generation: registry.generation, - 2190
at_unix: now_unix(), - 2191
action: PluginAuditAction::Removed, - 2192
plugin: installed.name.clone(), - 2193
version: installed.version.clone(), - 2194
digest: installed.digest.clone(), - 2195
trace_id: installed.trace_id.clone(), - 2196
source: installed.source.clone(), - 2197
}); - 2198
self.save(®istry)?; - 2199
for package_path in package_paths { - 2200
let package_path = - 2201
fs::canonicalize(&package_path).map_err(|error| io_error(&package_path, error))?; - 2202
if package_path.exists() { - 2203
fs::remove_dir_all(&package_path) - 2204
.map_err(|error| io_error(&package_path, error))?; - 2205
prune_empty_parents(&package_path, &packages_root)?; - 2206
} - 2207
} - 2208
Ok(installed) - 2209
} - 2210
- 2211
fn package_path(&self, inspection: &PackageInspection) -> PathBuf { - 2212
self.packages_root() - 2213
.join(&inspection.manifest.name) - 2214
.join(&inspection.manifest.version) - 2215
.join(&inspection.digest) - 2216
} - 2217
- 2218
fn copy_verified( - 2219
&self, - 2220
inspection: &PackageInspection, - 2221
destination: &Path, - 2222
) -> Result<(), PluginError> { - 2223
let parent = destination.parent().ok_or_else(|| { - 2224
PluginError::UnsafePackage("package destination has no parent".into()) - 2225
})?; - 2226
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2227
let staging = tempfile::Builder::new() - 2228
.prefix(".plugin-stage-") - 2229
.tempdir_in(parent) - 2230
.map_err(|error| io_error(parent, error))?; - 2231
for file in &inspection.files { - 2232
let relative = path_from_portable(&file.path); - 2233
let source = inspection.root.join(&relative); - 2234
let source_meta = - 2235
fs::symlink_metadata(&source).map_err(|error| io_error(&source, error))?; - 2236
if !source_meta.is_file() || source_meta.file_type().is_symlink() { - 2237
return Err(PluginError::UnsafePackage(format!( - 2238
"source changed during install: {}", - 2239
source.display() - 2240
))); - 2241
} - 2242
let canonical_source = - 2243
fs::canonicalize(&source).map_err(|error| io_error(&source, error))?; - 2244
if !canonical_source.starts_with(&inspection.root) { - 2245
return Err(PluginError::UnsafePackage(format!( - 2246
"source escaped package during install: {}", - 2247
source.display() - 2248
))); - 2249
} - 2250
let target = staging.path().join(&relative); - 2251
if let Some(directory) = target.parent() { - 2252
fs::create_dir_all(directory).map_err(|error| io_error(directory, error))?; - 2253
} - 2254
fs::copy(&source, &target).map_err(|error| io_error(&target, error))?; - 2255
} - 2256
let staged_inspection = inspect_package(staging.path())?; - 2257
if staged_inspection.digest != inspection.digest { - 2258
return Err(PluginError::UnsafePackage( - 2259
"package changed while it was being installed".into(), - 2260
)); - 2261
} - 2262
let staged_path = staging.keep(); - 2263
fs::rename(&staged_path, destination).map_err(|error| io_error(destination, error))?; - 2264
Ok(()) - 2265
} - 2266
- 2267
fn save(&self, registry: &PluginRegistry) -> Result<(), PluginError> { - 2268
let path = self.registry_path(); - 2269
let parent = path - 2270
.parent() - 2271
.ok_or_else(|| PluginError::UnsafePackage("registry path has no parent".into()))?; - 2272
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2273
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 2274
path: path.clone(), - 2275
source, - 2276
})?; - 2277
let mut staged = - 2278
tempfile::NamedTempFile::new_in(parent).map_err(|error| io_error(parent, error))?; - 2279
staged - 2280
.write_all(&bytes) - 2281
.map_err(|error| io_error(staged.path(), error))?; - 2282
staged - 2283
.as_file() - 2284
.sync_all() - 2285
.map_err(|error| io_error(staged.path(), error))?; - 2286
staged - 2287
.persist(&path) - 2288
.map_err(|error| io_error(&path, error.error))?; - 2289
Ok(()) - 2290
} - 2291
} - 2292
- 2293
fn prune_empty_parents(path: &Path, stop: &Path) -> Result<(), PluginError> { - 2294
let mut current = path.parent(); - 2295
while let Some(directory) = current { - 2296
if directory == stop { - 2297
break; - 2298
} - 2299
match fs::remove_dir(directory) { - 2300
Ok(()) => current = directory.parent(), - 2301
Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break, - 2302
Err(error) => return Err(io_error(directory, error)), - 2303
} - 2304
} - 2305
Ok(()) - 2306
} - 2307
- 2308
fn now_unix() -> u64 { - 2309
SystemTime::now() - 2310
.duration_since(UNIX_EPOCH) - 2311
.unwrap_or_default() - 2312
.as_secs() - 2313
} - 2314
- 2315
struct RegistryLock { - 2316
path: PathBuf, - 2317
} - 2318
- 2319
impl RegistryLock { - 2320
fn acquire(root: &Path) -> Result<Self, PluginError> { - 2321
fs::create_dir_all(root).map_err(|error| io_error(root, error))?; - 2322
let path = root.join(".registry.lock"); - 2323
match create_lock(&path) { - 2324
Ok(()) => Ok(Self { path }), - 2325
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - 2326
let stale = fs::metadata(&path) - 2327
.and_then(|metadata| metadata.modified()) - 2328
.ok() - 2329
.and_then(|modified| SystemTime::now().duration_since(modified).ok()) - 2330
.is_some_and(|age| age > Duration::from_secs(120)); - 2331
if !stale { - 2332
return Err(PluginError::RegistryBusy); - 2333
} - 2334
fs::remove_file(&path).map_err(|remove_error| io_error(&path, remove_error))?; - 2335
create_lock(&path).map_err(|retry_error| io_error(&path, retry_error))?; - 2336
Ok(Self { path }) - 2337
} - 2338
Err(error) => Err(io_error(&path, error)), - 2339
} - 2340
} - 2341
} - 2342
- 2343
impl Drop for RegistryLock { - 2344
fn drop(&mut self) { - 2345
let _ = fs::remove_file(&self.path); - 2346
} - 2347
} - 2348
- 2349
fn create_lock(path: &Path) -> std::io::Result<()> { - 2350
let mut lock = OpenOptions::new().write(true).create_new(true).open(path)?; - 2351
writeln!(lock, "{} {}", std::process::id(), now_unix())?; - 2352
lock.sync_all() - 2353
} - 2354
- 2355
#[cfg(test)] - 2356
mod tests { - 2357
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - 2358
- 2359
use super::*; - 2360
- 2361
fn write(path: &Path, text: &str) { - 2362
if let Some(parent) = path.parent() { - 2363
fs::create_dir_all(parent).unwrap(); - 2364
} - 2365
fs::write(path, text).unwrap(); - 2366
} - 2367
- 2368
fn package(root: &Path) { - 2369
write( - 2370
&root.join("vak-plugin.json"), - 2371
r#"{ - 2372
"schema": 1, - 2373
"name": "daily-brief", - 2374
"version": "1.2.3", - 2375
"description": "Prepare a daily brief.", - 2376
"license": "MIT", - 2377
"publisher": {"id":"vak-labs","name":"Vak Labs"}, - 2378
"components": {"skills":["skills"],"commands":["commands"]} - 2379
}"#, - 2380
); - 2381
write( - 2382
&root.join("skills/daily-brief/SKILL.md"), - 2383
"---\nname: daily-brief\ndescription: Prepare the brief.\n---\n\nDo it.\n", - 2384
); - 2385
write( - 2386
&root.join("skills/daily-brief/scripts/collect.sh"), - 2387
"#!/bin/sh\nprintf brief\n", - 2388
); - 2389
write(&root.join("commands/brief.md"), "Prepare a brief.\n"); - 2390
} - 2391
- 2392
#[test] - 2393
fn inspects_native_package_and_digest_is_deterministic() { - 2394
let temp = tempfile::tempdir().unwrap(); - 2395
package(temp.path()); - 2396
let first = inspect_package(temp.path()).unwrap(); - 2397
let second = inspect_package(temp.path()).unwrap(); - 2398
assert_eq!(first.digest, second.digest); - 2399
assert_eq!(first.manifest.name, "daily-brief"); - 2400
assert_eq!(first.capabilities.skills, ["daily-brief"]); - 2401
assert_eq!(first.capabilities.commands, ["commands/brief.md"]); - 2402
assert_eq!( - 2403
first.capabilities.scripts, - 2404
["skills/daily-brief/scripts/collect.sh"] - 2405
); - 2406
} - 2407
- 2408
#[test] - 2409
fn changing_content_changes_digest() { - 2410
let temp = tempfile::tempdir().unwrap(); - 2411
package(temp.path()); - 2412
let first = inspect_package(temp.path()).unwrap(); - 2413
write(&temp.path().join("commands/brief.md"), "Changed.\n"); - 2414
let second = inspect_package(temp.path()).unwrap(); - 2415
assert_ne!(first.digest, second.digest); - 2416
} - 2417
- 2418
#[test] - 2419
fn normalizes_codex_manifest_and_conventional_components() { - 2420
let temp = tempfile::tempdir().unwrap(); - 2421
write( - 2422
&temp.path().join(".codex-plugin/plugin.json"), - 2423
r#"{"name":"codex-compatible","version":"2.0.0","description":"Compatible","license":"Apache-2.0","skills":"./skills"}"#, - 2424
); - 2425
write( - 2426
&temp.path().join("skills/hello/SKILL.md"), - 2427
"---\nname: hello\ndescription: Hello.\n---\nHello.\n", - 2428
); - 2429
write(&temp.path().join(".mcp.json"), "{}\n"); - 2430
let inspected = inspect_package(temp.path()).unwrap(); - 2431
assert_eq!(inspected.format, ManifestFormat::Codex); - 2432
assert_eq!(inspected.capabilities.skills, ["hello"]); - 2433
assert_eq!(inspected.capabilities.mcp_manifests, [".mcp.json"]); - 2434
} - 2435
- 2436
#[test] - 2437
fn imports_agent_plugins_one_with_fixed_locations_and_dotted_name() { - 2438
let temp = tempfile::tempdir().unwrap(); - 2439
write( - 2440
&temp.path().join("plugin.json"), - 2441
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"acme.tools","version":"release-7","license":"MIT","skills":"ignored-by-fixed-discovery"}"#, - 2442
); - 2443
write( - 2444
&temp.path().join("skills/review/SKILL.md"), - 2445
"---\nname: review\ndescription: Review code.\n---\nReview.\n", - 2446
); - 2447
write( - 2448
&temp.path().join("mcp.json"), - 2449
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{}}"#, - 2450
); - 2451
let inspected = inspect_package(temp.path()).unwrap(); - 2452
assert_eq!(inspected.format, ManifestFormat::AgentPlugin); - 2453
assert_eq!(inspected.manifest.name, "acme.tools"); - 2454
assert!(inspected.manifest.version.starts_with("0.0.0+source.")); - 2455
assert_eq!(inspected.capabilities.skills, ["review"]); - 2456
assert_eq!(inspected.capabilities.mcp_manifests, ["mcp.json"]); - 2457
} - 2458
- 2459
#[test] - 2460
fn imports_claude_and_cursor_client_components() { - 2461
for (manifest, format) in [ - 2462
(".claude-plugin/plugin.json", ManifestFormat::Claude), - 2463
(".cursor-plugin/plugin.json", ManifestFormat::Cursor), - 2464
] { - 2465
let temp = tempfile::tempdir().unwrap(); - 2466
write( - 2467
&temp.path().join(manifest), - 2468
r#"{"name":"client-pack","version":"1.0.0","license":"MIT","hooks":{"PreToolUse":[]},"mcpServers":{"tools":{"command":"tool"}}}"#, - 2469
); - 2470
write( - 2471
&temp.path().join("agents/reviewer.md"), - 2472
"Review carefully.\n", - 2473
); - 2474
let inspected = inspect_package(temp.path()).unwrap(); - 2475
assert_eq!(inspected.format, format); - 2476
assert_eq!(inspected.capabilities.hooks, [manifest]); - 2477
assert_eq!(inspected.capabilities.mcp_manifests, [manifest]); - 2478
assert_eq!(inspected.capabilities.agents, ["agents/reviewer.md"]); - 2479
} - 2480
} - 2481
- 2482
#[test] - 2483
fn imports_gemini_extension_without_granting_policy() { - 2484
let temp = tempfile::tempdir().unwrap(); - 2485
write( - 2486
&temp.path().join("gemini-extension.json"), - 2487
r#"{"name":"gemini-pack","version":"1.0.0","license":"MIT","mcpServers":{"tools":{"command":"tool"}},"themes":[{"name":"dark"}]}"#, - 2488
); - 2489
write( - 2490
&temp.path().join("policies/restrict.toml"), - 2491
"decision = \"ask_user\"\n", - 2492
); - 2493
let inspected = inspect_package(temp.path()).unwrap(); - 2494
assert_eq!(inspected.format, ManifestFormat::Gemini); - 2495
assert_eq!( - 2496
inspected.capabilities.mcp_manifests, - 2497
["gemini-extension.json"] - 2498
); - 2499
assert_eq!(inspected.capabilities.themes, ["gemini-extension.json"]); - 2500
assert_eq!(inspected.capabilities.policies, ["policies/restrict.toml"]); - 2501
} - 2502
- 2503
#[test] - 2504
fn inspects_compatible_catalog_with_snapshot_trace() { - 2505
let temp = tempfile::tempdir().unwrap(); - 2506
write( - 2507
&temp.path().join(".claude-plugin/marketplace.json"), - 2508
r#"{ - 2509
"name":"team-catalog", - 2510
"plugins":[ - 2511
{"name":"local-tools","version":"1.0.0","license":"MIT","source":"./plugins/local-tools"}, - 2512
{"name":"remote-tools","source":{"source":"github","repo":"acme/tools","sha":"0123456789abcdef0123456789abcdef01234567"}} - 2513
] - 2514
}"#, - 2515
); - 2516
let first = inspect_catalog(temp.path()).unwrap(); - 2517
let second = inspect_catalog(temp.path()).unwrap(); - 2518
assert_eq!(first.format, MarketplaceFormat::Claude); - 2519
assert_eq!(first.entries.len(), 2); - 2520
assert_eq!(first.digest, second.digest); - 2521
assert_eq!( - 2522
first.trace_id, - 2523
format!("catalog:team-catalog:{}", first.digest) - 2524
); - 2525
assert_eq!(first.warnings.len(), 1); - 2526
} - 2527
- 2528
#[test] - 2529
fn catalog_rejects_duplicates_and_bad_pins() { - 2530
let duplicate = tempfile::tempdir().unwrap(); - 2531
write( - 2532
&duplicate.path().join("marketplace.json"), - 2533
r#"{"name":"bad","plugins":[{"name":"same","source":"./a"},{"name":"same","source":"./b"}]}"#, - 2534
); - 2535
assert!(matches!( - 2536
inspect_catalog(duplicate.path()), - 2537
Err(PluginError::InvalidManifest(_)) - 2538
)); - 2539
- 2540
let bad_pin = tempfile::tempdir().unwrap(); - 2541
write( - 2542
&bad_pin.path().join(".cursor-plugin/marketplace.json"), - 2543
r#"{"name":"bad-pin","plugins":[{"name":"tool","source":{"repo":"a/b","sha":"short"}}]}"#, - 2544
); - 2545
assert!(matches!( - 2546
inspect_catalog(bad_pin.path()), - 2547
Err(PluginError::InvalidManifest(_)) - 2548
)); - 2549
- 2550
let escaping = tempfile::tempdir().unwrap(); - 2551
write( - 2552
&escaping.path().join("marketplace.json"), - 2553
r#"{"name":"escape","plugins":[{"name":"tool","source":"../outside"}]}"#, - 2554
); - 2555
assert!(matches!( - 2556
inspect_catalog(escaping.path()), - 2557
Err(PluginError::UnsafePackage(_)) - 2558
)); - 2559
} - 2560
- 2561
#[cfg(unix)] - 2562
#[test] - 2563
fn rejects_symlinks_and_hard_links() { - 2564
use std::os::unix::fs::symlink; - 2565
- 2566
let symlinked = tempfile::tempdir().unwrap(); - 2567
package(symlinked.path()); - 2568
symlink("commands/brief.md", symlinked.path().join("alias.md")).unwrap(); - 2569
assert!(matches!( - 2570
inspect_package(symlinked.path()), - 2571
Err(PluginError::UnsafePackage(_)) - 2572
)); - 2573
- 2574
let linked = tempfile::tempdir().unwrap(); - 2575
package(linked.path()); - 2576
fs::hard_link( - 2577
linked.path().join("commands/brief.md"), - 2578
linked.path().join("commands/brief-copy.md"), - 2579
) - 2580
.unwrap(); - 2581
assert!(matches!( - 2582
inspect_package(linked.path()), - 2583
Err(PluginError::UnsafePackage(_)) - 2584
)); - 2585
} - 2586
- 2587
#[test] - 2588
fn enforces_file_and_size_limits() { - 2589
let temp = tempfile::tempdir().unwrap(); - 2590
package(temp.path()); - 2591
let error = inspect_package_with_limits( - 2592
temp.path(), - 2593
InspectLimits { - 2594
max_files: 1, - 2595
..InspectLimits::default() - 2596
}, - 2597
) - 2598
.unwrap_err(); - 2599
assert!(matches!(error, PluginError::LimitExceeded(_))); - 2600
} - 2601
- 2602
#[test] - 2603
fn installs_disabled_verifies_copy_and_removes() { - 2604
let source = tempfile::tempdir().unwrap(); - 2605
let home = tempfile::tempdir().unwrap(); - 2606
package(source.path()); - 2607
let store = PluginStore::new(home.path()); - 2608
let installed = store - 2609
.install_local(source.path(), InstallOptions::default()) - 2610
.unwrap(); - 2611
assert!(!installed.enabled); - 2612
assert!(installed.package_path.is_dir()); - 2613
assert_eq!( - 2614
inspect_package(&installed.package_path).unwrap().digest, - 2615
installed.digest - 2616
); - 2617
assert_eq!(store.list().unwrap().len(), 1); - 2618
let idempotent = store - 2619
.install_local(source.path(), InstallOptions::default()) - 2620
.unwrap(); - 2621
assert_eq!(installed.digest, idempotent.digest); - 2622
let removed = store.remove("daily-brief").unwrap(); - 2623
assert_eq!(removed.digest, installed.digest); - 2624
assert!(!installed.package_path.exists()); - 2625
assert!(store.list().unwrap().is_empty()); - 2626
let registry = store.load().unwrap(); - 2627
assert_eq!(registry.generation, 2); - 2628
assert_eq!(registry.audit.len(), 2); - 2629
assert_eq!(registry.audit[0].trace_id, installed.trace_id); - 2630
assert_eq!(registry.audit[1].trace_id, installed.trace_id); - 2631
} - 2632
- 2633
#[test] - 2634
fn refuses_unlicensed_install_without_explicit_override() { - 2635
let source = tempfile::tempdir().unwrap(); - 2636
let home = tempfile::tempdir().unwrap(); - 2637
write( - 2638
&source.path().join("SKILL.md"), - 2639
"---\nname: local-skill\ndescription: Local.\n---\nLocal.\n", - 2640
); - 2641
let store = PluginStore::new(home.path()); - 2642
assert!(matches!( - 2643
store.install_local(source.path(), InstallOptions::default()), - 2644
Err(PluginError::Unlicensed(_)) - 2645
)); - 2646
let installed = store - 2647
.install_local( - 2648
source.path(), - 2649
InstallOptions { - 2650
allow_unlicensed: true, - 2651
..InstallOptions::default() - 2652
}, - 2653
) - 2654
.unwrap(); - 2655
assert_eq!(installed.format, ManifestFormat::AgentSkill); - 2656
} - 2657
- 2658
#[test] - 2659
fn refuses_replacement_without_remove() { - 2660
let source = tempfile::tempdir().unwrap(); - 2661
let home = tempfile::tempdir().unwrap(); - 2662
package(source.path()); - 2663
let store = PluginStore::new(home.path()); - 2664
store - 2665
.install_local(source.path(), InstallOptions::default()) - 2666
.unwrap(); - 2667
write(&source.path().join("commands/brief.md"), "New content.\n"); - 2668
assert!(matches!( - 2669
store.install_local(source.path(), InstallOptions::default()), - 2670
Err(PluginError::AlreadyInstalled(_)) - 2671
)); - 2672
} - 2673
- 2674
#[test] - 2675
fn lifecycle_keeps_generations_and_audits_every_transition() { - 2676
let source = tempfile::tempdir().unwrap(); - 2677
let home = tempfile::tempdir().unwrap(); - 2678
package(source.path()); - 2679
let store = PluginStore::new(home.path()); - 2680
let first = store - 2681
.install_local(source.path(), InstallOptions::default()) - 2682
.unwrap(); - 2683
let enabled = store.enable("daily-brief").unwrap(); - 2684
assert!(enabled.enabled); - 2685
let disabled = store.disable("daily-brief").unwrap(); - 2686
assert!(!disabled.enabled); - 2687
write(&source.path().join("commands/brief.md"), "New content.\n"); - 2688
let second = store - 2689
.update_local(source.path(), InstallOptions::default()) - 2690
.unwrap(); - 2691
assert_ne!(first.digest, second.digest); - 2692
assert!(!second.enabled); - 2693
assert_eq!(store.versions("daily-brief").unwrap().len(), 2); - 2694
let rolled_back = store.rollback("daily-brief").unwrap(); - 2695
assert_eq!(rolled_back.digest, first.digest); - 2696
assert!(!rolled_back.enabled); - 2697
let actions: Vec<_> = store - 2698
.load() - 2699
.unwrap() - 2700
.audit - 2701
.into_iter() - 2702
.map(|event| event.action) - 2703
.collect(); - 2704
assert_eq!( - 2705
actions, - 2706
vec![ - 2707
PluginAuditAction::Installed, - 2708
PluginAuditAction::Enabled, - 2709
PluginAuditAction::Disabled, - 2710
PluginAuditAction::Updated, - 2711
PluginAuditAction::RolledBack, - 2712
] - 2713
); - 2714
} - 2715
- 2716
#[test] - 2717
fn catalog_sources_are_registered_disabled_with_snapshot_provenance() { - 2718
let catalog = tempfile::tempdir().unwrap(); - 2719
write( - 2720
&catalog.path().join("marketplace.json"), - 2721
r#"{"name":"team","plugins":[{"name":"tool","source":"./tool"}]}"#, - 2722
); - 2723
let home = tempfile::tempdir().unwrap(); - 2724
let store = PluginStore::new(home.path()); - 2725
let source = store - 2726
.register_catalog_source( - 2727
catalog.path(), - 2728
"Team catalog", - 2729
MarketplaceTrust::ManualReview, - 2730
) - 2731
.unwrap(); - 2732
assert!(!source.enabled); - 2733
assert_eq!(source.format, MarketplaceFormat::Copilot); - 2734
assert!(source.trace_id.starts_with("catalog:team:")); - 2735
assert_eq!(store.list_sources().unwrap(), vec![source.clone()]); - 2736
assert!(store.set_source_enabled(&source.id, true).unwrap().enabled); - 2737
assert!(store.load_sources().unwrap().sources[&source.id].enabled); - 2738
write( - 2739
&catalog.path().join("marketplace.json"), - 2740
r#"{"name":"team","plugins":[]}"#, - 2741
); - 2742
assert!(matches!( - 2743
store.set_source_enabled(&source.id, false), - 2744
Err(PluginError::UnsafePackage(_)) - 2745
)); - 2746
store - 2747
.record_invocation(&source.trace_id, "tool", "mcp:plugin.tool.lookup", true) - 2748
.unwrap(); - 2749
assert_eq!(store.invocations().unwrap().len(), 1); - 2750
} - 2751
- 2752
#[test] - 2753
fn signed_catalog_source_verifies_and_can_be_enabled() { - 2754
use ring::rand::SystemRandom; - 2755
use ring::signature::{Ed25519KeyPair, KeyPair}; - 2756
- 2757
let catalog = tempfile::tempdir().unwrap(); - 2758
write( - 2759
&catalog.path().join("marketplace.json"), - 2760
r#"{"name":"signed-team","plugins":[{"name":"tool","source":"./tool"}]}"#, - 2761
); - 2762
let inspection = inspect_catalog(catalog.path()).unwrap(); - 2763
let rng = SystemRandom::new(); - 2764
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - 2765
let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - 2766
let public_key = - 2767
base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()); - 2768
let signature = base64::engine::general_purpose::STANDARD - 2769
.encode(key_pair.sign(inspection.digest.as_bytes()).as_ref()); - 2770
let home = tempfile::tempdir().unwrap(); - 2771
let store = PluginStore::new(home.path()); - 2772
let source = store - 2773
.register_catalog_source_with_signature( - 2774
catalog.path(), - 2775
"Signed catalog", - 2776
MarketplaceTrust::PinnedCommit, - 2777
Some(SignatureEvidence { - 2778
algorithm: "ed25519".into(), - 2779
key_id: "team-key".into(), - 2780
public_key, - 2781
signature, - 2782
verified: false, - 2783
revoked: false, - 2784
}), - 2785
) - 2786
.unwrap(); - 2787
assert!(source.signature.as_ref().unwrap().verified); - 2788
assert!(store.set_source_enabled(&source.id, true).unwrap().enabled); - 2789
store.set_key_revoked("team-key", true).unwrap(); - 2790
assert!(matches!( - 2791
store.set_source_enabled(&source.id, false), - 2792
Err(PluginError::UnsafePackage(_)) - 2793
)); - 2794
} - 2795
- 2796
#[test] - 2797
fn enabled_plugin_hooks_are_loaded_from_real_manifest_files() { - 2798
let source = tempfile::tempdir().unwrap(); - 2799
package(source.path()); - 2800
write( - 2801
&source.path().join("vak-plugin.json"), - 2802
r#"{"schema":1,"name":"daily-brief","version":"1.2.3","description":"Prepare a daily brief.","license":"MIT","components":{"skills":["skills"],"commands":["commands"],"hooks":["hooks.json"]}}"#, - 2803
); - 2804
write( - 2805
&source.path().join("hooks.json"), - 2806
r#"{"hooks":[{"event":"pre_tool_use","match":"bash","command":"printf hook","timeout_ms":2500}]}"#, - 2807
); - 2808
let home = tempfile::tempdir().unwrap(); - 2809
let store = PluginStore::new(home.path()); - 2810
store - 2811
.install_local(source.path(), InstallOptions::default()) - 2812
.unwrap(); - 2813
store.enable("daily-brief").unwrap(); - 2814
let hooks = store.enabled_hooks().unwrap(); - 2815
assert_eq!(hooks.len(), 1); - 2816
assert_eq!(hooks[0].1.event, "pre_tool_use"); - 2817
assert_eq!(hooks[0].1.matcher.as_deref(), Some("bash")); - 2818
assert_eq!(hooks[0].1.command, "printf hook"); - 2819
} - 2820
- 2821
#[test] - 2822
fn materializes_local_catalog_entry_and_rejects_unpinned_remote() { - 2823
let catalog = tempfile::tempdir().unwrap(); - 2824
write( - 2825
&catalog.path().join("marketplace.json"), - 2826
r#"{"name":"team","plugins":[{"name":"tool","source":"./tool"}]}"#, - 2827
); - 2828
write( - 2829
&catalog.path().join("tool/vak-plugin.json"), - 2830
r#"{"schema":1,"name":"tool","version":"1.0.0","description":"Tool","license":"MIT"}"#, - 2831
); - 2832
let inspection = inspect_catalog(catalog.path()).unwrap(); - 2833
let local = materialize_catalog_entry( - 2834
catalog.path(), - 2835
&inspection.entries[0], - 2836
&catalog.path().join("downloads"), - 2837
) - 2838
.unwrap(); - 2839
assert!(local.ends_with("tool")); - 2840
let remote = CatalogEntry { - 2841
name: "remote".into(), - 2842
source: serde_json::json!("https://github.com/acme/tool.git"), - 2843
version: None, - 2844
description: None, - 2845
license: None, - 2846
}; - 2847
assert!(matches!( - 2848
materialize_catalog_entry(catalog.path(), &remote, &catalog.path().join("downloads")), - 2849
Err(PluginError::UnsafePackage(_)) - 2850
)); - 2851
} - 2852
- 2853
#[test] - 2854
fn signature_verification_rejects_malformed_or_wrong_evidence() { - 2855
assert!(matches!( - 2856
verify_ed25519_signature(b"catalog", "not-base64", "not-base64"), - 2857
Err(PluginError::UnsafePackage(_)) - 2858
)); - 2859
let key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]); - 2860
let sig = base64::engine::general_purpose::STANDARD.encode([0u8; 64]); - 2861
assert!(matches!( - 2862
verify_ed25519_signature(b"catalog", &key, &sig), - 2863
Err(PluginError::UnsafePackage(_)) - 2864
)); - 2865
} - 2866
- 2867
#[test] - 2868
fn retired_plugin_scan_detects_references_to_retired_tools() { - 2869
let temp = tempfile::tempdir().unwrap(); - 2870
// Build a plugin package that references `python_eval` in its skill - 2871
// description — the exact pattern that caused the hallucination. - 2872
write( - 2873
&temp.path().join("vak-plugin.json"), - 2874
r#"{ - 2875
"schema": 1, - 2876
"name": "legacy-python", - 2877
"version": "1.0.0", - 2878
"description": "Legacy", - 2879
"license": "MIT", - 2880
"components": {"skills": ["skills"]} - 2881
}"#, - 2882
); - 2883
write( - 2884
&temp.path().join("skills/python-exec/SKILL.md"), - 2885
"---\nname: python-exec\ndescription: Execute Python using the `python_eval` tool.\n---\nAlways use `python_eval`.\n", - 2886
); - 2887
let store = PluginStore::new(temp.path()); - 2888
store - 2889
.install_local( - 2890
temp.path(), - 2891
InstallOptions { - 2892
scope: InstallScope::Workspace, - 2893
allow_unlicensed: false, - 2894
}, - 2895
) - 2896
.unwrap(); - 2897
store.enable("legacy-python").unwrap(); - 2898
let flagged = store.retired_plugins().unwrap(); - 2899
assert_eq!(flagged.len(), 1); - 2900
assert_eq!(flagged[0].0, "legacy-python"); - 2901
assert!(flagged[0].1.contains(&"python_eval".to_string())); - 2902
} - 2903
- 2904
#[test] - 2905
fn clean_plugin_is_not_flagged_as_retired() { - 2906
let temp = tempfile::tempdir().unwrap(); - 2907
package(temp.path()); - 2908
let store = PluginStore::new(temp.path()); - 2909
store - 2910
.install_local( - 2911
temp.path(), - 2912
InstallOptions { - 2913
scope: InstallScope::Workspace, - 2914
allow_unlicensed: false, - 2915
}, - 2916
) - 2917
.unwrap(); - 2918
store.enable("daily-brief").unwrap(); - 2919
let flagged = store.retired_plugins().unwrap(); - 2920
assert!(flagged.is_empty()); - 2921
} - 2922
} - 2923
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.