1use crate::root_fs::SidecarCoreError;
2use secure_exec_bridge::FilesystemAccess;
3use secure_exec_kernel::permissions::{
4 permission_glob_matches, CommandAccessRequest, EnvAccessRequest, EnvironmentOperation,
5 FsAccessRequest, FsOperation, NetworkAccessRequest, NetworkOperation, PermissionDecision,
6 Permissions,
7};
8use secure_exec_vm_config as vm_config;
9use std::sync::Arc;
10
11pub fn deny_all_policy() -> vm_config::PermissionsPolicy {
12 vm_config::PermissionsPolicy {
13 fs: Some(vm_config::FsPermissionScope::Mode(
14 vm_config::PermissionMode::Deny,
15 )),
16 network: Some(vm_config::PatternPermissionScope::Mode(
17 vm_config::PermissionMode::Deny,
18 )),
19 child_process: Some(vm_config::PatternPermissionScope::Mode(
20 vm_config::PermissionMode::Deny,
21 )),
22 process: Some(vm_config::PatternPermissionScope::Mode(
23 vm_config::PermissionMode::Deny,
24 )),
25 env: Some(vm_config::PatternPermissionScope::Mode(
26 vm_config::PermissionMode::Deny,
27 )),
28 binding: Some(vm_config::PatternPermissionScope::Mode(
29 vm_config::PermissionMode::Deny,
30 )),
31 }
32}
33
34pub fn allow_all_policy() -> vm_config::PermissionsPolicy {
35 vm_config::PermissionsPolicy {
36 fs: Some(vm_config::FsPermissionScope::Mode(
37 vm_config::PermissionMode::Allow,
38 )),
39 network: Some(vm_config::PatternPermissionScope::Mode(
40 vm_config::PermissionMode::Allow,
41 )),
42 child_process: Some(vm_config::PatternPermissionScope::Mode(
43 vm_config::PermissionMode::Allow,
44 )),
45 process: Some(vm_config::PatternPermissionScope::Mode(
46 vm_config::PermissionMode::Allow,
47 )),
48 env: Some(vm_config::PatternPermissionScope::Mode(
49 vm_config::PermissionMode::Allow,
50 )),
51 binding: Some(vm_config::PatternPermissionScope::Mode(
52 vm_config::PermissionMode::Allow,
53 )),
54 }
55}
56
57pub fn evaluate_permissions_policy(
58 permissions: &vm_config::PermissionsPolicy,
59 domain: &str,
60 capability: &str,
61 resource: Option<&str>,
62) -> vm_config::PermissionMode {
63 match domain {
64 "fs" => evaluate_fs_permission_scope(
65 permissions.fs.as_ref(),
66 capability_operation(capability, domain),
67 resource,
68 ),
69 "network" => evaluate_pattern_permission_scope(
70 permissions.network.as_ref(),
71 capability_operation(capability, domain),
72 resource,
73 ),
74 "child_process" => evaluate_pattern_permission_scope(
75 permissions.child_process.as_ref(),
76 capability_operation(capability, domain),
77 resource,
78 ),
79 "process" => evaluate_pattern_permission_scope(
80 permissions.process.as_ref(),
81 capability_operation(capability, domain),
82 resource,
83 ),
84 "env" => evaluate_pattern_permission_scope(
85 permissions.env.as_ref(),
86 capability_operation(capability, domain),
87 resource,
88 ),
89 "binding" => evaluate_pattern_permission_scope(
90 permissions.binding.as_ref(),
91 capability_operation(capability, domain),
92 resource,
93 ),
94 _ => vm_config::PermissionMode::Deny,
95 }
96}
97
98fn evaluate_fs_permission_scope(
99 scope: Option<&vm_config::FsPermissionScope>,
100 operation: &str,
101 resource: Option<&str>,
102) -> vm_config::PermissionMode {
103 match scope {
104 Some(vm_config::FsPermissionScope::Mode(mode)) => *mode,
105 Some(vm_config::FsPermissionScope::Rules(rules)) => {
106 let mut mode = rules.default.unwrap_or(vm_config::PermissionMode::Deny);
107 for rule in &rules.rules {
108 if fs_rule_matches(rule, operation, resource) {
109 mode = rule.mode;
110 }
111 }
112 mode
113 }
114 None => vm_config::PermissionMode::Deny,
115 }
116}
117
118fn evaluate_pattern_permission_scope(
119 scope: Option<&vm_config::PatternPermissionScope>,
120 operation: &str,
121 resource: Option<&str>,
122) -> vm_config::PermissionMode {
123 match scope {
124 Some(vm_config::PatternPermissionScope::Mode(mode)) => *mode,
125 Some(vm_config::PatternPermissionScope::Rules(rules)) => {
126 let mut mode = rules.default.unwrap_or(vm_config::PermissionMode::Deny);
127 for rule in &rules.rules {
128 if pattern_rule_matches(rule, operation, resource) {
129 mode = rule.mode;
130 }
131 }
132 mode
133 }
134 None => vm_config::PermissionMode::Deny,
135 }
136}
137
138fn fs_rule_matches(
139 rule: &vm_config::FsPermissionRule,
140 operation: &str,
141 resource: Option<&str>,
142) -> bool {
143 let operations_match = permission_operation_matches(&rule.operations, operation);
144 let paths_match = permission_resource_matches(&rule.paths, resource);
145 operations_match && paths_match
146}
147
148fn pattern_rule_matches(
149 rule: &vm_config::PatternPermissionRule,
150 operation: &str,
151 resource: Option<&str>,
152) -> bool {
153 let operations_match = permission_operation_matches(&rule.operations, operation);
154 let patterns_match = permission_resource_matches(&rule.patterns, resource);
155 operations_match && patterns_match
156}
157
158fn permission_operation_matches(candidates: &[String], operation: &str) -> bool {
159 candidates
160 .iter()
161 .any(|candidate| candidate == "*" || candidate == operation)
162}
163
164fn permission_resource_matches(patterns: &[String], resource: Option<&str>) -> bool {
165 resource.is_some_and(|value| {
166 patterns
167 .iter()
168 .any(|pattern| permission_glob_matches(pattern, value))
169 })
170}
171
172pub fn validate_permissions_policy(
173 permissions: &vm_config::PermissionsPolicy,
174) -> Result<(), SidecarCoreError> {
175 if let Some(scope) = permissions.fs.as_ref() {
176 validate_fs_permission_scope("fs", scope)?;
177 }
178 if let Some(scope) = permissions.network.as_ref() {
179 validate_pattern_permission_scope("network", scope)?;
180 }
181 if let Some(scope) = permissions.child_process.as_ref() {
182 validate_pattern_permission_scope("child_process", scope)?;
183 }
184 if let Some(scope) = permissions.process.as_ref() {
185 validate_pattern_permission_scope("process", scope)?;
186 }
187 if let Some(scope) = permissions.env.as_ref() {
188 validate_pattern_permission_scope("env", scope)?;
189 }
190 if let Some(scope) = permissions.binding.as_ref() {
191 validate_pattern_permission_scope("binding", scope)?;
192 }
193 Ok(())
194}
195
196fn validate_fs_permission_scope(
197 domain: &str,
198 scope: &vm_config::FsPermissionScope,
199) -> Result<(), SidecarCoreError> {
200 let vm_config::FsPermissionScope::Rules(rule_set) = scope else {
201 return Ok(());
202 };
203
204 for (index, rule) in rule_set.rules.iter().enumerate() {
205 validate_permission_rule_field(
206 &rule.operations,
207 &format!("{domain}.rules[{index}].operations"),
208 )?;
209 validate_permission_rule_field(&rule.paths, &format!("{domain}.rules[{index}].paths"))?;
210 }
211
212 Ok(())
213}
214
215fn validate_pattern_permission_scope(
216 domain: &str,
217 scope: &vm_config::PatternPermissionScope,
218) -> Result<(), SidecarCoreError> {
219 let vm_config::PatternPermissionScope::Rules(rule_set) = scope else {
220 return Ok(());
221 };
222
223 for (index, rule) in rule_set.rules.iter().enumerate() {
224 validate_permission_rule_field(
225 &rule.operations,
226 &format!("{domain}.rules[{index}].operations"),
227 )?;
228 validate_permission_rule_field(
229 &rule.patterns,
230 &format!("{domain}.rules[{index}].patterns"),
231 )?;
232 }
233
234 Ok(())
235}
236
237fn validate_permission_rule_field(values: &[String], field: &str) -> Result<(), SidecarCoreError> {
238 if values.is_empty() {
239 return Err(SidecarCoreError::new(format!(
240 "invalid permissions policy: {field} must not be empty; use [\"*\"] for wildcard"
241 )));
242 }
243 Ok(())
244}
245
246fn capability_operation<'a>(capability: &'a str, domain: &str) -> &'a str {
247 capability
248 .strip_prefix(domain)
249 .and_then(|value| value.strip_prefix('.'))
250 .unwrap_or("")
251}
252
253pub fn permission_mode_to_kernel_decision(
254 mode: vm_config::PermissionMode,
255 capability: &str,
256) -> PermissionDecision {
257 match mode {
258 vm_config::PermissionMode::Allow => PermissionDecision::allow(),
259 vm_config::PermissionMode::Ask => {
260 PermissionDecision::deny(format!("permission prompt required for {capability}"))
261 }
262 vm_config::PermissionMode::Deny => {
263 PermissionDecision::deny(format!("blocked by {capability} policy"))
264 }
265 }
266}
267
268pub fn permissions_from_policy(policy: vm_config::PermissionsPolicy) -> Permissions {
269 let fs_policy = Arc::new(policy.clone());
270 let network_policy = Arc::new(policy.clone());
271 let child_process_policy = Arc::new(policy.clone());
272 let env_policy = Arc::new(policy);
273
274 Permissions {
275 filesystem: Some(Arc::new(move |request: &FsAccessRequest| {
276 let capability = fs_permission_capability(request.op);
277 permission_mode_to_kernel_decision(
278 evaluate_permissions_policy(&fs_policy, "fs", capability, Some(&request.path)),
279 capability,
280 )
281 })),
282 network: Some(Arc::new(move |request: &NetworkAccessRequest| {
283 let capability = network_permission_capability(request.op);
284 permission_mode_to_kernel_decision(
285 evaluate_permissions_policy(
286 &network_policy,
287 "network",
288 capability,
289 Some(&request.resource),
290 ),
291 capability,
292 )
293 })),
294 child_process: Some(Arc::new(move |request: &CommandAccessRequest| {
295 let capability = "child_process.spawn";
296 permission_mode_to_kernel_decision(
297 evaluate_permissions_policy(
298 &child_process_policy,
299 "child_process",
300 capability,
301 Some(&request.command),
302 ),
303 capability,
304 )
305 })),
306 environment: Some(Arc::new(move |request: &EnvAccessRequest| {
307 let capability = environment_permission_capability(request.op);
308 permission_mode_to_kernel_decision(
309 evaluate_permissions_policy(&env_policy, "env", capability, Some(&request.key)),
310 capability,
311 )
312 })),
313 }
314}
315
316pub fn fs_permission_capability(operation: FsOperation) -> &'static str {
317 match operation {
318 FsOperation::Read => filesystem_permission_capability(FilesystemAccess::Read),
319 FsOperation::Write => filesystem_permission_capability(FilesystemAccess::Write),
320 FsOperation::Mkdir | FsOperation::CreateDir => {
321 filesystem_permission_capability(FilesystemAccess::CreateDir)
322 }
323 FsOperation::ReadDir => filesystem_permission_capability(FilesystemAccess::ReadDir),
324 FsOperation::Stat | FsOperation::Exists => {
325 filesystem_permission_capability(FilesystemAccess::Stat)
326 }
327 FsOperation::Remove => filesystem_permission_capability(FilesystemAccess::Remove),
328 FsOperation::Rename => filesystem_permission_capability(FilesystemAccess::Rename),
329 FsOperation::Symlink => filesystem_permission_capability(FilesystemAccess::Symlink),
330 FsOperation::ReadLink => filesystem_permission_capability(FilesystemAccess::ReadLink),
331 FsOperation::Link | FsOperation::Chmod | FsOperation::Chown | FsOperation::Utimes => {
332 filesystem_permission_capability(FilesystemAccess::Write)
333 }
334 FsOperation::Truncate => filesystem_permission_capability(FilesystemAccess::Truncate),
335 FsOperation::MountSensitive => "fs.mount_sensitive",
336 }
337}
338
339pub fn filesystem_permission_capability(access: FilesystemAccess) -> &'static str {
340 match access {
341 FilesystemAccess::Read => "fs.read",
342 FilesystemAccess::Write => "fs.write",
343 FilesystemAccess::Stat => "fs.stat",
344 FilesystemAccess::ReadDir => "fs.readdir",
345 FilesystemAccess::CreateDir => "fs.create_dir",
346 FilesystemAccess::Remove => "fs.rm",
347 FilesystemAccess::Rename => "fs.rename",
348 FilesystemAccess::Symlink => "fs.symlink",
349 FilesystemAccess::ReadLink => "fs.readlink",
350 FilesystemAccess::Chmod => "fs.chmod",
351 FilesystemAccess::Truncate => "fs.truncate",
352 }
353}
354
355pub fn network_permission_capability(operation: NetworkOperation) -> &'static str {
356 match operation {
357 NetworkOperation::Fetch => "network.fetch",
358 NetworkOperation::Http => "network.http",
359 NetworkOperation::Dns => "network.dns",
360 NetworkOperation::Listen => "network.listen",
361 }
362}
363
364pub fn environment_permission_capability(operation: EnvironmentOperation) -> &'static str {
365 match operation {
366 EnvironmentOperation::Read => "env.read",
367 EnvironmentOperation::Write => "env.write",
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn permissions_default_to_deny() {
377 let policy = vm_config::PermissionsPolicy {
378 fs: None,
379 network: None,
380 child_process: None,
381 process: None,
382 env: None,
383 binding: None,
384 };
385
386 assert_eq!(
387 evaluate_permissions_policy(&policy, "fs", "fs.read", Some("/tmp/a")),
388 vm_config::PermissionMode::Deny
389 );
390 }
391
392 #[test]
393 fn permissions_default_to_deny_for_every_domain() {
394 let policy = vm_config::PermissionsPolicy {
395 fs: None,
396 network: None,
397 child_process: None,
398 process: None,
399 env: None,
400 binding: None,
401 };
402
403 for (domain, capability, resource) in [
404 ("fs", "fs.read", "/workspace/file.txt"),
405 ("network", "network.http", "example.com:443"),
406 ("child_process", "child_process.spawn", "sh"),
407 ("process", "process.kill", "123"),
408 ("env", "env.read", "TOKEN"),
409 ("binding", "binding.call", "shell"),
410 ] {
411 assert_eq!(
412 evaluate_permissions_policy(&policy, domain, capability, Some(resource)),
413 vm_config::PermissionMode::Deny,
414 "{domain} should default to deny",
415 );
416 }
417 }
418
419 #[test]
420 fn ask_permission_modes_become_kernel_denials() {
421 let decision =
422 permission_mode_to_kernel_decision(vm_config::PermissionMode::Ask, "network.http");
423
424 assert!(!decision.allow);
425 assert_eq!(
426 decision.reason.as_deref(),
427 Some("permission prompt required for network.http")
428 );
429 }
430
431 #[test]
432 fn ask_permission_modes_deny_every_policy_domain() {
433 let policy = vm_config::PermissionsPolicy {
434 fs: Some(vm_config::FsPermissionScope::Mode(
435 vm_config::PermissionMode::Ask,
436 )),
437 network: Some(vm_config::PatternPermissionScope::Mode(
438 vm_config::PermissionMode::Ask,
439 )),
440 child_process: Some(vm_config::PatternPermissionScope::Mode(
441 vm_config::PermissionMode::Ask,
442 )),
443 process: Some(vm_config::PatternPermissionScope::Mode(
444 vm_config::PermissionMode::Ask,
445 )),
446 env: Some(vm_config::PatternPermissionScope::Mode(
447 vm_config::PermissionMode::Ask,
448 )),
449 binding: Some(vm_config::PatternPermissionScope::Mode(
450 vm_config::PermissionMode::Ask,
451 )),
452 };
453
454 for (domain, capability, resource) in [
455 ("fs", "fs.read", "/workspace/file.txt"),
456 ("network", "network.http", "example.com:443"),
457 ("child_process", "child_process.spawn", "sh"),
458 ("process", "process.kill", "123"),
459 ("env", "env.read", "TOKEN"),
460 ("binding", "binding.call", "shell"),
461 ] {
462 let mode = evaluate_permissions_policy(&policy, domain, capability, Some(resource));
463 assert_eq!(
464 mode,
465 vm_config::PermissionMode::Ask,
466 "{domain} should preserve Ask until kernel-decision mapping",
467 );
468 let decision = permission_mode_to_kernel_decision(mode, capability);
469 assert!(
470 !decision.allow,
471 "{domain} Ask should map to a kernel denial",
472 );
473 assert_eq!(
474 decision.reason.as_deref(),
475 Some(format!("permission prompt required for {capability}").as_str()),
476 "{domain} Ask denial should explain the denied prompt",
477 );
478 }
479 }
480
481 #[test]
482 fn ask_permission_modes_deny_every_kernel_callback_domain() {
483 let permissions = permissions_from_policy(vm_config::PermissionsPolicy {
484 fs: Some(vm_config::FsPermissionScope::Mode(
485 vm_config::PermissionMode::Ask,
486 )),
487 network: Some(vm_config::PatternPermissionScope::Mode(
488 vm_config::PermissionMode::Ask,
489 )),
490 child_process: Some(vm_config::PatternPermissionScope::Mode(
491 vm_config::PermissionMode::Ask,
492 )),
493 process: None,
494 env: Some(vm_config::PatternPermissionScope::Mode(
495 vm_config::PermissionMode::Ask,
496 )),
497 binding: None,
498 });
499
500 assert!(
501 !permissions.filesystem.expect("filesystem callback")(&FsAccessRequest {
502 vm_id: String::from("vm"),
503 op: FsOperation::Read,
504 path: String::from("/workspace/file.txt"),
505 })
506 .allow
507 );
508 assert!(
509 !permissions.network.expect("network callback")(&NetworkAccessRequest {
510 vm_id: String::from("vm"),
511 op: NetworkOperation::Http,
512 resource: String::from("example.com:443"),
513 })
514 .allow
515 );
516 assert!(
517 !permissions.child_process.expect("child_process callback")(&CommandAccessRequest {
518 vm_id: String::from("vm"),
519 command: String::from("sh"),
520 args: Vec::new(),
521 cwd: None,
522 env: Default::default(),
523 })
524 .allow
525 );
526 assert!(
527 !permissions.environment.expect("environment callback")(&EnvAccessRequest {
528 vm_id: String::from("vm"),
529 op: EnvironmentOperation::Read,
530 key: String::from("TOKEN"),
531 value: None,
532 })
533 .allow
534 );
535 }
536
537 #[test]
538 fn permissions_from_policy_builds_kernel_callbacks() {
539 let policy = vm_config::PermissionsPolicy {
540 fs: Some(vm_config::FsPermissionScope::Rules(
541 vm_config::FsPermissionRuleSet {
542 default: Some(vm_config::PermissionMode::Deny),
543 rules: vec![vm_config::FsPermissionRule {
544 mode: vm_config::PermissionMode::Allow,
545 operations: vec![String::from("read")],
546 paths: vec![String::from("/workspace/**")],
547 }],
548 },
549 )),
550 network: None,
551 child_process: None,
552 process: None,
553 env: None,
554 binding: None,
555 };
556
557 let permissions = permissions_from_policy(policy);
558 let check = permissions.filesystem.expect("filesystem callback");
559
560 assert!(
561 check(&FsAccessRequest {
562 vm_id: String::from("vm"),
563 op: FsOperation::Read,
564 path: String::from("/workspace/file.txt"),
565 })
566 .allow
567 );
568 assert!(
569 !check(&FsAccessRequest {
570 vm_id: String::from("vm"),
571 op: FsOperation::Read,
572 path: String::from("/secrets/file.txt"),
573 })
574 .allow
575 );
576 }
577
578 #[test]
579 fn last_matching_rule_wins() {
580 let policy = vm_config::PermissionsPolicy {
581 fs: Some(vm_config::FsPermissionScope::Rules(
582 vm_config::FsPermissionRuleSet {
583 default: Some(vm_config::PermissionMode::Deny),
584 rules: vec![
585 vm_config::FsPermissionRule {
586 mode: vm_config::PermissionMode::Allow,
587 operations: vec![String::from("read")],
588 paths: vec![String::from("/workspace/**")],
589 },
590 vm_config::FsPermissionRule {
591 mode: vm_config::PermissionMode::Deny,
592 operations: vec![String::from("read")],
593 paths: vec![String::from("/workspace/secrets/**")],
594 },
595 ],
596 },
597 )),
598 network: None,
599 child_process: None,
600 process: None,
601 env: None,
602 binding: None,
603 };
604
605 assert_eq!(
606 evaluate_permissions_policy(&policy, "fs", "fs.read", Some("/workspace/secrets/key")),
607 vm_config::PermissionMode::Deny
608 );
609 }
610
611 #[test]
612 fn empty_rule_fields_are_rejected() {
613 let policy = vm_config::PermissionsPolicy {
614 fs: Some(vm_config::FsPermissionScope::Rules(
615 vm_config::FsPermissionRuleSet {
616 default: None,
617 rules: vec![vm_config::FsPermissionRule {
618 mode: vm_config::PermissionMode::Allow,
619 operations: Vec::new(),
620 paths: vec![String::from("*")],
621 }],
622 },
623 )),
624 network: None,
625 child_process: None,
626 process: None,
627 env: None,
628 binding: None,
629 };
630
631 let error = validate_permissions_policy(&policy).expect_err("policy should be invalid");
632 assert!(error
633 .to_string()
634 .contains("fs.rules[0].operations must not be empty"));
635 }
636}