Skip to main content

vsh/
runtime.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, MutexGuard};
6use std::time::Instant;
7
8use vsh_commit::{
9    CommitConfig, CommitError, CommitPlan, CommitPlanError, CommitReceipt, Committer,
10    RecoveryReport, SnapshotLimits,
11};
12use vsh_monty::{
13    ExecutionError, ExecutionLimits, ExecutionOutcome, ExecutionStats, InProcessConfig,
14    InProcessMonty, MontyObject, ResultCompatibility, ResultCompatibilityError, SubprocessConfig,
15    SubprocessMonty, VirtualRoot, validate_result_compatibility,
16};
17use vsh_policy::{
18    AccessKind, DeniedAccess, DenyManifest, PolicyDecision, PolicyInput, PolicyProfile,
19    RiskManifest, RiskMetrics, TransactionIdentityInput, TransactionPolicy, bind_transaction,
20};
21use vsh_store::{
22    ApprovalGrant, ApprovalGrantError, BlobStore, BlobStoreError, DataDirectory,
23    DataDirectoryError, FileStoreConfig, FileTransactionStore, TransactionRecord, TransactionStore,
24    TransactionStoreError,
25};
26use vsh_types::{
27    DiffDigest, DiffEntry, RuntimeConfigDigest, SnapshotId, TransactionId, TransactionState,
28};
29use vsh_vfs::{CanonicalDiff, VfsError, VirtualFs};
30
31use crate::artifact::{
32    ArtifactError, PendingTransaction, ReviewEvidence, decode_pending, encode_pending,
33};
34use crate::hook::{
35    CommitPreparation, CommitResolution, HookBaseline, HookConfig, HookDecision,
36    HookDecisionRecord, HookHandlerError, HookVerdict, RequestEvent,
37};
38
39/// Request-scoped resource caps enforced by the Monty/VFS adapter.
40pub type ExecutionBudget = ExecutionLimits;
41
42/// Hard allocation and cardinality bounds for durable approval artifacts.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct ArtifactLimits {
45    /// Maximum complete encoded artifact bytes.
46    pub max_bytes: usize,
47    /// Maximum postcard-encoded Monty result bytes.
48    pub max_value_bytes: usize,
49    /// Maximum retained UTF-8 stdout bytes.
50    pub max_stdout_bytes: usize,
51    /// Maximum canonical changed paths.
52    pub max_entries: usize,
53    /// Maximum read or write dependency entries.
54    pub max_dependencies: usize,
55    /// Maximum one-path UTF-8 byte length.
56    pub max_path_bytes: usize,
57    /// Maximum retained out-of-band intent bytes exposed to a hook.
58    pub max_intent_bytes: usize,
59    /// Maximum ordered operation-level effects exposed to a hook.
60    pub max_effects: usize,
61    /// Maximum process-local auto-approved previews retained by one runtime.
62    pub max_ephemeral_entries: usize,
63    /// Maximum encoded bytes retained by process-local auto-approved previews.
64    pub max_ephemeral_bytes: usize,
65}
66
67impl Default for ArtifactLimits {
68    fn default() -> Self {
69        Self {
70            max_bytes: 128 * 1024 * 1024,
71            max_value_bytes: 16 * 1024 * 1024,
72            max_stdout_bytes: 16 * 1024 * 1024,
73            max_entries: 100_000,
74            max_dependencies: 250_000,
75            max_path_bytes: 16 * 1024,
76            max_intent_bytes: 64 * 1024,
77            max_effects: 250_000,
78            max_ephemeral_entries: 64,
79            max_ephemeral_bytes: 128 * 1024 * 1024,
80        }
81    }
82}
83
84/// Whether one call stops after policy or commits deterministic auto-approvals.
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum RunMode {
87    /// Produce an approval-bound virtual result without changing the host workspace.
88    #[default]
89    Preview,
90    /// Commit only when deterministic policy returns `AutoApprove`.
91    Auto,
92}
93
94/// Amount of canonical change detail retained in a receipt.
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
96pub enum ReceiptDetail {
97    /// Retain bounded counts and digests, but no per-path diff entries.
98    #[default]
99    Compact,
100    /// Retain the complete bounded canonical diff.
101    Full,
102}
103
104/// One borrowed native execution request.
105#[derive(Clone, Copy, Debug)]
106pub struct RunRequest<'a> {
107    /// Exact Monty source executed against virtual state.
108    pub code: &'a str,
109    /// Optional out-of-band intent bound into transaction identity.
110    pub intent: Option<&'a str>,
111    /// Preview-only or deterministic auto-commit behavior.
112    pub mode: RunMode,
113    /// Compact or complete canonical change detail.
114    pub detail: ReceiptDetail,
115    /// Independent execution caps for this request.
116    pub budget: ExecutionBudget,
117}
118
119impl<'a> RunRequest<'a> {
120    /// Construct a safe preview request with default resource caps.
121    #[must_use]
122    pub fn new(code: &'a str) -> Self {
123        Self {
124            code,
125            intent: None,
126            mode: RunMode::Preview,
127            detail: ReceiptDetail::Compact,
128            budget: ExecutionBudget::default(),
129        }
130    }
131
132    /// Bind an out-of-band intent to this request.
133    #[must_use]
134    pub const fn with_intent(mut self, intent: &'a str) -> Self {
135        self.intent = Some(intent);
136        self
137    }
138
139    /// Select preview or deterministic auto-commit behavior.
140    #[must_use]
141    pub const fn with_mode(mut self, mode: RunMode) -> Self {
142        self.mode = mode;
143        self
144    }
145
146    /// Select compact or complete receipt detail.
147    #[must_use]
148    pub const fn with_detail(mut self, detail: ReceiptDetail) -> Self {
149        self.detail = detail;
150        self
151    }
152
153    /// Replace all request-scoped execution caps.
154    #[must_use]
155    pub const fn with_budget(mut self, budget: ExecutionBudget) -> Self {
156        self.budget = budget;
157        self
158    }
159}
160
161/// Deterministic policy result retained in the native receipt.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum RuntimeDecision {
164    /// Deterministic policy rejected the transaction.
165    Denied(DenyManifest),
166    /// Deterministic policy authorized reservation without a judge.
167    AutoApproved,
168    /// An exact independent approval is required before reservation.
169    PendingApproval(RiskManifest),
170}
171
172/// Monotonic stage costs recorded without string allocation in the hot path.
173#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
174pub struct StageTimings {
175    /// Capability-rooted metadata snapshot time.
176    pub snapshot_ns: u64,
177    /// Monty execution plus typed VFS-call time.
178    pub execute_ns: u64,
179    /// Canonical-diff freeze time.
180    pub diff_ns: u64,
181    /// Deterministic-policy evaluation time.
182    pub policy_ns: u64,
183    /// Artifact binding and short state-store transitions.
184    pub bind_and_store_ns: u64,
185    /// Reservation, dependency revalidation, commit, and verification time.
186    pub commit_ns: u64,
187    /// Complete native call time.
188    pub total_ns: u64,
189}
190
191/// Compact proof of virtual execution, policy, and optional verified commit.
192#[derive(Clone, Debug)]
193pub struct Receipt {
194    /// Approval- and commit-bound transaction identity.
195    pub transaction: TransactionId,
196    /// Immutable base snapshot identity.
197    pub base_snapshot: SnapshotId,
198    /// Current lifecycle state. Auto-approved previews may be process-local until commit.
199    pub state: TransactionState,
200    /// Deterministic policy result.
201    pub decision: RuntimeDecision,
202    /// Canonical diff identity.
203    pub diff: DiffDigest,
204    /// Number of canonical changed paths.
205    pub changed_paths: usize,
206    /// Complete canonical entries only when full detail was requested.
207    pub changes: Vec<DiffEntry>,
208    /// Bounded Monty return value.
209    pub value: MontyObject,
210    /// Bounded captured `print()` output.
211    pub stdout: String,
212    /// Independent execution counters.
213    pub execution: ExecutionStats,
214    /// Native stage timings.
215    pub timings: StageTimings,
216    /// Durable commit proof when the host was changed and verified.
217    pub commit: Option<CommitReceipt>,
218}
219
220/// Immutable runtime configuration shared by Rust and `PyO3` callers.
221#[derive(Clone, Debug)]
222pub struct RuntimeConfig {
223    workspace_root: PathBuf,
224    data_directory: PathBuf,
225    data_directory_authority: DataDirectoryAuthority,
226    worker_path: Option<PathBuf>,
227    max_idle_workers: usize,
228    result_compatibility: ResultCompatibility,
229    virtual_root: VirtualRoot,
230    policy: TransactionPolicy,
231    snapshot_limits: SnapshotLimits,
232    commit_config: CommitConfig,
233    store_config: FileStoreConfig,
234    artifact_limits: ArtifactLimits,
235    commit_hook: Option<HookConfig>,
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239enum DataDirectoryAuthority {
240    WorkspaceProtected,
241    TrustedExternal,
242}
243
244impl RuntimeConfig {
245    /// Construct a balanced runtime rooted at `workspace_root`.
246    ///
247    /// Durable internal artifacts default to `.vsh-runtime/data` below the workspace;
248    /// that namespace is excluded from snapshots and denied to Monty.
249    #[must_use]
250    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
251        let workspace_root = workspace_root.into();
252        let data_directory = workspace_root.join(".vsh-runtime").join("data");
253        Self {
254            workspace_root,
255            data_directory,
256            data_directory_authority: DataDirectoryAuthority::WorkspaceProtected,
257            worker_path: Some(default_worker_path()),
258            max_idle_workers: 4,
259            result_compatibility: ResultCompatibility::Native,
260            virtual_root: VirtualRoot::default(),
261            policy: TransactionPolicy::default(),
262            snapshot_limits: SnapshotLimits::default(),
263            commit_config: CommitConfig::default(),
264            store_config: FileStoreConfig::default(),
265            artifact_limits: ArtifactLimits::default(),
266            commit_hook: None,
267        }
268    }
269
270    /// Place immutable blobs in an explicit trusted data directory.
271    #[must_use]
272    pub fn with_data_directory(mut self, data_directory: impl Into<PathBuf>) -> Self {
273        self.data_directory = data_directory.into();
274        self.data_directory_authority = DataDirectoryAuthority::TrustedExternal;
275        self
276    }
277
278    /// Select the exact supervised Monty worker executable used for hostile code.
279    #[must_use]
280    pub fn with_worker_path(mut self, worker_path: impl Into<PathBuf>) -> Self {
281        self.worker_path = Some(worker_path.into());
282        self
283    }
284
285    /// Bound clean workers retained for low-latency reuse. Zero disables pooling.
286    #[must_use]
287    pub const fn with_max_idle_workers(mut self, max_idle_workers: usize) -> Self {
288        self.max_idle_workers = max_idle_workers;
289        self
290    }
291
292    /// Require every result to be faithfully representable by one host surface.
293    #[must_use]
294    pub const fn with_result_compatibility(
295        mut self,
296        result_compatibility: ResultCompatibility,
297    ) -> Self {
298        self.result_compatibility = result_compatibility;
299        self
300    }
301
302    /// Disable crash isolation for trusted embedding and deterministic test harnesses.
303    ///
304    /// This mode must never execute hostile or unreviewed code. Production Rust and
305    /// Python callers use the supervised worker by default.
306    #[must_use]
307    pub fn with_in_process_execution(mut self) -> Self {
308        self.worker_path = None;
309        self
310    }
311
312    /// Replace the synthetic absolute namespace exposed to Monty.
313    #[must_use]
314    pub fn with_virtual_root(mut self, virtual_root: VirtualRoot) -> Self {
315        self.virtual_root = virtual_root;
316        self
317    }
318
319    /// Replace deterministic transaction and pre-call policy.
320    #[must_use]
321    pub fn with_policy(mut self, policy: TransactionPolicy) -> Self {
322        self.policy = policy;
323        self
324    }
325
326    /// Select a built-in deterministic policy profile.
327    #[must_use]
328    pub fn with_policy_profile(self, profile: PolicyProfile) -> Self {
329        self.with_policy(TransactionPolicy::preset(profile))
330    }
331
332    /// Replace eager snapshot traversal bounds.
333    #[must_use]
334    pub const fn with_snapshot_limits(mut self, limits: SnapshotLimits) -> Self {
335        self.snapshot_limits = limits;
336        self
337    }
338
339    /// Replace trusted commit and recovery bounds.
340    #[must_use]
341    pub const fn with_commit_config(mut self, config: CommitConfig) -> Self {
342        self.commit_config = config;
343        self
344    }
345
346    /// Replace durable transaction-log bounds.
347    #[must_use]
348    pub const fn with_store_config(mut self, config: FileStoreConfig) -> Self {
349        self.store_config = config;
350        self
351    }
352
353    /// Replace durable pending-artifact allocation and cardinality bounds.
354    #[must_use]
355    pub const fn with_artifact_limits(mut self, limits: ArtifactLimits) -> Self {
356        self.artifact_limits = limits;
357        self
358    }
359
360    /// Require the native two-phase hook protocol for matching commit candidates.
361    #[must_use]
362    pub const fn with_commit_hook(mut self, hook: HookConfig) -> Self {
363        self.commit_hook = Some(hook);
364        self
365    }
366
367    /// Return the host workspace authority root.
368    #[must_use]
369    pub fn workspace_root(&self) -> &Path {
370        &self.workspace_root
371    }
372
373    /// Return the trusted immutable-artifact directory.
374    #[must_use]
375    pub fn data_directory(&self) -> &Path {
376        &self.data_directory
377    }
378
379    /// Return the supervised worker path, or `None` for explicit trusted in-process mode.
380    #[must_use]
381    pub fn worker_path(&self) -> Option<&Path> {
382        self.worker_path.as_deref()
383    }
384
385    /// Return deterministic transaction policy.
386    #[must_use]
387    pub const fn policy(&self) -> &TransactionPolicy {
388        &self.policy
389    }
390
391    /// Return the commit-hook configuration, when direct commits are guarded.
392    #[must_use]
393    pub const fn commit_hook(&self) -> Option<HookConfig> {
394        self.commit_hook
395    }
396}
397
398fn default_worker_path() -> PathBuf {
399    std::env::var_os("VSH_MONTY_WORKER")
400        .filter(|path| !path.is_empty())
401        .map_or_else(|| PathBuf::from("vsh-monty-worker"), PathBuf::from)
402}
403
404enum RuntimeExecution {
405    Subprocess(Box<SubprocessMonty>),
406    InProcess,
407}
408
409impl RuntimeExecution {
410    fn open(config: &RuntimeConfig) -> Result<Self, ExecutionError> {
411        let Some(worker_path) = &config.worker_path else {
412            return Ok(Self::InProcess);
413        };
414        let adapter = InProcessConfig::new(config.virtual_root.clone())
415            .with_call_policy(config.policy.call_policy().clone());
416        let worker = SubprocessMonty::new(
417            SubprocessConfig::new(worker_path, adapter)
418                .with_max_idle_workers(config.max_idle_workers),
419        )?;
420        Ok(Self::Subprocess(Box::new(worker)))
421    }
422
423    fn security_digest(&self, adapter: &InProcessConfig) -> RuntimeConfigDigest {
424        match self {
425            Self::Subprocess(worker) => worker.config().security_digest_for(adapter),
426            Self::InProcess => adapter.security_digest(),
427        }
428    }
429
430    fn execute(
431        &self,
432        code: &str,
433        filesystem: &mut VirtualFs,
434        adapter: &InProcessConfig,
435    ) -> Result<ExecutionOutcome, ExecutionError> {
436        match self {
437            Self::Subprocess(worker) => worker.execute_with_config(code, filesystem, adapter),
438            Self::InProcess => InProcessMonty::new(adapter.clone()).execute(code, filesystem),
439        }
440    }
441}
442
443/// One native VSH engine instance with no process-global execution lock.
444pub struct Runtime {
445    config: RuntimeConfig,
446    execution: RuntimeExecution,
447    committer: Committer,
448    store: FileTransactionStore,
449    artifacts: BlobStore,
450    pending: Mutex<PendingArtifacts>,
451    startup_recovery: RecoveryReport,
452}
453
454#[derive(Default)]
455struct PendingArtifacts {
456    entries: BTreeMap<TransactionId, (PendingTransaction, usize)>,
457    encoded_bytes: usize,
458}
459
460struct EvaluatedDiff {
461    diff: CanonicalDiff,
462    decision: PolicyDecision,
463    metrics: RiskMetrics,
464    diff_ns: u64,
465    policy_ns: u64,
466}
467
468impl Runtime {
469    /// Open one capability-rooted runtime and recover durable interrupted commits.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error when blob storage, workspace capability setup, recovery, or
474    /// fail-closed recovery conflict handling fails.
475    pub fn open(config: RuntimeConfig) -> Result<Self, VshError> {
476        let (committer, data_directory) = match config.data_directory_authority {
477            DataDirectoryAuthority::WorkspaceProtected => {
478                Committer::open_with_workspace_data(&config.workspace_root, config.commit_config)?
479            }
480            DataDirectoryAuthority::TrustedExternal => {
481                validate_disjoint_data_directory(&config.workspace_root, &config.data_directory)?;
482                let data_directory = DataDirectory::open_trusted(&config.data_directory)?;
483                validate_canonical_data_directory_separation(
484                    &config.workspace_root,
485                    data_directory.path(),
486                )?;
487                let artifacts = BlobStore::open_in(&data_directory)?;
488                let committer =
489                    Committer::open(&config.workspace_root, artifacts, config.commit_config)?;
490                (committer, data_directory)
491            }
492        };
493        let artifacts = committer.artifact_store();
494        let store = FileTransactionStore::open_in(&data_directory, config.store_config)?;
495        let execution = RuntimeExecution::open(&config)?;
496        let startup_recovery = committer.recover(&store)?;
497        if !startup_recovery.conflicts.is_empty() {
498            return Err(VshError::RecoveryConflicts(Box::new(startup_recovery)));
499        }
500        Ok(Self {
501            config,
502            execution,
503            committer,
504            store,
505            artifacts,
506            pending: Mutex::new(PendingArtifacts::default()),
507            startup_recovery,
508        })
509    }
510
511    /// Return the startup recovery work completed before accepting requests.
512    #[must_use]
513    pub const fn startup_recovery(&self) -> &RecoveryReport {
514        &self.startup_recovery
515    }
516
517    /// Execute, evaluate, and optionally auto-commit one exact transaction.
518    ///
519    /// # Errors
520    ///
521    /// Returns a typed error for snapshot, execution, diff, state, binding, reservation,
522    /// revalidation, commit, or recovery failures. Deterministic policy denial is a
523    /// successful receipt and never reaches the committer.
524    pub fn run(&self, request: RunRequest<'_>) -> Result<Receipt, VshError> {
525        validate_program_size(request.code, request.budget)?;
526        let total_started = Instant::now();
527        let (mut filesystem, base_snapshot, base_node_count, snapshot_ns) =
528            self.snapshot_filesystem()?;
529
530        let monty_config = self.monty_config(request.budget);
531        let runtime_config = self.runtime_config_digest(&monty_config);
532        let execute_started = Instant::now();
533        let ExecutionOutcome {
534            value,
535            stdout,
536            stats,
537            denied_accesses,
538        } = self
539            .execution
540            .execute(request.code, &mut filesystem, &monty_config)?;
541        validate_result_compatibility(&value, self.config.result_compatibility)?;
542        let execute_ns = elapsed_ns(execute_started);
543
544        let EvaluatedDiff {
545            diff,
546            decision: policy_decision,
547            metrics: risk_metrics,
548            diff_ns,
549            policy_ns,
550        } = self.evaluate_diff(&filesystem, &denied_accesses, base_node_count)?;
551
552        let bind_started = Instant::now();
553        let binding = bind_transaction(TransactionIdentityInput {
554            base_snapshot,
555            diff: &diff,
556            read_set: filesystem.read_set(),
557            write_set: filesystem.write_set(),
558            program: request.code,
559            policy: &self.config.policy,
560            runtime_config,
561            intent: request.intent,
562        });
563        let transaction = binding.transaction_id();
564        let (decision, state, record) =
565            Self::policy_record(transaction, base_snapshot, policy_decision)?;
566        let changed_paths = diff.entries().len();
567        let changes = receipt_changes(request.detail, &diff);
568        let mut receipt = Receipt {
569            transaction,
570            base_snapshot,
571            state,
572            decision,
573            diff: diff.digest(),
574            changed_paths,
575            changes,
576            value,
577            stdout,
578            execution: stats,
579            timings: StageTimings {
580                snapshot_ns,
581                execute_ns,
582                diff_ns,
583                policy_ns,
584                bind_and_store_ns: elapsed_ns(bind_started),
585                commit_ns: 0,
586                total_ns: elapsed_ns(total_started),
587            },
588            commit: None,
589        };
590
591        if state == TransactionState::Denied {
592            self.store.create(record)?;
593            receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
594            receipt.timings.total_ns = elapsed_ns(total_started);
595        } else {
596            receipt = self.store_pending(
597                record,
598                PendingTransaction {
599                    binding,
600                    diff,
601                    read_set: filesystem.read_set().clone(),
602                    write_set: filesystem.write_set().clone(),
603                    review: ReviewEvidence {
604                        intent: request.intent.map(str::to_owned),
605                        metrics: risk_metrics,
606                        effects: filesystem.effects().to_vec(),
607                        complete: true,
608                        truncated: false,
609                    },
610                    receipt,
611                },
612                request.mode,
613                bind_started,
614                total_started,
615            )?;
616        }
617
618        if request.mode == RunMode::Auto && state == TransactionState::AutoApproved {
619            receipt = self.commit(transaction, 0)?;
620            receipt.timings.total_ns = elapsed_ns(total_started);
621        }
622        Ok(receipt)
623    }
624
625    /// Force preview-only behavior regardless of the request's mode field.
626    ///
627    /// # Errors
628    ///
629    /// Returns the same typed failures as [`Self::run`].
630    pub fn preview(&self, mut request: RunRequest<'_>) -> Result<Receipt, VshError> {
631        request.mode = RunMode::Preview;
632        self.run(request)
633    }
634
635    /// Forget one process-local auto-approved preview without mutating the host.
636    ///
637    /// Durable approval-required artifacts are never removed by this method. `false`
638    /// means this runtime did not retain the supplied preview.
639    ///
640    /// # Errors
641    ///
642    /// Returns an error only when the bounded pending-artifact lock was poisoned.
643    pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
644        self.remove_pending(transaction)
645            .map(|artifact| artifact.is_some())
646    }
647
648    /// Bind an independent, expiring approval to one exact pending transaction.
649    ///
650    /// # Errors
651    ///
652    /// Returns an error for an invalid time window, missing transaction, mismatched
653    /// binding, wrong state, or internal artifact-state mismatch.
654    pub fn approve(
655        &self,
656        transaction: TransactionId,
657        principal: vsh_types::PrincipalId,
658        issued_at_unix_ms: u64,
659        expires_at_unix_ms: u64,
660    ) -> Result<TransactionRecord, VshError> {
661        self.load_pending(transaction)?;
662        let grant = ApprovalGrant::new(
663            transaction,
664            principal,
665            issued_at_unix_ms,
666            expires_at_unix_ms,
667        )?;
668        let record = self.store.approve(transaction, grant)?;
669        if let Some((artifact, _)) = self.pending()?.entries.get_mut(&transaction) {
670            artifact.receipt.state = TransactionState::Approved;
671        }
672        Ok(record)
673    }
674
675    /// Consume the single-use reservation and commit one previewed transaction.
676    ///
677    /// # Errors
678    ///
679    /// Returns an error for missing artifacts, expired approval, replay, stale host
680    /// dependencies, commit/recovery failures, or internal binding mismatch.
681    pub fn commit(
682        &self,
683        transaction: TransactionId,
684        now_unix_ms: u64,
685    ) -> Result<Receipt, VshError> {
686        if self.config.commit_hook.is_some() {
687            let preparation = self.prepare_commit(transaction)?;
688            if let Some(event) = preparation.event() {
689                return Err(VshError::HookRequired(Box::new(event.clone())));
690            }
691        }
692        self.commit_exact(transaction, now_unix_ms)
693    }
694
695    /// Freeze one exact commit candidate before invoking an external handler.
696    ///
697    /// No handler code runs in this method. Process-local auto-approved previews are
698    /// made durable before an event is returned, so a crash can regenerate the same
699    /// event from the exact transaction artifact.
700    ///
701    /// # Errors
702    ///
703    /// Returns a typed store, artifact, configuration, or evidence error.
704    pub fn prepare_commit(
705        &self,
706        transaction: TransactionId,
707    ) -> Result<CommitPreparation, VshError> {
708        let artifact = self.load_pending(transaction)?;
709        validate_result_compatibility(&artifact.receipt.value, self.config.result_compatibility)?;
710        self.persist_ephemeral(&artifact)?;
711        let record = self.store.get(transaction)?;
712        let state = record.state();
713        let Some(hook) = self.config.commit_hook else {
714            return Ok(CommitPreparation::Ready { transaction, state });
715        };
716        if !hook.scope().applies_to(state) {
717            return Ok(CommitPreparation::Ready { transaction, state });
718        }
719        Ok(CommitPreparation::Review(Box::new(
720            self.request_event(&artifact, hook, state)?,
721        )))
722    }
723
724    /// Apply one hook decision to the exact prepared transaction.
725    ///
726    /// The preparation is revalidated against durable state and regenerated evidence,
727    /// so callers cannot substitute a transaction or event after the handler returns.
728    ///
729    /// # Errors
730    ///
731    /// Returns a typed event-binding, state, approval, store, or commit error. The
732    /// exact preparation is checked again before any decision changes host files.
733    pub fn resolve_commit(
734        &self,
735        preparation: &CommitPreparation,
736        decision: &HookDecision,
737        now_unix_ms: u64,
738    ) -> Result<CommitResolution, VshError> {
739        let transaction = preparation.transaction();
740        let prepared_state = preparation.prepared_state();
741        let artifact = self.load_pending(transaction)?;
742        let event = self.validate_hook_preparation(preparation, decision, &artifact)?;
743
744        let (verdict, reason) = match &decision {
745            HookDecision::FollowPolicy => (HookVerdict::FollowPolicy, ""),
746            HookDecision::Approve { reason } => (HookVerdict::Approve, reason.as_str()),
747            HookDecision::Review { feedback } => (HookVerdict::Review, feedback.as_str()),
748            HookDecision::Reject { reason } => (HookVerdict::Reject, reason.as_str()),
749        };
750        if let Some((_, hook)) = event
751            && reason.len() > hook.max_reason_bytes()
752        {
753            return Err(VshError::HookReasonLimit {
754                observed: reason.len(),
755                maximum: hook.max_reason_bytes(),
756            });
757        }
758        let receipt = self.apply_hook_decision(
759            transaction,
760            prepared_state,
761            &artifact,
762            event,
763            decision,
764            now_unix_ms,
765        )?;
766
767        let hook_record = event.map(|(event, hook)| HookDecisionRecord {
768            event_id: event.event_id,
769            hook_id: event.hook_id,
770            verdict,
771            reason: reason.to_owned(),
772            principal: (verdict == HookVerdict::Approve).then(|| hook.principal()),
773        });
774        Ok(CommitResolution {
775            receipt,
776            hook: hook_record,
777        })
778    }
779
780    fn validate_hook_preparation<'a>(
781        &self,
782        preparation: &'a CommitPreparation,
783        decision: &HookDecision,
784        artifact: &PendingTransaction,
785    ) -> Result<Option<(&'a RequestEvent, HookConfig)>, VshError> {
786        let transaction = preparation.transaction();
787        let prepared_state = preparation.prepared_state();
788        let actual = self.store.get(transaction)?.state();
789        if actual != prepared_state {
790            return Err(VshError::HookStateChanged {
791                transaction,
792                prepared: prepared_state,
793                actual,
794            });
795        }
796        match preparation {
797            CommitPreparation::Ready { .. } => {
798                if let Some(hook) = self.config.commit_hook
799                    && hook.scope().applies_to(prepared_state)
800                {
801                    return Err(VshError::HookRequired(Box::new(self.request_event(
802                        artifact,
803                        hook,
804                        prepared_state,
805                    )?)));
806                }
807                if *decision != HookDecision::FollowPolicy {
808                    return Err(VshError::UnexpectedHookDecision { transaction });
809                }
810                Ok(None)
811            }
812            CommitPreparation::Review(event) => {
813                let hook = self
814                    .config
815                    .commit_hook
816                    .ok_or(VshError::HookConfigurationChanged { transaction })?;
817                let expected = self.request_event(artifact, hook, prepared_state)?;
818                if expected != **event {
819                    return Err(VshError::HookEventMismatch { transaction });
820                }
821                Ok(Some((event.as_ref(), hook)))
822            }
823        }
824    }
825
826    fn apply_hook_decision(
827        &self,
828        transaction: TransactionId,
829        prepared_state: TransactionState,
830        artifact: &PendingTransaction,
831        event: Option<(&RequestEvent, HookConfig)>,
832        decision: &HookDecision,
833        now_unix_ms: u64,
834    ) -> Result<Receipt, VshError> {
835        match decision {
836            HookDecision::FollowPolicy => match prepared_state {
837                TransactionState::AutoApproved | TransactionState::Approved => {
838                    self.commit_exact(transaction, now_unix_ms)
839                }
840                TransactionState::PendingApproval => Ok(artifact.receipt.clone()),
841                actual => Err(VshError::HookNotActionable {
842                    transaction,
843                    actual,
844                }),
845            },
846            HookDecision::Approve { .. } => {
847                let (_, hook) = event.ok_or(VshError::UnexpectedHookDecision { transaction })?;
848                if !artifact.review.complete || artifact.review.truncated {
849                    return Err(VshError::IncompleteHookEvidence { transaction });
850                }
851                if prepared_state == TransactionState::PendingApproval {
852                    let expires_at_unix_ms = now_unix_ms
853                        .checked_add(hook.approval_ttl_ms())
854                        .ok_or(VshError::HookApprovalWindow { transaction })?;
855                    self.approve(
856                        transaction,
857                        hook.principal(),
858                        now_unix_ms,
859                        expires_at_unix_ms,
860                    )?;
861                } else if prepared_state != TransactionState::AutoApproved {
862                    return Err(VshError::HookNotActionable {
863                        transaction,
864                        actual: prepared_state,
865                    });
866                }
867                self.commit_exact(transaction, now_unix_ms)
868            }
869            HookDecision::Review { .. } => {
870                if prepared_state == TransactionState::AutoApproved {
871                    self.store.compare_and_transition(
872                        transaction,
873                        TransactionState::AutoApproved,
874                        TransactionState::PendingApproval,
875                    )?;
876                    self.update_pending_state(transaction, TransactionState::PendingApproval)?;
877                } else if prepared_state != TransactionState::PendingApproval {
878                    return Err(VshError::HookNotActionable {
879                        transaction,
880                        actual: prepared_state,
881                    });
882                }
883                self.receipt_in_state(transaction, TransactionState::PendingApproval)
884            }
885            HookDecision::Reject { .. } => {
886                if !matches!(
887                    prepared_state,
888                    TransactionState::AutoApproved | TransactionState::PendingApproval
889                ) {
890                    return Err(VshError::HookNotActionable {
891                        transaction,
892                        actual: prepared_state,
893                    });
894                }
895                self.store.compare_and_transition(
896                    transaction,
897                    prepared_state,
898                    TransactionState::Rejected,
899                )?;
900                self.update_pending_state(transaction, TransactionState::Rejected)?;
901                let receipt = self.receipt_in_state(transaction, TransactionState::Rejected)?;
902                self.remove_pending(transaction)?;
903                Ok(receipt)
904            }
905        }
906    }
907
908    /// Apply fail-closed state after a handler exception, timeout, or cancellation.
909    ///
910    /// # Errors
911    ///
912    /// Returns a typed store or artifact error if automatic approval cannot be moved
913    /// into the existing pending-approval state.
914    pub fn fail_hook(&self, preparation: &CommitPreparation) -> Result<(), VshError> {
915        let transaction = preparation.transaction();
916        if preparation.prepared_state() == TransactionState::AutoApproved {
917            self.store.compare_and_transition(
918                transaction,
919                TransactionState::AutoApproved,
920                TransactionState::PendingApproval,
921            )?;
922            self.update_pending_state(transaction, TransactionState::PendingApproval)?;
923        }
924        Ok(())
925    }
926
927    fn commit_exact(
928        &self,
929        transaction: TransactionId,
930        now_unix_ms: u64,
931    ) -> Result<Receipt, VshError> {
932        let artifact = self.load_pending(transaction)?;
933        validate_result_compatibility(&artifact.receipt.value, self.config.result_compatibility)?;
934        self.persist_ephemeral(&artifact)?;
935        let plan = CommitPlan::new(
936            &artifact.binding,
937            &artifact.diff,
938            &artifact.read_set,
939            &artifact.write_set,
940        )?;
941        let reservation = self.store.reserve(transaction, now_unix_ms)?;
942        let commit_started = Instant::now();
943        let commit = self.committer.commit(&self.store, reservation, &plan);
944        let commit_ns = elapsed_ns(commit_started);
945        self.remove_pending(transaction)?;
946        let commit = commit?;
947        let mut receipt = artifact.receipt;
948        receipt.state = TransactionState::Committed;
949        receipt.timings.commit_ns = commit_ns;
950        receipt.timings.total_ns = receipt.timings.total_ns.saturating_add(commit_ns);
951        receipt.commit = Some(commit);
952        Ok(receipt)
953    }
954
955    /// Recover all durable commit artifacts under this runtime's capability root.
956    ///
957    /// # Errors
958    ///
959    /// Returns a typed commit/recovery error for corrupt or unsafe journals.
960    pub fn recover(&self) -> Result<RecoveryReport, VshError> {
961        self.committer.recover(&self.store).map_err(Into::into)
962    }
963
964    /// Return one persisted lifecycle record.
965    ///
966    /// # Errors
967    ///
968    /// Returns [`VshError::Store`] when the transaction does not exist.
969    pub fn transaction(&self, transaction: TransactionId) -> Result<TransactionRecord, VshError> {
970        match self.store.get(transaction) {
971            Ok(record) => Ok(record),
972            Err(TransactionStoreError::NotFound { id }) if id == transaction => {
973                let artifact = self
974                    .pending()?
975                    .entries
976                    .get(&transaction)
977                    .map(|(artifact, _)| artifact.clone())
978                    .ok_or(TransactionStoreError::NotFound { id })?;
979                Self::ephemeral_record(&artifact)
980            }
981            Err(source) => Err(source.into()),
982        }
983    }
984
985    fn request_event(
986        &self,
987        artifact: &PendingTransaction,
988        hook: HookConfig,
989        state: TransactionState,
990    ) -> Result<RequestEvent, VshError> {
991        let transaction = artifact.binding.transaction_id();
992        if artifact.binding.policy != self.config.policy.digest() {
993            return Err(VshError::HookConfigurationChanged { transaction });
994        }
995        let (baseline, risk_flags) = match &artifact.receipt.decision {
996            RuntimeDecision::AutoApproved => (HookBaseline::AutoApproved, Vec::new()),
997            RuntimeDecision::PendingApproval(manifest) => {
998                (HookBaseline::ReviewRequired, manifest.flags.clone())
999            }
1000            RuntimeDecision::Denied(_) => {
1001                return Err(VshError::HookNotActionable {
1002                    transaction,
1003                    actual: TransactionState::Denied,
1004                });
1005            }
1006        };
1007        let (contents, content_complete) = crate::review::collect_content(
1008            artifact.diff.entries(),
1009            &artifact.review.effects,
1010            self.config.policy.call_policy(),
1011            &self.artifacts,
1012            hook.max_content_bytes(),
1013        )?;
1014        Ok(RequestEvent {
1015            schema_version: 1,
1016            event_id: vsh_types::RequestEventId::derive(transaction, hook.id(), hook.scope().tag()),
1017            hook_id: hook.id(),
1018            transaction,
1019            state,
1020            baseline,
1021            base_snapshot: artifact.binding.base_snapshot,
1022            diff: artifact.binding.diff,
1023            read_set: artifact.binding.read_set,
1024            write_set: artifact.binding.write_set,
1025            program: artifact.binding.program,
1026            policy: artifact.binding.policy,
1027            runtime_config: artifact.binding.runtime_config,
1028            intent_digest: artifact.binding.intent,
1029            intent: artifact.review.intent.clone(),
1030            policy_profile: self.config.policy.profile(),
1031            policy_thresholds: self.config.policy.thresholds(),
1032            risk_metrics: artifact.review.metrics,
1033            risk_flags,
1034            canonical_diff: artifact.diff.entries().to_vec(),
1035            effects: artifact.review.effects.clone(),
1036            execution: artifact.receipt.execution,
1037            evidence_complete: artifact.review.complete,
1038            evidence_truncated: artifact.review.truncated,
1039            contents,
1040            content_complete,
1041        })
1042    }
1043
1044    fn evaluate_diff(
1045        &self,
1046        filesystem: &VirtualFs,
1047        denied_accesses: &[DeniedAccess],
1048        base_node_count: usize,
1049    ) -> Result<EvaluatedDiff, VshError> {
1050        let started = Instant::now();
1051        let mut diff = filesystem.canonical_diff()?;
1052        let mut diff_ns = elapsed_ns(started);
1053        let evaluate = |diff: &CanonicalDiff| {
1054            self.config.policy.evaluate_with_metrics(PolicyInput {
1055                diff,
1056                effects: filesystem.effects(),
1057                denied_accesses,
1058                base_node_count,
1059            })
1060        };
1061        let started = Instant::now();
1062        let (mut decision, mut metrics) = evaluate(&diff);
1063        let mut policy_ns = elapsed_ns(started);
1064        let state = match &decision {
1065            PolicyDecision::Deny(_) => TransactionState::Denied,
1066            PolicyDecision::AutoApprove => TransactionState::AutoApproved,
1067            PolicyDecision::Escalate(_) => TransactionState::PendingApproval,
1068        };
1069        if let Some(hook) = self.config.commit_hook
1070            && hook.max_content_bytes() > 0
1071            && hook.scope().applies_to(state)
1072            && !diff.entries().is_empty()
1073        {
1074            let started = Instant::now();
1075            let paths = diff.entries().iter().filter_map(|entry| {
1076                (entry.before.is_some()
1077                    && self
1078                        .config
1079                        .policy
1080                        .call_policy()
1081                        .authorize(&entry.path, AccessKind::ContentRead)
1082                        .is_ok())
1083                .then_some(&entry.path)
1084            });
1085            filesystem.capture_before_content(paths, hook.max_content_bytes())?;
1086            diff = filesystem.canonical_diff()?;
1087            diff_ns = diff_ns.saturating_add(elapsed_ns(started));
1088            let started = Instant::now();
1089            (decision, metrics) = evaluate(&diff);
1090            policy_ns = policy_ns.saturating_add(elapsed_ns(started));
1091        }
1092        Ok(EvaluatedDiff {
1093            diff,
1094            decision,
1095            metrics,
1096            diff_ns,
1097            policy_ns,
1098        })
1099    }
1100
1101    fn update_pending_state(
1102        &self,
1103        transaction: TransactionId,
1104        state: TransactionState,
1105    ) -> Result<(), VshError> {
1106        if let Some((artifact, _)) = self.pending()?.entries.get_mut(&transaction) {
1107            artifact.receipt.state = state;
1108        }
1109        Ok(())
1110    }
1111
1112    fn receipt_in_state(
1113        &self,
1114        transaction: TransactionId,
1115        state: TransactionState,
1116    ) -> Result<Receipt, VshError> {
1117        let mut artifact = self.load_pending(transaction)?;
1118        artifact.receipt.state = state;
1119        Ok(artifact.receipt)
1120    }
1121
1122    fn monty_config(&self, budget: ExecutionBudget) -> InProcessConfig {
1123        InProcessConfig::new(self.config.virtual_root.clone())
1124            .with_limits(budget)
1125            .with_call_policy(self.config.policy.call_policy().clone())
1126    }
1127
1128    fn runtime_config_digest(&self, monty_config: &InProcessConfig) -> RuntimeConfigDigest {
1129        aggregate_runtime_digest(
1130            self.execution.security_digest(monty_config),
1131            self.config.snapshot_limits,
1132            self.config.commit_config,
1133            self.config.store_config,
1134            self.config.artifact_limits,
1135            self.config.result_compatibility,
1136            self.config.commit_hook,
1137        )
1138    }
1139
1140    fn snapshot_filesystem(&self) -> Result<(VirtualFs, SnapshotId, usize, u64), VshError> {
1141        let started = Instant::now();
1142        let snapshot = self.committer.snapshot(self.config.snapshot_limits)?;
1143        let id = snapshot.id();
1144        let nodes = snapshot.len();
1145        Ok((VirtualFs::new(snapshot), id, nodes, elapsed_ns(started)))
1146    }
1147
1148    fn insert_pending(
1149        &self,
1150        artifact: PendingTransaction,
1151        encoded_bytes: usize,
1152    ) -> Result<(), VshError> {
1153        let transaction = artifact.binding.transaction_id();
1154        let mut pending = self.pending()?;
1155        let entries = pending.entries.len();
1156        let retained_bytes = pending.encoded_bytes;
1157        let attempted_bytes = retained_bytes.saturating_add(encoded_bytes);
1158        if entries >= self.config.artifact_limits.max_ephemeral_entries
1159            || attempted_bytes > self.config.artifact_limits.max_ephemeral_bytes
1160        {
1161            return Err(VshError::EphemeralCapacity {
1162                entries,
1163                max_entries: self.config.artifact_limits.max_ephemeral_entries,
1164                attempted_bytes,
1165                max_bytes: self.config.artifact_limits.max_ephemeral_bytes,
1166            });
1167        }
1168        if pending.entries.contains_key(&transaction) {
1169            return Err(VshError::DuplicatePending { transaction });
1170        }
1171        pending
1172            .entries
1173            .insert(transaction, (artifact, encoded_bytes));
1174        pending.encoded_bytes = attempted_bytes;
1175        Ok(())
1176    }
1177
1178    fn remove_pending(
1179        &self,
1180        transaction: TransactionId,
1181    ) -> Result<Option<PendingTransaction>, VshError> {
1182        let mut pending = self.pending()?;
1183        let Some((artifact, encoded_bytes)) = pending.entries.remove(&transaction) else {
1184            return Ok(None);
1185        };
1186        pending.encoded_bytes = pending.encoded_bytes.saturating_sub(encoded_bytes);
1187        Ok(Some(artifact))
1188    }
1189
1190    fn persist_pending(
1191        &self,
1192        record: TransactionRecord,
1193        mut artifact: PendingTransaction,
1194        bind_started: Instant,
1195        total_started: Instant,
1196    ) -> Result<Receipt, VshError> {
1197        let encoded = encode_pending(&artifact, self.config.artifact_limits)?;
1198        let artifact_id = self.artifacts.put(&encoded)?;
1199        self.store.create(record.with_artifact(artifact_id))?;
1200        artifact.receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
1201        artifact.receipt.timings.total_ns = elapsed_ns(total_started);
1202        let receipt = artifact.receipt.clone();
1203        Ok(receipt)
1204    }
1205
1206    fn store_pending(
1207        &self,
1208        record: TransactionRecord,
1209        artifact: PendingTransaction,
1210        mode: RunMode,
1211        bind_started: Instant,
1212        total_started: Instant,
1213    ) -> Result<Receipt, VshError> {
1214        if mode == RunMode::Preview && artifact.receipt.state == TransactionState::AutoApproved {
1215            self.retain_ephemeral(artifact, bind_started, total_started)
1216        } else {
1217            self.persist_pending(record, artifact, bind_started, total_started)
1218        }
1219    }
1220
1221    fn retain_ephemeral(
1222        &self,
1223        mut artifact: PendingTransaction,
1224        bind_started: Instant,
1225        total_started: Instant,
1226    ) -> Result<Receipt, VshError> {
1227        let encoded = encode_pending(&artifact, self.config.artifact_limits)?;
1228        artifact.receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
1229        artifact.receipt.timings.total_ns = elapsed_ns(total_started);
1230        let receipt = artifact.receipt.clone();
1231        self.insert_pending(artifact, encoded.len())?;
1232        Ok(receipt)
1233    }
1234
1235    fn persist_ephemeral(&self, artifact: &PendingTransaction) -> Result<(), VshError> {
1236        let transaction = artifact.binding.transaction_id();
1237        match self.store.get(transaction) {
1238            Ok(_) => return Ok(()),
1239            Err(TransactionStoreError::NotFound { id }) if id == transaction => {}
1240            Err(source) => return Err(source.into()),
1241        }
1242        let encoded = encode_pending(artifact, self.config.artifact_limits)?;
1243        let artifact_id = self.artifacts.put(&encoded)?;
1244        let record = Self::ephemeral_record(artifact)?.with_artifact(artifact_id);
1245        self.store.create(record)?;
1246        Ok(())
1247    }
1248
1249    fn ephemeral_record(artifact: &PendingTransaction) -> Result<TransactionRecord, VshError> {
1250        if artifact.receipt.state != TransactionState::AutoApproved
1251            || !matches!(&artifact.receipt.decision, RuntimeDecision::AutoApproved)
1252        {
1253            return Err(VshError::MissingPending {
1254                transaction: artifact.binding.transaction_id(),
1255            });
1256        }
1257        let mut record = TransactionRecord::new(
1258            artifact.binding.transaction_id(),
1259            artifact.binding.base_snapshot,
1260        );
1261        for state in [
1262            TransactionState::Running,
1263            TransactionState::VirtualComplete,
1264            TransactionState::AutoApproved,
1265        ] {
1266            record
1267                .transition(state)
1268                .map_err(TransactionStoreError::Transition)?;
1269        }
1270        Ok(record)
1271    }
1272
1273    fn load_pending(&self, transaction: TransactionId) -> Result<PendingTransaction, VshError> {
1274        if let Some(artifact) = self
1275            .pending()?
1276            .entries
1277            .get(&transaction)
1278            .map(|(artifact, _)| artifact.clone())
1279        {
1280            return Ok(artifact);
1281        }
1282        let record = self.store.get(transaction)?;
1283        let artifact_id = record
1284            .artifact()
1285            .ok_or(VshError::MissingPending { transaction })?;
1286        let bytes = self
1287            .artifacts
1288            .get_bounded(artifact_id, self.config.artifact_limits.max_bytes)?;
1289        let mut artifact = decode_pending(&bytes, self.config.artifact_limits)?;
1290        let actual = artifact.binding.transaction_id();
1291        if actual != transaction || artifact.binding.base_snapshot != record.base_snapshot() {
1292            return Err(VshError::ArtifactBinding {
1293                requested: transaction,
1294                decoded: actual,
1295            });
1296        }
1297        artifact.receipt.state = record.state();
1298        Ok(artifact)
1299    }
1300
1301    fn pending(&self) -> Result<MutexGuard<'_, PendingArtifacts>, VshError> {
1302        self.pending.lock().map_err(|_| VshError::PendingPoisoned)
1303    }
1304
1305    fn policy_record(
1306        transaction: TransactionId,
1307        base_snapshot: SnapshotId,
1308        decision: PolicyDecision,
1309    ) -> Result<(RuntimeDecision, TransactionState, TransactionRecord), VshError> {
1310        let mut record = TransactionRecord::new(transaction, base_snapshot);
1311        record
1312            .transition(TransactionState::Running)
1313            .map_err(TransactionStoreError::Transition)?;
1314        record
1315            .transition(TransactionState::VirtualComplete)
1316            .map_err(TransactionStoreError::Transition)?;
1317        let (decision, state) = match decision {
1318            PolicyDecision::Deny(manifest) => {
1319                record
1320                    .transition(TransactionState::Denied)
1321                    .map_err(TransactionStoreError::Transition)?;
1322                (RuntimeDecision::Denied(manifest), TransactionState::Denied)
1323            }
1324            PolicyDecision::AutoApprove => {
1325                record
1326                    .transition(TransactionState::AutoApproved)
1327                    .map_err(TransactionStoreError::Transition)?;
1328                (
1329                    RuntimeDecision::AutoApproved,
1330                    TransactionState::AutoApproved,
1331                )
1332            }
1333            PolicyDecision::Escalate(manifest) => {
1334                record
1335                    .transition(TransactionState::PendingApproval)
1336                    .map_err(TransactionStoreError::Transition)?;
1337                (
1338                    RuntimeDecision::PendingApproval(manifest),
1339                    TransactionState::PendingApproval,
1340                )
1341            }
1342        };
1343        Ok((decision, state, record))
1344    }
1345}
1346
1347fn aggregate_runtime_digest(
1348    monty: RuntimeConfigDigest,
1349    snapshot: SnapshotLimits,
1350    commit: CommitConfig,
1351    store: FileStoreConfig,
1352    artifact: ArtifactLimits,
1353    result_compatibility: ResultCompatibility,
1354    commit_hook: Option<HookConfig>,
1355) -> RuntimeConfigDigest {
1356    let mut canonical = Vec::with_capacity(66 + 8 * 23);
1357    canonical.extend_from_slice(b"vsh-runtime-config-v6");
1358    canonical.extend_from_slice(monty.as_bytes());
1359    encode_usize(snapshot.max_nodes, &mut canonical);
1360    encode_usize(snapshot.max_depth, &mut canonical);
1361    canonical.extend_from_slice(&snapshot.max_total_file_bytes.to_le_bytes());
1362    encode_usize(commit.max_operations, &mut canonical);
1363    encode_usize(commit.max_dependencies, &mut canonical);
1364    encode_usize(commit.max_path_bytes, &mut canonical);
1365    encode_usize(commit.max_plan_bytes, &mut canonical);
1366    encode_usize(commit.max_journal_bytes, &mut canonical);
1367    encode_usize(commit.max_conflicts, &mut canonical);
1368    canonical.extend_from_slice(&store.max_log_bytes.to_le_bytes());
1369    encode_usize(store.max_records, &mut canonical);
1370    encode_usize(artifact.max_bytes, &mut canonical);
1371    encode_usize(artifact.max_value_bytes, &mut canonical);
1372    encode_usize(artifact.max_stdout_bytes, &mut canonical);
1373    encode_usize(artifact.max_entries, &mut canonical);
1374    encode_usize(artifact.max_dependencies, &mut canonical);
1375    encode_usize(artifact.max_path_bytes, &mut canonical);
1376    encode_usize(artifact.max_intent_bytes, &mut canonical);
1377    encode_usize(artifact.max_effects, &mut canonical);
1378    encode_usize(artifact.max_ephemeral_entries, &mut canonical);
1379    encode_usize(artifact.max_ephemeral_bytes, &mut canonical);
1380    canonical.push(match result_compatibility {
1381        ResultCompatibility::Native => 0,
1382        ResultCompatibility::Python => 1,
1383    });
1384    match commit_hook {
1385        None => canonical.push(0),
1386        Some(hook) => {
1387            canonical.push(1);
1388            canonical.extend_from_slice(hook.id().as_bytes());
1389            canonical.push(hook.scope().tag());
1390            canonical.extend_from_slice(&hook.approval_ttl_ms().to_le_bytes());
1391            encode_usize(hook.max_reason_bytes(), &mut canonical);
1392            encode_usize(hook.max_content_bytes(), &mut canonical);
1393        }
1394    }
1395    RuntimeConfigDigest::digest_canonical(&canonical)
1396}
1397
1398fn encode_usize(value: usize, output: &mut Vec<u8>) {
1399    output.extend_from_slice(&u64::try_from(value).unwrap_or(u64::MAX).to_le_bytes());
1400}
1401
1402fn receipt_changes(detail: ReceiptDetail, diff: &CanonicalDiff) -> Vec<DiffEntry> {
1403    if detail == ReceiptDetail::Full {
1404        diff.entries().to_vec()
1405    } else {
1406        Vec::new()
1407    }
1408}
1409
1410fn elapsed_ns(started: Instant) -> u64 {
1411    u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX)
1412}
1413
1414fn validate_program_size(code: &str, budget: ExecutionBudget) -> Result<(), ExecutionError> {
1415    let attempted = u64::try_from(code.len()).unwrap_or(u64::MAX);
1416    let limit = u64::try_from(budget.max_program_bytes).unwrap_or(u64::MAX);
1417    if attempted > limit {
1418        Err(ExecutionError::Limit(Box::new(
1419            vsh_monty::ExecutionLimitExceeded::ProgramBytes { limit, attempted },
1420        )))
1421    } else {
1422        Ok(())
1423    }
1424}
1425
1426fn validate_disjoint_data_directory(
1427    workspace_root: &Path,
1428    data_directory: &Path,
1429) -> Result<(), VshError> {
1430    let workspace = lexical_absolute(workspace_root);
1431    let data = lexical_absolute(data_directory);
1432    let canonical_workspace = std::fs::canonicalize(workspace_root).ok();
1433    let prospective_data = canonicalize_prospective_path(data_directory);
1434    if workspace
1435        .as_deref()
1436        .zip(data.as_deref())
1437        .is_none_or(|(workspace, data)| paths_overlap(workspace, data))
1438        || canonical_workspace
1439            .as_deref()
1440            .zip(prospective_data.as_deref())
1441            .is_none_or(|(workspace, data)| paths_overlap(workspace, data))
1442    {
1443        return Err(VshError::UnsafeDataDirectory {
1444            workspace_root: workspace_root.to_path_buf(),
1445            data_directory: data_directory.to_path_buf(),
1446        });
1447    }
1448    Ok(())
1449}
1450
1451fn validate_canonical_data_directory_separation(
1452    workspace_root: &Path,
1453    data_directory: &Path,
1454) -> Result<(), VshError> {
1455    let Ok(workspace) = std::fs::canonicalize(workspace_root) else {
1456        return Err(VshError::UnsafeDataDirectory {
1457            workspace_root: workspace_root.to_path_buf(),
1458            data_directory: data_directory.to_path_buf(),
1459        });
1460    };
1461    let Ok(data) = std::fs::canonicalize(data_directory) else {
1462        return Err(VshError::UnsafeDataDirectory {
1463            workspace_root: workspace_root.to_path_buf(),
1464            data_directory: data_directory.to_path_buf(),
1465        });
1466    };
1467    if paths_overlap(&workspace, &data) {
1468        return Err(VshError::UnsafeDataDirectory {
1469            workspace_root: workspace_root.to_path_buf(),
1470            data_directory: data_directory.to_path_buf(),
1471        });
1472    }
1473    Ok(())
1474}
1475
1476fn canonicalize_prospective_path(path: &Path) -> Option<PathBuf> {
1477    let absolute = lexical_absolute(path)?;
1478    let mut existing = absolute.as_path();
1479    let mut missing = Vec::new();
1480    loop {
1481        match std::fs::canonicalize(existing) {
1482            Ok(mut canonical) => {
1483                for component in missing.iter().rev() {
1484                    canonical.push(component);
1485                }
1486                return Some(canonical);
1487            }
1488            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1489                missing.push(existing.file_name()?.to_owned());
1490                existing = existing.parent()?;
1491            }
1492            Err(_) => return None,
1493        }
1494    }
1495}
1496
1497fn lexical_absolute(path: &Path) -> Option<PathBuf> {
1498    let absolute = if path.is_absolute() {
1499        path.to_path_buf()
1500    } else {
1501        std::env::current_dir().ok()?.join(path)
1502    };
1503    let mut normalized = PathBuf::new();
1504    for component in absolute.components() {
1505        match component {
1506            std::path::Component::CurDir => {}
1507            std::path::Component::ParentDir => {
1508                if !normalized.pop() {
1509                    return None;
1510                }
1511            }
1512            _ => normalized.push(component.as_os_str()),
1513        }
1514    }
1515    Some(normalized)
1516}
1517
1518fn paths_overlap(left: &Path, right: &Path) -> bool {
1519    left.starts_with(right) || right.starts_with(left)
1520}
1521
1522/// Stable native error surface shared with the Python exception mapper.
1523#[derive(Debug)]
1524#[non_exhaustive]
1525pub enum VshError {
1526    /// The durable data-directory capability could not be established safely.
1527    DataDirectory(DataDirectoryError),
1528    /// Immutable blob storage failed.
1529    Blob(BlobStoreError),
1530    /// Capability-rooted commit or recovery failed.
1531    Commit(CommitError),
1532    /// Monty compilation, execution, or a hard execution budget failed.
1533    Execution(ExecutionError),
1534    /// Virtual filesystem integrity or canonical diff generation failed.
1535    Vfs(VfsError),
1536    /// Atomic transaction-state operation failed.
1537    Store(TransactionStoreError),
1538    /// An approval grant had an invalid time window.
1539    Approval(ApprovalGrantError),
1540    /// The internal commit artifact did not match its binding.
1541    CommitPlan(CommitPlanError),
1542    /// Durable pending-artifact encoding or validation failed.
1543    Artifact(ArtifactError),
1544    /// The selected SDK surface cannot faithfully project the Monty result.
1545    ResultCompatibility(ResultCompatibilityError),
1546    /// A caller-selected data directory overlaps the untrusted workspace.
1547    UnsafeDataDirectory {
1548        /// Host workspace capability root.
1549        workspace_root: PathBuf,
1550        /// Rejected caller-selected durable directory.
1551        data_directory: PathBuf,
1552    },
1553    /// A content-addressed artifact decoded to another transaction identity.
1554    ArtifactBinding {
1555        /// Transaction requested by the caller and state store.
1556        requested: TransactionId,
1557        /// Transaction recomputed from decoded artifact contents.
1558        decoded: TransactionId,
1559    },
1560    /// Startup recovery found ownership it could not prove and left it untouched.
1561    RecoveryConflicts(Box<RecoveryReport>),
1562    /// The transaction record has no durable exact artifact.
1563    MissingPending {
1564        /// Requested transaction.
1565        transaction: TransactionId,
1566    },
1567    /// A duplicate exact artifact attempted to occupy the pending map.
1568    DuplicatePending {
1569        /// Duplicate transaction.
1570        transaction: TransactionId,
1571    },
1572    /// Process-local preview retention reached its configured hard bound.
1573    EphemeralCapacity {
1574        /// Number of previews retained before this attempt.
1575        entries: usize,
1576        /// Maximum previews retained by one runtime.
1577        max_entries: usize,
1578        /// Total encoded bytes that retaining this preview would require.
1579        attempted_bytes: usize,
1580        /// Maximum encoded bytes retained by one runtime.
1581        max_bytes: usize,
1582    },
1583    /// The short-lived pending-artifact mutex was poisoned by a panic.
1584    PendingPoisoned,
1585    /// Direct commit was blocked because a configured hook must decide first.
1586    HookRequired(Box<RequestEvent>),
1587    /// The external hook handler failed after fail-closed state was applied.
1588    HookHandler(HookHandlerError),
1589    /// Durable state changed after the event was prepared.
1590    HookStateChanged {
1591        /// Exact transaction represented by the preparation.
1592        transaction: TransactionId,
1593        /// State captured while preparing the hook event.
1594        prepared: TransactionState,
1595        /// State observed when resolving the hook decision.
1596        actual: TransactionState,
1597    },
1598    /// Hook configuration no longer matches the prepared transaction evidence.
1599    HookConfigurationChanged {
1600        /// Transaction whose bound hook configuration changed.
1601        transaction: TransactionId,
1602    },
1603    /// The event supplied for resolution was not the exact regenerated event.
1604    HookEventMismatch {
1605        /// Transaction whose regenerated event did not match.
1606        transaction: TransactionId,
1607    },
1608    /// A handler decision was supplied when no handler was requested.
1609    UnexpectedHookDecision {
1610        /// Transaction that did not request a hook decision.
1611        transaction: TransactionId,
1612    },
1613    /// The selected transaction state cannot accept a hook decision.
1614    HookNotActionable {
1615        /// Transaction rejected by the hook state guard.
1616        transaction: TransactionId,
1617        /// Non-actionable state observed by the resolver.
1618        actual: TransactionState,
1619    },
1620    /// Hook feedback exceeded its configured hard UTF-8 bound.
1621    HookReasonLimit {
1622        /// Observed UTF-8 byte length.
1623        observed: usize,
1624        /// Configured maximum UTF-8 byte length.
1625        maximum: usize,
1626    },
1627    /// Hook approval expiry overflowed or did not advance host time.
1628    HookApprovalWindow {
1629        /// Transaction whose approval window was invalid.
1630        transaction: TransactionId,
1631    },
1632    /// Legacy or truncated evidence cannot be approved by an automated hook.
1633    IncompleteHookEvidence {
1634        /// Transaction without complete hook evidence.
1635        transaction: TransactionId,
1636    },
1637}
1638
1639impl fmt::Display for VshError {
1640    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1641        match self {
1642            Self::DataDirectory(source) => fmt::Display::fmt(source, formatter),
1643            Self::Blob(source) => fmt::Display::fmt(source, formatter),
1644            Self::Commit(source) => fmt::Display::fmt(source, formatter),
1645            Self::Execution(source) => fmt::Display::fmt(source, formatter),
1646            Self::Vfs(source) => fmt::Display::fmt(source, formatter),
1647            Self::Store(source) => fmt::Display::fmt(source, formatter),
1648            Self::Approval(source) => fmt::Display::fmt(source, formatter),
1649            Self::CommitPlan(source) => fmt::Display::fmt(source, formatter),
1650            Self::Artifact(source) => fmt::Display::fmt(source, formatter),
1651            Self::ResultCompatibility(source) => fmt::Display::fmt(source, formatter),
1652            Self::UnsafeDataDirectory {
1653                workspace_root,
1654                data_directory,
1655            } => write!(
1656                formatter,
1657                "trusted data directory {} must be disjoint from workspace {}",
1658                data_directory.display(),
1659                workspace_root.display()
1660            ),
1661            Self::ArtifactBinding { requested, decoded } => write!(
1662                formatter,
1663                "pending artifact for {requested} decodes to transaction {decoded}"
1664            ),
1665            Self::RecoveryConflicts(report) => write!(
1666                formatter,
1667                "startup recovery left {} ambiguous transaction(s)",
1668                report.conflicts.len()
1669            ),
1670            Self::MissingPending { transaction } => {
1671                write!(
1672                    formatter,
1673                    "no durable pending artifact for transaction {transaction}"
1674                )
1675            }
1676            Self::DuplicatePending { transaction } => {
1677                write!(
1678                    formatter,
1679                    "pending artifact already exists for {transaction}"
1680                )
1681            }
1682            Self::EphemeralCapacity {
1683                entries,
1684                max_entries,
1685                attempted_bytes,
1686                max_bytes,
1687            } => write!(
1688                formatter,
1689                "process-local preview capacity exceeded: {entries}/{max_entries} entries, \
1690                 {attempted_bytes}/{max_bytes} encoded bytes"
1691            ),
1692            Self::PendingPoisoned => formatter.write_str("pending artifact lock was poisoned"),
1693            Self::HookRequired(event) => write!(
1694                formatter,
1695                "commit hook {} must decide request event {} for transaction {}",
1696                event.hook_id, event.event_id, event.transaction
1697            ),
1698            Self::HookHandler(source) => write!(formatter, "commit hook handler failed: {source}"),
1699            Self::HookStateChanged {
1700                transaction,
1701                prepared,
1702                actual,
1703            } => write!(
1704                formatter,
1705                "transaction {transaction} changed from prepared state {prepared:?} to {actual:?}"
1706            ),
1707            Self::HookConfigurationChanged { transaction } => write!(
1708                formatter,
1709                "commit hook configuration changed for transaction {transaction}"
1710            ),
1711            Self::HookEventMismatch { transaction } => write!(
1712                formatter,
1713                "commit hook event does not match transaction {transaction}"
1714            ),
1715            Self::UnexpectedHookDecision { transaction } => write!(
1716                formatter,
1717                "transaction {transaction} did not request a hook decision"
1718            ),
1719            Self::HookNotActionable {
1720                transaction,
1721                actual,
1722            } => write!(
1723                formatter,
1724                "transaction {transaction} in state {actual:?} cannot accept a hook decision"
1725            ),
1726            Self::HookReasonLimit { observed, maximum } => write!(
1727                formatter,
1728                "hook feedback uses {observed} bytes, exceeding the {maximum}-byte limit"
1729            ),
1730            Self::HookApprovalWindow { transaction } => write!(
1731                formatter,
1732                "hook approval window is invalid for transaction {transaction}"
1733            ),
1734            Self::IncompleteHookEvidence { transaction } => write!(
1735                formatter,
1736                "transaction {transaction} has incomplete evidence and cannot be hook-approved"
1737            ),
1738        }
1739    }
1740}
1741
1742impl Error for VshError {
1743    fn source(&self) -> Option<&(dyn Error + 'static)> {
1744        match self {
1745            Self::DataDirectory(source) => Some(source),
1746            Self::Blob(source) => Some(source),
1747            Self::Commit(source) => Some(source),
1748            Self::Execution(source) => Some(source),
1749            Self::Vfs(source) => Some(source),
1750            Self::Store(source) => Some(source),
1751            Self::Approval(source) => Some(source),
1752            Self::CommitPlan(source) => Some(source),
1753            Self::Artifact(source) => Some(source),
1754            Self::ResultCompatibility(source) => Some(source),
1755            Self::HookHandler(source) => Some(source),
1756            Self::RecoveryConflicts(_)
1757            | Self::UnsafeDataDirectory { .. }
1758            | Self::ArtifactBinding { .. }
1759            | Self::MissingPending { .. }
1760            | Self::DuplicatePending { .. }
1761            | Self::EphemeralCapacity { .. }
1762            | Self::PendingPoisoned
1763            | Self::HookRequired(_)
1764            | Self::HookStateChanged { .. }
1765            | Self::HookConfigurationChanged { .. }
1766            | Self::HookEventMismatch { .. }
1767            | Self::UnexpectedHookDecision { .. }
1768            | Self::HookNotActionable { .. }
1769            | Self::HookReasonLimit { .. }
1770            | Self::HookApprovalWindow { .. }
1771            | Self::IncompleteHookEvidence { .. } => None,
1772        }
1773    }
1774}
1775
1776impl From<DataDirectoryError> for VshError {
1777    fn from(source: DataDirectoryError) -> Self {
1778        Self::DataDirectory(source)
1779    }
1780}
1781
1782impl From<BlobStoreError> for VshError {
1783    fn from(source: BlobStoreError) -> Self {
1784        Self::Blob(source)
1785    }
1786}
1787
1788impl From<CommitError> for VshError {
1789    fn from(source: CommitError) -> Self {
1790        Self::Commit(source)
1791    }
1792}
1793
1794impl From<ExecutionError> for VshError {
1795    fn from(source: ExecutionError) -> Self {
1796        Self::Execution(source)
1797    }
1798}
1799
1800impl From<VfsError> for VshError {
1801    fn from(source: VfsError) -> Self {
1802        Self::Vfs(source)
1803    }
1804}
1805
1806impl From<TransactionStoreError> for VshError {
1807    fn from(source: TransactionStoreError) -> Self {
1808        Self::Store(source)
1809    }
1810}
1811
1812impl From<ApprovalGrantError> for VshError {
1813    fn from(source: ApprovalGrantError) -> Self {
1814        Self::Approval(source)
1815    }
1816}
1817
1818impl From<CommitPlanError> for VshError {
1819    fn from(source: CommitPlanError) -> Self {
1820        Self::CommitPlan(source)
1821    }
1822}
1823
1824impl From<ArtifactError> for VshError {
1825    fn from(source: ArtifactError) -> Self {
1826        Self::Artifact(source)
1827    }
1828}
1829
1830impl From<ResultCompatibilityError> for VshError {
1831    fn from(source: ResultCompatibilityError) -> Self {
1832        Self::ResultCompatibility(source)
1833    }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838    use std::error::Error;
1839    use std::fs;
1840    use std::path::{Path, PathBuf};
1841    use std::sync::atomic::{AtomicU64, Ordering};
1842    use std::sync::{Arc, Mutex};
1843
1844    use vsh_commit::CommitError;
1845    use vsh_policy::{DenyReason, PolicyProfile};
1846    use vsh_types::{PrincipalId, TransactionState};
1847
1848    use super::{
1849        ApprovalGrantError, ArtifactError, ArtifactLimits, BlobStoreError, CommitPlanError,
1850        DataDirectory, ExecutionBudget, ExecutionError, ReceiptDetail, ResultCompatibility,
1851        ResultCompatibilityError, RunMode, RunRequest, Runtime, RuntimeConfig, RuntimeDecision,
1852        SnapshotLimits, TransactionStoreError, VfsError, VshError,
1853    };
1854    use crate::hook::{
1855        HookConfig, HookDecision, HookHandlerError, HookScope, HookVerdict, HookedRuntime,
1856        RequestEvent,
1857    };
1858
1859    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1860
1861    struct TestDirectory(PathBuf);
1862
1863    impl TestDirectory {
1864        fn new(name: &str) -> Self {
1865            let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1866            let path = std::env::temp_dir().join(format!(
1867                "vsh-runtime-{name}-{}-{sequence}",
1868                std::process::id()
1869            ));
1870            fs::create_dir(&path).expect("unique test workspace should be created");
1871            Self(path)
1872        }
1873
1874        fn path(&self) -> &Path {
1875            &self.0
1876        }
1877    }
1878
1879    impl Drop for TestDirectory {
1880        fn drop(&mut self) {
1881            let _ = fs::remove_dir_all(&self.0);
1882        }
1883    }
1884
1885    #[test]
1886    fn auto_mode_commits_one_exact_virtual_result() {
1887        let directory = TestDirectory::new("auto");
1888        fs::write(directory.path().join("input.txt"), b"hello\n").unwrap();
1889        let runtime =
1890            Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
1891                .unwrap();
1892        let receipt = runtime
1893            .run(
1894                RunRequest::new(
1895                    r"
1896from pathlib import Path
1897value = Path('/workspace/input.txt').read_text()
1898Path('/workspace/output.txt').write_text(value.upper())
1899len(value)
1900",
1901                )
1902                .with_mode(RunMode::Auto)
1903                .with_detail(ReceiptDetail::Full),
1904            )
1905            .unwrap();
1906
1907        assert_eq!(receipt.state, TransactionState::Committed);
1908        assert!(matches!(receipt.decision, RuntimeDecision::AutoApproved));
1909        assert_eq!(receipt.changed_paths, 1);
1910        assert_eq!(receipt.changes.len(), 1);
1911        assert_eq!(
1912            fs::read(directory.path().join("output.txt")).unwrap(),
1913            b"HELLO\n"
1914        );
1915        assert!(receipt.commit.is_some());
1916    }
1917
1918    #[test]
1919    fn oversized_program_is_rejected_before_workspace_snapshot() {
1920        let directory = TestDirectory::new("program-preflight");
1921        let runtime = Runtime::open(
1922            RuntimeConfig::new(directory.path())
1923                .with_snapshot_limits(SnapshotLimits {
1924                    max_nodes: 0,
1925                    ..SnapshotLimits::default()
1926                })
1927                .with_in_process_execution(),
1928        )
1929        .unwrap();
1930        let budget = ExecutionBudget {
1931            max_program_bytes: 1,
1932            ..ExecutionBudget::default()
1933        };
1934
1935        let error = runtime
1936            .run(RunRequest::new("42").with_budget(budget))
1937            .unwrap_err();
1938        assert!(matches!(
1939            error,
1940            VshError::Execution(ExecutionError::Limit(source))
1941                if matches!(*source, vsh_monty::ExecutionLimitExceeded::ProgramBytes {
1942                    limit: 1,
1943                    attempted: 2,
1944                })
1945        ));
1946    }
1947
1948    #[test]
1949    fn process_local_preview_cache_is_bounded_and_explicitly_releasable() {
1950        let directory = TestDirectory::new("ephemeral-capacity");
1951        let runtime = Runtime::open(
1952            RuntimeConfig::new(directory.path())
1953                .with_artifact_limits(ArtifactLimits {
1954                    max_ephemeral_entries: 1,
1955                    ..ArtifactLimits::default()
1956                })
1957                .with_in_process_execution(),
1958        )
1959        .unwrap();
1960
1961        let first = runtime.preview(RunRequest::new("None")).unwrap();
1962        let error = runtime.preview(RunRequest::new("0")).unwrap_err();
1963        assert!(matches!(
1964            error,
1965            VshError::EphemeralCapacity {
1966                entries: 1,
1967                max_entries: 1,
1968                ..
1969            }
1970        ));
1971
1972        assert!(runtime.discard_preview(first.transaction).unwrap());
1973        assert!(!runtime.discard_preview(first.transaction).unwrap());
1974        runtime.preview(RunRequest::new("1")).unwrap();
1975    }
1976
1977    #[test]
1978    fn python_result_incompatibility_prevents_auto_commit() {
1979        let directory = TestDirectory::new("python-result");
1980        let runtime = Runtime::open(
1981            RuntimeConfig::new(directory.path())
1982                .with_result_compatibility(ResultCompatibility::Python)
1983                .with_in_process_execution(),
1984        )
1985        .unwrap();
1986
1987        let error = runtime
1988            .run(
1989                RunRequest::new(
1990                    r"
1991from pathlib import Path
1992Path('/workspace/must-not-exist.txt').write_text('blocked')
1993type({}.keys())
1994",
1995                )
1996                .with_mode(RunMode::Auto),
1997            )
1998            .unwrap_err();
1999
2000        assert!(matches!(error, VshError::ResultCompatibility(_)));
2001        assert!(!directory.path().join("must-not-exist.txt").exists());
2002    }
2003
2004    #[test]
2005    fn strict_preview_requires_exact_approval_before_commit() {
2006        let directory = TestDirectory::new("approval");
2007        let runtime = Runtime::open(
2008            RuntimeConfig::new(directory.path())
2009                .with_policy_profile(PolicyProfile::Strict)
2010                .with_in_process_execution(),
2011        )
2012        .unwrap();
2013        let receipt = runtime
2014            .preview(RunRequest::new(
2015                "from pathlib import Path\nPath('/workspace/approved.txt').write_text('yes')",
2016            ))
2017            .unwrap();
2018        assert_eq!(receipt.state, TransactionState::PendingApproval);
2019        assert!(matches!(
2020            receipt.decision,
2021            RuntimeDecision::PendingApproval(_)
2022        ));
2023        assert!(!directory.path().join("approved.txt").exists());
2024
2025        runtime
2026            .approve(
2027                receipt.transaction,
2028                PrincipalId::digest_label("independent-test-principal"),
2029                10,
2030                20,
2031            )
2032            .unwrap();
2033        let committed = runtime.commit(receipt.transaction, 11).unwrap();
2034        assert_eq!(committed.state, TransactionState::Committed);
2035        assert_eq!(
2036            fs::read(directory.path().join("approved.txt")).unwrap(),
2037            b"yes"
2038        );
2039    }
2040
2041    #[test]
2042    fn approval_artifact_survives_runtime_restart() {
2043        let directory = TestDirectory::new("approval-restart");
2044        let config = RuntimeConfig::new(directory.path())
2045            .with_policy_profile(PolicyProfile::Strict)
2046            .with_in_process_execution();
2047        let receipt = Runtime::open(config.clone())
2048            .unwrap()
2049            .preview(
2050                RunRequest::new(
2051                    "from pathlib import Path\nPath('/workspace/restarted.txt').write_text('yes')\n{'answer': 42}",
2052                )
2053                .with_detail(ReceiptDetail::Full),
2054            )
2055            .unwrap();
2056        assert_eq!(receipt.state, TransactionState::PendingApproval);
2057
2058        let reopened = Runtime::open(config).unwrap();
2059        reopened
2060            .approve(
2061                receipt.transaction,
2062                PrincipalId::digest_label("restart-principal"),
2063                100,
2064                200,
2065            )
2066            .unwrap();
2067        let committed = reopened.commit(receipt.transaction, 101).unwrap();
2068
2069        assert_eq!(committed.state, TransactionState::Committed);
2070        assert_eq!(committed.value.py_repr(), "{'answer': 42}");
2071        assert_eq!(committed.changes, receipt.changes);
2072        assert_eq!(
2073            fs::read(directory.path().join("restarted.txt")).unwrap(),
2074            b"yes"
2075        );
2076    }
2077
2078    #[test]
2079    fn caught_protected_read_deterministically_denies_all_changes() {
2080        let directory = TestDirectory::new("deny");
2081        fs::write(directory.path().join(".env"), b"TOKEN=secret\n").unwrap();
2082        let runtime =
2083            Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
2084                .unwrap();
2085        let receipt = runtime
2086            .run(
2087                RunRequest::new(
2088                    r"
2089from pathlib import Path
2090try:
2091    Path('/workspace/.env').read_text()
2092except PermissionError:
2093    Path('/workspace/should-not-exist.txt').write_text('blocked')
2094",
2095                )
2096                .with_mode(RunMode::Auto),
2097            )
2098            .unwrap();
2099
2100        assert_eq!(receipt.state, TransactionState::Denied);
2101        assert!(matches!(
2102            receipt.decision,
2103            RuntimeDecision::Denied(ref manifest)
2104                if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
2105        ));
2106        assert!(!directory.path().join("should-not-exist.txt").exists());
2107    }
2108
2109    #[test]
2110    fn stale_preview_never_overwrites_external_work() {
2111        let directory = TestDirectory::new("stale");
2112        fs::write(directory.path().join("input.txt"), b"before").unwrap();
2113        let runtime =
2114            Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
2115                .unwrap();
2116        let receipt = runtime
2117            .preview(RunRequest::new(
2118                r"
2119from pathlib import Path
2120value = Path('/workspace/input.txt').read_text()
2121Path('/workspace/output.txt').write_text(value)
2122",
2123            ))
2124            .unwrap();
2125        fs::write(directory.path().join("input.txt"), b"external").unwrap();
2126
2127        let error = runtime.commit(receipt.transaction, 0).unwrap_err();
2128        assert!(matches!(error, VshError::Commit(CommitError::Stale { .. })));
2129        assert!(!directory.path().join("output.txt").exists());
2130        assert_eq!(
2131            runtime.transaction(receipt.transaction).unwrap().state(),
2132            TransactionState::Stale
2133        );
2134    }
2135
2136    #[test]
2137    fn explicit_data_directory_is_external_and_capability_rooted() {
2138        let workspace = TestDirectory::new("external-data-workspace");
2139        let data = TestDirectory::new("external-data-store");
2140        let config = RuntimeConfig::new(workspace.path())
2141            .with_data_directory(data.path())
2142            .with_in_process_execution();
2143
2144        assert_eq!(config.workspace_root(), workspace.path());
2145        assert_eq!(config.data_directory(), data.path());
2146        assert!(config.worker_path().is_none());
2147
2148        let runtime = Runtime::open(config).unwrap();
2149        runtime.preview(RunRequest::new("42")).unwrap();
2150
2151        assert!(data.path().join("blobs").is_dir());
2152        assert!(data.path().join("transactions.lock").is_file());
2153        assert!(workspace.path().join(".vsh-runtime/transactions").is_dir());
2154    }
2155
2156    #[test]
2157    fn explicit_data_directory_cannot_overlap_workspace() {
2158        let workspace = TestDirectory::new("overlapping-data");
2159        let data = workspace.path().join("caller-selected-data");
2160        let result = Runtime::open(
2161            RuntimeConfig::new(workspace.path())
2162                .with_data_directory(&data)
2163                .with_in_process_execution(),
2164        );
2165
2166        assert!(matches!(result, Err(VshError::UnsafeDataDirectory { .. })));
2167        assert!(!data.exists());
2168    }
2169
2170    #[cfg(unix)]
2171    #[test]
2172    fn default_runtime_symlink_fails_before_external_write() {
2173        use std::os::unix::fs::symlink;
2174
2175        let workspace = TestDirectory::new("runtime-symlink-workspace");
2176        let outside = TestDirectory::new("runtime-symlink-outside");
2177        symlink(outside.path(), workspace.path().join(".vsh-runtime")).unwrap();
2178
2179        let result =
2180            Runtime::open(RuntimeConfig::new(workspace.path()).with_in_process_execution());
2181
2182        assert!(matches!(
2183            result,
2184            Err(VshError::Commit(CommitError::InternalIo { .. }))
2185        ));
2186        assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
2187    }
2188
2189    #[cfg(unix)]
2190    #[test]
2191    fn canonical_alias_into_workspace_is_rejected_before_store_files() {
2192        use std::os::unix::fs::symlink;
2193
2194        let workspace = TestDirectory::new("canonical-overlap-workspace");
2195        let alias_root = TestDirectory::new("canonical-overlap-alias");
2196        let alias = alias_root.path().join("workspace-alias");
2197        symlink(workspace.path(), &alias).unwrap();
2198        let data = alias.join("nested-data");
2199
2200        let result = Runtime::open(
2201            RuntimeConfig::new(workspace.path())
2202                .with_data_directory(&data)
2203                .with_in_process_execution(),
2204        );
2205
2206        assert!(matches!(result, Err(VshError::UnsafeDataDirectory { .. })));
2207        assert!(!workspace.path().join("nested-data").exists());
2208    }
2209
2210    #[test]
2211    fn review_hook_receives_complete_canonical_evidence_and_can_commit() {
2212        let directory = TestDirectory::new("review-hook-approve");
2213        let observed = Arc::new(Mutex::new(None::<RequestEvent>));
2214        let observed_by_hook = Arc::clone(&observed);
2215        let runtime = HookedRuntime::open(
2216            RuntimeConfig::new(directory.path())
2217                .with_policy_profile(PolicyProfile::Strict)
2218                .with_in_process_execution(),
2219            HookConfig::new("security-review"),
2220            move |event: &RequestEvent| {
2221                *observed_by_hook.lock().unwrap() = Some(event.clone());
2222                Ok(HookDecision::approve("canonical evidence is safe"))
2223            },
2224        )
2225        .unwrap();
2226
2227        let receipt = runtime
2228            .run(
2229                RunRequest::new(
2230                    "from pathlib import Path\nPath('/workspace/reviewed.txt').write_text('safe')",
2231                )
2232                .with_intent("create the reviewed output")
2233                .with_mode(RunMode::Auto),
2234                1_000,
2235            )
2236            .unwrap();
2237
2238        assert_eq!(receipt.state, TransactionState::Committed);
2239        assert_eq!(
2240            fs::read(directory.path().join("reviewed.txt")).unwrap(),
2241            b"safe"
2242        );
2243        let event = observed.lock().unwrap().clone().unwrap();
2244        assert_eq!(event.transaction, receipt.transaction);
2245        assert_eq!(event.intent.as_deref(), Some("create the reviewed output"));
2246        assert_eq!(event.canonical_diff.len(), 1);
2247        assert_eq!(event.canonical_diff[0].path.as_str(), "reviewed.txt");
2248        assert!(!event.effects.is_empty());
2249        assert!(event.evidence_complete);
2250        assert!(!event.evidence_truncated);
2251    }
2252
2253    #[test]
2254    fn review_content_binds_before_after_and_survives_restart_without_live_reads() {
2255        let directory = TestDirectory::new("review-content-restart");
2256        let path = directory.path().join("config.txt");
2257        fs::write(&path, b"before").unwrap();
2258        let config = RuntimeConfig::new(directory.path())
2259            .with_policy_profile(PolicyProfile::Strict)
2260            .with_commit_hook(HookConfig::new("content-review").with_max_content_bytes(1024))
2261            .with_in_process_execution();
2262        let runtime = Runtime::open(config.clone()).unwrap();
2263        let preview = runtime
2264            .preview(RunRequest::new(
2265                "vsh_write('/workspace/config.txt', 'after')",
2266            ))
2267            .unwrap();
2268        let prepared = runtime.prepare_commit(preview.transaction).unwrap();
2269        let event = prepared.event().unwrap();
2270        assert!(event.content_complete);
2271        assert_eq!(
2272            event
2273                .contents
2274                .iter()
2275                .map(|content| content.bytes.as_slice())
2276                .collect::<Vec<_>>(),
2277            vec![b"before".as_slice(), b"after".as_slice()]
2278        );
2279        assert!(
2280            event
2281                .contents
2282                .iter()
2283                .all(|content| content.path.as_str() == "config.txt")
2284        );
2285        drop(runtime);
2286
2287        fs::write(&path, b"external change").unwrap();
2288        let restarted = Runtime::open(config).unwrap();
2289        let after_restart = restarted.prepare_commit(preview.transaction).unwrap();
2290        assert_eq!(after_restart.event(), prepared.event());
2291        assert!(matches!(
2292            restarted.resolve_commit(
2293                &after_restart,
2294                &HookDecision::approve("reviewed exact bytes"),
2295                1000
2296            ),
2297            Err(VshError::Commit(CommitError::Stale { .. }))
2298        ));
2299        assert_eq!(fs::read(path).unwrap(), b"external change");
2300    }
2301
2302    #[test]
2303    fn review_content_budget_is_explicit_and_never_labels_partial_content_complete() {
2304        let directory = TestDirectory::new("review-content-budget");
2305        fs::write(directory.path().join("file.txt"), b"large before").unwrap();
2306        for maximum in [0, 3] {
2307            let runtime = Runtime::open(
2308                RuntimeConfig::new(directory.path())
2309                    .with_policy_profile(PolicyProfile::Strict)
2310                    .with_commit_hook(HookConfig::new("bounded").with_max_content_bytes(maximum))
2311                    .with_in_process_execution(),
2312            )
2313            .unwrap();
2314            let preview = runtime
2315                .preview(RunRequest::new("vsh_write('/workspace/file.txt', 'new')"))
2316                .unwrap();
2317            let prepared = runtime.prepare_commit(preview.transaction).unwrap();
2318            let event = prepared.event().unwrap();
2319            assert!(!event.content_complete);
2320            assert!(
2321                event
2322                    .contents
2323                    .iter()
2324                    .map(|content| content.bytes.len())
2325                    .sum::<usize>()
2326                    <= maximum
2327            );
2328            assert_eq!(
2329                fs::read(directory.path().join("file.txt")).unwrap(),
2330                b"large before"
2331            );
2332        }
2333    }
2334
2335    #[test]
2336    fn read_only_review_contains_exact_observed_content_once() {
2337        let directory = TestDirectory::new("review-read-content");
2338        fs::write(directory.path().join("read.txt"), b"read evidence").unwrap();
2339        let runtime = Runtime::open(
2340            RuntimeConfig::new(directory.path())
2341                .with_commit_hook(
2342                    HookConfig::new("read-content")
2343                        .with_scope(HookScope::AllRequests)
2344                        .with_max_content_bytes(100),
2345                )
2346                .with_in_process_execution(),
2347        )
2348        .unwrap();
2349        let receipt = runtime
2350            .preview(RunRequest::new(
2351                "vsh_read('/workspace/read.txt')\nvsh_read('/workspace/read.txt')",
2352            ))
2353            .unwrap();
2354        let preparation = runtime.prepare_commit(receipt.transaction).unwrap();
2355        let event = preparation.event().unwrap();
2356        assert!(event.canonical_diff.is_empty());
2357        assert!(event.content_complete);
2358        assert_eq!(event.contents.len(), 1);
2359        assert_eq!(event.contents[0].bytes, b"read evidence");
2360    }
2361
2362    #[test]
2363    fn empty_after_content_fits_an_exact_before_byte_budget() {
2364        let directory = TestDirectory::new("review-empty-content");
2365        fs::write(directory.path().join("file.txt"), b"abc").unwrap();
2366        let runtime = Runtime::open(
2367            RuntimeConfig::new(directory.path())
2368                .with_policy_profile(PolicyProfile::Strict)
2369                .with_commit_hook(HookConfig::new("empty-after").with_max_content_bytes(3))
2370                .with_in_process_execution(),
2371        )
2372        .unwrap();
2373        let preview = runtime
2374            .preview(RunRequest::new("vsh_write('/workspace/file.txt', '')"))
2375            .unwrap();
2376        let preparation = runtime.prepare_commit(preview.transaction).unwrap();
2377        let event = preparation.event().unwrap();
2378        assert!(event.content_complete);
2379        assert_eq!(event.contents.len(), 2);
2380        assert_eq!(event.contents[0].bytes, b"abc");
2381        assert!(event.contents[1].bytes.is_empty());
2382    }
2383
2384    #[test]
2385    fn ready_preparation_cannot_bypass_an_applicable_hook() {
2386        let directory = TestDirectory::new("hook-forged-ready");
2387        let runtime = Runtime::open(
2388            RuntimeConfig::new(directory.path())
2389                .with_commit_hook(HookConfig::new("required").with_scope(HookScope::AllRequests))
2390                .with_in_process_execution(),
2391        )
2392        .unwrap();
2393        let receipt = runtime
2394            .preview(RunRequest::new(
2395                "vsh_write('/workspace/result.txt', 'must not commit')",
2396            ))
2397            .unwrap();
2398        runtime.prepare_commit(receipt.transaction).unwrap();
2399        let forged = super::CommitPreparation::Ready {
2400            transaction: receipt.transaction,
2401            state: receipt.state,
2402        };
2403        assert!(matches!(
2404            runtime.resolve_commit(&forged, &HookDecision::FollowPolicy, 1000),
2405            Err(VshError::HookRequired(_))
2406        ));
2407        assert!(!directory.path().join("result.txt").exists());
2408    }
2409
2410    #[test]
2411    fn content_review_never_widens_native_read_permissions() {
2412        use vsh_policy::{AccessSet, CallPolicy, ProtectedRule, TransactionPolicy};
2413
2414        let directory = TestDirectory::new("review-read-permissions");
2415        fs::write(directory.path().join("restricted.txt"), b"private before").unwrap();
2416        let policy = TransactionPolicy::new(
2417            PolicyProfile::Balanced,
2418            TransactionPolicy::default().thresholds(),
2419            CallPolicy::new(vec![
2420                ProtectedRule::new("restricted.txt", AccessSet::CONTENT_READ).unwrap(),
2421            ]),
2422        )
2423        .unwrap();
2424        let runtime = Runtime::open(
2425            RuntimeConfig::new(directory.path())
2426                .with_policy(policy)
2427                .with_commit_hook(
2428                    HookConfig::new("no-read")
2429                        .with_scope(HookScope::AllRequests)
2430                        .with_max_content_bytes(1024),
2431                )
2432                .with_in_process_execution(),
2433        )
2434        .unwrap();
2435        let receipt = runtime
2436            .preview(RunRequest::new(
2437                "vsh_write('/workspace/restricted.txt', 'replacement')",
2438            ))
2439            .unwrap();
2440        let prepared = runtime.prepare_commit(receipt.transaction).unwrap();
2441        let event = prepared.event().unwrap();
2442        assert!(!event.content_complete);
2443        assert!(event.contents.is_empty());
2444    }
2445
2446    #[test]
2447    fn all_hook_can_return_feedback_and_keep_a_transaction_pending() {
2448        let directory = TestDirectory::new("hook-review-feedback");
2449        let calls = Arc::new(AtomicU64::new(0));
2450        let calls_by_hook = Arc::clone(&calls);
2451        let runtime = HookedRuntime::open(
2452            RuntimeConfig::new(directory.path()).with_in_process_execution(),
2453            HookConfig::new("evidence-judge").with_scope(HookScope::AllRequests),
2454            move |_event: &RequestEvent| {
2455                calls_by_hook.fetch_add(1, Ordering::Relaxed);
2456                Ok(HookDecision::review(
2457                    "generated file requires an explicit main-agent confirmation",
2458                ))
2459            },
2460        )
2461        .unwrap();
2462        let preview = runtime
2463            .preview(RunRequest::new(
2464                "from pathlib import Path\nPath('/workspace/check-me.txt').write_text('value')",
2465            ))
2466            .unwrap();
2467        assert_eq!(preview.state, TransactionState::AutoApproved);
2468
2469        let resolution = runtime.commit(preview.transaction, 2_000).unwrap();
2470        assert_eq!(resolution.receipt.state, TransactionState::PendingApproval);
2471        let decision = resolution.hook.unwrap();
2472        assert_eq!(decision.verdict, HookVerdict::Review);
2473        assert!(decision.reason.contains("main-agent"));
2474        assert!(!directory.path().join("check-me.txt").exists());
2475        assert_eq!(calls.load(Ordering::Relaxed), 1);
2476
2477        runtime
2478            .approve(
2479                preview.transaction,
2480                PrincipalId::digest_label("main-agent"),
2481                2_100,
2482                3_000,
2483            )
2484            .unwrap();
2485        let committed = runtime.commit(preview.transaction, 2_200).unwrap();
2486        assert_eq!(committed.receipt.state, TransactionState::Committed);
2487        assert_eq!(calls.load(Ordering::Relaxed), 1);
2488    }
2489
2490    #[test]
2491    fn all_requests_scope_delivers_read_only_evidence() {
2492        let directory = TestDirectory::new("hook-read-only");
2493        fs::write(directory.path().join("input.txt"), b"evidence").unwrap();
2494        let observed = Arc::new(Mutex::new(None::<RequestEvent>));
2495        let observed_by_hook = Arc::clone(&observed);
2496        let runtime = HookedRuntime::open(
2497            RuntimeConfig::new(directory.path()).with_in_process_execution(),
2498            HookConfig::new("read-review").with_scope(HookScope::AllRequests),
2499            move |event: &RequestEvent| {
2500                *observed_by_hook.lock().unwrap() = Some(event.clone());
2501                Ok(HookDecision::approve("bounded read is acceptable"))
2502            },
2503        )
2504        .unwrap();
2505
2506        let receipt = runtime
2507            .run(
2508                RunRequest::new(
2509                    "from pathlib import Path\nPath('/workspace/input.txt').read_text()",
2510                )
2511                .with_mode(RunMode::Auto),
2512                10,
2513            )
2514            .unwrap();
2515
2516        assert_eq!(receipt.state, TransactionState::Committed);
2517        assert_eq!(receipt.changed_paths, 0);
2518        let event = observed.lock().unwrap().clone().unwrap();
2519        assert!(event.canonical_diff.is_empty());
2520        assert!(
2521            event
2522                .effects
2523                .iter()
2524                .any(|effect| matches!(effect.effect, vsh_vfs::Effect::ContentRead { .. }))
2525        );
2526        assert!(event.execution.read_bytes > 0);
2527    }
2528
2529    #[test]
2530    fn failed_hook_closes_auto_approval_into_pending_review() {
2531        let directory = TestDirectory::new("hook-failure");
2532        let runtime = HookedRuntime::open(
2533            RuntimeConfig::new(directory.path()).with_in_process_execution(),
2534            HookConfig::new("failing-hook").with_scope(HookScope::AllRequests),
2535            |_event: &RequestEvent| Err(HookHandlerError::new("judge unavailable")),
2536        )
2537        .unwrap();
2538        let preview = runtime
2539            .preview(RunRequest::new(
2540                "from pathlib import Path\nPath('/workspace/not-yet.txt').write_text('value')",
2541            ))
2542            .unwrap();
2543
2544        let error = runtime.commit(preview.transaction, 0).unwrap_err();
2545        assert!(matches!(error, VshError::HookHandler(_)));
2546        assert_eq!(
2547            runtime.transaction(preview.transaction).unwrap().state(),
2548            TransactionState::PendingApproval
2549        );
2550        assert!(!directory.path().join("not-yet.txt").exists());
2551    }
2552
2553    #[test]
2554    fn hard_policy_denial_never_invokes_hook() {
2555        let directory = TestDirectory::new("hook-hard-deny");
2556        fs::write(directory.path().join(".env"), b"secret").unwrap();
2557        let calls = Arc::new(AtomicU64::new(0));
2558        let calls_by_hook = Arc::clone(&calls);
2559        let runtime = HookedRuntime::open(
2560            RuntimeConfig::new(directory.path()).with_in_process_execution(),
2561            HookConfig::new("deny-proof").with_scope(HookScope::AllRequests),
2562            move |_event: &RequestEvent| {
2563                calls_by_hook.fetch_add(1, Ordering::Relaxed);
2564                Ok(HookDecision::approve("must not run"))
2565            },
2566        )
2567        .unwrap();
2568        let receipt = runtime
2569            .run(
2570                RunRequest::new(
2571                    "from pathlib import Path\ntry:\n    Path('/workspace/.env').read_text()\nexcept PermissionError:\n    pass",
2572                )
2573                .with_mode(RunMode::Auto),
2574                0,
2575            )
2576            .unwrap();
2577
2578        assert_eq!(receipt.state, TransactionState::Denied);
2579        assert_eq!(calls.load(Ordering::Relaxed), 0);
2580    }
2581
2582    #[test]
2583    fn native_error_surface_is_catchable_and_stable() {
2584        let directory = TestDirectory::new("runtime-errors");
2585        let not_a_directory = directory.path().join("file");
2586        fs::write(&not_a_directory, b"file").unwrap();
2587        let data_error = DataDirectory::open_trusted(&not_a_directory).unwrap_err();
2588        let transaction = vsh_types::TransactionId::from_bytes([7; 32]);
2589        let sourced = [
2590            VshError::DataDirectory(data_error),
2591            VshError::Blob(BlobStoreError::Io {
2592                operation: "read",
2593                path: PathBuf::from("blob"),
2594                source: std::io::Error::other("test"),
2595            }),
2596            VshError::Commit(CommitError::BaseSnapshotBinding),
2597            VshError::Execution(ExecutionError::UnsupportedSuspension {
2598                kind: "test",
2599                name: Some("name".to_owned()),
2600            }),
2601            VshError::Vfs(VfsError::RootMutation),
2602            VshError::Store(TransactionStoreError::NotFound { id: transaction }),
2603            VshError::Approval(ApprovalGrantError::InvalidWindow {
2604                issued_at_unix_ms: 2,
2605                expires_at_unix_ms: 1,
2606            }),
2607            VshError::CommitPlan(CommitPlanError::RootMutation),
2608            VshError::Artifact(ArtifactError::BindingMismatch),
2609            VshError::ResultCompatibility(ResultCompatibilityError::Depth {
2610                limit: 1,
2611                attempted: 2,
2612            }),
2613        ];
2614        for error in sourced {
2615            assert!(!error.to_string().is_empty());
2616            assert!(Error::source(&error).is_some());
2617        }
2618
2619        let unsourced = [
2620            VshError::UnsafeDataDirectory {
2621                workspace_root: PathBuf::from("workspace"),
2622                data_directory: PathBuf::from("workspace/data"),
2623            },
2624            VshError::ArtifactBinding {
2625                requested: transaction,
2626                decoded: vsh_types::TransactionId::from_bytes([8; 32]),
2627            },
2628            VshError::RecoveryConflicts(Box::default()),
2629            VshError::MissingPending { transaction },
2630            VshError::DuplicatePending { transaction },
2631            VshError::EphemeralCapacity {
2632                entries: 2,
2633                max_entries: 1,
2634                attempted_bytes: 2,
2635                max_bytes: 1,
2636            },
2637            VshError::PendingPoisoned,
2638        ];
2639        for error in unsourced {
2640            assert!(!error.to_string().is_empty());
2641            assert!(Error::source(&error).is_none());
2642        }
2643    }
2644}