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.len() == 1 && components[0] != "**",
241 source,
242 components,
243 })
244 }
245
246 fn matches(&self, path: &VPath) -> bool {
247 let path_components = if path.is_root() {
248 Vec::new()
249 } else {
250 path.as_str().split('/').collect::<Vec<_>>()
251 };
252 if self.basename_only {
253 return path_components
254 .last()
255 .is_some_and(|name| component_matches(&self.components[0], name));
256 }
257 path_components_match(&self.components, &path_components)
258 }
259}
260
261fn component_matches(pattern: &str, value: &str) -> bool {
262 let pattern = pattern.as_bytes();
263 let value = value.as_bytes();
264 let (mut pattern_index, mut value_index) = (0, 0);
265 let (mut last_star, mut star_value_index) = (None, 0);
266
267 while value_index < value.len() {
268 if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] {
269 pattern_index += 1;
270 value_index += 1;
271 } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
272 last_star = Some(pattern_index);
273 pattern_index += 1;
274 star_value_index = value_index;
275 } else if let Some(star) = last_star {
276 star_value_index += 1;
277 value_index = star_value_index;
278 pattern_index = star + 1;
279 } else {
280 return false;
281 }
282 }
283 while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
284 pattern_index += 1;
285 }
286 pattern_index == pattern.len()
287}
288
289fn path_components_match(pattern: &[String], path: &[&str]) -> bool {
290 let columns = path.len() + 1;
291 let mut previous = vec![false; columns];
292 previous[0] = true;
293
294 for component in pattern {
295 let mut current = vec![false; columns];
296 if component == "**" {
297 current[0] = previous[0];
298 for path_index in 1..columns {
299 current[path_index] = previous[path_index] || current[path_index - 1];
300 }
301 } else {
302 for path_index in 1..columns {
303 current[path_index] =
304 previous[path_index - 1] && component_matches(component, path[path_index - 1]);
305 }
306 }
307 previous = current;
308 }
309 previous[path.len()]
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct ProtectedRule {
315 pattern: PathPattern,
316 denied: AccessSet,
317}
318
319impl ProtectedRule {
320 pub fn new(pattern: impl Into<String>, denied: AccessSet) -> Result<Self, PatternError> {
326 Ok(Self {
327 pattern: PathPattern::compile(pattern)?,
328 denied,
329 })
330 }
331
332 #[must_use]
334 pub fn pattern(&self) -> &str {
335 &self.pattern.source
336 }
337
338 #[must_use]
340 pub const fn denied(&self) -> AccessSet {
341 self.denied
342 }
343}
344
345#[derive(Clone, Debug, Eq, PartialEq)]
347pub struct DeniedAccess {
348 pub path: VPath,
350 pub access: AccessKind,
352 pub rule: String,
354}
355
356#[derive(Clone, Debug, Eq, PartialEq)]
358pub struct CallPolicy {
359 rules: Vec<ProtectedRule>,
360}
361
362impl CallPolicy {
363 #[must_use]
365 pub fn new(mut rules: Vec<ProtectedRule>) -> Self {
366 rules.sort_by(|left, right| {
367 left.pattern()
368 .cmp(right.pattern())
369 .then_with(|| left.denied.bits().cmp(&right.denied.bits()))
370 });
371 let mut canonical: Vec<ProtectedRule> = Vec::with_capacity(rules.len());
372 for rule in rules {
373 if let Some(previous) = canonical.last_mut()
374 && previous.pattern() == rule.pattern()
375 {
376 previous.denied = previous.denied | rule.denied;
377 } else {
378 canonical.push(rule);
379 }
380 }
381 Self { rules: canonical }
382 }
383
384 #[must_use]
390 pub fn secure_default() -> Self {
391 let mut rules = DEFAULT_SECRET_PATTERNS
392 .iter()
393 .chain(INTERNAL_RUNTIME_PATTERNS)
394 .map(|pattern| {
395 ProtectedRule::new(*pattern, AccessSet::ALL)
396 .expect("built-in protected patterns are valid")
397 })
398 .collect::<Vec<_>>();
399 for pattern in [".git", ".git/**"] {
400 rules.push(
401 ProtectedRule::new(pattern, AccessSet::MUTATIONS)
402 .expect("built-in git patterns are valid"),
403 );
404 }
405 Self::new(rules)
406 }
407
408 pub fn authorize(&self, path: &VPath, access: AccessKind) -> Result<(), DeniedAccess> {
414 for rule in &self.rules {
415 if rule.denied.contains(access) && rule.pattern.matches(path) {
416 return Err(DeniedAccess {
417 path: path.clone(),
418 access,
419 rule: rule.pattern.source.clone(),
420 });
421 }
422 }
423 Ok(())
424 }
425
426 #[must_use]
428 pub fn rules(&self) -> &[ProtectedRule] {
429 &self.rules
430 }
431
432 fn encode_canonical(&self, output: &mut Vec<u8>) {
433 encode_usize(self.rules.len(), output);
434 for rule in &self.rules {
435 encode_bytes(rule.pattern().as_bytes(), output);
436 output.extend_from_slice(&rule.denied.bits().to_le_bytes());
437 }
438 }
439}
440
441impl Default for CallPolicy {
442 fn default() -> Self {
443 Self::secure_default()
444 }
445}
446
447#[derive(Clone, Copy, Debug, Eq, PartialEq)]
449pub enum PolicyProfile {
450 Balanced,
452 Strict,
454 Paranoid,
456}
457
458impl PolicyProfile {
459 const fn tag(self) -> u8 {
460 match self {
461 Self::Balanced => 1,
462 Self::Strict => 2,
463 Self::Paranoid => 3,
464 }
465 }
466}
467
468#[derive(Clone, Copy, Debug, Eq, PartialEq)]
470pub struct PolicyThresholds {
471 pub escalate_touched_paths: usize,
473 pub escalate_changed_bytes: u64,
475 pub deny_touched_paths: usize,
477 pub deny_changed_bytes: u64,
479 pub deny_deleted_paths: usize,
481 pub delete_ratio_minimum_paths: usize,
483 pub deny_delete_ratio_bps: u16,
485}
486
487impl PolicyThresholds {
488 #[must_use]
490 pub const fn for_profile(profile: PolicyProfile) -> Self {
491 match profile {
492 PolicyProfile::Balanced => Self {
493 escalate_touched_paths: 500,
494 escalate_changed_bytes: 64 * 1024 * 1024,
495 deny_touched_paths: 50_000,
496 deny_changed_bytes: 1024 * 1024 * 1024,
497 deny_deleted_paths: 10_000,
498 delete_ratio_minimum_paths: 100,
499 deny_delete_ratio_bps: 7_500,
500 },
501 PolicyProfile::Strict => Self {
502 escalate_touched_paths: 100,
503 escalate_changed_bytes: 8 * 1024 * 1024,
504 deny_touched_paths: 10_000,
505 deny_changed_bytes: 256 * 1024 * 1024,
506 deny_deleted_paths: 2_000,
507 delete_ratio_minimum_paths: 25,
508 deny_delete_ratio_bps: 5_000,
509 },
510 PolicyProfile::Paranoid => Self {
511 escalate_touched_paths: 25,
512 escalate_changed_bytes: 1024 * 1024,
513 deny_touched_paths: 5_000,
514 deny_changed_bytes: 128 * 1024 * 1024,
515 deny_deleted_paths: 500,
516 delete_ratio_minimum_paths: 10,
517 deny_delete_ratio_bps: 2_500,
518 },
519 }
520 }
521}
522
523#[derive(Clone, Copy, Debug, Eq, PartialEq)]
525#[non_exhaustive]
526pub enum PolicyConfigError {
527 TouchedPathThreshold,
529 ChangedByteThreshold,
531 DeleteThreshold,
533 DeleteRatio,
535}
536
537impl fmt::Display for PolicyConfigError {
538 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
539 formatter.write_str(match self {
540 Self::TouchedPathThreshold => "invalid touched-path policy thresholds",
541 Self::ChangedByteThreshold => "invalid changed-byte policy thresholds",
542 Self::DeleteThreshold => "invalid delete policy thresholds",
543 Self::DeleteRatio => "delete ratio must be within 1..=10000 basis points",
544 })
545 }
546}
547
548impl Error for PolicyConfigError {}
549
550#[derive(Clone, Debug, Eq, PartialEq)]
552pub struct TransactionPolicy {
553 profile: PolicyProfile,
554 thresholds: PolicyThresholds,
555 call_policy: CallPolicy,
556 digest: PolicyDigest,
557}
558
559impl TransactionPolicy {
560 pub fn new(
566 profile: PolicyProfile,
567 thresholds: PolicyThresholds,
568 call_policy: CallPolicy,
569 ) -> Result<Self, PolicyConfigError> {
570 validate_thresholds(thresholds)?;
571 let digest = policy_digest(profile, thresholds, &call_policy);
572 Ok(Self {
573 profile,
574 thresholds,
575 call_policy,
576 digest,
577 })
578 }
579
580 #[must_use]
586 pub fn preset(profile: PolicyProfile) -> Self {
587 Self::new(
588 profile,
589 PolicyThresholds::for_profile(profile),
590 CallPolicy::default(),
591 )
592 .expect("built-in policy thresholds are valid")
593 }
594
595 #[must_use]
597 pub const fn profile(&self) -> PolicyProfile {
598 self.profile
599 }
600
601 #[must_use]
603 pub const fn thresholds(&self) -> PolicyThresholds {
604 self.thresholds
605 }
606
607 #[must_use]
609 pub const fn call_policy(&self) -> &CallPolicy {
610 &self.call_policy
611 }
612
613 #[must_use]
615 pub const fn digest(&self) -> PolicyDigest {
616 self.digest
617 }
618
619 #[must_use]
621 pub fn evaluate(&self, input: PolicyInput<'_>) -> PolicyDecision {
622 let metrics = RiskMetrics::derive(input.diff, input.effects, input.base_node_count);
623
624 if let Some(attempt) = input.denied_accesses.first() {
625 return PolicyDecision::Deny(DenyManifest {
626 reason: DenyReason::ProtectedAccessAttempt(attempt.clone()),
627 metrics,
628 policy: self.digest,
629 });
630 }
631
632 for entry in input.diff.entries() {
633 let access = match entry.kind {
634 DiffKind::Create => AccessKind::Create,
635 DiffKind::Delete => AccessKind::Delete,
636 DiffKind::Modify | DiffKind::MetadataChange => AccessKind::Modify,
637 };
638 if let Err(denial) = self.call_policy.authorize(&entry.path, access) {
639 return PolicyDecision::Deny(DenyManifest {
640 reason: DenyReason::ProtectedMutation(denial),
641 metrics,
642 policy: self.digest,
643 });
644 }
645 }
646
647 if metrics.touched_paths > self.thresholds.deny_touched_paths {
648 return self.deny(
649 metrics,
650 DenyReason::TouchedPathLimit {
651 limit: self.thresholds.deny_touched_paths,
652 observed: metrics.touched_paths,
653 },
654 );
655 }
656 if metrics.changed_bytes > self.thresholds.deny_changed_bytes {
657 return self.deny(
658 metrics,
659 DenyReason::ChangedByteLimit {
660 limit: self.thresholds.deny_changed_bytes,
661 observed: metrics.changed_bytes,
662 },
663 );
664 }
665 if metrics.deleted_paths > self.thresholds.deny_deleted_paths {
666 return self.deny(
667 metrics,
668 DenyReason::DeletePathLimit {
669 limit: self.thresholds.deny_deleted_paths,
670 observed: metrics.deleted_paths,
671 },
672 );
673 }
674 if metrics.deleted_paths >= self.thresholds.delete_ratio_minimum_paths
675 && metrics.delete_ratio_bps >= self.thresholds.deny_delete_ratio_bps
676 {
677 return self.deny(
678 metrics,
679 DenyReason::DeleteRatioLimit {
680 limit_bps: self.thresholds.deny_delete_ratio_bps,
681 observed_bps: metrics.delete_ratio_bps,
682 },
683 );
684 }
685
686 if input.diff.is_empty() {
687 return PolicyDecision::AutoApprove;
688 }
689
690 let mut flags = BTreeSet::new();
691 if matches!(
692 self.profile,
693 PolicyProfile::Strict | PolicyProfile::Paranoid
694 ) {
695 flags.insert(RiskFlag::Mutation);
696 }
697 if metrics.deleted_paths > 0 {
698 flags.insert(RiskFlag::Deletion);
699 }
700 if metrics.renamed_paths > 0 {
701 flags.insert(RiskFlag::Rename);
702 }
703 if metrics.executable_changes > 0 {
704 flags.insert(RiskFlag::ExecutableChange);
705 }
706 if metrics.symlink_changes > 0 {
707 flags.insert(RiskFlag::SymlinkChange);
708 }
709 if metrics.touched_paths >= self.thresholds.escalate_touched_paths {
710 flags.insert(RiskFlag::LargeTouchedSet);
711 }
712 if metrics.changed_bytes >= self.thresholds.escalate_changed_bytes {
713 flags.insert(RiskFlag::LargeByteChange);
714 }
715
716 if flags.is_empty() {
717 PolicyDecision::AutoApprove
718 } else {
719 PolicyDecision::Escalate(RiskManifest {
720 metrics,
721 flags: flags.into_iter().collect(),
722 policy: self.digest,
723 })
724 }
725 }
726
727 fn deny(&self, metrics: RiskMetrics, reason: DenyReason) -> PolicyDecision {
728 PolicyDecision::Deny(DenyManifest {
729 reason,
730 metrics,
731 policy: self.digest,
732 })
733 }
734}
735
736impl Default for TransactionPolicy {
737 fn default() -> Self {
738 Self::preset(PolicyProfile::Balanced)
739 }
740}
741
742fn validate_thresholds(thresholds: PolicyThresholds) -> Result<(), PolicyConfigError> {
743 if thresholds.escalate_touched_paths == 0
744 || thresholds.deny_touched_paths == 0
745 || thresholds.escalate_touched_paths > thresholds.deny_touched_paths
746 {
747 return Err(PolicyConfigError::TouchedPathThreshold);
748 }
749 if thresholds.escalate_changed_bytes == 0
750 || thresholds.deny_changed_bytes == 0
751 || thresholds.escalate_changed_bytes > thresholds.deny_changed_bytes
752 {
753 return Err(PolicyConfigError::ChangedByteThreshold);
754 }
755 if thresholds.deny_deleted_paths == 0 || thresholds.delete_ratio_minimum_paths == 0 {
756 return Err(PolicyConfigError::DeleteThreshold);
757 }
758 if !(1..=10_000).contains(&thresholds.deny_delete_ratio_bps) {
759 return Err(PolicyConfigError::DeleteRatio);
760 }
761 Ok(())
762}
763
764fn policy_digest(
765 profile: PolicyProfile,
766 thresholds: PolicyThresholds,
767 call_policy: &CallPolicy,
768) -> PolicyDigest {
769 let mut canonical = Vec::new();
770 encode_bytes(POLICY_SCHEMA_VERSION.as_bytes(), &mut canonical);
771 canonical.push(profile.tag());
772 canonical.extend_from_slice(&(thresholds.escalate_touched_paths as u64).to_le_bytes());
773 canonical.extend_from_slice(&thresholds.escalate_changed_bytes.to_le_bytes());
774 canonical.extend_from_slice(&(thresholds.deny_touched_paths as u64).to_le_bytes());
775 canonical.extend_from_slice(&thresholds.deny_changed_bytes.to_le_bytes());
776 canonical.extend_from_slice(&(thresholds.deny_deleted_paths as u64).to_le_bytes());
777 canonical.extend_from_slice(&(thresholds.delete_ratio_minimum_paths as u64).to_le_bytes());
778 canonical.extend_from_slice(&thresholds.deny_delete_ratio_bps.to_le_bytes());
779 call_policy.encode_canonical(&mut canonical);
780 PolicyDigest::digest_canonical(&canonical)
781}
782
783#[derive(Clone, Copy)]
785pub struct PolicyInput<'a> {
786 pub diff: &'a CanonicalDiff,
788 pub effects: &'a [EffectEvent],
790 pub denied_accesses: &'a [DeniedAccess],
792 pub base_node_count: usize,
794}
795
796#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
798pub struct RiskMetrics {
799 pub touched_paths: usize,
801 pub created_paths: usize,
803 pub modified_paths: usize,
805 pub deleted_paths: usize,
807 pub renamed_paths: usize,
809 pub changed_bytes: u64,
811 pub delete_ratio_bps: u16,
813 pub executable_changes: usize,
815 pub symlink_changes: usize,
817}
818
819impl RiskMetrics {
820 fn derive(diff: &CanonicalDiff, effects: &[EffectEvent], base_node_count: usize) -> Self {
821 let mut metrics = Self {
822 touched_paths: diff.entries().len(),
823 ..Self::default()
824 };
825 for entry in diff.entries() {
826 match entry.kind {
827 DiffKind::Create => metrics.created_paths += 1,
828 DiffKind::Delete => metrics.deleted_paths += 1,
829 DiffKind::Modify | DiffKind::MetadataChange => metrics.modified_paths += 1,
830 }
831 metrics.changed_bytes = metrics
832 .changed_bytes
833 .saturating_add(entry.before.map_or(0, NodeState::size))
834 .saturating_add(entry.after.map_or(0, NodeState::size));
835 let before_executable = entry.before.is_some_and(|state| state.mode() & 0o111 != 0);
836 let after_executable = entry.after.is_some_and(|state| state.mode() & 0o111 != 0);
837 if before_executable != after_executable {
838 metrics.executable_changes += 1;
839 }
840 if entry
841 .before
842 .is_some_and(|state| state.kind() == NodeKind::Symlink)
843 || entry
844 .after
845 .is_some_and(|state| state.kind() == NodeKind::Symlink)
846 {
847 metrics.symlink_changes += 1;
848 }
849 }
850 metrics.renamed_paths = effects
851 .iter()
852 .filter(|event| matches!(event.effect, Effect::Rename { .. }))
853 .count();
854 let base_user_nodes = base_node_count.saturating_sub(1);
855 let numerator = metrics.deleted_paths.saturating_mul(10_000);
856 let ratio = numerator.checked_div(base_user_nodes).unwrap_or(0);
857 metrics.delete_ratio_bps = u16::try_from(ratio.min(10_000)).unwrap_or(10_000);
858 metrics
859 }
860}
861
862#[derive(Clone, Debug, Eq, PartialEq)]
864#[non_exhaustive]
865pub enum DenyReason {
866 ProtectedAccessAttempt(DeniedAccess),
868 ProtectedMutation(DeniedAccess),
870 TouchedPathLimit {
872 limit: usize,
874 observed: usize,
876 },
877 ChangedByteLimit {
879 limit: u64,
881 observed: u64,
883 },
884 DeletePathLimit {
886 limit: usize,
888 observed: usize,
890 },
891 DeleteRatioLimit {
893 limit_bps: u16,
895 observed_bps: u16,
897 },
898}
899
900#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
902pub enum RiskFlag {
903 Mutation,
905 Deletion,
907 Rename,
909 ExecutableChange,
911 SymlinkChange,
913 LargeTouchedSet,
915 LargeByteChange,
917}
918
919#[derive(Clone, Debug, Eq, PartialEq)]
921pub struct DenyManifest {
922 pub reason: DenyReason,
924 pub metrics: RiskMetrics,
926 pub policy: PolicyDigest,
928}
929
930#[derive(Clone, Debug, Eq, PartialEq)]
932pub struct RiskManifest {
933 pub metrics: RiskMetrics,
935 pub flags: Vec<RiskFlag>,
937 pub policy: PolicyDigest,
939}
940
941#[derive(Clone, Debug, Eq, PartialEq)]
943pub enum PolicyDecision {
944 Deny(DenyManifest),
946 AutoApprove,
948 Escalate(RiskManifest),
950}
951
952#[must_use]
954pub fn read_set_digest(read_set: &BTreeMap<VPath, ReadObservation>) -> ReadSetDigest {
955 let mut canonical = Vec::new();
956 encode_usize(read_set.len(), &mut canonical);
957 for (path, observation) in read_set {
958 encode_path(path, &mut canonical);
959 match observation.metadata {
960 None => canonical.push(0),
961 Some(None) => canonical.push(1),
962 Some(Some(state)) => {
963 canonical.push(2);
964 state.encode_canonical(&mut canonical);
965 }
966 }
967 encode_optional_digest(
968 observation.content.map(|digest| *digest.as_bytes()),
969 &mut canonical,
970 );
971 encode_optional_digest(
972 observation.directory.map(|digest| *digest.as_bytes()),
973 &mut canonical,
974 );
975 }
976 ReadSetDigest::digest_canonical(&canonical)
977}
978
979#[must_use]
981pub fn write_set_digest(write_set: &BTreeMap<VPath, WritePrecondition>) -> WriteSetDigest {
982 let mut canonical = Vec::new();
983 encode_usize(write_set.len(), &mut canonical);
984 for (path, precondition) in write_set {
985 encode_path(path, &mut canonical);
986 encode_optional_state(precondition.expected, &mut canonical);
987 }
988 WriteSetDigest::digest_canonical(&canonical)
989}
990
991#[derive(Clone, Copy)]
993pub struct TransactionIdentityInput<'a> {
994 pub base_snapshot: SnapshotId,
996 pub diff: &'a CanonicalDiff,
998 pub read_set: &'a BTreeMap<VPath, ReadObservation>,
1000 pub write_set: &'a BTreeMap<VPath, WritePrecondition>,
1002 pub program: &'a str,
1004 pub policy: &'a TransactionPolicy,
1006 pub runtime_config: RuntimeConfigDigest,
1008 pub intent: Option<&'a str>,
1010}
1011
1012#[must_use]
1014pub fn bind_transaction(input: TransactionIdentityInput<'_>) -> TransactionBinding {
1015 TransactionBinding {
1016 base_snapshot: input.base_snapshot,
1017 diff: input.diff.digest(),
1018 read_set: read_set_digest(input.read_set),
1019 write_set: write_set_digest(input.write_set),
1020 program: ProgramDigest::digest_source(input.program),
1021 policy: input.policy.digest(),
1022 runtime_config: input.runtime_config,
1023 intent: input.intent.map(IntentDigest::digest_text),
1024 }
1025}
1026
1027fn encode_usize(value: usize, output: &mut Vec<u8>) {
1028 output.extend_from_slice(&(value as u64).to_le_bytes());
1029}
1030
1031fn encode_bytes(bytes: &[u8], output: &mut Vec<u8>) {
1032 output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
1033 output.extend_from_slice(bytes);
1034}
1035
1036fn encode_path(path: &VPath, output: &mut Vec<u8>) {
1037 encode_bytes(path.as_str().as_bytes(), output);
1038}
1039
1040fn encode_optional_state(state: Option<NodeState>, output: &mut Vec<u8>) {
1041 match state {
1042 Some(state) => {
1043 output.push(1);
1044 state.encode_canonical(output);
1045 }
1046 None => output.push(0),
1047 }
1048}
1049
1050fn encode_optional_digest(digest: Option<[u8; 32]>, output: &mut Vec<u8>) {
1051 match digest {
1052 Some(digest) => {
1053 output.push(1);
1054 output.extend_from_slice(&digest);
1055 }
1056 None => output.push(0),
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use std::fs;
1063 use std::path::{Path, PathBuf};
1064 use std::sync::atomic::{AtomicU64, Ordering};
1065
1066 use vsh_store::BlobStore;
1067 use vsh_types::{RuntimeConfigDigest, VPath};
1068 use vsh_vfs::{SnapshotBuilder, VirtualFs};
1069
1070 use super::*;
1071
1072 static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1073
1074 struct TestDirectory(PathBuf);
1075
1076 impl TestDirectory {
1077 fn new() -> Self {
1078 let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1079 let path = std::env::temp_dir()
1080 .join(format!("vsh-policy-test-{}-{sequence}", std::process::id()));
1081 fs::create_dir(&path).expect("test directory should be unique");
1082 Self(path)
1083 }
1084
1085 fn path(&self) -> &Path {
1086 &self.0
1087 }
1088 }
1089
1090 impl Drop for TestDirectory {
1091 fn drop(&mut self) {
1092 let _ = fs::remove_dir_all(&self.0);
1093 }
1094 }
1095
1096 fn filesystem(files: &[(&str, &[u8])]) -> (TestDirectory, VirtualFs) {
1097 let directory = TestDirectory::new();
1098 let store = BlobStore::open(directory.path()).unwrap();
1099 let mut builder = SnapshotBuilder::new(store);
1100 for (path, bytes) in files {
1101 if let Some(parent) = VPath::parse(path).unwrap().parent()
1102 && !parent.is_root()
1103 {
1104 let _ = builder.add_directory(parent, 0o755);
1105 }
1106 builder
1107 .add_file(VPath::parse(path).unwrap(), bytes, 0o644)
1108 .unwrap();
1109 }
1110 let snapshot = builder.build().unwrap();
1111 (directory, VirtualFs::new(snapshot))
1112 }
1113
1114 #[test]
1115 fn portable_patterns_cover_root_nested_and_subtree_secrets() {
1116 let policy = CallPolicy::default();
1117 for path in [
1118 ".env",
1119 ".env/token",
1120 "app/.env.local",
1121 "app/.env.local/token",
1122 "secrets/token.txt",
1123 "nested/private.key",
1124 "deploy/id_rsa",
1125 "a/credentials.json",
1126 ] {
1127 assert!(
1128 policy
1129 .authorize(&VPath::parse(path).unwrap(), AccessKind::ContentRead)
1130 .is_err(),
1131 "path should be protected: {path}"
1132 );
1133 }
1134 assert!(
1135 policy
1136 .authorize(
1137 &VPath::parse("src/main.rs").unwrap(),
1138 AccessKind::ContentRead
1139 )
1140 .is_ok()
1141 );
1142 }
1143
1144 #[test]
1145 fn git_reads_are_allowed_but_mutations_are_denied() {
1146 let policy = CallPolicy::default();
1147 let path = VPath::parse(".git/config").unwrap();
1148 assert!(policy.authorize(&path, AccessKind::ContentRead).is_ok());
1149 let denial = policy.authorize(&path, AccessKind::Modify).unwrap_err();
1150 assert_eq!(denial.rule, ".git/**");
1151 }
1152
1153 #[test]
1154 fn malformed_patterns_fail_closed() {
1155 for (pattern, expected) in [
1156 ("", PatternError::Empty),
1157 ("/secret", PatternError::Absolute),
1158 ("a\\b", PatternError::Backslash),
1159 ("../secret", PatternError::ParentComponent),
1160 ("foo**bar", PatternError::InvalidGlobstar),
1161 ("secret?.txt", PatternError::UnsupportedMetacharacter),
1162 ] {
1163 assert_eq!(
1164 ProtectedRule::new(pattern, AccessSet::ALL).unwrap_err(),
1165 expected
1166 );
1167 }
1168 }
1169
1170 #[test]
1171 fn balanced_auto_approves_small_non_destructive_edit() {
1172 let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1173 filesystem
1174 .write(&VPath::parse("output.txt").unwrap(), b"two")
1175 .unwrap();
1176 let diff = filesystem.canonical_diff().unwrap();
1177 let policy = TransactionPolicy::default();
1178 let decision = policy.evaluate(PolicyInput {
1179 diff: &diff,
1180 effects: filesystem.effects(),
1181 denied_accesses: &[],
1182 base_node_count: 2,
1183 });
1184 assert_eq!(decision, PolicyDecision::AutoApprove);
1185 }
1186
1187 #[test]
1188 fn strict_escalates_small_mutation_without_mislabeling_it_as_large() {
1189 let (_guard, mut filesystem) = filesystem(&[]);
1190 filesystem
1191 .write(&VPath::parse("output.txt").unwrap(), b"two")
1192 .unwrap();
1193 let diff = filesystem.canonical_diff().unwrap();
1194 let decision = TransactionPolicy::preset(PolicyProfile::Strict).evaluate(PolicyInput {
1195 diff: &diff,
1196 effects: filesystem.effects(),
1197 denied_accesses: &[],
1198 base_node_count: 1,
1199 });
1200 let PolicyDecision::Escalate(manifest) = decision else {
1201 panic!("strict mutation should escalate")
1202 };
1203 assert_eq!(manifest.flags, vec![RiskFlag::Mutation]);
1204 }
1205
1206 #[test]
1207 fn balanced_escalates_delete_and_rename() {
1208 let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1209 filesystem
1210 .rename(
1211 &VPath::parse("input.txt").unwrap(),
1212 &VPath::parse("archive.txt").unwrap(),
1213 )
1214 .unwrap();
1215 let diff = filesystem.canonical_diff().unwrap();
1216 let decision = TransactionPolicy::default().evaluate(PolicyInput {
1217 diff: &diff,
1218 effects: filesystem.effects(),
1219 denied_accesses: &[],
1220 base_node_count: 2,
1221 });
1222 let PolicyDecision::Escalate(manifest) = decision else {
1223 panic!("rename should escalate")
1224 };
1225 assert_eq!(manifest.metrics.deleted_paths, 1);
1226 assert_eq!(manifest.metrics.renamed_paths, 1);
1227 assert_eq!(manifest.flags, vec![RiskFlag::Deletion, RiskFlag::Rename]);
1228 }
1229
1230 #[test]
1231 fn caught_protected_attempt_forces_final_deny() {
1232 let (_guard, filesystem) = filesystem(&[]);
1233 let diff = filesystem.canonical_diff().unwrap();
1234 let attempt = DeniedAccess {
1235 path: VPath::parse(".env").unwrap(),
1236 access: AccessKind::ContentRead,
1237 rule: ".env".to_owned(),
1238 };
1239 let decision = TransactionPolicy::default().evaluate(PolicyInput {
1240 diff: &diff,
1241 effects: filesystem.effects(),
1242 denied_accesses: std::slice::from_ref(&attempt),
1243 base_node_count: 1,
1244 });
1245 assert!(matches!(
1246 decision,
1247 PolicyDecision::Deny(DenyManifest {
1248 reason: DenyReason::ProtectedAccessAttempt(ref denied),
1249 ..
1250 }) if denied == &attempt
1251 ));
1252 }
1253
1254 #[test]
1255 fn final_policy_rechecks_protected_mutations() {
1256 let (_guard, mut filesystem) = filesystem(&[]);
1257 filesystem
1258 .write(&VPath::parse(".env").unwrap(), b"secret")
1259 .unwrap();
1260 let diff = filesystem.canonical_diff().unwrap();
1261 let decision = TransactionPolicy::default().evaluate(PolicyInput {
1262 diff: &diff,
1263 effects: filesystem.effects(),
1264 denied_accesses: &[],
1265 base_node_count: 1,
1266 });
1267 assert!(matches!(
1268 decision,
1269 PolicyDecision::Deny(DenyManifest {
1270 reason: DenyReason::ProtectedMutation(_),
1271 ..
1272 })
1273 ));
1274 }
1275
1276 #[test]
1277 fn transaction_identity_changes_with_every_bound_context() {
1278 let (_guard, mut filesystem) = filesystem(&[("input.txt", b"one")]);
1279 filesystem
1280 .read(&VPath::parse("input.txt").unwrap())
1281 .unwrap();
1282 filesystem
1283 .write(&VPath::parse("output.txt").unwrap(), b"two")
1284 .unwrap();
1285 let diff = filesystem.canonical_diff().unwrap();
1286 let policy = TransactionPolicy::default();
1287 let runtime_config = RuntimeConfigDigest::digest_canonical(b"limits-a");
1288 let first = bind_transaction(TransactionIdentityInput {
1289 base_snapshot: SnapshotId::from_bytes([1; 32]),
1290 diff: &diff,
1291 read_set: filesystem.read_set(),
1292 write_set: filesystem.write_set(),
1293 program: "program-a",
1294 policy: &policy,
1295 runtime_config,
1296 intent: Some("intent-a"),
1297 });
1298 let second = bind_transaction(TransactionIdentityInput {
1299 program: "program-b",
1300 ..TransactionIdentityInput {
1301 base_snapshot: SnapshotId::from_bytes([1; 32]),
1302 diff: &diff,
1303 read_set: filesystem.read_set(),
1304 write_set: filesystem.write_set(),
1305 program: "program-a",
1306 policy: &policy,
1307 runtime_config,
1308 intent: Some("intent-a"),
1309 }
1310 });
1311 assert_ne!(first.transaction_id(), second.transaction_id());
1312 assert_eq!(
1313 first.transaction_id(),
1314 bind_transaction(TransactionIdentityInput {
1315 base_snapshot: SnapshotId::from_bytes([1; 32]),
1316 diff: &diff,
1317 read_set: filesystem.read_set(),
1318 write_set: filesystem.write_set(),
1319 program: "program-a",
1320 policy: &policy,
1321 runtime_config,
1322 intent: Some("intent-a"),
1323 })
1324 .transaction_id()
1325 );
1326 }
1327
1328 #[test]
1329 fn policy_configuration_errors_have_distinct_stable_messages() {
1330 let pattern_errors = [
1331 PatternError::Empty,
1332 PatternError::Absolute,
1333 PatternError::Backslash,
1334 PatternError::NulByte,
1335 PatternError::ParentComponent,
1336 PatternError::InvalidGlobstar,
1337 PatternError::UnsupportedMetacharacter,
1338 ];
1339 assert_eq!(
1340 pattern_errors
1341 .map(|error| error.to_string())
1342 .into_iter()
1343 .collect::<BTreeSet<_>>()
1344 .len(),
1345 pattern_errors.len()
1346 );
1347
1348 let config_errors = [
1349 PolicyConfigError::TouchedPathThreshold,
1350 PolicyConfigError::ChangedByteThreshold,
1351 PolicyConfigError::DeleteThreshold,
1352 PolicyConfigError::DeleteRatio,
1353 ];
1354 assert_eq!(
1355 config_errors
1356 .map(|error| error.to_string())
1357 .into_iter()
1358 .collect::<BTreeSet<_>>()
1359 .len(),
1360 config_errors.len()
1361 );
1362
1363 for access in [
1364 AccessKind::MetadataRead,
1365 AccessKind::ContentRead,
1366 AccessKind::DirectoryRead,
1367 ] {
1368 assert!(!access.is_mutation());
1369 }
1370 for access in [
1371 AccessKind::Create,
1372 AccessKind::Modify,
1373 AccessKind::Delete,
1374 AccessKind::RenameSource,
1375 AccessKind::RenameDestination,
1376 ] {
1377 assert!(access.is_mutation());
1378 assert!(AccessSet::MUTATIONS.contains(access));
1379 }
1380 assert!(!AccessSet::NONE.contains(AccessKind::Create));
1381 }
1382}