Skip to main content

runx_parser/skill/
source.rs

1// rust-style-allow: large-file because skill-source validation keeps the source-kind
2// parsing, the artifact/mint coherence rules, and their error construction as one
3// cohesive unit; splitting it would scatter the source contract across files.
4use runx_contracts::{JsonObject, JsonValue};
5
6use crate::ValidationError;
7use crate::graph::{RawGraphIr, validate_graph_document};
8
9use crate::graph::MintScopeSource;
10
11use super::{
12    ActDeclaration, FIELDS, InputMode, SkillHttpSource, SkillMcpServer, SkillSource, SourceKind,
13    field_value, first_value, validate_sandbox,
14};
15
16const SOURCE_FIELDS: &[&str] = &[
17    "act",
18    "agent",
19    "agent_card_url",
20    "agent_identity",
21    "allow_private_network",
22    "args",
23    "arguments",
24    "catalog_ref",
25    "command",
26    "cwd",
27    "external_adapter",
28    "external_adapter_manifest",
29    "external_adapter_manifest_path",
30    "graph",
31    "headers",
32    "hook",
33    "http",
34    "input_mode",
35    "invocation_id",
36    "method",
37    "outputs",
38    "run_id",
39    "sandbox",
40    "server",
41    "skill_ref",
42    "task",
43    "timeout_seconds",
44    "tool",
45    "type",
46    "url",
47];
48
49pub fn validate_skill_source(
50    source: &JsonObject,
51    runx: Option<&JsonObject>,
52) -> Result<SkillSource, ValidationError> {
53    validate_source(source, runx)
54}
55
56pub(super) fn validate_source_fields(
57    source: &JsonObject,
58    field: &str,
59) -> Result<(), ValidationError> {
60    FIELDS.reject_unknown_fields(source, field, SOURCE_FIELDS)
61}
62
63pub(super) fn validate_source(
64    source: &JsonObject,
65    runx: Option<&JsonObject>,
66) -> Result<SkillSource, ValidationError> {
67    let source_type = FIELDS.required_string(source.get("type"), "source.type")?;
68    let args = FIELDS
69        .optional_string_array(source.get("args"), "source.args")?
70        .unwrap_or_default();
71    let input_mode = optional_input_mode(source.get("input_mode"))?;
72    let timeout_seconds =
73        FIELDS.optional_u64(source.get("timeout_seconds"), "source.timeout_seconds")?;
74
75    if source_type == "cli-tool" {
76        FIELDS.required_string(source.get("command"), "source.command")?;
77    }
78    validate_agent_command_boundary(source, &source_type)?;
79    let source_kind = parse_source_kind(&source_type, "source.type")?;
80
81    Ok(SkillSource {
82        command: FIELDS.optional_string(source.get("command"), "source.command")?,
83        args,
84        cwd: FIELDS.optional_string(source.get("cwd"), "source.cwd")?,
85        timeout_seconds,
86        input_mode,
87        sandbox: validate_sandbox(first_value(
88            source.get("sandbox"),
89            field_value(runx, "sandbox"),
90        ))?,
91        server: validate_mcp_server(source, &source_type)?,
92        catalog_ref: validate_catalog_ref(source, &source_type)?,
93        tool: validate_mcp_tool(source, &source_type)?,
94        arguments: FIELDS.optional_object(source.get("arguments"), "source.arguments")?,
95        agent_card_url: validate_a2a_url(source, &source_type)?,
96        agent_identity: FIELDS
97            .optional_string(source.get("agent_identity"), "source.agent_identity")?,
98        agent: validate_agent(source, &source_type)?,
99        task: validate_task(source, &source_type)?,
100        hook: validate_hook(source, &source_type)?,
101        outputs: FIELDS.optional_object(source.get("outputs"), "source.outputs")?,
102        graph: validate_graph_source(source, &source_type)?,
103        http: validate_http_source(source, &source_type)?,
104        act: validate_act_declaration(source.get("act"))?,
105        raw: source.clone(),
106        source_type: source_kind,
107    })
108}
109
110/// Validate a declared `act:` block at load: deserialize it into the typed
111/// `ActDeclaration` and fail closed if it is present but malformed, so a skill
112/// author sees the error instead of silently sealing a generic observation act.
113fn validate_act_declaration(
114    value: Option<&JsonValue>,
115) -> Result<Option<ActDeclaration>, ValidationError> {
116    let Some(value) = value else {
117        return Ok(None);
118    };
119    let act = serde_json::to_value(value)
120        .and_then(serde_json::from_value::<ActDeclaration>)
121        .map_err(|error| FIELDS.validation_error(format!("source.act is malformed: {error}")))?;
122    validate_act_authority_coherence(&act)?;
123    Ok(Some(act))
124}
125
126/// Reject incoherent authority declarations: the compute path (`mint_authority`)
127/// and the explicit pre-built path (`authority_*_from`) are mutually exclusive,
128/// and each mint source draws from exactly one place (`requested_scope` needs
129/// `requested_scope_from`; `static_scopes` must not declare it).
130fn validate_act_authority_coherence(act: &ActDeclaration) -> Result<(), ValidationError> {
131    let Some(directive) = act.mint_authority else {
132        if act.requested_scope_from.is_some() {
133            return Err(FIELDS.validation_error(
134                "source.act.requested_scope_from is only valid with a mint_authority directive.",
135            ));
136        }
137        return Ok(());
138    };
139    if act.authority_term_from.is_some()
140        || act.authority_parent_from.is_some()
141        || act.authority_subset_proof_from.is_some()
142    {
143        return Err(FIELDS.validation_error(
144            "source.act.mint_authority (compute path) is mutually exclusive with the pre-built authority_term_from / authority_parent_from / authority_subset_proof_from keys.",
145        ));
146    }
147    match directive.source {
148        MintScopeSource::StaticScopes => {
149            if act.requested_scope_from.is_some() {
150                return Err(FIELDS.validation_error(
151                    "source.act.mint_authority source static_scopes must not declare requested_scope_from.",
152                ));
153            }
154        }
155        MintScopeSource::RequestedScope => {
156            if act.requested_scope_from.is_none() {
157                return Err(FIELDS.validation_error(
158                    "source.act.mint_authority source requested_scope requires requested_scope_from.",
159                ));
160            }
161        }
162    }
163    Ok(())
164}
165
166fn parse_source_kind(value: &str, field: &str) -> Result<SourceKind, ValidationError> {
167    match value {
168        "cli-tool" => Ok(SourceKind::CliTool),
169        "mcp" => Ok(SourceKind::Mcp),
170        "catalog" => Ok(SourceKind::Catalog),
171        "a2a" => Ok(SourceKind::A2a),
172        "agent" => Ok(SourceKind::Agent),
173        "agent-task" => Ok(SourceKind::AgentStep),
174        "harness-hook" => Ok(SourceKind::HarnessHook),
175        "graph" => Ok(SourceKind::Graph),
176        "http" => Ok(SourceKind::Http),
177        "external-adapter" => Ok(SourceKind::ExternalAdapter),
178        "thread-outbox-provider" => Ok(SourceKind::ThreadOutboxProvider),
179        other => {
180            Err(FIELDS.validation_error(format!("{field} {other} is not a supported source type.")))
181        }
182    }
183}
184
185fn optional_input_mode(value: Option<&JsonValue>) -> Result<Option<InputMode>, ValidationError> {
186    let Some(value) = FIELDS.optional_string(value, "source.input_mode")? else {
187        return Ok(None);
188    };
189    match value.as_str() {
190        "args" => Ok(Some(InputMode::Args)),
191        "stdin" => Ok(Some(InputMode::Stdin)),
192        "none" => Ok(Some(InputMode::None)),
193        _ => Err(FIELDS.validation_error("source.input_mode must be args, stdin, or none.")),
194    }
195}
196
197pub(super) fn default_agent_source() -> JsonObject {
198    [("type".to_owned(), JsonValue::String("agent".to_owned()))]
199        .into_iter()
200        .collect()
201}
202
203fn validate_mcp_server(
204    source: &JsonObject,
205    source_type: &str,
206) -> Result<Option<SkillMcpServer>, ValidationError> {
207    if source_type != "mcp" {
208        return Ok(None);
209    }
210    let server = FIELDS.required_object(source.get("server"), "source.server")?;
211    Ok(Some(SkillMcpServer {
212        command: FIELDS.required_string(server.get("command"), "source.server.command")?,
213        args: FIELDS
214            .optional_string_array(server.get("args"), "source.server.args")?
215            .unwrap_or_default(),
216        cwd: FIELDS.optional_string(server.get("cwd"), "source.server.cwd")?,
217    }))
218}
219
220fn validate_mcp_tool(
221    source: &JsonObject,
222    source_type: &str,
223) -> Result<Option<String>, ValidationError> {
224    if source_type == "mcp" {
225        return Ok(Some(
226            FIELDS.required_string(source.get("tool"), "source.tool")?,
227        ));
228    }
229    FIELDS.optional_string(source.get("tool"), "source.tool")
230}
231
232fn validate_catalog_ref(
233    source: &JsonObject,
234    source_type: &str,
235) -> Result<Option<String>, ValidationError> {
236    if source_type == "catalog" {
237        return Ok(Some(FIELDS.required_string(
238            source.get("catalog_ref"),
239            "source.catalog_ref",
240        )?));
241    }
242    FIELDS.optional_string(source.get("catalog_ref"), "source.catalog_ref")
243}
244
245fn validate_a2a_url(
246    source: &JsonObject,
247    source_type: &str,
248) -> Result<Option<String>, ValidationError> {
249    if source_type == "a2a" {
250        return Ok(Some(FIELDS.required_string(
251            source.get("agent_card_url"),
252            "source.agent_card_url",
253        )?));
254    }
255    FIELDS.optional_string(source.get("agent_card_url"), "source.agent_card_url")
256}
257
258fn validate_agent(
259    source: &JsonObject,
260    source_type: &str,
261) -> Result<Option<String>, ValidationError> {
262    if source_type == "agent-task" {
263        return Ok(Some(
264            FIELDS.required_string(source.get("agent"), "source.agent")?,
265        ));
266    }
267    FIELDS.optional_string(source.get("agent"), "source.agent")
268}
269
270fn validate_task(
271    source: &JsonObject,
272    source_type: &str,
273) -> Result<Option<String>, ValidationError> {
274    if matches!(source_type, "agent-task" | "a2a") {
275        return Ok(Some(
276            FIELDS.required_string(source.get("task"), "source.task")?,
277        ));
278    }
279    FIELDS.optional_string(source.get("task"), "source.task")
280}
281
282fn validate_hook(
283    source: &JsonObject,
284    source_type: &str,
285) -> Result<Option<String>, ValidationError> {
286    if source_type == "harness-hook" {
287        return Ok(Some(
288            FIELDS.required_string(source.get("hook"), "source.hook")?,
289        ));
290    }
291    FIELDS.optional_string(source.get("hook"), "source.hook")
292}
293
294fn validate_graph_source(
295    source: &JsonObject,
296    source_type: &str,
297) -> Result<Option<crate::ExecutionGraph>, ValidationError> {
298    if source_type != "graph" {
299        return Ok(None);
300    }
301    let graph = FIELDS
302        .required_object(source.get("graph"), "source.graph")?
303        .clone();
304    validate_graph_document(graph.clone(), Some(RawGraphIr { document: graph })).map(Some)
305}
306
307fn validate_http_source(
308    source: &JsonObject,
309    source_type: &str,
310) -> Result<Option<SkillHttpSource>, ValidationError> {
311    if source_type != "http" {
312        return Ok(None);
313    }
314    let http = FIELDS
315        .optional_object(source.get("http"), "source.http")?
316        .unwrap_or_else(|| source.clone());
317    let url = FIELDS.required_string(http.get("url"), "source.url")?;
318    let method = match FIELDS.optional_string(http.get("method"), "source.method")? {
319        Some(method) => {
320            if !matches!(
321                method.to_ascii_uppercase().as_str(),
322                "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
323            ) {
324                return Err(FIELDS.validation_error(format!(
325                    "source.method {method} is not supported; use GET, POST, PUT, PATCH, or DELETE."
326                )));
327            }
328            Some(method)
329        }
330        None => None,
331    };
332    Ok(Some(SkillHttpSource {
333        url,
334        method,
335        headers: validate_http_headers(http.get("headers"))?,
336        allow_private_network: FIELDS.optional_bool(
337            http.get("allow_private_network"),
338            "source.allow_private_network",
339        )?,
340    }))
341}
342
343fn validate_http_headers(
344    value: Option<&JsonValue>,
345) -> Result<Option<std::collections::BTreeMap<String, String>>, ValidationError> {
346    let Some(value) = value else {
347        return Ok(None);
348    };
349    let object = value.as_object().ok_or_else(|| {
350        FIELDS.validation_error(
351            "source.headers must be an object of header name to value.".to_owned(),
352        )
353    })?;
354    let mut headers = std::collections::BTreeMap::new();
355    for (name, value) in object {
356        let value = value.as_str().ok_or_else(|| {
357            FIELDS.validation_error(format!("source.headers.{name} must be a string."))
358        })?;
359        headers.insert(name.clone(), value.to_owned());
360    }
361    Ok(Some(headers))
362}
363
364fn validate_agent_command_boundary(
365    source: &JsonObject,
366    source_type: &str,
367) -> Result<(), ValidationError> {
368    if matches!(source_type, "agent-task" | "harness-hook")
369        && (source.contains_key("command") || source.contains_key("args"))
370    {
371        return Err(FIELDS.validation_error(format!(
372            "{source_type} sources must not declare source.command or source.args."
373        )));
374    }
375    Ok(())
376}
377
378#[cfg(test)]
379#[allow(clippy::expect_used, clippy::unwrap_used)]
380mod tests {
381    use crate::graph::MintScopeSource;
382    use runx_contracts::JsonValue;
383
384    use super::validate_act_declaration;
385    use serde_json::json;
386
387    fn act_value(value: serde_json::Value) -> JsonValue {
388        serde_json::from_value(value).expect("convertible act value")
389    }
390
391    fn act_err(value: serde_json::Value) -> String {
392        validate_act_declaration(Some(&act_value(value)))
393            .err()
394            .map(|error| error.to_string())
395            .expect("act unexpectedly validated")
396    }
397
398    #[test]
399    fn requested_scope_mint_act_validates() {
400        let act = validate_act_declaration(Some(&act_value(json!({
401            "mint_authority": {"source": "requested_scope"},
402            "requested_scope_from": "needed_scope",
403        }))))
404        .expect("valid act")
405        .expect("present act");
406        assert_eq!(
407            act.mint_authority.map(|directive| directive.source),
408            Some(MintScopeSource::RequestedScope)
409        );
410    }
411
412    #[test]
413    fn mint_authority_conflicts_with_prebuilt_path() {
414        let message = act_err(json!({
415            "mint_authority": {"source": "static_scopes"},
416            "authority_term_from": "member_authority",
417        }));
418        assert!(message.contains("mutually exclusive"));
419    }
420
421    #[test]
422    fn requested_scope_act_requires_input_key() {
423        let message = act_err(json!({
424            "mint_authority": {"source": "requested_scope"},
425        }));
426        assert!(message.contains("requires requested_scope_from"));
427    }
428
429    #[test]
430    fn static_scopes_act_rejects_requested_scope_from() {
431        let message = act_err(json!({
432            "mint_authority": {"source": "static_scopes"},
433            "requested_scope_from": "needed_scope",
434        }));
435        assert!(message.contains("must not declare requested_scope_from"));
436    }
437
438    #[test]
439    fn dangling_requested_scope_from_in_act_is_rejected() {
440        let message = act_err(json!({
441            "requested_scope_from": "needed_scope",
442        }));
443        assert!(message.contains("only valid with a mint_authority directive"));
444    }
445}