1use 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
18pub const POLICY_SCHEMA_VERSION: &str = "vsh-policy-v1";
20
21pub 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
43pub const INTERNAL_RUNTIME_PATTERNS: &[&str] = &[
45 ".vsh-runtime",
46 ".vsh-runtime/**",
47 ".vsh-runtime-owner",
48 "**/.vsh-runtime-owner",
49];
50
51#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub enum AccessKind {
54 MetadataRead,
56 ContentRead,
58 DirectoryRead,
60 Create,
62 Modify,
64 Delete,
66 RenameSource,
68 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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct AccessSet(u16);
103
104impl AccessSet {
105 pub const NONE: Self = Self(0);
107 pub const METADATA_READ: Self = Self(AccessKind::MetadataRead.bit());
109 pub const CONTENT_READ: Self = Self(AccessKind::ContentRead.bit());
111 pub const DIRECTORY_READ: Self = Self(AccessKind::DirectoryRead.bit());
113 pub const READS: Self = Self(
115 AccessKind::MetadataRead.bit()
116 | AccessKind::ContentRead.bit()
117 | AccessKind::DirectoryRead.bit(),
118 );
119 pub const CREATE: Self = Self(AccessKind::Create.bit());
121 pub const MODIFY: Self = Self(AccessKind::Modify.bit());
123 pub const DELETE: Self = Self(AccessKind::Delete.bit());
125 pub const RENAME_SOURCE: Self = Self(AccessKind::RenameSource.bit());
127 pub const RENAME_DESTINATION: Self = Self(AccessKind::RenameDestination.bit());
129 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 pub const ALL: Self = Self(Self::READS.0 | Self::MUTATIONS.0);
139
140 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161#[non_exhaustive]
162pub enum PatternError {
163 Empty,
165 Absolute,
167 Backslash,
169 NulByte,
171 ParentComponent,
173 InvalidGlobstar,
175 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.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 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#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct ProtectedRule {
330 pattern: PathPattern,
331 denied: AccessSet,
332}
333
334impl ProtectedRule {
335 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 #[must_use]
349 pub fn pattern(&self) -> &str {
350 &self.pattern.source
351 }
352
353 #[must_use]
355 pub const fn denied(&self) -> AccessSet {
356 self.denied
357 }
358}
359
360#[derive(Clone, Debug, Eq, PartialEq)]
362pub struct DeniedAccess {
363 pub path: VPath,
365 pub access: AccessKind,
367 pub rule: String,
369}
370
371#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct CallPolicy {
374 rules: Vec<ProtectedRule>,
375}
376
377impl CallPolicy {
378 #[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 #[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 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
464pub enum PolicyProfile {
465 Balanced,
467 Strict,
469 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
485pub struct PolicyThresholds {
486 pub escalate_touched_paths: usize,
488 pub escalate_changed_bytes: u64,
490 pub deny_touched_paths: usize,
492 pub deny_changed_bytes: u64,
494 pub deny_deleted_paths: usize,
496 pub delete_ratio_minimum_paths: usize,
498 pub deny_delete_ratio_bps: u16,
500}
501
502impl PolicyThresholds {
503 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540#[non_exhaustive]
541pub enum PolicyConfigError {
542 TouchedPathThreshold,
544 ChangedByteThreshold,
546 DeleteThreshold,
548 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#[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 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 #[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 #[must_use]
612 pub const fn profile(&self) -> PolicyProfile {
613 self.profile
614 }
615
616 #[must_use]
618 pub const fn thresholds(&self) -> PolicyThresholds {
619 self.thresholds
620 }
621
622 #[must_use]
624 pub const fn call_policy(&self) -> &CallPolicy {
625 &self.call_policy
626 }
627
628 #[must_use]
630 pub const fn digest(&self) -> PolicyDigest {
631 self.digest
632 }
633
634 #[must_use]
636 pub fn evaluate(&self, input: PolicyInput<'_>) -> PolicyDecision {
637 self.evaluate_with_metrics(input).0
638 }
639
640 #[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#[derive(Clone, Copy)]
823pub struct PolicyInput<'a> {
824 pub diff: &'a CanonicalDiff,
826 pub effects: &'a [EffectEvent],
828 pub denied_accesses: &'a [DeniedAccess],
830 pub base_node_count: usize,
832}
833
834#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
836pub struct RiskMetrics {
837 pub touched_paths: usize,
839 pub created_paths: usize,
841 pub modified_paths: usize,
843 pub deleted_paths: usize,
845 pub renamed_paths: usize,
847 pub changed_bytes: u64,
849 pub delete_ratio_bps: u16,
851 pub executable_changes: usize,
853 pub symlink_changes: usize,
855}
856
857impl RiskMetrics {
858 #[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#[derive(Clone, Debug, Eq, PartialEq)]
908#[non_exhaustive]
909pub enum DenyReason {
910 ProtectedAccessAttempt(DeniedAccess),
912 ProtectedMutation(DeniedAccess),
914 TouchedPathLimit {
916 limit: usize,
918 observed: usize,
920 },
921 ChangedByteLimit {
923 limit: u64,
925 observed: u64,
927 },
928 DeletePathLimit {
930 limit: usize,
932 observed: usize,
934 },
935 DeleteRatioLimit {
937 limit_bps: u16,
939 observed_bps: u16,
941 },
942}
943
944#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
946pub enum RiskFlag {
947 Mutation,
949 Deletion,
951 Rename,
953 ExecutableChange,
955 SymlinkChange,
957 LargeTouchedSet,
959 LargeByteChange,
961}
962
963#[derive(Clone, Debug, Eq, PartialEq)]
965pub struct DenyManifest {
966 pub reason: DenyReason,
968 pub metrics: RiskMetrics,
970 pub policy: PolicyDigest,
972}
973
974#[derive(Clone, Debug, Eq, PartialEq)]
976pub struct RiskManifest {
977 pub metrics: RiskMetrics,
979 pub flags: Vec<RiskFlag>,
981 pub policy: PolicyDigest,
983}
984
985#[derive(Clone, Debug, Eq, PartialEq)]
987pub enum PolicyDecision {
988 Deny(DenyManifest),
990 AutoApprove,
992 Escalate(RiskManifest),
994}
995
996#[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#[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#[derive(Clone, Copy)]
1037pub struct TransactionIdentityInput<'a> {
1038 pub base_snapshot: SnapshotId,
1040 pub diff: &'a CanonicalDiff,
1042 pub read_set: &'a BTreeMap<VPath, ReadObservation>,
1044 pub write_set: &'a BTreeMap<VPath, WritePrecondition>,
1046 pub program: &'a str,
1048 pub policy: &'a TransactionPolicy,
1050 pub runtime_config: RuntimeConfigDigest,
1052 pub intent: Option<&'a str>,
1054}
1055
1056#[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 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}