Skip to main content

scc_plugin_api/
lib.rs

1//! Stable SCC plugin contracts (spec sections 15-16, 21, 31).
2//!
3//! Plugins depend on THIS crate only — never on `scc-engine` or `scc-cli`.
4//! Manifests are `scc-plugin.toml`; the process-plugin wire protocol is
5//! plain JSON over stdio using [`PluginRequest`]/[`PluginResponse`].
6
7use serde::{Deserialize, Serialize};
8
9// trace:exempt reason=internal-detail
10pub const PLUGIN_API_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14// trace:exempt reason=internal-detail
15pub enum Permission {
16    RepoRead,
17    GraphRead,
18    GraphContribute,
19    #[serde(rename = "evidence.contribute")]
20    EvidenceContribute,
21    StateRead,
22    StateWrite,
23    Network,
24    Subprocess,
25}
26
27// trace:exempt reason=internal-detail
28impl Permission {
29// trace:exempt reason=internal-detail
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Permission::RepoRead => "repo.read",
33            Permission::GraphRead => "graph.read",
34            Permission::GraphContribute => "graph.contribute",
35            Permission::EvidenceContribute => "evidence.contribute",
36            Permission::StateRead => "state.read",
37            Permission::StateWrite => "state.write",
38            Permission::Network => "network",
39            Permission::Subprocess => "subprocess",
40        }
41    }
42
43    /// Parse a project-config grant name. Unknown names are an `Err` —
44    /// callers must surface it (diagnostic or hard error), never silently
45    /// narrow the grant set (a typo'd permission must not read as "deny").
46    // trace:exempt reason=internal-detail
47    pub fn parse(s: &str) -> Result<Self, String> {
48        match s {
49            "repo.read" => Ok(Permission::RepoRead),
50            "graph.read" => Ok(Permission::GraphRead),
51            "graph.contribute" => Ok(Permission::GraphContribute),
52            "evidence.contribute" => Ok(Permission::EvidenceContribute),
53            "state.read" => Ok(Permission::StateRead),
54            "state.write" => Ok(Permission::StateWrite),
55            "network" => Ok(Permission::Network),
56            "subprocess" => Ok(Permission::Subprocess),
57            other => Err(format!(
58                "unknown permission '{other}' (known: repo.read, graph.read, graph.contribute, evidence.contribute, state.read, state.write, network, subprocess)"
59            )),
60        }
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65// trace:v1 id=impl.crates-scc-plugin-api-src-lib.plugin-manifest work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
66pub struct PluginManifest {
67    pub id: String,
68    pub name: String,
69    pub version: String,
70    #[serde(default = "default_api")]
71    pub api: String,
72    #[serde(default)]
73    pub operations: Vec<String>,
74    #[serde(default)]
75    pub permissions: Vec<Permission>,
76    #[serde(default = "default_timeout")]
77    pub timeout_ms: u64,
78    #[serde(default = "default_policy")]
79    pub failure_policy: String,
80    #[serde(default = "default_true")]
81    pub deterministic: bool,
82    #[serde(skip)]
83    pub command: Vec<String>,
84    #[serde(default = "default_runtime")]
85    pub runtime: PluginRuntime,
86    /// Declared extension registrations (spec §17): (extension-type, id,
87    /// priority, after, before). Process plugins declare these in
88    /// `[extensions] "rank-feature:acme.id" = {priority=10, after=[...]}`.
89    #[serde(default)]
90    pub extensions: Vec<ExtensionRegistration>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94// trace:exempt reason=internal-detail
95pub struct ExtensionRegistration {
96    #[serde(rename = "type")]
97    pub extension_type: String,
98    pub id: String,
99    #[serde(default)]
100    pub priority: i32,
101    #[serde(default)]
102    pub after: Vec<String>,
103    #[serde(default)]
104    pub before: Vec<String>,
105}
106
107// trace:exempt reason=internal-detail
108impl ExtensionRegistration {
109    /// Canonical id `type:id` (e.g. `rank-feature:acme.risk`).
110// trace:exempt reason=internal-detail
111    pub fn canonical_id(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116// trace:exempt reason=internal-detail
117pub enum PluginRuntime {
118    /// External process over stdio (spec §15). The only supported runtime
119    /// today; `command` carries the argv.
120    #[default]
121    Process,
122    /// WASM Component Model (spec §14): declared via the checked-in WIT.
123    /// Loading is a loud `UnsupportedRuntime` diagnostic until the wasmtime
124    /// host lands — never silent misloading.
125    Wasm,
126}
127
128// trace:exempt reason=internal-detail
129fn default_runtime() -> PluginRuntime { PluginRuntime::Process }
130
131// trace:exempt reason=internal-detail
132fn default_api() -> String { "1".into() }
133// trace:exempt reason=internal-detail
134fn default_timeout() -> u64 { 5000 }
135// trace:exempt reason=internal-detail
136fn default_policy() -> String { "warn".into() }
137// trace:exempt reason=internal-detail
138fn default_true() -> bool { true }
139
140// trace:exempt reason=internal-detail
141impl PluginManifest {
142// trace:exempt reason=internal-detail
143    pub fn from_toml(text: &str) -> Result<Self, String> {
144        let mut m = PluginManifest {
145            id: String::new(), name: String::new(), version: String::new(),
146            api: default_api(), operations: Vec::new(), permissions: Vec::new(),
147            timeout_ms: default_timeout(), failure_policy: default_policy(),
148            deterministic: true, command: Vec::new(), runtime: PluginRuntime::Process, extensions: Vec::new(),
149        };
150        let mut section = String::new();
151        for (ln, raw) in text.lines().enumerate() {
152            let line = raw.split('#').next().unwrap_or("").trim();
153            if line.is_empty() { continue; }
154            if line.starts_with('[') && line.ends_with(']') {
155                section = line[1..line.len()-1].trim().to_string();
156                continue;
157            }
158            let (k, v) = line.split_once('=').ok_or(format!("line {}: expected key = value", ln + 1))?;
159            let (k, v) = (k.trim(), v.trim());
160            let unq = |s: &str| s.trim_matches('"').trim_matches('\'').to_string();
161            let flag = v == "true";
162            match (section.as_str(), k) {
163                ("plugin", "id") => m.id = unq(v),
164                ("plugin", "name") => m.name = unq(v),
165                ("plugin", "version") => m.version = unq(v),
166                ("plugin", "api") => m.api = unq(v),
167                ("plugin", "operations") => m.operations = parse_str_array(v)?,
168                ("plugin", "timeout_ms") => m.timeout_ms = v.parse().unwrap_or(5000),
169                ("plugin", "failure_policy") => m.failure_policy = unq(v),
170                ("plugin", "deterministic") => m.deterministic = flag,
171                ("runtime", "command") => m.command = parse_str_array(v)?,
172                ("runtime", "type") => m.runtime = match unq(v).as_str() {
173                    "wasm" => PluginRuntime::Wasm,
174                    _ => PluginRuntime::Process,
175                },
176                ("extensions", k) => m.extensions.push(parse_extension(k, v)?),
177                ("permissions", key) => {
178                    let perm = match key {
179                        "repo_read" => Permission::RepoRead,
180                        "graph_read" => Permission::GraphRead,
181                        "graph_contribute" => Permission::GraphContribute,
182                        "evidence_contribute" => Permission::EvidenceContribute,
183                        "state_read" => Permission::StateRead,
184                        "state_write" => Permission::StateWrite,
185                        "network" => Permission::Network,
186                        "subprocess" => Permission::Subprocess,
187                        _ => continue,
188                    };
189                    if flag { m.permissions.push(perm); }
190                }
191                _ => {}
192            }
193        }
194        if m.id.is_empty() { return Err("missing plugin.id".into()); }
195        if m.command.is_empty() { return Err("missing runtime.command".into()); }
196        Ok(m)
197    }
198
199    /// Compatibility check (spec 41): never "try it and see whether it crashes".
200// trace:exempt reason=internal-detail
201    pub fn check_api_compatible(&self) -> Result<(), String> {
202        let major = self.api.split('.').next().unwrap_or("");
203        if major == "1" || self.api == "1" { Ok(()) } else {
204            Err(format!("plugin '{}' requires plugin API {}, host provides {}", self.id, self.api, PLUGIN_API_VERSION))
205        }
206    }
207}
208
209// trace:exempt reason=internal-detail
210fn parse_str_array(s: &str) -> Result<Vec<String>, String> {
211    let s = s.trim();
212    if !s.starts_with('[') || !s.ends_with(']') { return Err(format!("expected string array, got {s:?}")); }
213    let mut out = Vec::new();
214    for part in s[1..s.len()-1].split(',') {
215        let part = part.trim();
216        if part.is_empty() { continue; }
217        out.push(part.trim_matches('"').trim_matches('\'').to_string());
218    }
219    Ok(out)
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223// trace:exempt reason=internal-detail
224pub struct PluginRequest {
225    pub operation: String,
226    pub input: serde_json::Value,
227    pub config: serde_json::Value,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231// trace:exempt reason=internal-detail
232pub struct PluginResponse {
233    #[serde(default)]
234    pub output: serde_json::Value,
235    #[serde(default)]
236    pub error: Option<String>,
237}
238
239// trace:v1 id=impl.crates-scc-plugin-api-src-lib.plugin-manifest-extension work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
240fn parse_extension(key: &str, value: &str) -> Result<ExtensionRegistration, String> {
241    // Key: `"rank-feature:acme.id"` (quotes stripped). Value: inline table
242    // `{priority=10, after=[...], before=[...]}`; bare values default.
243    let key = key.trim_matches('"').trim_matches('\'');
244    let (extension_type, id) = key.split_once(':').ok_or(format!("bad extension key {key:?} (want \"type:id\")"))?;
245    let mut reg = ExtensionRegistration {
246        extension_type: extension_type.trim().into(),
247        id: id.trim().into(),
248        ..Default::default()
249    };
250    let body = value.trim().trim_matches(|c| c == '{' || c == '}');
251    for part in body.split(',') {
252        let part = part.trim();
253        if part.is_empty() { continue; }
254        let (k, v) = part.split_once('=').ok_or(format!("bad extension field {part:?}"))?;
255        match k.trim() {
256            "priority" => reg.priority = v.trim().parse().unwrap_or(0),
257            "after" => reg.after = parse_str_array(v)?,
258            "before" => reg.before = parse_str_array(v)?,
259            _ => {}
260        }
261    }
262    if reg.extension_type.is_empty() || reg.id.is_empty() {
263        return Err(format!("bad extension key {key:?}"));
264    }
265    Ok(reg)
266}
267
268/// WIT interface definition for the WASM Component Model host (spec §14).
269/// Checked in as the versioned ABI contract: any runtime implementing this
270/// world (wasmtime-based or otherwise) hosts `scc-plugin.wit` plugins.
271/// The JSON shapes (`PluginRequest`/`PluginResponse`) are identical to the
272/// process-plugin wire protocol, so a plugin written against these schemas
273/// runs on either runtime unchanged.
274// trace:exempt reason=internal-detail
275pub const PLUGIN_WIT: &str = include_str!("plugin.wit");
276
277#[cfg(test)]
278mod tests {
279    #[test]
280// trace:exempt reason=unit-test
281    fn manifest_parses() {
282        let m = super::PluginManifest::from_toml("[plugin]\nid = \"acme.demo\"\noperations = [\"acme.echo\"]\n[runtime]\ncommand = [\"python3\", \"p.py\"]\n[permissions]\nrepo_read = true\n").unwrap();
283        assert_eq!(m.id, "acme.demo");
284        assert_eq!(m.command, vec!["python3", "p.py"]);
285        assert!(m.check_api_compatible().is_ok());
286    }
287
288    #[test]
289// trace:exempt reason=unit-test
290    fn incompatible_api_rejected() {
291        let m = super::PluginManifest::from_toml("[plugin]\nid = \"x\"\napi = \"2\"\n[runtime]\ncommand = [\"a\"]\n").unwrap();
292        assert!(m.check_api_compatible().is_err());
293    }
294
295    #[test]
296// trace:exempt reason=unit-test
297    fn extensions_parse_with_ordering() {
298        let m = super::PluginManifest::from_toml("[plugin]\nid = \"x\"\n[runtime]\ncommand = [\"a\"]\n[extensions]\n\"rank-feature:acme.risk\" = {priority=10, after=[\"core.lex\"], before=[]}\n").unwrap();
299        assert_eq!(m.extensions.len(), 1);
300        let e = &m.extensions[0];
301        assert_eq!(e.canonical_id(), "rank-feature:acme.risk");
302        assert_eq!(e.priority, 10);
303        assert_eq!(e.after, vec!["core.lex"]);
304    }
305
306    #[test]
307// trace:exempt reason=unit-test
308    fn wit_contract_declares_plugin_world() {
309        // The checked-in WIT is the versioned ABI: world, host imports,
310        // and the three plugin exports must all be present.
311        assert!(super::PLUGIN_WIT.contains("world scc-plugin"));
312        assert!(super::PLUGIN_WIT.contains("invoke-hook"));
313        assert!(super::PLUGIN_WIT.contains("manifest: func()"));
314        assert!(super::PLUGIN_WIT.contains("register: func()"));
315    }
316}
317