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
39pub type ExecutionBudget = ExecutionLimits;
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct ArtifactLimits {
45 pub max_bytes: usize,
47 pub max_value_bytes: usize,
49 pub max_stdout_bytes: usize,
51 pub max_entries: usize,
53 pub max_dependencies: usize,
55 pub max_path_bytes: usize,
57 pub max_intent_bytes: usize,
59 pub max_effects: usize,
61 pub max_ephemeral_entries: usize,
63 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum RunMode {
87 #[default]
89 Preview,
90 Auto,
92}
93
94#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
96pub enum ReceiptDetail {
97 #[default]
99 Compact,
100 Full,
102}
103
104#[derive(Clone, Copy, Debug)]
106pub struct RunRequest<'a> {
107 pub code: &'a str,
109 pub intent: Option<&'a str>,
111 pub mode: RunMode,
113 pub detail: ReceiptDetail,
115 pub budget: ExecutionBudget,
117}
118
119impl<'a> RunRequest<'a> {
120 #[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 #[must_use]
134 pub const fn with_intent(mut self, intent: &'a str) -> Self {
135 self.intent = Some(intent);
136 self
137 }
138
139 #[must_use]
141 pub const fn with_mode(mut self, mode: RunMode) -> Self {
142 self.mode = mode;
143 self
144 }
145
146 #[must_use]
148 pub const fn with_detail(mut self, detail: ReceiptDetail) -> Self {
149 self.detail = detail;
150 self
151 }
152
153 #[must_use]
155 pub const fn with_budget(mut self, budget: ExecutionBudget) -> Self {
156 self.budget = budget;
157 self
158 }
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum RuntimeDecision {
164 Denied(DenyManifest),
166 AutoApproved,
168 PendingApproval(RiskManifest),
170}
171
172#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
174pub struct StageTimings {
175 pub snapshot_ns: u64,
177 pub execute_ns: u64,
179 pub diff_ns: u64,
181 pub policy_ns: u64,
183 pub bind_and_store_ns: u64,
185 pub commit_ns: u64,
187 pub total_ns: u64,
189}
190
191#[derive(Clone, Debug)]
193pub struct Receipt {
194 pub transaction: TransactionId,
196 pub base_snapshot: SnapshotId,
198 pub state: TransactionState,
200 pub decision: RuntimeDecision,
202 pub diff: DiffDigest,
204 pub changed_paths: usize,
206 pub changes: Vec<DiffEntry>,
208 pub value: MontyObject,
210 pub stdout: String,
212 pub execution: ExecutionStats,
214 pub timings: StageTimings,
216 pub commit: Option<CommitReceipt>,
218}
219
220#[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
307 pub fn with_in_process_execution(mut self) -> Self {
308 self.worker_path = None;
309 self
310 }
311
312 #[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 #[must_use]
321 pub fn with_policy(mut self, policy: TransactionPolicy) -> Self {
322 self.policy = policy;
323 self
324 }
325
326 #[must_use]
328 pub fn with_policy_profile(self, profile: PolicyProfile) -> Self {
329 self.with_policy(TransactionPolicy::preset(profile))
330 }
331
332 #[must_use]
334 pub const fn with_snapshot_limits(mut self, limits: SnapshotLimits) -> Self {
335 self.snapshot_limits = limits;
336 self
337 }
338
339 #[must_use]
341 pub const fn with_commit_config(mut self, config: CommitConfig) -> Self {
342 self.commit_config = config;
343 self
344 }
345
346 #[must_use]
348 pub const fn with_store_config(mut self, config: FileStoreConfig) -> Self {
349 self.store_config = config;
350 self
351 }
352
353 #[must_use]
355 pub const fn with_artifact_limits(mut self, limits: ArtifactLimits) -> Self {
356 self.artifact_limits = limits;
357 self
358 }
359
360 #[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 #[must_use]
369 pub fn workspace_root(&self) -> &Path {
370 &self.workspace_root
371 }
372
373 #[must_use]
375 pub fn data_directory(&self) -> &Path {
376 &self.data_directory
377 }
378
379 #[must_use]
381 pub fn worker_path(&self) -> Option<&Path> {
382 self.worker_path.as_deref()
383 }
384
385 #[must_use]
387 pub const fn policy(&self) -> &TransactionPolicy {
388 &self.policy
389 }
390
391 #[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
443pub 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 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 #[must_use]
513 pub const fn startup_recovery(&self) -> &RecoveryReport {
514 &self.startup_recovery
515 }
516
517 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 pub fn preview(&self, mut request: RunRequest<'_>) -> Result<Receipt, VshError> {
631 request.mode = RunMode::Preview;
632 self.run(request)
633 }
634
635 pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
644 self.remove_pending(transaction)
645 .map(|artifact| artifact.is_some())
646 }
647
648 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 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 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 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 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 pub fn recover(&self) -> Result<RecoveryReport, VshError> {
961 self.committer.recover(&self.store).map_err(Into::into)
962 }
963
964 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#[derive(Debug)]
1524#[non_exhaustive]
1525pub enum VshError {
1526 DataDirectory(DataDirectoryError),
1528 Blob(BlobStoreError),
1530 Commit(CommitError),
1532 Execution(ExecutionError),
1534 Vfs(VfsError),
1536 Store(TransactionStoreError),
1538 Approval(ApprovalGrantError),
1540 CommitPlan(CommitPlanError),
1542 Artifact(ArtifactError),
1544 ResultCompatibility(ResultCompatibilityError),
1546 UnsafeDataDirectory {
1548 workspace_root: PathBuf,
1550 data_directory: PathBuf,
1552 },
1553 ArtifactBinding {
1555 requested: TransactionId,
1557 decoded: TransactionId,
1559 },
1560 RecoveryConflicts(Box<RecoveryReport>),
1562 MissingPending {
1564 transaction: TransactionId,
1566 },
1567 DuplicatePending {
1569 transaction: TransactionId,
1571 },
1572 EphemeralCapacity {
1574 entries: usize,
1576 max_entries: usize,
1578 attempted_bytes: usize,
1580 max_bytes: usize,
1582 },
1583 PendingPoisoned,
1585 HookRequired(Box<RequestEvent>),
1587 HookHandler(HookHandlerError),
1589 HookStateChanged {
1591 transaction: TransactionId,
1593 prepared: TransactionState,
1595 actual: TransactionState,
1597 },
1598 HookConfigurationChanged {
1600 transaction: TransactionId,
1602 },
1603 HookEventMismatch {
1605 transaction: TransactionId,
1607 },
1608 UnexpectedHookDecision {
1610 transaction: TransactionId,
1612 },
1613 HookNotActionable {
1615 transaction: TransactionId,
1617 actual: TransactionState,
1619 },
1620 HookReasonLimit {
1622 observed: usize,
1624 maximum: usize,
1626 },
1627 HookApprovalWindow {
1629 transaction: TransactionId,
1631 },
1632 IncompleteHookEvidence {
1634 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(¬_a_directory, b"file").unwrap();
2587 let data_error = DataDirectory::open_trusted(¬_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}