Skip to main content

runx_runtime/execution/
prepared_skill.rs

1// rust-style-allow: large-file - prepared requests keep digest construction,
2// drift guards, approval evidence, and their security fixtures co-located.
3//! Digest-bound preparation for operator-approved skill execution.
4//!
5//! The public report is deliberately safe to print or serialize. The owned
6//! request and canonical digest preimage are private so raw input bodies and
7//! credential material cannot leak through the approval surface.
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use runx_contracts::{JsonValue, Reference, ReferenceType, sha256_prefixed};
14use runx_parser::{SkillRunnerDefinition, SkillRunnerManifest};
15use serde::{Deserialize, Serialize};
16
17use super::operator_context::{
18    SkillOperatorContextChain, SkillOperatorContextNode, SkillOperatorContextOptions,
19    load_skill_operator_context_chain,
20};
21use super::orchestrator::{LocalCredentialDescriptor, SkillRunRequest};
22use super::skill_front::SkillRunError;
23use super::skill_front::runner_manifest::{
24    load_runner_manifest, resolve_skill_dir, selected_runner,
25};
26use crate::RuntimeError;
27
28pub const PREPARED_SKILL_REPORT_SCHEMA: &str = "runx.prepared_skill_run.v1";
29pub(crate) const PREPARED_CONTEXT_DIGEST_ENV: &str = "RUNX_INTERNAL_PREPARED_CONTEXT_DIGEST";
30pub(crate) const PREPARED_APPROVAL_ACTOR_ENV: &str = "RUNX_INTERNAL_PREPARED_APPROVAL_ACTOR";
31pub(crate) const PREPARED_APPROVAL_MODE_ENV: &str = "RUNX_INTERNAL_PREPARED_APPROVAL_MODE";
32pub(crate) const PREPARED_APPROVAL_TIME_ENV: &str = "RUNX_INTERNAL_PREPARED_APPROVAL_TIME";
33pub(crate) const PREPARED_ARTIFACT_GUARDS_ENV: &str = "RUNX_INTERNAL_PREPARED_ARTIFACT_GUARDS";
34
35pub(crate) fn strip_untrusted_prepared_env(env: &mut BTreeMap<String, String>) {
36    for name in [
37        PREPARED_CONTEXT_DIGEST_ENV,
38        PREPARED_APPROVAL_ACTOR_ENV,
39        PREPARED_APPROVAL_MODE_ENV,
40        PREPARED_APPROVAL_TIME_ENV,
41        PREPARED_ARTIFACT_GUARDS_ENV,
42    ] {
43        env.remove(name);
44    }
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum PreparedSkillRunStatus {
50    Ready,
51    Blocked,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct PreparedEntryProvenance {
56    pub kind: String,
57    pub reference: Option<String>,
58    pub source: String,
59    pub source_label: String,
60    pub skill_id: Option<String>,
61    pub version: Option<String>,
62    pub digest: Option<String>,
63    pub package_digest: Option<String>,
64    pub trust_tier: Option<String>,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68pub struct PreparedInputSummary {
69    pub name: String,
70    pub value_type: String,
71    pub canonical_bytes: usize,
72    pub sha256: String,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub struct PreparedCredentialSummary {
77    pub provider: String,
78    pub auth_mode: String,
79    pub env_var: String,
80    pub material_ref_sha256: String,
81    pub scopes: Vec<String>,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PreparedRequestSummary {
86    pub skill_path: PathBuf,
87    pub cwd: PathBuf,
88    pub runner: String,
89    pub receipt_dir: Option<PathBuf>,
90    pub run_id: Option<String>,
91    pub answers_path: Option<PathBuf>,
92    pub inputs: Vec<PreparedInputSummary>,
93    pub credential: Option<PreparedCredentialSummary>,
94    pub entry: PreparedEntryProvenance,
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct PreparedGovernanceSummary {
99    pub declared_steps: usize,
100    pub conditional_steps: usize,
101    pub mutating_steps: Vec<String>,
102    pub tool_refs: Vec<String>,
103    pub authority_scopes: Vec<String>,
104    pub gates: Vec<String>,
105    pub retry_policies: Vec<String>,
106    pub idempotency_keys: Vec<String>,
107    pub recovery_notes: Vec<String>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct PreparedTraceEntry {
112    pub node_path: String,
113    pub stage: String,
114    pub outcome: String,
115    pub detail: String,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119pub struct PreparedSkillRunApproval {
120    pub actor: String,
121    pub mode: String,
122    pub observed_at: String,
123}
124
125impl PreparedSkillRunApproval {
126    #[must_use]
127    pub fn now(actor: impl Into<String>, mode: impl Into<String>) -> Self {
128        Self {
129            actor: actor.into(),
130            mode: mode.into(),
131            observed_at: crate::time::now_iso8601(),
132        }
133    }
134}
135
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137pub struct PreparedSkillRunReport {
138    pub schema: String,
139    pub status: PreparedSkillRunStatus,
140    pub digest: String,
141    pub request: PreparedRequestSummary,
142    pub governance: PreparedGovernanceSummary,
143    pub chain: Option<SkillOperatorContextChain>,
144    pub trace: Vec<PreparedTraceEntry>,
145    pub blocked_reason: Option<String>,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub(crate) struct PreparedArtifactGuard {
150    pub path: PathBuf,
151    pub sha256: String,
152}
153
154#[derive(Clone)]
155pub struct PreparedSkillRun {
156    request: SkillRunRequest,
157    selected_runner: String,
158    manifest: SkillRunnerManifest,
159    runner: SkillRunnerDefinition,
160    report: PreparedSkillRunReport,
161    guards: Vec<PreparedArtifactGuard>,
162    approval: Option<PreparedSkillRunApproval>,
163}
164
165impl std::fmt::Debug for PreparedSkillRun {
166    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        formatter
168            .debug_struct("PreparedSkillRun")
169            .field("selected_runner", &self.selected_runner)
170            .field("report", &self.report)
171            .field("guard_count", &self.guards.len())
172            .finish_non_exhaustive()
173    }
174}
175
176impl PreparedSkillRun {
177    #[must_use]
178    pub fn report(&self) -> &PreparedSkillRunReport {
179        &self.report
180    }
181
182    #[must_use]
183    pub fn digest(&self) -> &str {
184        &self.report.digest
185    }
186
187    #[must_use]
188    pub fn is_ready(&self) -> bool {
189        self.report.status == PreparedSkillRunStatus::Ready
190    }
191
192    pub fn approve(&mut self, approval: PreparedSkillRunApproval) -> Result<(), SkillRunError> {
193        if !self.is_ready() {
194            return Err(SkillRunError::Invalid(
195                self.report
196                    .blocked_reason
197                    .clone()
198                    .unwrap_or_else(|| "prepared skill run is blocked".to_owned()),
199            ));
200        }
201        self.request.env.insert(
202            PREPARED_CONTEXT_DIGEST_ENV.to_owned(),
203            self.report.digest.clone(),
204        );
205        self.request.env.insert(
206            PREPARED_APPROVAL_ACTOR_ENV.to_owned(),
207            approval.actor.clone(),
208        );
209        self.request
210            .env
211            .insert(PREPARED_APPROVAL_MODE_ENV.to_owned(), approval.mode.clone());
212        self.request.env.insert(
213            PREPARED_APPROVAL_TIME_ENV.to_owned(),
214            approval.observed_at.clone(),
215        );
216        let guards = self
217            .guards
218            .iter()
219            .map(|guard| {
220                let path = fs::canonicalize(&guard.path).unwrap_or_else(|_| guard.path.clone());
221                (path.to_string_lossy().into_owned(), guard.sha256.clone())
222            })
223            .collect::<BTreeMap<_, _>>();
224        let encoded = serde_json::to_string(&guards)
225            .map_err(|source| RuntimeError::json("serializing prepared artifact guards", source))?;
226        self.request
227            .env
228            .insert(PREPARED_ARTIFACT_GUARDS_ENV.to_owned(), encoded);
229        self.approval = Some(approval);
230        Ok(())
231    }
232
233    #[must_use]
234    pub fn approval(&self) -> Option<&PreparedSkillRunApproval> {
235        self.approval.as_ref()
236    }
237
238    pub(crate) fn request(&self) -> &SkillRunRequest {
239        &self.request
240    }
241
242    pub(crate) fn selected_runner(&self) -> &str {
243        &self.selected_runner
244    }
245
246    pub(crate) fn manifest(&self) -> &SkillRunnerManifest {
247        &self.manifest
248    }
249
250    pub(crate) fn runner(&self) -> &SkillRunnerDefinition {
251        &self.runner
252    }
253
254    pub(crate) fn verify_artifacts(&self) -> Result<(), SkillRunError> {
255        for guard in &self.guards {
256            let content = fs::read(&guard.path).map_err(|source| {
257                RuntimeError::io(
258                    format!("verifying prepared artifact {}", guard.path.display()),
259                    source,
260                )
261            })?;
262            let actual = sha256_prefixed(&content);
263            if actual != guard.sha256 {
264                return Err(SkillRunError::Invalid(format!(
265                    "prepared artifact drift at {}: expected {}, actual {}",
266                    guard.path.display(),
267                    guard.sha256,
268                    actual
269                )));
270            }
271        }
272        Ok(())
273    }
274}
275
276#[derive(Serialize)]
277struct PreparedDigestPreimage<'a> {
278    schema: &'static str,
279    skill_path: &'a Path,
280    cwd: &'a Path,
281    runner: &'a str,
282    receipt_dir: Option<&'a Path>,
283    run_id: Option<&'a str>,
284    answers_path: Option<&'a Path>,
285    inputs: &'a BTreeMap<String, JsonValue>,
286    credential: Option<PreparedCredentialSummary>,
287    entry: &'a PreparedEntryProvenance,
288    chain: Option<&'a SkillOperatorContextChain>,
289    blocked_reason: Option<&'a str>,
290}
291
292// rust-style-allow: long-function - preparation builds one canonical snapshot,
293// trace, digest preimage, governance summary, and guard set atomically.
294pub fn prepare_skill_run(
295    mut request: SkillRunRequest,
296    selected_runner_name: Option<&str>,
297    entry: PreparedEntryProvenance,
298) -> Result<PreparedSkillRun, SkillRunError> {
299    strip_untrusted_prepared_env(&mut request.env);
300    let skill_dir = resolve_skill_dir(&request.skill_path)?;
301    let manifest = load_runner_manifest(&skill_dir)?;
302    let runner = selected_runner(&manifest, selected_runner_name)?.clone();
303    let request_summary = request_summary(&request, &skill_dir, &runner.name, entry);
304    let missing = runner
305        .inputs
306        .iter()
307        .filter(|(name, input)| {
308            input.required && input.default.is_none() && !request.inputs.contains_key(*name)
309        })
310        .map(|(name, _)| name.clone())
311        .collect::<Vec<_>>();
312
313    let mut trace = vec![PreparedTraceEntry {
314        node_path: "entry".to_owned(),
315        stage: "resolve_runner".to_owned(),
316        outcome: "resolved".to_owned(),
317        detail: format!("selected runner {}", runner.name),
318    }];
319    let (status, chain, blocked_reason) = if missing.is_empty() {
320        match load_skill_operator_context_chain(
321            &skill_dir,
322            Some(&runner.name),
323            SkillOperatorContextOptions::new(request.env.clone(), request.cwd.clone()),
324        ) {
325            Ok(chain) => {
326                trace.push(PreparedTraceEntry {
327                    node_path: "entry".to_owned(),
328                    stage: "expand_chain".to_owned(),
329                    outcome: "resolved".to_owned(),
330                    detail: format!("expanded {} nodes", chain.node_count),
331                });
332                (PreparedSkillRunStatus::Ready, Some(chain), None)
333            }
334            Err(error) => {
335                let reason = error.to_string();
336                trace.push(PreparedTraceEntry {
337                    node_path: trace_node_path(&reason),
338                    stage: "expand_chain".to_owned(),
339                    outcome: "blocked".to_owned(),
340                    detail: reason.clone(),
341                });
342                (PreparedSkillRunStatus::Blocked, None, Some(reason))
343            }
344        }
345    } else {
346        let reason = format!("missing required inputs: {}", missing.join(", "));
347        trace.push(PreparedTraceEntry {
348            node_path: "entry".to_owned(),
349            stage: "validate_inputs".to_owned(),
350            outcome: "blocked".to_owned(),
351            detail: reason.clone(),
352        });
353        (PreparedSkillRunStatus::Blocked, None, Some(reason))
354    };
355
356    let governance = chain.as_ref().map(governance_summary).unwrap_or_default();
357    let preimage = PreparedDigestPreimage {
358        schema: PREPARED_SKILL_REPORT_SCHEMA,
359        skill_path: &request_summary.skill_path,
360        cwd: &request_summary.cwd,
361        runner: &request_summary.runner,
362        receipt_dir: request_summary.receipt_dir.as_deref(),
363        run_id: request_summary.run_id.as_deref(),
364        answers_path: request_summary.answers_path.as_deref(),
365        inputs: &request.inputs,
366        credential: request_summary.credential.clone(),
367        entry: &request_summary.entry,
368        chain: chain.as_ref(),
369        blocked_reason: blocked_reason.as_deref(),
370    };
371    let bytes = serde_json::to_vec(&preimage)
372        .map_err(|source| RuntimeError::json("serializing prepared skill digest", source))?;
373    let digest = sha256_prefixed(&bytes);
374    let guards = chain.as_ref().map(artifact_guards).unwrap_or_default();
375    Ok(PreparedSkillRun {
376        request,
377        selected_runner: runner.name.clone(),
378        manifest,
379        runner,
380        report: PreparedSkillRunReport {
381            schema: PREPARED_SKILL_REPORT_SCHEMA.to_owned(),
382            status,
383            digest,
384            request: request_summary,
385            governance,
386            chain,
387            trace,
388            blocked_reason,
389        },
390        guards,
391        approval: None,
392    })
393}
394
395pub(crate) fn prepared_receipt_references(env: &BTreeMap<String, String>) -> Vec<Reference> {
396    let Some(digest) = env.get(PREPARED_CONTEXT_DIGEST_ENV) else {
397        return Vec::new();
398    };
399    let digest_id = digest.strip_prefix("sha256:").unwrap_or(digest);
400    let artifact = Reference {
401        reference_type: ReferenceType::Artifact,
402        uri: format!("runx:artifact:operator_context:{digest_id}").into(),
403        provider: Some("runx".to_owned().into()),
404        locator: Some(digest.clone().into()),
405        label: Some("approved operator context".to_owned().into()),
406        observed_at: None,
407        proof_kind: None,
408    };
409    let actor = env
410        .get(PREPARED_APPROVAL_ACTOR_ENV)
411        .map(String::as_str)
412        .unwrap_or("local_operator");
413    let mode = env
414        .get(PREPARED_APPROVAL_MODE_ENV)
415        .map(String::as_str)
416        .unwrap_or("explicit_digest");
417    let decision = Reference {
418        reference_type: ReferenceType::Decision,
419        uri: format!("runx:decision:operator_context_approval:{digest_id}").into(),
420        provider: Some("runx".to_owned().into()),
421        locator: Some(format!("actor={actor};mode={mode}").into()),
422        label: Some("operator context approval".to_owned().into()),
423        observed_at: env.get(PREPARED_APPROVAL_TIME_ENV).cloned().map(Into::into),
424        proof_kind: None,
425    };
426    vec![artifact, decision]
427}
428
429pub(crate) fn verify_prepared_artifact_at_use(
430    env: &BTreeMap<String, String>,
431    path: &Path,
432) -> Result<(), RuntimeError> {
433    let Some(encoded) = env.get(PREPARED_ARTIFACT_GUARDS_ENV) else {
434        return Ok(());
435    };
436    let guards = serde_json::from_str::<BTreeMap<String, String>>(encoded)
437        .map_err(|source| RuntimeError::json("parsing prepared artifact guards", source))?;
438    let canonical = fs::canonicalize(path).map_err(|source| {
439        RuntimeError::io(
440            format!("canonicalizing prepared artifact {}", path.display()),
441            source,
442        )
443    })?;
444    let key = canonical.to_string_lossy();
445    let Some(expected) = guards.get(key.as_ref()) else {
446        return Ok(());
447    };
448    let content = fs::read(&canonical).map_err(|source| {
449        RuntimeError::io(
450            format!("verifying prepared artifact {} at use", canonical.display()),
451            source,
452        )
453    })?;
454    let actual = sha256_prefixed(&content);
455    if &actual != expected {
456        return Err(RuntimeError::SkillFailed {
457            skill_name: "prepared-run".to_owned(),
458            message: format!(
459                "prepared artifact drift at use boundary {}: expected {}, actual {}",
460                canonical.display(),
461                expected,
462                actual
463            ),
464        });
465    }
466    Ok(())
467}
468
469fn request_summary(
470    request: &SkillRunRequest,
471    skill_dir: &Path,
472    runner: &str,
473    mut entry: PreparedEntryProvenance,
474) -> PreparedRequestSummary {
475    if entry.kind.is_empty() {
476        entry.kind = "local_path".to_owned();
477    }
478    if entry.source.is_empty() {
479        entry.source = "local-path".to_owned();
480    }
481    if entry.source_label.is_empty() {
482        entry.source_label = skill_dir.to_string_lossy().into_owned();
483    }
484    PreparedRequestSummary {
485        skill_path: skill_dir.to_path_buf(),
486        cwd: request.cwd.clone(),
487        runner: runner.to_owned(),
488        receipt_dir: request.receipt_dir.clone(),
489        run_id: request.run_id.clone(),
490        answers_path: request.answers_path.clone(),
491        inputs: request
492            .inputs
493            .iter()
494            .map(|(name, value)| {
495                let bytes = serde_json::to_vec(value).unwrap_or_default();
496                PreparedInputSummary {
497                    name: name.clone(),
498                    value_type: json_type(value).to_owned(),
499                    canonical_bytes: bytes.len(),
500                    sha256: sha256_prefixed(&bytes),
501                }
502            })
503            .collect(),
504        credential: request.local_credential.as_ref().map(credential_summary),
505        entry,
506    }
507}
508
509fn credential_summary(value: &LocalCredentialDescriptor) -> PreparedCredentialSummary {
510    PreparedCredentialSummary {
511        provider: value.provider.clone(),
512        auth_mode: value.auth_mode.clone(),
513        env_var: value.env_var.clone(),
514        material_ref_sha256: sha256_prefixed(value.material_ref.as_bytes()),
515        scopes: value.scopes.clone(),
516    }
517}
518
519fn json_type(value: &JsonValue) -> &'static str {
520    match value {
521        JsonValue::Null => "null",
522        JsonValue::Bool(_) => "boolean",
523        JsonValue::Number(_) => "number",
524        JsonValue::String(_) => "string",
525        JsonValue::Array(_) => "array",
526        JsonValue::Object(_) => "object",
527    }
528}
529
530fn governance_summary(chain: &SkillOperatorContextChain) -> PreparedGovernanceSummary {
531    let mut summary = PreparedGovernanceSummary::default();
532    summarize_node(&chain.entry, &mut summary);
533    summary.mutating_steps.sort();
534    summary.tool_refs.sort();
535    summary.tool_refs.dedup();
536    summary.authority_scopes.sort();
537    summary.authority_scopes.dedup();
538    summary.gates.sort();
539    summary.gates.dedup();
540    summary.retry_policies.sort();
541    summary.retry_policies.dedup();
542    summary.idempotency_keys.sort();
543    summary.idempotency_keys.dedup();
544    summary
545}
546
547fn summarize_node(node: &SkillOperatorContextNode, summary: &mut PreparedGovernanceSummary) {
548    for step in &node.steps {
549        if json_field(&step.raw, "when").is_some() {
550            summary.conditional_steps += 1;
551        } else {
552            summary.declared_steps += 1;
553        }
554        if step.mutating {
555            summary.mutating_steps.push(step.node_path.clone());
556        }
557        summary.tool_refs.extend(step.tool_refs.iter().cloned());
558        collect_string_values(&step.raw, "authority", &mut summary.authority_scopes);
559        collect_string_values(&step.raw, "approval", &mut summary.gates);
560        collect_string_values(&step.raw, "gate", &mut summary.gates);
561        collect_string_values(&step.raw, "retry", &mut summary.retry_policies);
562        collect_string_values(&step.raw, "idempotency_key", &mut summary.idempotency_keys);
563        collect_string_values(&step.raw, "recovery", &mut summary.recovery_notes);
564        if let Some(child) = &step.child {
565            summarize_node(child, summary);
566        }
567    }
568}
569
570fn collect_string_values(value: &JsonValue, key: &str, output: &mut Vec<String>) {
571    if let Some(value) = json_field(value, key) {
572        match value {
573            JsonValue::String(value) => output.push(value.clone()),
574            JsonValue::Array(values) => output.extend(
575                values
576                    .iter()
577                    .filter_map(JsonValue::as_str)
578                    .map(str::to_owned),
579            ),
580            other => output.push(
581                serde_json::to_string(other).unwrap_or_else(|_| "<unserializable>".to_owned()),
582            ),
583        }
584    }
585}
586
587fn json_field<'a>(value: &'a JsonValue, key: &str) -> Option<&'a JsonValue> {
588    match value {
589        JsonValue::Object(object) => object.get(key),
590        _ => None,
591    }
592}
593
594fn artifact_guards(chain: &SkillOperatorContextChain) -> Vec<PreparedArtifactGuard> {
595    let mut guards = BTreeMap::<PathBuf, String>::new();
596    collect_node_guards(&chain.entry, &mut guards);
597    guards
598        .into_iter()
599        .map(|(path, sha256)| PreparedArtifactGuard { path, sha256 })
600        .collect()
601}
602
603fn collect_node_guards(node: &SkillOperatorContextNode, guards: &mut BTreeMap<PathBuf, String>) {
604    if let Some(path) = &node.skill_markdown.path {
605        guards.insert(path.clone(), node.skill_markdown.sha256.clone());
606    }
607    let manifest_path = node.package.directory.join("X.yaml");
608    if let Ok(content) = fs::read(&manifest_path) {
609        guards.insert(manifest_path, sha256_prefixed(&content));
610    }
611    for tool in &node.tools {
612        if let (Some(path), Some(sha256)) = (&tool.path, &tool.sha256) {
613            guards.insert(path.clone(), sha256.clone());
614        }
615    }
616    for step in &node.steps {
617        for context in &step.context_skills {
618            if let Some(path) = &context.document.path {
619                guards.insert(path.clone(), context.document.sha256.clone());
620            }
621        }
622        if let Some(child) = &step.child {
623            collect_node_guards(child, guards);
624        }
625    }
626}
627
628fn trace_node_path(message: &str) -> String {
629    message
630        .split_whitespace()
631        .find(|word| word.starts_with("entry"))
632        .map(|word| word.trim_matches(|value: char| !value.is_alphanumeric() && value != '.'))
633        .filter(|word| !word.is_empty())
634        .unwrap_or("entry")
635        .to_owned()
636}
637
638#[cfg(test)]
639mod tests {
640    use std::error::Error;
641
642    use tempfile::tempdir;
643
644    use super::*;
645
646    fn write_skill(directory: &Path, inputs: &str, body: &str) -> Result<(), Box<dyn Error>> {
647        fs::create_dir_all(directory)?;
648        fs::write(directory.join("SKILL.md"), body)?;
649        fs::write(
650            directory.join("X.yaml"),
651            format!(
652                "skill: prepared\nrunners:\n  main:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: review\n{inputs}"
653            ),
654        )?;
655        Ok(())
656    }
657
658    fn request(path: &Path) -> SkillRunRequest {
659        SkillRunRequest {
660            skill_path: path.to_path_buf(),
661            receipt_dir: None,
662            run_id: None,
663            answers_path: None,
664            inputs: BTreeMap::new(),
665            env: BTreeMap::new(),
666            cwd: path.to_path_buf(),
667            local_credential: None,
668        }
669    }
670
671    #[test]
672    fn prepared_skill_digest_is_deterministic_and_binds_inputs() -> Result<(), Box<dyn Error>> {
673        let temp = tempdir()?;
674        write_skill(temp.path(), "", "# Prepared")?;
675        let first = prepare_skill_run(
676            request(temp.path()),
677            None,
678            PreparedEntryProvenance::default(),
679        )?;
680        let second = prepare_skill_run(
681            request(temp.path()),
682            None,
683            PreparedEntryProvenance::default(),
684        )?;
685        assert!(first.is_ready());
686        assert_eq!(first.digest(), second.digest());
687        let mut changed = request(temp.path());
688        changed
689            .inputs
690            .insert("prompt".to_owned(), JsonValue::String("changed".to_owned()));
691        let changed = prepare_skill_run(changed, None, PreparedEntryProvenance::default())?;
692        assert_ne!(first.digest(), changed.digest());
693        Ok(())
694    }
695
696    #[test]
697    fn prepared_skill_missing_input_returns_blocked_trace() -> Result<(), Box<dyn Error>> {
698        let temp = tempdir()?;
699        write_skill(
700            temp.path(),
701            "    inputs:\n      prompt:\n        type: string\n        required: true\n",
702            "# Prepared",
703        )?;
704        let prepared = prepare_skill_run(
705            request(temp.path()),
706            None,
707            PreparedEntryProvenance::default(),
708        )?;
709        assert_eq!(prepared.report().status, PreparedSkillRunStatus::Blocked);
710        assert!(
711            prepared
712                .report()
713                .blocked_reason
714                .as_deref()
715                .unwrap_or_default()
716                .contains("prompt")
717        );
718        assert!(
719            prepared
720                .report()
721                .trace
722                .iter()
723                .any(|entry| entry.outcome == "blocked")
724        );
725        Ok(())
726    }
727
728    #[test]
729    fn prepared_skill_secret_never_appears_in_public_output_or_debug() -> Result<(), Box<dyn Error>>
730    {
731        let temp = tempdir()?;
732        write_skill(temp.path(), "", "# Prepared")?;
733        let sentinel = "SECRET-SENTINEL-DO-NOT-PRINT";
734        let mut request = request(temp.path());
735        request.local_credential = Some(LocalCredentialDescriptor {
736            provider: "example".to_owned(),
737            auth_mode: "token".to_owned(),
738            env_var: "EXAMPLE_TOKEN".to_owned(),
739            material_ref: "opaque-material".to_owned(),
740            scopes: vec!["read".to_owned()],
741            secret: sentinel.to_owned(),
742        });
743        let prepared = prepare_skill_run(request, None, PreparedEntryProvenance::default())?;
744        let public = serde_json::to_string(prepared.report())?;
745        assert!(!public.contains(sentinel));
746        assert!(!format!("{prepared:?}").contains(sentinel));
747        Ok(())
748    }
749
750    #[test]
751    fn prepared_skill_strict_tool_resolution_blocks_with_trace() -> Result<(), Box<dyn Error>> {
752        let temp = tempdir()?;
753        fs::create_dir_all(temp.path())?;
754        fs::write(temp.path().join("SKILL.md"), "# Prepared")?;
755        fs::write(
756            temp.path().join("X.yaml"),
757            "skill: prepared\nrunners:\n  main:\n    default: true\n    type: graph\n    graph:\n      name: prepared\n      steps:\n        - id: call\n          tool: missing.tool\n",
758        )?;
759        let prepared = prepare_skill_run(
760            request(temp.path()),
761            None,
762            PreparedEntryProvenance::default(),
763        )?;
764        assert_eq!(prepared.report().status, PreparedSkillRunStatus::Blocked);
765        assert!(
766            prepared
767                .report()
768                .blocked_reason
769                .as_deref()
770                .unwrap_or_default()
771                .contains("missing.tool")
772        );
773        Ok(())
774    }
775
776    #[test]
777    fn prepared_skill_execution_matches_unprepared_and_rejects_drift() -> Result<(), Box<dyn Error>>
778    {
779        use crate::execution::orchestrator::LocalOrchestrator;
780
781        let temp = tempdir()?;
782        write_skill(temp.path(), "", "# Prepared")?;
783        let orchestrator = LocalOrchestrator::default();
784        let baseline_request = request(temp.path());
785        let baseline = orchestrator.run_skill(&baseline_request)?;
786        let mut prepared = orchestrator.prepare_skill(
787            request(temp.path()),
788            None,
789            PreparedEntryProvenance::default(),
790        )?;
791        prepared.approve(PreparedSkillRunApproval::now("test", "explicit_digest"))?;
792        let prepared_result = orchestrator.run_prepared_skill(&prepared)?;
793        assert_eq!(baseline.status, prepared_result.status);
794
795        fs::write(temp.path().join("SKILL.md"), "# Changed after approval")?;
796        let error = orchestrator
797            .run_prepared_skill(&prepared)
798            .expect_err("drift must fail");
799        let message = error.to_string();
800        assert!(message.contains("prepared artifact drift"));
801        assert!(message.contains("SKILL.md"));
802        assert!(message.contains("expected sha256:"));
803        assert!(message.contains("actual sha256:"));
804        Ok(())
805    }
806
807    #[test]
808    fn prepared_skill_execution_rejects_child_drift_at_load_boundary() -> Result<(), Box<dyn Error>>
809    {
810        use crate::execution::graph::{StepSkillLoadOptions, load_step_skill};
811
812        let temp = tempdir()?;
813        let entry = temp.path().join("entry");
814        let child = entry.join("child");
815        fs::create_dir_all(&child)?;
816        fs::write(entry.join("SKILL.md"), "# Entry")?;
817        fs::write(child.join("SKILL.md"), "# Child")?;
818        fs::write(
819            child.join("X.yaml"),
820            "skill: child\nrunners:\n  child:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: before\n",
821        )?;
822        fs::write(
823            entry.join("X.yaml"),
824            "skill: entry\nrunners:\n  main:\n    default: true\n    type: graph\n    graph:\n      name: entry\n      steps:\n        - id: child\n          skill: ./child\n",
825        )?;
826        let mut prepared =
827            prepare_skill_run(request(&entry), None, PreparedEntryProvenance::default())?;
828        prepared.approve(PreparedSkillRunApproval::now("test", "explicit_digest"))?;
829        fs::write(
830            child.join("X.yaml"),
831            "skill: child\nrunners:\n  child:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: after\n",
832        )?;
833        let step = &prepared
834            .runner
835            .source
836            .graph
837            .as_ref()
838            .ok_or("missing graph")?
839            .steps[0];
840        let error = match load_step_skill(
841            &entry,
842            step,
843            StepSkillLoadOptions {
844                env: &prepared.request.env,
845            },
846        ) {
847            Ok(_) => return Err("child drift must fail at load boundary".into()),
848            Err(error) => error,
849        };
850        assert!(error.to_string().contains("drift at use boundary"));
851        assert!(error.to_string().contains("child/X.yaml"));
852        Ok(())
853    }
854
855    #[test]
856    fn prepared_skill_receipt_binds_context_artifact_and_approval_decision()
857    -> Result<(), Box<dyn Error>> {
858        use crate::adapter::{InvocationStatus, SkillOutput};
859        use crate::execution::output_projection::project_step_output;
860        use crate::receipts::{
861            RuntimeReceiptSignaturePolicy, StepSeal, StepSealClosure, seal_step,
862        };
863        use runx_contracts::ClosureDisposition;
864
865        let mut env = BTreeMap::new();
866        env.insert(
867            PREPARED_CONTEXT_DIGEST_ENV.to_owned(),
868            "sha256:abc123".to_owned(),
869        );
870        env.insert(
871            PREPARED_APPROVAL_ACTOR_ENV.to_owned(),
872            "test-operator".to_owned(),
873        );
874        env.insert(
875            PREPARED_APPROVAL_MODE_ENV.to_owned(),
876            "explicit_digest".to_owned(),
877        );
878        env.insert(
879            PREPARED_APPROVAL_TIME_ENV.to_owned(),
880            "2026-07-12T00:00:00Z".to_owned(),
881        );
882        let output = SkillOutput {
883            status: InvocationStatus::Success,
884            stdout: "{}".to_owned(),
885            stderr: String::new(),
886            exit_code: Some(0),
887            duration_ms: 0,
888            metadata: BTreeMap::new(),
889        };
890        let projection = project_step_output(&output);
891        let receipt = seal_step(
892            StepSeal {
893                graph_name: "prepared",
894                step_id: "execute",
895                attempt: 1,
896                output: &output,
897                projection: &projection,
898                created_at: "2026-07-12T00:00:00Z",
899                authority_grant_refs: Vec::new(),
900                operator_refs: prepared_receipt_references(&env),
901                closure: Some(StepSealClosure {
902                    disposition: ClosureDisposition::Closed,
903                    reason_code: "prepared_complete".to_owned(),
904                    summary: "prepared run completed".to_owned(),
905                }),
906            },
907            RuntimeReceiptSignaturePolicy::local_development(),
908        )?;
909        let refs = &receipt.acts[0].artifact_refs;
910        assert!(refs.iter().any(|reference| {
911            reference.reference_type == ReferenceType::Artifact
912                && reference.uri.as_str().contains("operator_context:abc123")
913        }));
914        assert!(refs.iter().any(|reference| {
915            reference.reference_type == ReferenceType::Decision
916                && reference
917                    .locator
918                    .as_ref()
919                    .is_some_and(|value| value.as_str().contains("test-operator"))
920        }));
921        assert!(
922            receipt.seal.criteria[0]
923                .verification_refs
924                .iter()
925                .any(|reference| reference.reference_type == ReferenceType::Decision)
926        );
927        assert!(prepared_receipt_references(&BTreeMap::new()).is_empty());
928        Ok(())
929    }
930
931    #[test]
932    fn prepared_skill_untrusted_env_cannot_forge_receipt_references() {
933        let mut env = BTreeMap::from([
934            (
935                PREPARED_CONTEXT_DIGEST_ENV.to_owned(),
936                "sha256:forged".to_owned(),
937            ),
938            (
939                PREPARED_APPROVAL_ACTOR_ENV.to_owned(),
940                "attacker".to_owned(),
941            ),
942            (PREPARED_APPROVAL_MODE_ENV.to_owned(), "forged".to_owned()),
943            (
944                PREPARED_APPROVAL_TIME_ENV.to_owned(),
945                "2026-07-12T00:00:00Z".to_owned(),
946            ),
947        ]);
948        strip_untrusted_prepared_env(&mut env);
949        assert!(prepared_receipt_references(&env).is_empty());
950        assert!(
951            !env.keys()
952                .any(|key| key.starts_with("RUNX_INTERNAL_PREPARED_"))
953        );
954    }
955
956    #[cfg(feature = "cli-tool")]
957    #[test]
958    fn prepared_skill_unprepared_receipt_rejects_forged_internal_env() -> Result<(), Box<dyn Error>>
959    {
960        use crate::execution::orchestrator::LocalOrchestrator;
961
962        let temp = tempdir()?;
963        fs::write(temp.path().join("SKILL.md"), "# Unprepared")?;
964        fs::write(
965            temp.path().join("X.yaml"),
966            "skill: unprepared\nrunners:\n  main:\n    default: true\n    type: cli-tool\n    command: \"true\"\n    args: []\n",
967        )?;
968        let mut request = request(temp.path());
969        request.env.extend([
970            (
971                PREPARED_CONTEXT_DIGEST_ENV.to_owned(),
972                "sha256:forged".to_owned(),
973            ),
974            (
975                PREPARED_APPROVAL_ACTOR_ENV.to_owned(),
976                "attacker".to_owned(),
977            ),
978            (PREPARED_APPROVAL_MODE_ENV.to_owned(), "forged".to_owned()),
979            (
980                PREPARED_APPROVAL_TIME_ENV.to_owned(),
981                "2026-07-12T00:00:00Z".to_owned(),
982            ),
983        ]);
984        let result = LocalOrchestrator::default().run_skill(&request)?;
985        let output = serde_json::to_string(&result.output)?;
986        assert!(!output.contains("operator_context"));
987        assert!(!output.contains("attacker"));
988        assert!(!output.contains("forged"));
989        Ok(())
990    }
991}