Skip to main content

scc_plugin_host/
lib.rs

1//! SCC plugin host: discovery, loading, permissions, execution, diagnostics.
2//!
3//! Process plugins (spec section 15): a `scc-plugin.toml` manifest plus a
4//! command speaking JSON over stdio. One request per process spawn (simple,
5//! crash-isolated, no lingering children); `timeout_ms` + `failure_policy`
6//! bound every call. Grants are checked before spawn — a plugin never
7//! executes code it was not granted (spec 21).
8
9use scc_plugin_api::{Permission, PluginManifest, PluginRequest, PluginResponse};
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13// trace:exempt reason=internal-detail
14pub const MANIFEST_FILE: &str = "scc-plugin.toml";
15
16#[derive(Debug, Clone)]
17// trace:exempt reason=internal-detail
18pub struct LoadedPlugin {
19    pub manifest: PluginManifest,
20    pub dir: PathBuf,
21    pub config: serde_json::Value,
22    pub grants: Vec<Permission>,
23}
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26// trace:exempt reason=internal-detail
27pub struct PluginDiagnostic {
28    pub plugin: String,
29    pub operation: String,
30    pub error: String,
31    pub action: String,
32}
33
34#[derive(Debug, Clone, thiserror::Error)]
35// trace:exempt reason=internal-detail
36pub enum HostError {
37    #[error("plugin {0}: {1}")]
38    Failed(String, String),
39    #[error("permission denied: plugin {0} lacks {1}")]
40    Denied(String, String),
41    #[error("no plugin provides operation {0}")]
42    NoProvider(String),
43    #[error("ambiguous: {0}")]
44    Ambiguous(String),
45    #[error("io: {0}")]
46    Io(String),
47    #[error("plugin {0} declares runtime wasm: no WASM host yet (see scc-plugin-api PLUGIN_WIT); use runtime.command process plugin instead")]
48    UnsupportedRuntime(String),
49}
50
51// trace:v1 id=impl.crates-scc-plugin-host-src-lib.discover work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
52pub fn discover(repo_root: &std::path::Path) -> Vec<LoadedPlugin> {
53    let mut out = Vec::new();
54    let mut seen = std::collections::BTreeSet::new();
55    let mut dirs: Vec<PathBuf> = Vec::new();
56    if let Ok(p) = std::env::var("SCC_PLUGIN_PATH") {
57        for part in std::env::split_paths(&p) { dirs.push(part); }
58    }
59    dirs.push(repo_root.join(".scc").join("plugins"));
60    if let Some(home) = dirs_home() { dirs.push(home.join(".config").join("scc").join("plugins")); }
61    for dir in dirs {
62        let Ok(entries) = std::fs::read_dir(&dir) else { continue; };
63        for entry in entries.flatten() {
64            let path = entry.path();
65            if !path.is_dir() { continue; }
66            let manifest_path = path.join(MANIFEST_FILE);
67            let Ok(text) = std::fs::read_to_string(&manifest_path) else { continue; };
68            let Ok(manifest) = PluginManifest::from_toml(&text) else { continue; };
69            if manifest.check_api_compatible().is_err() { continue; }
70            if !seen.insert(manifest.id.clone()) { continue; }
71            // Grants come from project config; discovery defaults to the
72            // manifest-declared permissions (explicit grants narrow them).
73            out.push(LoadedPlugin { manifest, dir: path, config: serde_json::json!({}), grants: Vec::new() });
74        }
75    }
76    out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
77    out
78}
79
80// trace:exempt reason=internal-detail
81fn dirs_home() -> Option<PathBuf> {
82    std::env::var_os("HOME").map(PathBuf::from)
83}
84
85// trace:exempt reason=internal-detail
86pub fn apply_grants(plugins: &mut [LoadedPlugin], grants: &BTreeMap<String, Vec<Permission>>) {
87    for p in plugins {
88        if let Some(g) = grants.get(&p.manifest.id) { p.grants = g.clone(); }
89        else { p.grants = p.manifest.permissions.clone(); }
90    }
91}
92
93// trace:exempt reason=internal-detail
94pub fn provider_for<'a>(plugins: &'a [LoadedPlugin], operation: &str) -> Result<&'a LoadedPlugin, HostError> {
95    let mut found: Option<&LoadedPlugin> = None;
96    for p in plugins {
97        if p.manifest.operations.iter().any(|o| o == operation) {
98            if found.is_some() {
99                return Err(HostError::Ambiguous(format!("{operation} provided by multiple plugins")));
100            }
101            found = Some(p);
102        }
103    }
104    found.ok_or_else(|| HostError::NoProvider(operation.into()))
105}
106
107/// Execute one plugin operation in a spawned child (crash-isolated).
108/// Grants are enforced: state read/write ops need the matching grant;
109/// everything else needs at least discovery (the plugin is enabled).
110// trace:exempt reason=internal-detail
111pub fn call(plugin: &LoadedPlugin, operation: &str, input: serde_json::Value, timeout_override_ms: Option<u64>) -> Result<serde_json::Value, HostError> {
112    require_grant(plugin, operation)?;
113    // WASM runtime: declared via the checked-in WIT (spec §14) but not yet
114    // hosted — fail loudly with an actionable diagnostic, never silently
115    // misload a .wasm artifact as a process command.
116    if plugin.manifest.runtime == scc_plugin_api::PluginRuntime::Wasm {
117        return Err(HostError::UnsupportedRuntime(plugin.manifest.id.clone()));
118    }
119    let req = PluginRequest { operation: operation.into(), input, config: plugin.config.clone() };
120    let body = serde_json::to_string(&req).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), e.to_string()))?;
121    let timeout = std::time::Duration::from_millis(timeout_override_ms.unwrap_or(plugin.manifest.timeout_ms));
122    let mut child = std::process::Command::new(&plugin.manifest.command[0])
123        .args(&plugin.manifest.command[1..])
124        .current_dir(&plugin.dir)
125        .stdin(std::process::Stdio::piped())
126        .stdout(std::process::Stdio::piped())
127        .stderr(std::process::Stdio::null())
128        .spawn()
129        .map_err(|e| HostError::Failed(plugin.manifest.id.clone(), format!("spawn: {e}")))?;
130    use std::io::Write;
131    if let Some(mut stdin) = child.stdin.take() {
132        let _ = stdin.write_all(body.as_bytes());
133    }
134    let out = wait_with_timeout(&mut child, timeout).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), e))?;
135    let resp: PluginResponse = serde_json::from_slice(&out).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), format!("bad response: {e}")))?;
136    if let Some(err) = resp.error {
137        return Err(HostError::Failed(plugin.manifest.id.clone(), err));
138    }
139    Ok(resp.output)
140}
141
142// trace:exempt reason=internal-detail
143fn require_grant(plugin: &LoadedPlugin, operation: &str) -> Result<(), HostError> {
144    // Custom namespaced ops (acme.foo) run on enablement alone. State ops
145    // stay gated: a plugin without the matching grant cannot touch state.
146    if operation.starts_with("state.") {
147        let need = if operation.contains("write") || operation.contains("put") {
148            Permission::StateWrite
149        } else {
150            Permission::StateRead
151        };
152        if !plugin.grants.contains(&need) && !plugin.manifest.permissions.contains(&need) {
153            return Err(HostError::Denied(plugin.manifest.id.clone(), need.as_str().into()));
154        }
155    }
156    Ok(())
157}
158
159// trace:exempt reason=internal-detail
160fn wait_with_timeout(child: &mut std::process::Child, timeout: std::time::Duration) -> Result<Vec<u8>, String> {
161    let start = std::time::Instant::now();
162    loop {
163        match child.try_wait() {
164            Ok(Some(status)) => {
165                let mut out = Vec::new();
166                if let Some(mut stdout) = child.stdout.take() {
167                    use std::io::Read;
168                    let _ = stdout.read_to_end(&mut out);
169                }
170                if !status.success() { return Err(format!("exit {}", status)); }
171                return Ok(out);
172            }
173            Ok(None) => {
174                if start.elapsed() > timeout {
175                    let _ = child.kill();
176                    let _ = child.wait();
177                    return Err("timeout".into());
178                }
179                std::thread::sleep(std::time::Duration::from_millis(10));
180            }
181            Err(e) => return Err(format!("wait: {e}")),
182        }
183    }
184}
185// trace:exempt reason=internal-detail
186pub fn lock_entry(p: &LoadedPlugin) -> serde_json::Value {
187    let mut h = blake3::Hasher::new();
188    h.update(p.manifest.id.as_bytes());
189    h.update(p.manifest.version.as_bytes());
190    h.update(p.manifest.api.as_bytes());
191    h.update(format!("{:?}", p.manifest.runtime).as_bytes());
192    let extensions: Vec<serde_json::Value> = p.manifest.extensions.iter().map(|e| {
193        h.update(e.canonical_id().as_bytes());
194        h.update(e.priority.to_string().as_bytes());
195        for x in e.after.iter().chain(e.before.iter()) { h.update(x.as_bytes()); }
196        serde_json::json!({"type": e.extension_type, "id": e.id, "priority": e.priority, "after": e.after, "before": e.before})
197    }).collect();
198    // Spec 27: effective per-instance config and grants change behavior, so
199    // they fold into the artifact hash — otherwise a config-only change
200    // (e.g. risk_weight) or grant narrowing serves stale cached packs.
201    // serde_json::Map is BTree-backed, so to_string is canonical.
202    let config_str = serde_json::to_string(&p.config).unwrap_or_default();
203    h.update(config_str.as_bytes());
204    let mut grants: Vec<&str> = p.grants.iter().map(|x| x.as_str()).collect();
205    grants.sort();
206    for g in &grants {
207        h.update(g.as_bytes());
208        h.update(b"\0");
209    }
210    serde_json::json!({
211        "id": p.manifest.id,
212        "version": p.manifest.version,
213        "api": p.manifest.api,
214        "artifact_hash": format!("{}", h.finalize().to_hex()),
215        "operations": p.manifest.operations,
216        "permissions": p.manifest.permissions.iter().map(|x| x.as_str()).collect::<Vec<_>>(),
217        "grants": grants,
218        "config": p.config,
219        "runtime": format!("{:?}", p.manifest.runtime).to_lowercase(),
220        "extensions": extensions,
221    })
222}
223
224
225/// Project plugin lockfile (§29): `.scc/plugins.lock` records the resolved
226/// plugin set so behavior reproduces across machines.
227///
228/// Each entry carries id, version, source dir, artifact hash, API version,
229/// and granted permissions — the fields `lock_entry` already emits. Write
230/// is atomic (temp + rename); read returns an empty set when absent (fresh
231/// checkout, no plugins pinned yet).
232// trace:v1 id=impl.scc-plugin-host.lockfile work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
233pub fn lockfile_path(repo_root: &std::path::Path) -> PathBuf {
234    repo_root.join(".scc").join("plugins.lock")
235}
236
237// trace:exempt reason=internal-detail
238pub fn write_lockfile(repo_root: &std::path::Path, plugins: &[LoadedPlugin]) -> Result<PathBuf, String> {
239    let path = lockfile_path(repo_root);
240    if let Some(parent) = path.parent() {
241        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
242    }
243    let entries: Vec<serde_json::Value> = plugins.iter().map(lock_entry).collect();
244    let doc = serde_json::json!({"version": 1, "plugins": entries});
245    let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
246    let tmp = path.with_extension("lock.tmp");
247    std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
248    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
249    Ok(path)
250}
251
252// trace:exempt reason=internal-detail
253pub fn read_lockfile(repo_root: &std::path::Path) -> Result<Vec<serde_json::Value>, String> {
254    let path = lockfile_path(repo_root);
255    let Ok(text) = std::fs::read_to_string(&path) else { return Ok(Vec::new()); };
256    let doc: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
257    Ok(doc.get("plugins").and_then(|v| v.as_array()).cloned().unwrap_or_default())
258}
259
260/// Verify the live plugin set against the lockfile: version or artifact
261/// drift fails loudly with the drifted ids named — never silently answers
262/// from a moved plugin set.
263// trace:v1 id=impl.scc-plugin-host.lockfile-check work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
264pub fn check_lockfile(repo_root: &std::path::Path, plugins: &[LoadedPlugin]) -> Result<(), String> {
265    let locked = read_lockfile(repo_root)?;
266    if locked.is_empty() {
267        return Ok(());
268    }
269    let live: std::collections::BTreeMap<String, serde_json::Value> = plugins
270        .iter()
271        .map(|p| (p.manifest.id.clone(), lock_entry(p)))
272        .collect();
273    let mut drifted = Vec::new();
274    for entry in &locked {
275        let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
276        match live.get(id) {
277            None => drifted.push(format!("{id} (missing)")),
278            Some(cur) => {
279                let same = cur.get("version") == entry.get("version")
280                    && cur.get("artifact_hash") == entry.get("artifact_hash");
281                if !same {
282                    drifted.push(id.to_string());
283                }
284            }
285        }
286    }
287    if drifted.is_empty() {
288        Ok(())
289    } else {
290        Err(format!("plugin set drifted from .scc/plugins.lock: {}", drifted.join(", ")))
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    #[test]
297// trace:exempt reason=unit-test
298    fn no_provider_is_not_ambiguous() {
299        let r = super::provider_for(&[], "acme.missing");
300        assert!(matches!(r, Err(super::HostError::NoProvider(_))));
301    }
302
303    #[test]
304// trace:exempt reason=unit-test
305    fn state_op_without_grant_is_denied() {
306        let p = super::LoadedPlugin {
307            manifest: scc_plugin_api::PluginManifest {
308                id: "x".into(), name: "X".into(), version: "1".into(), api: "1".into(),
309                operations: vec![], permissions: vec![], timeout_ms: 50, runtime: Default::default(),
310                failure_policy: "warn".into(), deterministic: true, command: vec!["true".into()], extensions: vec![],
311            },
312            dir: std::path::PathBuf::from("."),
313            config: serde_json::json!({}),
314            grants: vec![],
315        };
316        assert!(super::call(&p, "state.get", serde_json::json!({}), None).is_err());
317    }
318}