1use 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
292pub 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}