Skip to main content

vsh_policy/
lib.rs

1//! Deterministic capability and transaction policy for VSH.
2//!
3//! The call policy runs before bytes or mutations reach [`vsh_vfs::VirtualFs`]. The
4//! transaction policy then evaluates the observed diff and any denied attempts. Both
5//! paths are pure, synchronous, and use only canonical VSH value types.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::error::Error;
9use std::fmt;
10use std::ops::BitOr;
11
12use vsh_types::{
13    DiffKind, IntentDigest, NodeKind, NodeState, PolicyDigest, ProgramDigest, ReadSetDigest,
14    RuntimeConfigDigest, SnapshotId, TransactionBinding, VPath, WriteSetDigest,
15};
16use vsh_vfs::{CanonicalDiff, Effect, EffectEvent, ReadObservation, WritePrecondition};
17
18/// Version of the canonical deterministic-policy encoding.
19pub const POLICY_SCHEMA_VERSION: &str = "vsh-policy-v1";
20
21/// Secret-like paths denied by the default call policy for every access kind.
22pub const DEFAULT_SECRET_PATTERNS: &[&str] = &[
23    ".env",
24    ".env/**",
25    ".env.*",
26    ".env.*/**",
27    "**/.env",
28    "**/.env/**",
29    "**/.env.*",
30    "**/.env.*/**",
31    "**/secrets/**",
32    "**/id_rsa",
33    "**/id_rsa.pub",
34    "*.pem",
35    "*.key",
36    "**/*.pem",
37    "**/*.key",
38    "**/credentials.json",
39    "**/*_credentials.json",
40    "**/.ssh/**",
41];
42
43/// Trusted runtime paths that untrusted code may neither observe nor mutate.
44pub const INTERNAL_RUNTIME_PATTERNS: &[&str] = &[
45    ".vsh-runtime",
46    ".vsh-runtime/**",
47    ".vsh-runtime-owner",
48    "**/.vsh-runtime-owner",
49];
50
51/// Semantic capability requested for one virtual path.
52#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub enum AccessKind {
54    /// Existence or node metadata.
55    MetadataRead,
56    /// File or symbolic-link content.
57    ContentRead,
58    /// Directory child enumeration.
59    DirectoryRead,
60    /// Creation of a previously absent path.
61    Create,
62    /// Content or metadata replacement.
63    Modify,
64    /// Removal of an existing path.
65    Delete,
66    /// Removal side of a rename.
67    RenameSource,
68    /// Creation/replacement side of a rename.
69    RenameDestination,
70}
71
72impl AccessKind {
73    const fn bit(self) -> u16 {
74        match self {
75            Self::MetadataRead => 1 << 0,
76            Self::ContentRead => 1 << 1,
77            Self::DirectoryRead => 1 << 2,
78            Self::Create => 1 << 3,
79            Self::Modify => 1 << 4,
80            Self::Delete => 1 << 5,
81            Self::RenameSource => 1 << 6,
82            Self::RenameDestination => 1 << 7,
83        }
84    }
85
86    /// Return whether this access may change virtual state.
87    #[must_use]
88    pub const fn is_mutation(self) -> bool {
89        matches!(
90            self,
91            Self::Create
92                | Self::Modify
93                | Self::Delete
94                | Self::RenameSource
95                | Self::RenameDestination
96        )
97    }
98}
99
100/// Compact set of path capabilities denied by a protected rule.
101#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct AccessSet(u16);
103
104impl AccessSet {
105    /// Empty capability set.
106    pub const NONE: Self = Self(0);
107    /// Existence and metadata access.
108    pub const METADATA_READ: Self = Self(AccessKind::MetadataRead.bit());
109    /// File/link byte access.
110    pub const CONTENT_READ: Self = Self(AccessKind::ContentRead.bit());
111    /// Directory enumeration access.
112    pub const DIRECTORY_READ: Self = Self(AccessKind::DirectoryRead.bit());
113    /// All read-like access.
114    pub const READS: Self = Self(
115        AccessKind::MetadataRead.bit()
116            | AccessKind::ContentRead.bit()
117            | AccessKind::DirectoryRead.bit(),
118    );
119    /// Path creation.
120    pub const CREATE: Self = Self(AccessKind::Create.bit());
121    /// Content or metadata modification.
122    pub const MODIFY: Self = Self(AccessKind::Modify.bit());
123    /// Path deletion.
124    pub const DELETE: Self = Self(AccessKind::Delete.bit());
125    /// Source side of rename.
126    pub const RENAME_SOURCE: Self = Self(AccessKind::RenameSource.bit());
127    /// Destination side of rename.
128    pub const RENAME_DESTINATION: Self = Self(AccessKind::RenameDestination.bit());
129    /// All state-changing access.
130    pub const MUTATIONS: Self = Self(
131        AccessKind::Create.bit()
132            | AccessKind::Modify.bit()
133            | AccessKind::Delete.bit()
134            | AccessKind::RenameSource.bit()
135            | AccessKind::RenameDestination.bit(),
136    );
137    /// Every currently defined capability.
138    pub const ALL: Self = Self(Self::READS.0 | Self::MUTATIONS.0);
139
140    /// Return whether `access` is in this set.
141    #[must_use]
142    pub const fn contains(self, access: AccessKind) -> bool {
143        self.0 & access.bit() != 0
144    }
145
146    const fn bits(self) -> u16 {
147        self.0
148    }
149}
150
151impl BitOr for AccessSet {
152    type Output = Self;
153
154    fn bitor(self, rhs: Self) -> Self::Output {
155        Self(self.0 | rhs.0)
156    }
157}
158
159/// Invalid protected-path pattern.
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161#[non_exhaustive]
162pub enum PatternError {
163    /// The pattern was empty.
164    Empty,
165    /// Absolute patterns are not allowed.
166    Absolute,
167    /// Backslashes would make matching host-dependent.
168    Backslash,
169    /// A NUL byte was present.
170    NulByte,
171    /// Parent traversal is forbidden.
172    ParentComponent,
173    /// `**` is supported only as a complete path component.
174    InvalidGlobstar,
175    /// The deliberately small policy language does not support this metacharacter.
176    UnsupportedMetacharacter,
177}
178
179impl fmt::Display for PatternError {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        formatter.write_str(match self {
182            Self::Empty => "protected path pattern must not be empty",
183            Self::Absolute => "protected path pattern must be relative",
184            Self::Backslash => "protected path pattern must use portable separators",
185            Self::NulByte => "protected path pattern contains a NUL byte",
186            Self::ParentComponent => "protected path pattern contains parent traversal",
187            Self::InvalidGlobstar => "globstar must occupy a complete path component",
188            Self::UnsupportedMetacharacter => {
189                "protected path pattern supports only literal text, '*' and complete '**' components"
190            }
191        })
192    }
193}
194
195impl Error for PatternError {}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
198struct PathPattern {
199    source: String,
200    basename_only: bool,
201    components: Vec<String>,
202}
203
204impl PathPattern {
205    fn compile(source: impl Into<String>) -> Result<Self, PatternError> {
206        let source = source.into();
207        if source.is_empty() {
208            return Err(PatternError::Empty);
209        }
210        if source.starts_with('/') {
211            return Err(PatternError::Absolute);
212        }
213        if source.contains('\\') {
214            return Err(PatternError::Backslash);
215        }
216        if source.contains('\0') {
217            return Err(PatternError::NulByte);
218        }
219        if source.contains(['?', '[', ']']) {
220            return Err(PatternError::UnsupportedMetacharacter);
221        }
222
223        let mut components = Vec::new();
224        for component in source.split('/') {
225            if matches!(component, "" | ".") {
226                continue;
227            }
228            if component == ".." {
229                return Err(PatternError::ParentComponent);
230            }
231            if component.contains("**") && component != "**" {
232                return Err(PatternError::InvalidGlobstar);
233            }
234            components.push(component.to_owned());
235        }
236        if components.is_empty() {
237            return Err(PatternError::Empty);
238        }
239        Ok(Self {
240            basename_only: components.len() == 1 && components[0] != "**",
241            source,
242            components,
243        })
244    }
245
246    fn matches(&self, path: &VPath) -> bool {
247        let path_components = if path.is_root() {
248            Vec::new()
249        } else {
250            path.as_str().split('/').collect::<Vec<_>>()
251        };
252        if self.basename_only {
253            return path_components
254                .last()
255                .is_some_and(|name| component_matches(&self.components[0], name));
256        }
257        path_components_match(&self.components, &path_components)
258    }
259}
260
261fn component_matches(pattern: &str, value: &str) -> bool {
262    let pattern = pattern.as_bytes();
263    let value = value.as_bytes();
264    let (mut pattern_index, mut value_index) = (0, 0);
265    let (mut last_star, mut star_value_index) = (None, 0);
266
267    while value_index < value.len() {
268        if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] {
269            pattern_index += 1;
270            value_index += 1;
271        } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
272            last_star = Some(pattern_index);
273            pattern_index += 1;
274            star_value_index = value_index;
275        } else if let Some(star) = last_star {
276            star_value_index += 1;
277            value_index = star_value_index;
278            pattern_index = star + 1;
279        } else {
280            return false;
281        }
282    }
283    while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
284        pattern_index += 1;
285    }
286    pattern_index == pattern.len()
287}
288
289fn path_components_match(pattern: &[String], path: &[&str]) -> bool {
290    let columns = path.len() + 1;
291    let mut previous = vec![false; columns];
292    previous[0] = true;
293
294    for component in pattern {
295        let mut current = vec![false; columns];
296        if component == "**" {
297            current[0] = previous[0];
298            for path_index in 1..columns {
299                current[path_index] = previous[path_index] || current[path_index - 1];
300            }
301        } else {
302            for path_index in 1..columns {
303                current[path_index] =
304                    previous[path_index - 1] && component_matches(component, path[path_index - 1]);
305            }
306        }
307        previous = current;
308    }
309    previous[path.len()]
310}
311
312/// One canonical protected-path rule.
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct ProtectedRule {
315    pattern: PathPattern,
316    denied: AccessSet,
317}
318
319impl ProtectedRule {
320    /// Compile a protected rule in VSH's bounded portable pattern language.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`PatternError`] for ambiguous or unsupported patterns.
325    pub fn new(pattern: impl Into<String>, denied: AccessSet) -> Result<Self, PatternError> {
326        Ok(Self {
327            pattern: PathPattern::compile(pattern)?,
328            denied,
329        })
330    }
331
332    /// Return the exact canonical pattern.
333    #[must_use]
334    pub fn pattern(&self) -> &str {
335        &self.pattern.source
336    }
337
338    /// Return capabilities denied by this rule.
339    #[must_use]
340    pub const fn denied(&self) -> AccessSet {
341        self.denied
342    }
343}
344
345/// A denied path capability, retained even when sandboxed code catches the exception.
346#[derive(Clone, Debug, Eq, PartialEq)]
347pub struct DeniedAccess {
348    /// Normalized virtual path that was denied.
349    pub path: VPath,
350    /// Requested semantic capability.
351    pub access: AccessKind,
352    /// Canonical rule pattern responsible for denial.
353    pub rule: String,
354}
355
356/// Immutable pre-call policy used on the Monty hot path.
357#[derive(Clone, Debug, Eq, PartialEq)]
358pub struct CallPolicy {
359    rules: Vec<ProtectedRule>,
360}
361
362impl CallPolicy {
363    /// Construct a policy and canonicalize rule order and duplicates.
364    #[must_use]
365    pub fn new(mut rules: Vec<ProtectedRule>) -> Self {
366        rules.sort_by(|left, right| {
367            left.pattern()
368                .cmp(right.pattern())
369                .then_with(|| left.denied.bits().cmp(&right.denied.bits()))
370        });
371        let mut canonical: Vec<ProtectedRule> = Vec::with_capacity(rules.len());
372        for rule in rules {
373            if let Some(previous) = canonical.last_mut()
374                && previous.pattern() == rule.pattern()
375            {
376                previous.denied = previous.denied | rule.denied;
377            } else {
378                canonical.push(rule);
379            }
380        }
381        Self { rules: canonical }
382    }
383
384    /// Build the secure default secret policy plus mutation-only `.git` protection.
385    ///
386    /// # Panics
387    ///
388    /// Panics only if a compile-time built-in path pattern violates VSH's pattern DSL.
389    #[must_use]
390    pub fn secure_default() -> Self {
391        let mut rules = DEFAULT_SECRET_PATTERNS
392            .iter()
393            .chain(INTERNAL_RUNTIME_PATTERNS)
394            .map(|pattern| {
395                ProtectedRule::new(*pattern, AccessSet::ALL)
396                    .expect("built-in protected patterns are valid")
397            })
398            .collect::<Vec<_>>();
399        for pattern in [".git", ".git/**"] {
400            rules.push(
401                ProtectedRule::new(pattern, AccessSet::MUTATIONS)
402                    .expect("built-in git patterns are valid"),
403            );
404        }
405        Self::new(rules)
406    }
407
408    /// Return the first deterministic denial for `path`, if any.
409    ///
410    /// # Errors
411    ///
412    /// Returns the matching [`DeniedAccess`] when the requested capability is protected.
413    pub fn authorize(&self, path: &VPath, access: AccessKind) -> Result<(), DeniedAccess> {
414        for rule in &self.rules {
415            if rule.denied.contains(access) && rule.pattern.matches(path) {
416                return Err(DeniedAccess {
417                    path: path.clone(),
418                    access,
419                    rule: rule.pattern.source.clone(),
420                });
421            }
422        }
423        Ok(())
424    }
425
426    /// Return canonical policy rules.
427    #[must_use]
428    pub fn rules(&self) -> &[ProtectedRule] {
429        &self.rules
430    }
431
432    fn encode_canonical(&self, output: &mut Vec<u8>) {
433        encode_usize(self.rules.len(), output);
434        for rule in &self.rules {
435            encode_bytes(rule.pattern().as_bytes(), output);
436            output.extend_from_slice(&rule.denied.bits().to_le_bytes());
437        }
438    }
439}
440
441impl Default for CallPolicy {
442    fn default() -> Self {
443        Self::secure_default()
444    }
445}
446
447/// Built-in deterministic transaction posture.
448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
449pub enum PolicyProfile {
450    /// Small non-destructive edits may proceed without a judge.
451    Balanced,
452    /// Every mutation escalates; catastrophic changes are denied.
453    Strict,
454    /// Every mutation escalates with tighter hard-denial ceilings.
455    Paranoid,
456}
457
458impl PolicyProfile {
459    const fn tag(self) -> u8 {
460        match self {
461            Self::Balanced => 1,
462            Self::Strict => 2,
463            Self::Paranoid => 3,
464        }
465    }
466}
467
468/// Deterministic thresholds for escalation and hard denial.
469#[derive(Clone, Copy, Debug, Eq, PartialEq)]
470pub struct PolicyThresholds {
471    /// Escalate at or above this number of touched paths.
472    pub escalate_touched_paths: usize,
473    /// Escalate at or above this many changed bytes.
474    pub escalate_changed_bytes: u64,
475    /// Hard-deny above this number of touched paths.
476    pub deny_touched_paths: usize,
477    /// Hard-deny above this many changed bytes.
478    pub deny_changed_bytes: u64,
479    /// Hard-deny above this number of deleted paths.
480    pub deny_deleted_paths: usize,
481    /// Minimum delete count before the ratio ceiling applies.
482    pub delete_ratio_minimum_paths: usize,
483    /// Hard-deny at or above this deletion ratio in basis points.
484    pub deny_delete_ratio_bps: u16,
485}
486
487impl PolicyThresholds {
488    /// Return thresholds for a built-in profile.
489    #[must_use]
490    pub const fn for_profile(profile: PolicyProfile) -> Self {
491        match profile {
492            PolicyProfile::Balanced => Self {
493                escalate_touched_paths: 500,
494                escalate_changed_bytes: 64 * 1024 * 1024,
495                deny_touched_paths: 50_000,
496                deny_changed_bytes: 1024 * 1024 * 1024,
497                deny_deleted_paths: 10_000,
498                delete_ratio_minimum_paths: 100,
499                deny_delete_ratio_bps: 7_500,
500            },
501            PolicyProfile::Strict => Self {
502                escalate_touched_paths: 100,
503                escalate_changed_bytes: 8 * 1024 * 1024,
504                deny_touched_paths: 10_000,
505                deny_changed_bytes: 256 * 1024 * 1024,
506                deny_deleted_paths: 2_000,
507                delete_ratio_minimum_paths: 25,
508                deny_delete_ratio_bps: 5_000,
509            },
510            PolicyProfile::Paranoid => Self {
511                escalate_touched_paths: 25,
512                escalate_changed_bytes: 1024 * 1024,
513                deny_touched_paths: 5_000,
514                deny_changed_bytes: 128 * 1024 * 1024,
515                deny_deleted_paths: 500,
516                delete_ratio_minimum_paths: 10,
517                deny_delete_ratio_bps: 2_500,
518            },
519        }
520    }
521}
522
523/// Invalid deterministic-policy threshold configuration.
524#[derive(Clone, Copy, Debug, Eq, PartialEq)]
525#[non_exhaustive]
526pub enum PolicyConfigError {
527    /// A touched-path threshold was zero or escalation exceeded denial.
528    TouchedPathThreshold,
529    /// A changed-byte threshold was zero or escalation exceeded denial.
530    ChangedByteThreshold,
531    /// A delete threshold was zero.
532    DeleteThreshold,
533    /// Deletion ratio was outside 1..=10,000 basis points.
534    DeleteRatio,
535}
536
537impl fmt::Display for PolicyConfigError {
538    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
539        formatter.write_str(match self {
540            Self::TouchedPathThreshold => "invalid touched-path policy thresholds",
541            Self::ChangedByteThreshold => "invalid changed-byte policy thresholds",
542            Self::DeleteThreshold => "invalid delete policy thresholds",
543            Self::DeleteRatio => "delete ratio must be within 1..=10000 basis points",
544        })
545    }
546}
547
548impl Error for PolicyConfigError {}
549
550/// Deterministic transaction policy and its pre-call capability rules.
551#[derive(Clone, Debug, Eq, PartialEq)]
552pub struct TransactionPolicy {
553    profile: PolicyProfile,
554    thresholds: PolicyThresholds,
555    call_policy: CallPolicy,
556    digest: PolicyDigest,
557}
558
559impl TransactionPolicy {
560    /// Construct and validate an exact policy configuration.
561    ///
562    /// # Errors
563    ///
564    /// Returns [`PolicyConfigError`] for contradictory or zero thresholds.
565    pub fn new(
566        profile: PolicyProfile,
567        thresholds: PolicyThresholds,
568        call_policy: CallPolicy,
569    ) -> Result<Self, PolicyConfigError> {
570        validate_thresholds(thresholds)?;
571        let digest = policy_digest(profile, thresholds, &call_policy);
572        Ok(Self {
573            profile,
574            thresholds,
575            call_policy,
576            digest,
577        })
578    }
579
580    /// Construct a built-in profile with secure default protected paths.
581    ///
582    /// # Panics
583    ///
584    /// Panics only if compile-time built-in thresholds become internally contradictory.
585    #[must_use]
586    pub fn preset(profile: PolicyProfile) -> Self {
587        Self::new(
588            profile,
589            PolicyThresholds::for_profile(profile),
590            CallPolicy::default(),
591        )
592        .expect("built-in policy thresholds are valid")
593    }
594
595    /// Return the built-in profile.
596    #[must_use]
597    pub const fn profile(&self) -> PolicyProfile {
598        self.profile
599    }
600
601    /// Return exact deterministic thresholds.
602    #[must_use]
603    pub const fn thresholds(&self) -> PolicyThresholds {
604        self.thresholds
605    }
606
607    /// Return the pre-call capability policy.
608    #[must_use]
609    pub const fn call_policy(&self) -> &CallPolicy {
610        &self.call_policy
611    }
612
613    /// Return the canonical digest bound into transaction identity.
614    #[must_use]
615    pub const fn digest(&self) -> PolicyDigest {
616        self.digest
617    }
618
619    /// Evaluate exact observed artifacts without I/O or mutable global state.
620    #[must_use]
621    pub fn evaluate(&self, input: PolicyInput<'_>) -> PolicyDecision {
622        let metrics = RiskMetrics::derive(input.diff, input.effects, input.base_node_count);
623
624        if let Some(attempt) = input.denied_accesses.first() {
625            return PolicyDecision::Deny(DenyManifest {
626                reason: DenyReason::ProtectedAccessAttempt(attempt.clone()),
627                metrics,
628                policy: self.digest,
629            });
630        }
631
632        for entry in input.diff.entries() {
633            let access = match entry.kind {
634                DiffKind::Create => AccessKind::Create,
635                DiffKind::Delete => AccessKind::Delete,
636                DiffKind::Modify | DiffKind::MetadataChange => AccessKind::Modify,
637            };
638            if let Err(denial) = self.call_policy.authorize(&entry.path, access) {
639                return PolicyDecision::Deny(DenyManifest {
640                    reason: DenyReason::ProtectedMutation(denial),
641                    metrics,
642                    policy: self.digest,
643                });
644            }
645        }
646
647        if metrics.touched_paths > self.thresholds.deny_touched_paths {
648            return self.deny(
649                metrics,
650                DenyReason::TouchedPathLimit {
651                    limit: self.thresholds.deny_touched_paths,
652                    observed: metrics.touched_paths,
653                },
654            );
655        }
656        if metrics.changed_bytes > self.thresholds.deny_changed_bytes {
657            return self.deny(
658                metrics,
659                DenyReason::ChangedByteLimit {
660                    limit: self.thresholds.deny_changed_bytes,
661                    observed: metrics.changed_bytes,
662                },
663            );
664        }
665        if metrics.deleted_paths > self.thresholds.deny_deleted_paths {
666            return self.deny(
667                metrics,
668                DenyReason::DeletePathLimit {
669                    limit: self.thresholds.deny_deleted_paths,
670                    observed: metrics.deleted_paths,
671                },
672            );
673        }
674        if metrics.deleted_paths >= self.thresholds.delete_ratio_minimum_paths
675            && metrics.delete_ratio_bps >= self.thresholds.deny_delete_ratio_bps
676        {
677            return self.deny(
678                metrics,
679                DenyReason::DeleteRatioLimit {
680                    limit_bps: self.thresholds.deny_delete_ratio_bps,
681                    observed_bps: metrics.delete_ratio_bps,
682                },
683            );
684        }
685
686        if input.diff.is_empty() {
687            return PolicyDecision::AutoApprove;
688        }
689
690        let mut flags = BTreeSet::new();
691        if matches!(
692            self.profile,
693            PolicyProfile::Strict | PolicyProfile::Paranoid
694        ) {
695            flags.insert(RiskFlag::Mutation);
696        }
697        if metrics.deleted_paths > 0 {
698            flags.insert(RiskFlag::Deletion);
699        }
700        if metrics.renamed_paths > 0 {
701            flags.insert(RiskFlag::Rename);
702        }
703        if metrics.executable_changes > 0 {
704            flags.insert(RiskFlag::ExecutableChange);
705        }
706        if metrics.symlink_changes > 0 {
707            flags.insert(RiskFlag::SymlinkChange);
708        }
709        if metrics.touched_paths >= self.thresholds.escalate_touched_paths {
710            flags.insert(RiskFlag::LargeTouchedSet);
711        }
712        if metrics.changed_bytes >= self.thresholds.escalate_changed_bytes {
713            flags.insert(RiskFlag::LargeByteChange);
714        }
715
716        if flags.is_empty() {
717            PolicyDecision::AutoApprove
718        } else {
719            PolicyDecision::Escalate(RiskManifest {
720                metrics,
721                flags: flags.into_iter().collect(),
722                policy: self.digest,
723            })
724        }
725    }
726
727    fn deny(&self, metrics: RiskMetrics, reason: DenyReason) -> PolicyDecision {
728        PolicyDecision::Deny(DenyManifest {
729            reason,
730            metrics,
731            policy: self.digest,
732        })
733    }
734}
735
736impl Default for TransactionPolicy {
737    fn default() -> Self {
738        Self::preset(PolicyProfile::Balanced)
739    }
740}
741
742fn validate_thresholds(thresholds: PolicyThresholds) -> Result<(), PolicyConfigError> {
743    if thresholds.escalate_touched_paths == 0
744        || thresholds.deny_touched_paths == 0
745        || thresholds.escalate_touched_paths > thresholds.deny_touched_paths
746    {
747        return Err(PolicyConfigError::TouchedPathThreshold);
748    }
749    if thresholds.escalate_changed_bytes == 0
750        || thresholds.deny_changed_bytes == 0
751        || thresholds.escalate_changed_bytes > thresholds.deny_changed_bytes
752    {
753        return Err(PolicyConfigError::ChangedByteThreshold);
754    }
755    if thresholds.deny_deleted_paths == 0 || thresholds.delete_ratio_minimum_paths == 0 {
756        return Err(PolicyConfigError::DeleteThreshold);
757    }
758    if !(1..=10_000).contains(&thresholds.deny_delete_ratio_bps) {
759        return Err(PolicyConfigError::DeleteRatio);
760    }
761    Ok(())
762}
763
764fn policy_digest(
765    profile: PolicyProfile,
766    thresholds: PolicyThresholds,
767    call_policy: &CallPolicy,
768) -> PolicyDigest {
769    let mut canonical = Vec::new();
770    encode_bytes(POLICY_SCHEMA_VERSION.as_bytes(), &mut canonical);
771    canonical.push(profile.tag());
772    canonical.extend_from_slice(&(thresholds.escalate_touched_paths as u64).to_le_bytes());
773    canonical.extend_from_slice(&thresholds.escalate_changed_bytes.to_le_bytes());
774    canonical.extend_from_slice(&(thresholds.deny_touched_paths as u64).to_le_bytes());
775    canonical.extend_from_slice(&thresholds.deny_changed_bytes.to_le_bytes());
776    canonical.extend_from_slice(&(thresholds.deny_deleted_paths as u64).to_le_bytes());
777    canonical.extend_from_slice(&(thresholds.delete_ratio_minimum_paths as u64).to_le_bytes());
778    canonical.extend_from_slice(&thresholds.deny_delete_ratio_bps.to_le_bytes());
779    call_policy.encode_canonical(&mut canonical);
780    PolicyDigest::digest_canonical(&canonical)
781}
782
783/// Inputs observed by deterministic transaction policy.
784#[derive(Clone, Copy)]
785pub struct PolicyInput<'a> {
786    /// Exact canonical virtual diff.
787    pub diff: &'a CanonicalDiff,
788    /// Operation-local observations, including rename semantics.
789    pub effects: &'a [EffectEvent],
790    /// Attempts denied before VFS access, including attempts caught by the program.
791    pub denied_accesses: &'a [DeniedAccess],
792    /// Number of base nodes including the virtual root.
793    pub base_node_count: usize,
794}
795
796/// Exact bounded metrics used for a deterministic policy decision.
797#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
798pub struct RiskMetrics {
799    /// All canonical changed paths.
800    pub touched_paths: usize,
801    /// Newly created paths.
802    pub created_paths: usize,
803    /// Content or metadata modifications.
804    pub modified_paths: usize,
805    /// Deleted paths, including recursive-delete closure.
806    pub deleted_paths: usize,
807    /// Semantic rename operations observed in the ledger.
808    pub renamed_paths: usize,
809    /// Sum of before and after bytes represented by changed non-directory nodes.
810    pub changed_bytes: u64,
811    /// Deletions divided by user-visible base nodes, in basis points.
812    pub delete_ratio_bps: u16,
813    /// Changes that add/remove an executable mode bit.
814    pub executable_changes: usize,
815    /// Changes involving an opaque symbolic link.
816    pub symlink_changes: usize,
817}
818
819impl RiskMetrics {
820    fn derive(diff: &CanonicalDiff, effects: &[EffectEvent], base_node_count: usize) -> Self {
821        let mut metrics = Self {
822            touched_paths: diff.entries().len(),
823            ..Self::default()
824        };
825        for entry in diff.entries() {
826            match entry.kind {
827                DiffKind::Create => metrics.created_paths += 1,
828                DiffKind::Delete => metrics.deleted_paths += 1,
829                DiffKind::Modify | DiffKind::MetadataChange => metrics.modified_paths += 1,
830            }
831            metrics.changed_bytes = metrics
832                .changed_bytes
833                .saturating_add(entry.before.map_or(0, NodeState::size))
834                .saturating_add(entry.after.map_or(0, NodeState::size));
835            let before_executable = entry.before.is_some_and(|state| state.mode() & 0o111 != 0);
836            let after_executable = entry.after.is_some_and(|state| state.mode() & 0o111 != 0);
837            if before_executable != after_executable {
838                metrics.executable_changes += 1;
839            }
840            if entry
841                .before
842                .is_some_and(|state| state.kind() == NodeKind::Symlink)
843                || entry
844                    .after
845                    .is_some_and(|state| state.kind() == NodeKind::Symlink)
846            {
847                metrics.symlink_changes += 1;
848            }
849        }
850        metrics.renamed_paths = effects
851            .iter()
852            .filter(|event| matches!(event.effect, Effect::Rename { .. }))
853            .count();
854        let base_user_nodes = base_node_count.saturating_sub(1);
855        let numerator = metrics.deleted_paths.saturating_mul(10_000);
856        let ratio = numerator.checked_div(base_user_nodes).unwrap_or(0);
857        metrics.delete_ratio_bps = u16::try_from(ratio.min(10_000)).unwrap_or(10_000);
858        metrics
859    }
860}
861
862/// Stable reason a deterministic policy must reject a transaction.
863#[derive(Clone, Debug, Eq, PartialEq)]
864#[non_exhaustive]
865pub enum DenyReason {
866    /// Sandboxed code attempted a protected capability, even if it caught the error.
867    ProtectedAccessAttempt(DeniedAccess),
868    /// Final state changed a protected path through a non-Monty/core caller.
869    ProtectedMutation(DeniedAccess),
870    /// Canonical touched-path count exceeded the hard ceiling.
871    TouchedPathLimit {
872        /// Configured hard ceiling.
873        limit: usize,
874        /// Observed count.
875        observed: usize,
876    },
877    /// Changed bytes exceeded the hard ceiling.
878    ChangedByteLimit {
879        /// Configured hard ceiling.
880        limit: u64,
881        /// Observed count.
882        observed: u64,
883    },
884    /// Deleted paths exceeded the hard ceiling.
885    DeletePathLimit {
886        /// Configured hard ceiling.
887        limit: usize,
888        /// Observed count.
889        observed: usize,
890    },
891    /// A sufficiently large deletion exceeded the workspace-ratio ceiling.
892    DeleteRatioLimit {
893        /// Configured ceiling in basis points.
894        limit_bps: u16,
895        /// Observed ratio in basis points.
896        observed_bps: u16,
897    },
898}
899
900/// Why an otherwise valid transaction requires an independent approval principal.
901#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
902pub enum RiskFlag {
903    /// The selected profile escalates every mutation.
904    Mutation,
905    /// One or more paths are deleted.
906    Deletion,
907    /// A semantic rename was observed.
908    Rename,
909    /// Executable mode changed.
910    ExecutableChange,
911    /// An opaque symbolic link is created, removed, or replaced.
912    SymlinkChange,
913    /// Touched-path escalation threshold was reached.
914    LargeTouchedSet,
915    /// Changed-byte escalation threshold was reached.
916    LargeByteChange,
917}
918
919/// Deterministic denial payload.
920#[derive(Clone, Debug, Eq, PartialEq)]
921pub struct DenyManifest {
922    /// Stable deterministic reason.
923    pub reason: DenyReason,
924    /// Exact metrics evaluated.
925    pub metrics: RiskMetrics,
926    /// Exact policy configuration digest.
927    pub policy: PolicyDigest,
928}
929
930/// Bounded evidence shown to a fresh approval principal.
931#[derive(Clone, Debug, Eq, PartialEq)]
932pub struct RiskManifest {
933    /// Exact metrics evaluated.
934    pub metrics: RiskMetrics,
935    /// Stable, sorted risk flags.
936    pub flags: Vec<RiskFlag>,
937    /// Exact policy configuration digest.
938    pub policy: PolicyDigest,
939}
940
941/// Final deterministic transaction decision.
942#[derive(Clone, Debug, Eq, PartialEq)]
943pub enum PolicyDecision {
944    /// Hard policy rejected the transaction; no judge may reverse it.
945    Deny(DenyManifest),
946    /// Deterministic rules authorize reservation without a judge.
947    AutoApprove,
948    /// An independent fresh judge or human must narrow the decision.
949    Escalate(RiskManifest),
950}
951
952/// Canonically hash a read dependency set.
953#[must_use]
954pub fn read_set_digest(read_set: &BTreeMap<VPath, ReadObservation>) -> ReadSetDigest {
955    let mut canonical = Vec::new();
956    encode_usize(read_set.len(), &mut canonical);
957    for (path, observation) in read_set {
958        encode_path(path, &mut canonical);
959        match observation.metadata {
960            None => canonical.push(0),
961            Some(None) => canonical.push(1),
962            Some(Some(state)) => {
963                canonical.push(2);
964                state.encode_canonical(&mut canonical);
965            }
966        }
967        encode_optional_digest(
968            observation.content.map(|digest| *digest.as_bytes()),
969            &mut canonical,
970        );
971        encode_optional_digest(
972            observation.directory.map(|digest| *digest.as_bytes()),
973            &mut canonical,
974        );
975    }
976    ReadSetDigest::digest_canonical(&canonical)
977}
978
979/// Canonically hash write preconditions.
980#[must_use]
981pub fn write_set_digest(write_set: &BTreeMap<VPath, WritePrecondition>) -> WriteSetDigest {
982    let mut canonical = Vec::new();
983    encode_usize(write_set.len(), &mut canonical);
984    for (path, precondition) in write_set {
985        encode_path(path, &mut canonical);
986        encode_optional_state(precondition.expected, &mut canonical);
987    }
988    WriteSetDigest::digest_canonical(&canonical)
989}
990
991/// Inputs used to construct an approval-bound transaction identity.
992#[derive(Clone, Copy)]
993pub struct TransactionIdentityInput<'a> {
994    /// Immutable base snapshot.
995    pub base_snapshot: SnapshotId,
996    /// Exact canonical diff.
997    pub diff: &'a CanonicalDiff,
998    /// Exact read dependencies.
999    pub read_set: &'a BTreeMap<VPath, ReadObservation>,
1000    /// Exact write preconditions.
1001    pub write_set: &'a BTreeMap<VPath, WritePrecondition>,
1002    /// Exact program source.
1003    pub program: &'a str,
1004    /// Exact deterministic policy configuration.
1005    pub policy: &'a TransactionPolicy,
1006    /// Canonical security-relevant execution configuration.
1007    pub runtime_config: RuntimeConfigDigest,
1008    /// Optional original intent carried out of band.
1009    pub intent: Option<&'a str>,
1010}
1011
1012/// Bind every approval-relevant artifact into one immutable transaction identity.
1013#[must_use]
1014pub fn bind_transaction(input: TransactionIdentityInput<'_>) -> TransactionBinding {
1015    TransactionBinding {
1016        base_snapshot: input.base_snapshot,
1017        diff: input.diff.digest(),
1018        read_set: read_set_digest(input.read_set),
1019        write_set: write_set_digest(input.write_set),
1020        program: ProgramDigest::digest_source(input.program),
1021        policy: input.policy.digest(),
1022        runtime_config: input.runtime_config,
1023        intent: input.intent.map(IntentDigest::digest_text),
1024    }
1025}
1026
1027fn encode_usize(value: usize, output: &mut Vec<u8>) {
1028    output.extend_from_slice(&(value as u64).to_le_bytes());
1029}
1030
1031fn encode_bytes(bytes: &[u8], output: &mut Vec<u8>) {
1032    output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
1033    output.extend_from_slice(bytes);
1034}
1035
1036fn encode_path(path: &VPath, output: &mut Vec<u8>) {
1037    encode_bytes(path.as_str().as_bytes(), output);
1038}
1039
1040fn encode_optional_state(state: Option<NodeState>, output: &mut Vec<u8>) {
1041    match state {
1042        Some(state) => {
1043            output.push(1);
1044            state.encode_canonical(output);
1045        }
1046        None => output.push(0),
1047    }
1048}
1049
1050fn encode_optional_digest(digest: Option<[u8; 32]>, output: &mut Vec<u8>) {
1051    match digest {
1052        Some(digest) => {
1053            output.push(1);
1054            output.extend_from_slice(&digest);
1055        }
1056        None => output.push(0),
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use std::fs;
1063    use std::path::{Path, PathBuf};
1064    use std::sync::atomic::{AtomicU64, Ordering};
1065
1066    use vsh_store::BlobStore;
1067    use vsh_types::{RuntimeConfigDigest, VPath};
1068    use vsh_vfs::{SnapshotBuilder, VirtualFs};
1069
1070    use super::*;
1071
1072    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1073
1074    struct TestDirectory(PathBuf);
1075
1076    impl TestDirectory {
1077        fn new() -> Self {
1078            let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1079            let path = std::env::temp_dir()
1080                .join(format!("vsh-policy-test-{}-{sequence}", std::process::id()));
1081            fs::create_dir(&path).expect("test directory should be unique");
1082            Self(path)
1083        }
1084
1085        fn path(&self) -> &Path {
1086            &self.0
1087        }
1088    }
1089
1090    impl Drop for TestDirectory {
1091        fn drop(&mut self) {
1092            let _ = fs::remove_dir_all(&self.0);
1093        }
1094    }
1095
1096    fn filesystem(files: &[(&str, &[u8])]) -> (TestDirectory, VirtualFs) {
1097        let directory = TestDirectory::new();
1098        let store = BlobStore::open(directory.path()).unwrap();
1099        let mut builder = SnapshotBuilder::new(store);
1100        for (path, bytes) in files {
1101            if let Some(parent) = VPath::parse(path).unwrap().parent()
1102                && !parent.is_root()
1103            {
1104                let _ = builder.add_directory(parent, 0o755);
1105            }
1106            builder
1107                .add_file(VPath::parse(path).unwrap(), bytes, 0o644)
1108                .unwrap();
1109        }
1110        let snapshot = builder.build().unwrap();
1111        (directory, VirtualFs::new(snapshot))
1112    }
1113
1114    #[test]
1115    fn portable_patterns_cover_root_nested_and_subtree_secrets() {
1116        let policy = CallPolicy::default();
1117        for path in [
1118            ".env",
1119            ".env/token",
1120            "app/.env.local",
1121            "app/.env.local/token",
1122            "secrets/token.txt",
1123            "nested/private.key",
1124            "deploy/id_rsa",
1125            "a/credentials.json",
1126        ] {
1127            assert!(
1128                policy
1129                    .authorize(&VPath::parse(path).unwrap(), AccessKind::ContentRead)
1130                    .is_err(),
1131                "path should be protected: {path}"
1132            );
1133        }
1134        assert!(
1135            policy
1136                .authorize(
1137                    &VPath::parse("src/main.rs").unwrap(),
1138                    AccessKind::ContentRead
1139                )
1140                .is_ok()
1141        );
1142    }
1143
1144    #[test]
1145    fn git_reads_are_allowed_but_mutations_are_denied() {
1146        let policy = CallPolicy::default();
1147        let path = VPath::parse(".git/config").unwrap();
1148        assert!(policy.authorize(&path, AccessKind::ContentRead).is_ok());
1149        let denial = policy.authorize(&path, AccessKind::Modify).unwrap_err();
1150        assert_eq!(denial.rule, ".git/**");
1151    }
1152
1153    #[test]
1154    fn malformed_patterns_fail_closed() {
1155        for (pattern, expected) in [
1156            ("", PatternError::Empty),
1157            ("/secret", PatternError::Absolute),
1158            ("a\\b", PatternError::Backslash),
1159            ("../secret", PatternError::ParentComponent),
1160            ("foo**bar", PatternError::InvalidGlobstar),
1161            ("secret?.txt", PatternError::UnsupportedMetacharacter),
1162        ] {
1163            assert_eq!(
1164                ProtectedRule::new(pattern, AccessSet::ALL).unwrap_err(),
1165                expected
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn balanced_auto_approves_small_non_destructive_edit() {
1172        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1173        filesystem
1174            .write(&VPath::parse("output.txt").unwrap(), b"two")
1175            .unwrap();
1176        let diff = filesystem.canonical_diff().unwrap();
1177        let policy = TransactionPolicy::default();
1178        let decision = policy.evaluate(PolicyInput {
1179            diff: &diff,
1180            effects: filesystem.effects(),
1181            denied_accesses: &[],
1182            base_node_count: 2,
1183        });
1184        assert_eq!(decision, PolicyDecision::AutoApprove);
1185    }
1186
1187    #[test]
1188    fn strict_escalates_small_mutation_without_mislabeling_it_as_large() {
1189        let (_guard, mut filesystem) = filesystem(&[]);
1190        filesystem
1191            .write(&VPath::parse("output.txt").unwrap(), b"two")
1192            .unwrap();
1193        let diff = filesystem.canonical_diff().unwrap();
1194        let decision = TransactionPolicy::preset(PolicyProfile::Strict).evaluate(PolicyInput {
1195            diff: &diff,
1196            effects: filesystem.effects(),
1197            denied_accesses: &[],
1198            base_node_count: 1,
1199        });
1200        let PolicyDecision::Escalate(manifest) = decision else {
1201            panic!("strict mutation should escalate")
1202        };
1203        assert_eq!(manifest.flags, vec![RiskFlag::Mutation]);
1204    }
1205
1206    #[test]
1207    fn balanced_escalates_delete_and_rename() {
1208        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1209        filesystem
1210            .rename(
1211                &VPath::parse("input.txt").unwrap(),
1212                &VPath::parse("archive.txt").unwrap(),
1213            )
1214            .unwrap();
1215        let diff = filesystem.canonical_diff().unwrap();
1216        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1217            diff: &diff,
1218            effects: filesystem.effects(),
1219            denied_accesses: &[],
1220            base_node_count: 2,
1221        });
1222        let PolicyDecision::Escalate(manifest) = decision else {
1223            panic!("rename should escalate")
1224        };
1225        assert_eq!(manifest.metrics.deleted_paths, 1);
1226        assert_eq!(manifest.metrics.renamed_paths, 1);
1227        assert_eq!(manifest.flags, vec![RiskFlag::Deletion, RiskFlag::Rename]);
1228    }
1229
1230    #[test]
1231    fn caught_protected_attempt_forces_final_deny() {
1232        let (_guard, filesystem) = filesystem(&[]);
1233        let diff = filesystem.canonical_diff().unwrap();
1234        let attempt = DeniedAccess {
1235            path: VPath::parse(".env").unwrap(),
1236            access: AccessKind::ContentRead,
1237            rule: ".env".to_owned(),
1238        };
1239        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1240            diff: &diff,
1241            effects: filesystem.effects(),
1242            denied_accesses: std::slice::from_ref(&attempt),
1243            base_node_count: 1,
1244        });
1245        assert!(matches!(
1246            decision,
1247            PolicyDecision::Deny(DenyManifest {
1248                reason: DenyReason::ProtectedAccessAttempt(ref denied),
1249                ..
1250            }) if denied == &attempt
1251        ));
1252    }
1253
1254    #[test]
1255    fn final_policy_rechecks_protected_mutations() {
1256        let (_guard, mut filesystem) = filesystem(&[]);
1257        filesystem
1258            .write(&VPath::parse(".env").unwrap(), b"secret")
1259            .unwrap();
1260        let diff = filesystem.canonical_diff().unwrap();
1261        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1262            diff: &diff,
1263            effects: filesystem.effects(),
1264            denied_accesses: &[],
1265            base_node_count: 1,
1266        });
1267        assert!(matches!(
1268            decision,
1269            PolicyDecision::Deny(DenyManifest {
1270                reason: DenyReason::ProtectedMutation(_),
1271                ..
1272            })
1273        ));
1274    }
1275
1276    #[test]
1277    fn transaction_identity_changes_with_every_bound_context() {
1278        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1279        filesystem
1280            .read(&VPath::parse("input.txt").unwrap())
1281            .unwrap();
1282        filesystem
1283            .write(&VPath::parse("output.txt").unwrap(), b"two")
1284            .unwrap();
1285        let diff = filesystem.canonical_diff().unwrap();
1286        let policy = TransactionPolicy::default();
1287        let runtime_config = RuntimeConfigDigest::digest_canonical(b"limits-a");
1288        let first = bind_transaction(TransactionIdentityInput {
1289            base_snapshot: SnapshotId::from_bytes([1; 32]),
1290            diff: &diff,
1291            read_set: filesystem.read_set(),
1292            write_set: filesystem.write_set(),
1293            program: "program-a",
1294            policy: &policy,
1295            runtime_config,
1296            intent: Some("intent-a"),
1297        });
1298        let second = bind_transaction(TransactionIdentityInput {
1299            program: "program-b",
1300            ..TransactionIdentityInput {
1301                base_snapshot: SnapshotId::from_bytes([1; 32]),
1302                diff: &diff,
1303                read_set: filesystem.read_set(),
1304                write_set: filesystem.write_set(),
1305                program: "program-a",
1306                policy: &policy,
1307                runtime_config,
1308                intent: Some("intent-a"),
1309            }
1310        });
1311        assert_ne!(first.transaction_id(), second.transaction_id());
1312        assert_eq!(
1313            first.transaction_id(),
1314            bind_transaction(TransactionIdentityInput {
1315                base_snapshot: SnapshotId::from_bytes([1; 32]),
1316                diff: &diff,
1317                read_set: filesystem.read_set(),
1318                write_set: filesystem.write_set(),
1319                program: "program-a",
1320                policy: &policy,
1321                runtime_config,
1322                intent: Some("intent-a"),
1323            })
1324            .transaction_id()
1325        );
1326    }
1327
1328    #[test]
1329    fn policy_configuration_errors_have_distinct_stable_messages() {
1330        let pattern_errors = [
1331            PatternError::Empty,
1332            PatternError::Absolute,
1333            PatternError::Backslash,
1334            PatternError::NulByte,
1335            PatternError::ParentComponent,
1336            PatternError::InvalidGlobstar,
1337            PatternError::UnsupportedMetacharacter,
1338        ];
1339        assert_eq!(
1340            pattern_errors
1341                .map(|error| error.to_string())
1342                .into_iter()
1343                .collect::<BTreeSet<_>>()
1344                .len(),
1345            pattern_errors.len()
1346        );
1347
1348        let config_errors = [
1349            PolicyConfigError::TouchedPathThreshold,
1350            PolicyConfigError::ChangedByteThreshold,
1351            PolicyConfigError::DeleteThreshold,
1352            PolicyConfigError::DeleteRatio,
1353        ];
1354        assert_eq!(
1355            config_errors
1356                .map(|error| error.to_string())
1357                .into_iter()
1358                .collect::<BTreeSet<_>>()
1359                .len(),
1360            config_errors.len()
1361        );
1362
1363        for access in [
1364            AccessKind::MetadataRead,
1365            AccessKind::ContentRead,
1366            AccessKind::DirectoryRead,
1367        ] {
1368            assert!(!access.is_mutation());
1369        }
1370        for access in [
1371            AccessKind::Create,
1372            AccessKind::Modify,
1373            AccessKind::Delete,
1374            AccessKind::RenameSource,
1375            AccessKind::RenameDestination,
1376        ] {
1377            assert!(access.is_mutation());
1378            assert!(AccessSet::MUTATIONS.contains(access));
1379        }
1380        assert!(!AccessSet::NONE.contains(AccessKind::Create));
1381    }
1382}