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            // Any leading globstars can absorb the entire parent path, so
241            // **/*.key has exactly the same basename semantics as *.key.
242            basename_only: components.last().is_some_and(|part| part != "**")
243                && components[..components.len() - 1]
244                    .iter()
245                    .all(|part| part == "**"),
246            source,
247            components,
248        })
249    }
250
251    fn matches(&self, path: &VPath) -> bool {
252        if self.basename_only {
253            return path.file_name().is_some_and(|name| {
254                component_matches(
255                    self.components.last().expect("compiled non-empty pattern"),
256                    name,
257                )
258            });
259        }
260        path_components_match(
261            &self.components,
262            if path.is_root() { "" } else { path.as_str() },
263        )
264    }
265}
266
267fn component_matches(pattern: &str, value: &str) -> bool {
268    let pattern = pattern.as_bytes();
269    let value = value.as_bytes();
270    let (mut pattern_index, mut value_index) = (0, 0);
271    let (mut last_star, mut star_value_index) = (None, 0);
272
273    while value_index < value.len() {
274        if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] {
275            pattern_index += 1;
276            value_index += 1;
277        } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
278            last_star = Some(pattern_index);
279            pattern_index += 1;
280            star_value_index = value_index;
281        } else if let Some(star) = last_star {
282            star_value_index += 1;
283            value_index = star_value_index;
284            pattern_index = star + 1;
285        } else {
286            return false;
287        }
288    }
289    while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
290        pattern_index += 1;
291    }
292    pattern_index == pattern.len()
293}
294
295fn path_components_match(pattern: &[String], path: &str) -> bool {
296    // A globstar consumes zero or more whole components. Only the most recent
297    // globstar needs a retry point: an earlier one cannot help once a later one
298    // has matched. Cloneable string cursors avoid per-rule heap allocations and
299    // recursion, with O(pattern components * path components) component matches.
300    let mut remaining = path.split('/').filter(|component| !component.is_empty());
301    let mut pattern_index = 0;
302    let mut retry = None;
303    while let Some(value) = remaining.clone().next() {
304        if pattern.get(pattern_index).is_some_and(|part| part == "**") {
305            pattern_index += 1;
306            if pattern_index == pattern.len() {
307                return true;
308            }
309            retry = Some((pattern_index, remaining.clone()));
310        } else if pattern
311            .get(pattern_index)
312            .is_some_and(|part| component_matches(part, value))
313        {
314            pattern_index += 1;
315            remaining.next();
316        } else if let Some((restart, cursor)) = &mut retry {
317            cursor.next();
318            remaining = cursor.clone();
319            pattern_index = *restart;
320        } else {
321            return false;
322        }
323    }
324    pattern[pattern_index..].iter().all(|part| part == "**")
325}
326
327/// One canonical protected-path rule.
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct ProtectedRule {
330    pattern: PathPattern,
331    denied: AccessSet,
332}
333
334impl ProtectedRule {
335    /// Compile a protected rule in VSH's bounded portable pattern language.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`PatternError`] for ambiguous or unsupported patterns.
340    pub fn new(pattern: impl Into<String>, denied: AccessSet) -> Result<Self, PatternError> {
341        Ok(Self {
342            pattern: PathPattern::compile(pattern)?,
343            denied,
344        })
345    }
346
347    /// Return the exact canonical pattern.
348    #[must_use]
349    pub fn pattern(&self) -> &str {
350        &self.pattern.source
351    }
352
353    /// Return capabilities denied by this rule.
354    #[must_use]
355    pub const fn denied(&self) -> AccessSet {
356        self.denied
357    }
358}
359
360/// A denied path capability, retained even when sandboxed code catches the exception.
361#[derive(Clone, Debug, Eq, PartialEq)]
362pub struct DeniedAccess {
363    /// Normalized virtual path that was denied.
364    pub path: VPath,
365    /// Requested semantic capability.
366    pub access: AccessKind,
367    /// Canonical rule pattern responsible for denial.
368    pub rule: String,
369}
370
371/// Immutable pre-call policy used on the Monty hot path.
372#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct CallPolicy {
374    rules: Vec<ProtectedRule>,
375}
376
377impl CallPolicy {
378    /// Construct a policy and canonicalize rule order and duplicates.
379    #[must_use]
380    pub fn new(mut rules: Vec<ProtectedRule>) -> Self {
381        rules.sort_by(|left, right| {
382            left.pattern()
383                .cmp(right.pattern())
384                .then_with(|| left.denied.bits().cmp(&right.denied.bits()))
385        });
386        let mut canonical: Vec<ProtectedRule> = Vec::with_capacity(rules.len());
387        for rule in rules {
388            if let Some(previous) = canonical.last_mut()
389                && previous.pattern() == rule.pattern()
390            {
391                previous.denied = previous.denied | rule.denied;
392            } else {
393                canonical.push(rule);
394            }
395        }
396        Self { rules: canonical }
397    }
398
399    /// Build the secure default secret policy plus mutation-only `.git` protection.
400    ///
401    /// # Panics
402    ///
403    /// Panics only if a compile-time built-in path pattern violates VSH's pattern DSL.
404    #[must_use]
405    pub fn secure_default() -> Self {
406        let mut rules = DEFAULT_SECRET_PATTERNS
407            .iter()
408            .chain(INTERNAL_RUNTIME_PATTERNS)
409            .map(|pattern| {
410                ProtectedRule::new(*pattern, AccessSet::ALL)
411                    .expect("built-in protected patterns are valid")
412            })
413            .collect::<Vec<_>>();
414        for pattern in [".git", ".git/**"] {
415            rules.push(
416                ProtectedRule::new(pattern, AccessSet::MUTATIONS)
417                    .expect("built-in git patterns are valid"),
418            );
419        }
420        Self::new(rules)
421    }
422
423    /// Return the first deterministic denial for `path`, if any.
424    ///
425    /// # Errors
426    ///
427    /// Returns the matching [`DeniedAccess`] when the requested capability is protected.
428    pub fn authorize(&self, path: &VPath, access: AccessKind) -> Result<(), DeniedAccess> {
429        for rule in &self.rules {
430            if rule.denied.contains(access) && rule.pattern.matches(path) {
431                return Err(DeniedAccess {
432                    path: path.clone(),
433                    access,
434                    rule: rule.pattern.source.clone(),
435                });
436            }
437        }
438        Ok(())
439    }
440
441    /// Return canonical policy rules.
442    #[must_use]
443    pub fn rules(&self) -> &[ProtectedRule] {
444        &self.rules
445    }
446
447    fn encode_canonical(&self, output: &mut Vec<u8>) {
448        encode_usize(self.rules.len(), output);
449        for rule in &self.rules {
450            encode_bytes(rule.pattern().as_bytes(), output);
451            output.extend_from_slice(&rule.denied.bits().to_le_bytes());
452        }
453    }
454}
455
456impl Default for CallPolicy {
457    fn default() -> Self {
458        Self::secure_default()
459    }
460}
461
462/// Built-in deterministic transaction posture.
463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
464pub enum PolicyProfile {
465    /// Small non-destructive edits may proceed without a judge.
466    Balanced,
467    /// Every mutation escalates; catastrophic changes are denied.
468    Strict,
469    /// Every mutation escalates with tighter hard-denial ceilings.
470    Paranoid,
471}
472
473impl PolicyProfile {
474    const fn tag(self) -> u8 {
475        match self {
476            Self::Balanced => 1,
477            Self::Strict => 2,
478            Self::Paranoid => 3,
479        }
480    }
481}
482
483/// Deterministic thresholds for escalation and hard denial.
484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
485pub struct PolicyThresholds {
486    /// Escalate at or above this number of touched paths.
487    pub escalate_touched_paths: usize,
488    /// Escalate at or above this many changed bytes.
489    pub escalate_changed_bytes: u64,
490    /// Hard-deny above this number of touched paths.
491    pub deny_touched_paths: usize,
492    /// Hard-deny above this many changed bytes.
493    pub deny_changed_bytes: u64,
494    /// Hard-deny above this number of deleted paths.
495    pub deny_deleted_paths: usize,
496    /// Minimum delete count before the ratio ceiling applies.
497    pub delete_ratio_minimum_paths: usize,
498    /// Hard-deny at or above this deletion ratio in basis points.
499    pub deny_delete_ratio_bps: u16,
500}
501
502impl PolicyThresholds {
503    /// Return thresholds for a built-in profile.
504    #[must_use]
505    pub const fn for_profile(profile: PolicyProfile) -> Self {
506        match profile {
507            PolicyProfile::Balanced => Self {
508                escalate_touched_paths: 500,
509                escalate_changed_bytes: 64 * 1024 * 1024,
510                deny_touched_paths: 50_000,
511                deny_changed_bytes: 1024 * 1024 * 1024,
512                deny_deleted_paths: 10_000,
513                delete_ratio_minimum_paths: 100,
514                deny_delete_ratio_bps: 7_500,
515            },
516            PolicyProfile::Strict => Self {
517                escalate_touched_paths: 100,
518                escalate_changed_bytes: 8 * 1024 * 1024,
519                deny_touched_paths: 10_000,
520                deny_changed_bytes: 256 * 1024 * 1024,
521                deny_deleted_paths: 2_000,
522                delete_ratio_minimum_paths: 25,
523                deny_delete_ratio_bps: 5_000,
524            },
525            PolicyProfile::Paranoid => Self {
526                escalate_touched_paths: 25,
527                escalate_changed_bytes: 1024 * 1024,
528                deny_touched_paths: 5_000,
529                deny_changed_bytes: 128 * 1024 * 1024,
530                deny_deleted_paths: 500,
531                delete_ratio_minimum_paths: 10,
532                deny_delete_ratio_bps: 2_500,
533            },
534        }
535    }
536}
537
538/// Invalid deterministic-policy threshold configuration.
539#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540#[non_exhaustive]
541pub enum PolicyConfigError {
542    /// A touched-path threshold was zero or escalation exceeded denial.
543    TouchedPathThreshold,
544    /// A changed-byte threshold was zero or escalation exceeded denial.
545    ChangedByteThreshold,
546    /// A delete threshold was zero.
547    DeleteThreshold,
548    /// Deletion ratio was outside 1..=10,000 basis points.
549    DeleteRatio,
550}
551
552impl fmt::Display for PolicyConfigError {
553    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
554        formatter.write_str(match self {
555            Self::TouchedPathThreshold => "invalid touched-path policy thresholds",
556            Self::ChangedByteThreshold => "invalid changed-byte policy thresholds",
557            Self::DeleteThreshold => "invalid delete policy thresholds",
558            Self::DeleteRatio => "delete ratio must be within 1..=10000 basis points",
559        })
560    }
561}
562
563impl Error for PolicyConfigError {}
564
565/// Deterministic transaction policy and its pre-call capability rules.
566#[derive(Clone, Debug, Eq, PartialEq)]
567pub struct TransactionPolicy {
568    profile: PolicyProfile,
569    thresholds: PolicyThresholds,
570    call_policy: CallPolicy,
571    digest: PolicyDigest,
572}
573
574impl TransactionPolicy {
575    /// Construct and validate an exact policy configuration.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`PolicyConfigError`] for contradictory or zero thresholds.
580    pub fn new(
581        profile: PolicyProfile,
582        thresholds: PolicyThresholds,
583        call_policy: CallPolicy,
584    ) -> Result<Self, PolicyConfigError> {
585        validate_thresholds(thresholds)?;
586        let digest = policy_digest(profile, thresholds, &call_policy);
587        Ok(Self {
588            profile,
589            thresholds,
590            call_policy,
591            digest,
592        })
593    }
594
595    /// Construct a built-in profile with secure default protected paths.
596    ///
597    /// # Panics
598    ///
599    /// Panics only if compile-time built-in thresholds become internally contradictory.
600    #[must_use]
601    pub fn preset(profile: PolicyProfile) -> Self {
602        Self::new(
603            profile,
604            PolicyThresholds::for_profile(profile),
605            CallPolicy::default(),
606        )
607        .expect("built-in policy thresholds are valid")
608    }
609
610    /// Return the built-in profile.
611    #[must_use]
612    pub const fn profile(&self) -> PolicyProfile {
613        self.profile
614    }
615
616    /// Return exact deterministic thresholds.
617    #[must_use]
618    pub const fn thresholds(&self) -> PolicyThresholds {
619        self.thresholds
620    }
621
622    /// Return the pre-call capability policy.
623    #[must_use]
624    pub const fn call_policy(&self) -> &CallPolicy {
625        &self.call_policy
626    }
627
628    /// Return the canonical digest bound into transaction identity.
629    #[must_use]
630    pub const fn digest(&self) -> PolicyDigest {
631        self.digest
632    }
633
634    /// Evaluate exact observed artifacts without I/O or mutable global state.
635    #[must_use]
636    pub fn evaluate(&self, input: PolicyInput<'_>) -> PolicyDecision {
637        self.evaluate_with_metrics(input).0
638    }
639
640    /// Evaluate once and return the exact metrics used for the decision.
641    ///
642    /// This avoids a second diff/effect traversal when a caller must retain review
643    /// evidence for an external commit hook.
644    #[must_use]
645    pub fn evaluate_with_metrics(&self, input: PolicyInput<'_>) -> (PolicyDecision, RiskMetrics) {
646        let metrics = RiskMetrics::from_evidence(input.diff, input.effects, input.base_node_count);
647        let decision = self.evaluate_observed(&input, metrics);
648        (decision, metrics)
649    }
650
651    fn evaluate_observed(&self, input: &PolicyInput<'_>, metrics: RiskMetrics) -> PolicyDecision {
652        if let Some(denial) = self.hard_denial(input, metrics) {
653            return denial;
654        }
655        if input.diff.is_empty() {
656            return PolicyDecision::AutoApprove;
657        }
658
659        let flags = self.risk_flags(metrics);
660        if flags.is_empty() {
661            PolicyDecision::AutoApprove
662        } else {
663            PolicyDecision::Escalate(RiskManifest {
664                metrics,
665                flags: flags.into_iter().collect(),
666                policy: self.digest,
667            })
668        }
669    }
670
671    fn hard_denial(&self, input: &PolicyInput<'_>, metrics: RiskMetrics) -> Option<PolicyDecision> {
672        if let Some(attempt) = input.denied_accesses.first() {
673            return Some(PolicyDecision::Deny(DenyManifest {
674                reason: DenyReason::ProtectedAccessAttempt(attempt.clone()),
675                metrics,
676                policy: self.digest,
677            }));
678        }
679
680        for entry in input.diff.entries() {
681            let access = match entry.kind {
682                DiffKind::Create => AccessKind::Create,
683                DiffKind::Delete => AccessKind::Delete,
684                DiffKind::Modify | DiffKind::MetadataChange => AccessKind::Modify,
685            };
686            if let Err(denial) = self.call_policy.authorize(&entry.path, access) {
687                return Some(PolicyDecision::Deny(DenyManifest {
688                    reason: DenyReason::ProtectedMutation(denial),
689                    metrics,
690                    policy: self.digest,
691                }));
692            }
693        }
694
695        if metrics.touched_paths > self.thresholds.deny_touched_paths {
696            return Some(self.deny(
697                metrics,
698                DenyReason::TouchedPathLimit {
699                    limit: self.thresholds.deny_touched_paths,
700                    observed: metrics.touched_paths,
701                },
702            ));
703        }
704        if metrics.changed_bytes > self.thresholds.deny_changed_bytes {
705            return Some(self.deny(
706                metrics,
707                DenyReason::ChangedByteLimit {
708                    limit: self.thresholds.deny_changed_bytes,
709                    observed: metrics.changed_bytes,
710                },
711            ));
712        }
713        if metrics.deleted_paths > self.thresholds.deny_deleted_paths {
714            return Some(self.deny(
715                metrics,
716                DenyReason::DeletePathLimit {
717                    limit: self.thresholds.deny_deleted_paths,
718                    observed: metrics.deleted_paths,
719                },
720            ));
721        }
722        if metrics.deleted_paths >= self.thresholds.delete_ratio_minimum_paths
723            && metrics.delete_ratio_bps >= self.thresholds.deny_delete_ratio_bps
724        {
725            return Some(self.deny(
726                metrics,
727                DenyReason::DeleteRatioLimit {
728                    limit_bps: self.thresholds.deny_delete_ratio_bps,
729                    observed_bps: metrics.delete_ratio_bps,
730                },
731            ));
732        }
733        None
734    }
735
736    fn risk_flags(&self, metrics: RiskMetrics) -> BTreeSet<RiskFlag> {
737        let mut flags = BTreeSet::new();
738        if matches!(
739            self.profile,
740            PolicyProfile::Strict | PolicyProfile::Paranoid
741        ) {
742            flags.insert(RiskFlag::Mutation);
743        }
744        if metrics.deleted_paths > 0 {
745            flags.insert(RiskFlag::Deletion);
746        }
747        if metrics.renamed_paths > 0 {
748            flags.insert(RiskFlag::Rename);
749        }
750        if metrics.executable_changes > 0 {
751            flags.insert(RiskFlag::ExecutableChange);
752        }
753        if metrics.symlink_changes > 0 {
754            flags.insert(RiskFlag::SymlinkChange);
755        }
756        if metrics.touched_paths >= self.thresholds.escalate_touched_paths {
757            flags.insert(RiskFlag::LargeTouchedSet);
758        }
759        if metrics.changed_bytes >= self.thresholds.escalate_changed_bytes {
760            flags.insert(RiskFlag::LargeByteChange);
761        }
762        flags
763    }
764
765    fn deny(&self, metrics: RiskMetrics, reason: DenyReason) -> PolicyDecision {
766        PolicyDecision::Deny(DenyManifest {
767            reason,
768            metrics,
769            policy: self.digest,
770        })
771    }
772}
773
774impl Default for TransactionPolicy {
775    fn default() -> Self {
776        Self::preset(PolicyProfile::Balanced)
777    }
778}
779
780fn validate_thresholds(thresholds: PolicyThresholds) -> Result<(), PolicyConfigError> {
781    if thresholds.escalate_touched_paths == 0
782        || thresholds.deny_touched_paths == 0
783        || thresholds.escalate_touched_paths > thresholds.deny_touched_paths
784    {
785        return Err(PolicyConfigError::TouchedPathThreshold);
786    }
787    if thresholds.escalate_changed_bytes == 0
788        || thresholds.deny_changed_bytes == 0
789        || thresholds.escalate_changed_bytes > thresholds.deny_changed_bytes
790    {
791        return Err(PolicyConfigError::ChangedByteThreshold);
792    }
793    if thresholds.deny_deleted_paths == 0 || thresholds.delete_ratio_minimum_paths == 0 {
794        return Err(PolicyConfigError::DeleteThreshold);
795    }
796    if !(1..=10_000).contains(&thresholds.deny_delete_ratio_bps) {
797        return Err(PolicyConfigError::DeleteRatio);
798    }
799    Ok(())
800}
801
802fn policy_digest(
803    profile: PolicyProfile,
804    thresholds: PolicyThresholds,
805    call_policy: &CallPolicy,
806) -> PolicyDigest {
807    let mut canonical = Vec::new();
808    encode_bytes(POLICY_SCHEMA_VERSION.as_bytes(), &mut canonical);
809    canonical.push(profile.tag());
810    canonical.extend_from_slice(&(thresholds.escalate_touched_paths as u64).to_le_bytes());
811    canonical.extend_from_slice(&thresholds.escalate_changed_bytes.to_le_bytes());
812    canonical.extend_from_slice(&(thresholds.deny_touched_paths as u64).to_le_bytes());
813    canonical.extend_from_slice(&thresholds.deny_changed_bytes.to_le_bytes());
814    canonical.extend_from_slice(&(thresholds.deny_deleted_paths as u64).to_le_bytes());
815    canonical.extend_from_slice(&(thresholds.delete_ratio_minimum_paths as u64).to_le_bytes());
816    canonical.extend_from_slice(&thresholds.deny_delete_ratio_bps.to_le_bytes());
817    call_policy.encode_canonical(&mut canonical);
818    PolicyDigest::digest_canonical(&canonical)
819}
820
821/// Inputs observed by deterministic transaction policy.
822#[derive(Clone, Copy)]
823pub struct PolicyInput<'a> {
824    /// Exact canonical virtual diff.
825    pub diff: &'a CanonicalDiff,
826    /// Operation-local observations, including rename semantics.
827    pub effects: &'a [EffectEvent],
828    /// Attempts denied before VFS access, including attempts caught by the program.
829    pub denied_accesses: &'a [DeniedAccess],
830    /// Number of base nodes including the virtual root.
831    pub base_node_count: usize,
832}
833
834/// Exact bounded metrics used for a deterministic policy decision.
835#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
836pub struct RiskMetrics {
837    /// All canonical changed paths.
838    pub touched_paths: usize,
839    /// Newly created paths.
840    pub created_paths: usize,
841    /// Content or metadata modifications.
842    pub modified_paths: usize,
843    /// Deleted paths, including recursive-delete closure.
844    pub deleted_paths: usize,
845    /// Semantic rename operations observed in the ledger.
846    pub renamed_paths: usize,
847    /// Sum of before and after bytes represented by changed non-directory nodes.
848    pub changed_bytes: u64,
849    /// Deletions divided by user-visible base nodes, in basis points.
850    pub delete_ratio_bps: u16,
851    /// Changes that add/remove an executable mode bit.
852    pub executable_changes: usize,
853    /// Changes involving an opaque symbolic link.
854    pub symlink_changes: usize,
855}
856
857impl RiskMetrics {
858    /// Derive the exact metrics used by policy from bounded transaction evidence.
859    #[must_use]
860    pub fn from_evidence(
861        diff: &CanonicalDiff,
862        effects: &[EffectEvent],
863        base_node_count: usize,
864    ) -> Self {
865        let mut metrics = Self {
866            touched_paths: diff.entries().len(),
867            ..Self::default()
868        };
869        for entry in diff.entries() {
870            match entry.kind {
871                DiffKind::Create => metrics.created_paths += 1,
872                DiffKind::Delete => metrics.deleted_paths += 1,
873                DiffKind::Modify | DiffKind::MetadataChange => metrics.modified_paths += 1,
874            }
875            metrics.changed_bytes = metrics
876                .changed_bytes
877                .saturating_add(entry.before.map_or(0, NodeState::size))
878                .saturating_add(entry.after.map_or(0, NodeState::size));
879            let before_executable = entry.before.is_some_and(|state| state.mode() & 0o111 != 0);
880            let after_executable = entry.after.is_some_and(|state| state.mode() & 0o111 != 0);
881            if before_executable != after_executable {
882                metrics.executable_changes += 1;
883            }
884            if entry
885                .before
886                .is_some_and(|state| state.kind() == NodeKind::Symlink)
887                || entry
888                    .after
889                    .is_some_and(|state| state.kind() == NodeKind::Symlink)
890            {
891                metrics.symlink_changes += 1;
892            }
893        }
894        metrics.renamed_paths = effects
895            .iter()
896            .filter(|event| matches!(event.effect, Effect::Rename { .. }))
897            .count();
898        let base_user_nodes = base_node_count.saturating_sub(1);
899        let numerator = metrics.deleted_paths.saturating_mul(10_000);
900        let ratio = numerator.checked_div(base_user_nodes).unwrap_or(0);
901        metrics.delete_ratio_bps = u16::try_from(ratio.min(10_000)).unwrap_or(10_000);
902        metrics
903    }
904}
905
906/// Stable reason a deterministic policy must reject a transaction.
907#[derive(Clone, Debug, Eq, PartialEq)]
908#[non_exhaustive]
909pub enum DenyReason {
910    /// Sandboxed code attempted a protected capability, even if it caught the error.
911    ProtectedAccessAttempt(DeniedAccess),
912    /// Final state changed a protected path through a non-Monty/core caller.
913    ProtectedMutation(DeniedAccess),
914    /// Canonical touched-path count exceeded the hard ceiling.
915    TouchedPathLimit {
916        /// Configured hard ceiling.
917        limit: usize,
918        /// Observed count.
919        observed: usize,
920    },
921    /// Changed bytes exceeded the hard ceiling.
922    ChangedByteLimit {
923        /// Configured hard ceiling.
924        limit: u64,
925        /// Observed count.
926        observed: u64,
927    },
928    /// Deleted paths exceeded the hard ceiling.
929    DeletePathLimit {
930        /// Configured hard ceiling.
931        limit: usize,
932        /// Observed count.
933        observed: usize,
934    },
935    /// A sufficiently large deletion exceeded the workspace-ratio ceiling.
936    DeleteRatioLimit {
937        /// Configured ceiling in basis points.
938        limit_bps: u16,
939        /// Observed ratio in basis points.
940        observed_bps: u16,
941    },
942}
943
944/// Why an otherwise valid transaction requires an independent approval principal.
945#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
946pub enum RiskFlag {
947    /// The selected profile escalates every mutation.
948    Mutation,
949    /// One or more paths are deleted.
950    Deletion,
951    /// A semantic rename was observed.
952    Rename,
953    /// Executable mode changed.
954    ExecutableChange,
955    /// An opaque symbolic link is created, removed, or replaced.
956    SymlinkChange,
957    /// Touched-path escalation threshold was reached.
958    LargeTouchedSet,
959    /// Changed-byte escalation threshold was reached.
960    LargeByteChange,
961}
962
963/// Deterministic denial payload.
964#[derive(Clone, Debug, Eq, PartialEq)]
965pub struct DenyManifest {
966    /// Stable deterministic reason.
967    pub reason: DenyReason,
968    /// Exact metrics evaluated.
969    pub metrics: RiskMetrics,
970    /// Exact policy configuration digest.
971    pub policy: PolicyDigest,
972}
973
974/// Bounded evidence shown to a fresh approval principal.
975#[derive(Clone, Debug, Eq, PartialEq)]
976pub struct RiskManifest {
977    /// Exact metrics evaluated.
978    pub metrics: RiskMetrics,
979    /// Stable, sorted risk flags.
980    pub flags: Vec<RiskFlag>,
981    /// Exact policy configuration digest.
982    pub policy: PolicyDigest,
983}
984
985/// Final deterministic transaction decision.
986#[derive(Clone, Debug, Eq, PartialEq)]
987pub enum PolicyDecision {
988    /// Hard policy rejected the transaction; no judge may reverse it.
989    Deny(DenyManifest),
990    /// Deterministic rules authorize reservation without a judge.
991    AutoApprove,
992    /// An independent fresh judge or human must narrow the decision.
993    Escalate(RiskManifest),
994}
995
996/// Canonically hash a read dependency set.
997#[must_use]
998pub fn read_set_digest(read_set: &BTreeMap<VPath, ReadObservation>) -> ReadSetDigest {
999    let mut canonical = Vec::new();
1000    encode_usize(read_set.len(), &mut canonical);
1001    for (path, observation) in read_set {
1002        encode_path(path, &mut canonical);
1003        match observation.metadata {
1004            None => canonical.push(0),
1005            Some(None) => canonical.push(1),
1006            Some(Some(state)) => {
1007                canonical.push(2);
1008                state.encode_canonical(&mut canonical);
1009            }
1010        }
1011        encode_optional_digest(
1012            observation.content.map(|digest| *digest.as_bytes()),
1013            &mut canonical,
1014        );
1015        encode_optional_digest(
1016            observation.directory.map(|digest| *digest.as_bytes()),
1017            &mut canonical,
1018        );
1019    }
1020    ReadSetDigest::digest_canonical(&canonical)
1021}
1022
1023/// Canonically hash write preconditions.
1024#[must_use]
1025pub fn write_set_digest(write_set: &BTreeMap<VPath, WritePrecondition>) -> WriteSetDigest {
1026    let mut canonical = Vec::new();
1027    encode_usize(write_set.len(), &mut canonical);
1028    for (path, precondition) in write_set {
1029        encode_path(path, &mut canonical);
1030        encode_optional_state(precondition.expected, &mut canonical);
1031    }
1032    WriteSetDigest::digest_canonical(&canonical)
1033}
1034
1035/// Inputs used to construct an approval-bound transaction identity.
1036#[derive(Clone, Copy)]
1037pub struct TransactionIdentityInput<'a> {
1038    /// Immutable base snapshot.
1039    pub base_snapshot: SnapshotId,
1040    /// Exact canonical diff.
1041    pub diff: &'a CanonicalDiff,
1042    /// Exact read dependencies.
1043    pub read_set: &'a BTreeMap<VPath, ReadObservation>,
1044    /// Exact write preconditions.
1045    pub write_set: &'a BTreeMap<VPath, WritePrecondition>,
1046    /// Exact program source.
1047    pub program: &'a str,
1048    /// Exact deterministic policy configuration.
1049    pub policy: &'a TransactionPolicy,
1050    /// Canonical security-relevant execution configuration.
1051    pub runtime_config: RuntimeConfigDigest,
1052    /// Optional original intent carried out of band.
1053    pub intent: Option<&'a str>,
1054}
1055
1056/// Bind every approval-relevant artifact into one immutable transaction identity.
1057#[must_use]
1058pub fn bind_transaction(input: TransactionIdentityInput<'_>) -> TransactionBinding {
1059    TransactionBinding {
1060        base_snapshot: input.base_snapshot,
1061        diff: input.diff.digest(),
1062        read_set: read_set_digest(input.read_set),
1063        write_set: write_set_digest(input.write_set),
1064        program: ProgramDigest::digest_source(input.program),
1065        policy: input.policy.digest(),
1066        runtime_config: input.runtime_config,
1067        intent: input.intent.map(IntentDigest::digest_text),
1068    }
1069}
1070
1071fn encode_usize(value: usize, output: &mut Vec<u8>) {
1072    output.extend_from_slice(&(value as u64).to_le_bytes());
1073}
1074
1075fn encode_bytes(bytes: &[u8], output: &mut Vec<u8>) {
1076    output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
1077    output.extend_from_slice(bytes);
1078}
1079
1080fn encode_path(path: &VPath, output: &mut Vec<u8>) {
1081    encode_bytes(path.as_str().as_bytes(), output);
1082}
1083
1084fn encode_optional_state(state: Option<NodeState>, output: &mut Vec<u8>) {
1085    match state {
1086        Some(state) => {
1087            output.push(1);
1088            state.encode_canonical(output);
1089        }
1090        None => output.push(0),
1091    }
1092}
1093
1094fn encode_optional_digest(digest: Option<[u8; 32]>, output: &mut Vec<u8>) {
1095    match digest {
1096        Some(digest) => {
1097            output.push(1);
1098            output.extend_from_slice(&digest);
1099        }
1100        None => output.push(0),
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use std::fs;
1107    use std::path::{Path, PathBuf};
1108    use std::sync::atomic::{AtomicU64, Ordering};
1109
1110    use vsh_store::BlobStore;
1111    use vsh_types::{RuntimeConfigDigest, VPath};
1112    use vsh_vfs::{SnapshotBuilder, VirtualFs};
1113
1114    use super::*;
1115
1116    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1117
1118    struct TestDirectory(PathBuf);
1119
1120    impl TestDirectory {
1121        fn new() -> Self {
1122            let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1123            let path = std::env::temp_dir()
1124                .join(format!("vsh-policy-test-{}-{sequence}", std::process::id()));
1125            fs::create_dir(&path).expect("test directory should be unique");
1126            Self(path)
1127        }
1128
1129        fn path(&self) -> &Path {
1130            &self.0
1131        }
1132    }
1133
1134    impl Drop for TestDirectory {
1135        fn drop(&mut self) {
1136            let _ = fs::remove_dir_all(&self.0);
1137        }
1138    }
1139
1140    fn filesystem(files: &[(&str, &[u8])]) -> (TestDirectory, VirtualFs) {
1141        let directory = TestDirectory::new();
1142        let store = BlobStore::open(directory.path()).unwrap();
1143        let mut builder = SnapshotBuilder::new(store);
1144        for (path, bytes) in files {
1145            if let Some(parent) = VPath::parse(path).unwrap().parent()
1146                && !parent.is_root()
1147            {
1148                let _ = builder.add_directory(parent, 0o755);
1149            }
1150            builder
1151                .add_file(VPath::parse(path).unwrap(), bytes, 0o644)
1152                .unwrap();
1153        }
1154        let snapshot = builder.build().unwrap();
1155        (directory, VirtualFs::new(snapshot))
1156    }
1157
1158    #[test]
1159    fn cursor_globstar_matches_dynamic_programming_oracle() {
1160        fn sequences<'a>(alphabet: &[&'a str], depth: usize) -> Vec<Vec<&'a str>> {
1161            let mut sequences = vec![Vec::new()];
1162            let mut frontier = vec![Vec::new()];
1163            for _ in 0..depth {
1164                frontier = frontier
1165                    .iter()
1166                    .flat_map(|prefix| {
1167                        alphabet.iter().map(move |component| {
1168                            let mut next = prefix.clone();
1169                            next.push(*component);
1170                            next
1171                        })
1172                    })
1173                    .collect();
1174                sequences.extend(frontier.iter().cloned());
1175            }
1176            sequences
1177        }
1178
1179        // The original matcher is deliberately retained as an independent
1180        // oracle. Include empty paths, consecutive and separated globstars,
1181        // failed suffix retries, basename wildcards and UTF-8 components.
1182        fn oracle(pattern: &[String], path: &[&str]) -> bool {
1183            let mut previous = vec![false; path.len() + 1];
1184            previous[0] = true;
1185            for component in pattern {
1186                let mut current = vec![false; path.len() + 1];
1187                if component == "**" {
1188                    current[0] = previous[0];
1189                    for index in 1..current.len() {
1190                        current[index] = previous[index] || current[index - 1];
1191                    }
1192                } else {
1193                    for index in 1..current.len() {
1194                        current[index] =
1195                            previous[index - 1] && component_matches(component, path[index - 1]);
1196                    }
1197                }
1198                previous = current;
1199            }
1200            previous[path.len()]
1201        }
1202
1203        let paths = sequences(&["a", "b", "é"], 4);
1204        for components in sequences(&["a", "b", "*", "a*", "**"], 4) {
1205            let pattern: Vec<String> = components.iter().map(ToString::to_string).collect();
1206            let compiled = (!components.is_empty())
1207                .then(|| PathPattern::compile(components.join("/")).unwrap());
1208            for path in &paths {
1209                assert_eq!(
1210                    path_components_match(&pattern, &path.join("/")),
1211                    oracle(&pattern, path),
1212                    "pattern {pattern:?}, path {path:?}"
1213                );
1214                if let Some(compiled) = &compiled {
1215                    let expected = if pattern.len() == 1 && pattern[0] != "**" {
1216                        path.last()
1217                            .is_some_and(|name| component_matches(&pattern[0], name))
1218                    } else {
1219                        oracle(&pattern, path)
1220                    };
1221                    let virtual_path =
1222                        VPath::parse(&path.join("/")).unwrap_or_else(|_| VPath::root());
1223                    assert_eq!(
1224                        compiled.matches(&virtual_path),
1225                        expected,
1226                        "{pattern:?} {path:?}"
1227                    );
1228                }
1229            }
1230        }
1231        let deep = vec!["a"; 4096].join("/");
1232        assert!(path_components_match(&["**".into(), "a".into()], &deep));
1233        assert!(!path_components_match(&["**".into(), "b".into()], &deep));
1234    }
1235
1236    #[test]
1237    fn pattern_fast_paths_preserve_root_and_normalization() {
1238        for (pattern, path, expected) in [
1239            ("*", ".", false),
1240            ("**", ".", true),
1241            ("**/**", ".", true),
1242            ("a/**", ".", false),
1243            ("./a//**/b/", "a/b", true),
1244            ("**/é*", "a/été", true),
1245            ("**/**/*.key", "a/b/private.key", true),
1246            ("**/*.key", ".", false),
1247            ("**/a/**/b", "a/x/a/y/b", true),
1248            ("**/a/**/b", "a/x/a/y/c", false),
1249            ("*.key", "a/private.key", true),
1250            ("a/*", "a/b/c", false),
1251        ] {
1252            assert_eq!(
1253                PathPattern::compile(pattern)
1254                    .unwrap()
1255                    .matches(&VPath::parse(path).unwrap()),
1256                expected,
1257                "pattern {pattern}, path {path}"
1258            );
1259        }
1260    }
1261
1262    #[test]
1263    fn portable_patterns_cover_root_nested_and_subtree_secrets() {
1264        let policy = CallPolicy::default();
1265        for path in [
1266            ".env",
1267            ".env/token",
1268            "app/.env.local",
1269            "app/.env.local/token",
1270            "secrets/token.txt",
1271            "nested/private.key",
1272            "deploy/id_rsa",
1273            "a/credentials.json",
1274        ] {
1275            assert!(
1276                policy
1277                    .authorize(&VPath::parse(path).unwrap(), AccessKind::ContentRead)
1278                    .is_err(),
1279                "path should be protected: {path}"
1280            );
1281        }
1282        assert!(
1283            policy
1284                .authorize(
1285                    &VPath::parse("src/main.rs").unwrap(),
1286                    AccessKind::ContentRead
1287                )
1288                .is_ok()
1289        );
1290    }
1291
1292    #[test]
1293    fn git_reads_are_allowed_but_mutations_are_denied() {
1294        let policy = CallPolicy::default();
1295        let path = VPath::parse(".git/config").unwrap();
1296        assert!(policy.authorize(&path, AccessKind::ContentRead).is_ok());
1297        let denial = policy.authorize(&path, AccessKind::Modify).unwrap_err();
1298        assert_eq!(denial.rule, ".git/**");
1299    }
1300
1301    #[test]
1302    fn malformed_patterns_fail_closed() {
1303        for (pattern, expected) in [
1304            ("", PatternError::Empty),
1305            ("/secret", PatternError::Absolute),
1306            ("a\\b", PatternError::Backslash),
1307            ("../secret", PatternError::ParentComponent),
1308            ("foo**bar", PatternError::InvalidGlobstar),
1309            ("secret?.txt", PatternError::UnsupportedMetacharacter),
1310        ] {
1311            assert_eq!(
1312                ProtectedRule::new(pattern, AccessSet::ALL).unwrap_err(),
1313                expected
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn balanced_auto_approves_small_non_destructive_edit() {
1320        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1321        filesystem
1322            .write(&VPath::parse("output.txt").unwrap(), b"two")
1323            .unwrap();
1324        let diff = filesystem.canonical_diff().unwrap();
1325        let policy = TransactionPolicy::default();
1326        let decision = policy.evaluate(PolicyInput {
1327            diff: &diff,
1328            effects: filesystem.effects(),
1329            denied_accesses: &[],
1330            base_node_count: 2,
1331        });
1332        assert_eq!(decision, PolicyDecision::AutoApprove);
1333    }
1334
1335    #[test]
1336    fn strict_escalates_small_mutation_without_mislabeling_it_as_large() {
1337        let (_guard, mut filesystem) = filesystem(&[]);
1338        filesystem
1339            .write(&VPath::parse("output.txt").unwrap(), b"two")
1340            .unwrap();
1341        let diff = filesystem.canonical_diff().unwrap();
1342        let decision = TransactionPolicy::preset(PolicyProfile::Strict).evaluate(PolicyInput {
1343            diff: &diff,
1344            effects: filesystem.effects(),
1345            denied_accesses: &[],
1346            base_node_count: 1,
1347        });
1348        let PolicyDecision::Escalate(manifest) = decision else {
1349            panic!("strict mutation should escalate")
1350        };
1351        assert_eq!(manifest.flags, vec![RiskFlag::Mutation]);
1352    }
1353
1354    #[test]
1355    fn balanced_escalates_delete_and_rename() {
1356        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1357        filesystem
1358            .rename(
1359                &VPath::parse("input.txt").unwrap(),
1360                &VPath::parse("archive.txt").unwrap(),
1361            )
1362            .unwrap();
1363        let diff = filesystem.canonical_diff().unwrap();
1364        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1365            diff: &diff,
1366            effects: filesystem.effects(),
1367            denied_accesses: &[],
1368            base_node_count: 2,
1369        });
1370        let PolicyDecision::Escalate(manifest) = decision else {
1371            panic!("rename should escalate")
1372        };
1373        assert_eq!(manifest.metrics.deleted_paths, 1);
1374        assert_eq!(manifest.metrics.renamed_paths, 1);
1375        assert_eq!(manifest.flags, vec![RiskFlag::Deletion, RiskFlag::Rename]);
1376    }
1377
1378    #[test]
1379    fn caught_protected_attempt_forces_final_deny() {
1380        let (_guard, filesystem) = filesystem(&[]);
1381        let diff = filesystem.canonical_diff().unwrap();
1382        let attempt = DeniedAccess {
1383            path: VPath::parse(".env").unwrap(),
1384            access: AccessKind::ContentRead,
1385            rule: ".env".to_owned(),
1386        };
1387        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1388            diff: &diff,
1389            effects: filesystem.effects(),
1390            denied_accesses: std::slice::from_ref(&attempt),
1391            base_node_count: 1,
1392        });
1393        assert!(matches!(
1394            decision,
1395            PolicyDecision::Deny(DenyManifest {
1396                reason: DenyReason::ProtectedAccessAttempt(ref denied),
1397                ..
1398            }) if denied == &attempt
1399        ));
1400    }
1401
1402    #[test]
1403    fn final_policy_rechecks_protected_mutations() {
1404        let (_guard, mut filesystem) = filesystem(&[]);
1405        filesystem
1406            .write(&VPath::parse(".env").unwrap(), b"secret")
1407            .unwrap();
1408        let diff = filesystem.canonical_diff().unwrap();
1409        let decision = TransactionPolicy::default().evaluate(PolicyInput {
1410            diff: &diff,
1411            effects: filesystem.effects(),
1412            denied_accesses: &[],
1413            base_node_count: 1,
1414        });
1415        assert!(matches!(
1416            decision,
1417            PolicyDecision::Deny(DenyManifest {
1418                reason: DenyReason::ProtectedMutation(_),
1419                ..
1420            })
1421        ));
1422    }
1423
1424    #[test]
1425    fn transaction_identity_changes_with_every_bound_context() {
1426        let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1427        filesystem
1428            .read(&VPath::parse("input.txt").unwrap())
1429            .unwrap();
1430        filesystem
1431            .write(&VPath::parse("output.txt").unwrap(), b"two")
1432            .unwrap();
1433        let diff = filesystem.canonical_diff().unwrap();
1434        let policy = TransactionPolicy::default();
1435        let runtime_config = RuntimeConfigDigest::digest_canonical(b"limits-a");
1436        let first = bind_transaction(TransactionIdentityInput {
1437            base_snapshot: SnapshotId::from_bytes([1; 32]),
1438            diff: &diff,
1439            read_set: filesystem.read_set(),
1440            write_set: filesystem.write_set(),
1441            program: "program-a",
1442            policy: &policy,
1443            runtime_config,
1444            intent: Some("intent-a"),
1445        });
1446        let second = bind_transaction(TransactionIdentityInput {
1447            program: "program-b",
1448            ..TransactionIdentityInput {
1449                base_snapshot: SnapshotId::from_bytes([1; 32]),
1450                diff: &diff,
1451                read_set: filesystem.read_set(),
1452                write_set: filesystem.write_set(),
1453                program: "program-a",
1454                policy: &policy,
1455                runtime_config,
1456                intent: Some("intent-a"),
1457            }
1458        });
1459        assert_ne!(first.transaction_id(), second.transaction_id());
1460        assert_eq!(
1461            first.transaction_id(),
1462            bind_transaction(TransactionIdentityInput {
1463                base_snapshot: SnapshotId::from_bytes([1; 32]),
1464                diff: &diff,
1465                read_set: filesystem.read_set(),
1466                write_set: filesystem.write_set(),
1467                program: "program-a",
1468                policy: &policy,
1469                runtime_config,
1470                intent: Some("intent-a"),
1471            })
1472            .transaction_id()
1473        );
1474    }
1475
1476    #[test]
1477    fn policy_configuration_errors_have_distinct_stable_messages() {
1478        let pattern_errors = [
1479            PatternError::Empty,
1480            PatternError::Absolute,
1481            PatternError::Backslash,
1482            PatternError::NulByte,
1483            PatternError::ParentComponent,
1484            PatternError::InvalidGlobstar,
1485            PatternError::UnsupportedMetacharacter,
1486        ];
1487        assert_eq!(
1488            pattern_errors
1489                .map(|error| error.to_string())
1490                .into_iter()
1491                .collect::<BTreeSet<_>>()
1492                .len(),
1493            pattern_errors.len()
1494        );
1495
1496        let config_errors = [
1497            PolicyConfigError::TouchedPathThreshold,
1498            PolicyConfigError::ChangedByteThreshold,
1499            PolicyConfigError::DeleteThreshold,
1500            PolicyConfigError::DeleteRatio,
1501        ];
1502        assert_eq!(
1503            config_errors
1504                .map(|error| error.to_string())
1505                .into_iter()
1506                .collect::<BTreeSet<_>>()
1507                .len(),
1508            config_errors.len()
1509        );
1510
1511        for access in [
1512            AccessKind::MetadataRead,
1513            AccessKind::ContentRead,
1514            AccessKind::DirectoryRead,
1515        ] {
1516            assert!(!access.is_mutation());
1517        }
1518        for access in [
1519            AccessKind::Create,
1520            AccessKind::Modify,
1521            AccessKind::Delete,
1522            AccessKind::RenameSource,
1523            AccessKind::RenameDestination,
1524        ] {
1525            assert!(access.is_mutation());
1526            assert!(AccessSet::MUTATIONS.contains(access));
1527        }
1528        assert!(!AccessSet::NONE.contains(AccessKind::Create));
1529    }
1530}