1use super::facet::*;
13use crate::parse::{Token, has_flag};
14
15mod capability;
16mod flags;
17mod locus;
18pub(crate) mod regions;
19#[cfg(test)]
20mod scenarios;
21
22use capability::{
23 breadth_scale, creates, destroys, executes, mutates, observes, overwrites, reads_content,
24 reads_to_model, relocates, transfer_profile, worst, writes_export_file,
25};
26use flags::{walk_positionals, walk_value};
27use locus::{classify_locus, read_locus, write_locus};
28pub(crate) use locus::{hidden_peer_reach, is_unpinnable, reads_secret};
29
30pub(crate) fn loop_reprs(items: &[String]) -> Option<(String, String)> {
37 if items.is_empty() {
38 return None;
39 }
40 let faced: Vec<(String, LocalLocus, LocalLocus)> = items
41 .iter()
42 .map(|s| {
43 if s.contains("__SAFE_CHAINS_") {
44 ("$loop_sub".to_string(), LocalLocus::Machine, LocalLocus::Machine)
45 } else {
46 (s.clone(), read_locus(s), write_locus(s))
47 }
48 })
49 .collect();
50 let read_item = faced.iter().max_by_key(|(_, r, _)| *r).map(|(s, _, _)| s.clone())?;
51 let write_item = faced.iter().max_by_key(|(_, _, w)| *w).map(|(s, _, _)| s.clone())?;
52 let read_repr = crate::pathctx::expand_vars(&read_item, false).into_owned();
55 let write_repr = crate::pathctx::expand_vars(&write_item, true).into_owned();
56 Some((read_repr, write_repr))
57}
58
59pub(crate) fn read_content_verdict(path: &str) -> crate::verdict::Verdict {
63 let cap = reads_content(read_locus(path), Scale::Single, "reads a redirect source");
64 crate::engine::bridge::project(&Profile::of(vec![cap]))
65}
66
67pub(crate) fn write_target_verdict(path: &str) -> crate::verdict::Verdict {
70 let cap = overwrites(write_locus(path), Scale::Single, false);
71 crate::engine::bridge::project(&Profile::of(vec![cap]))
72}
73
74pub(crate) fn execute_file_verdict(path: &str) -> crate::verdict::Verdict {
81 if path.contains(['*', '?', '[']) {
86 return crate::engine::bridge::project(&worst("glob executor — the code that would run is unknown (§6)"));
87 }
88 let cap = executes(classify_locus(path), ExecutionTrust::CallerFile, "runs code from a named file");
89 crate::engine::bridge::project(&Profile::of(vec![cap]))
90}
91
92pub(crate) fn execute_project_verdict() -> crate::verdict::Verdict {
98 let cap = executes(LocalLocus::Worktree, ExecutionTrust::SelfCode, "runs the current project's own code");
99 crate::engine::bridge::project(&Profile::of(vec![cap]))
100}
101
102pub fn resolve(tokens: &[Token]) -> Option<Profile> {
107 let arg0 = tokens.first()?;
108 let canonical = crate::registry::canonical_name(arg0.command_name());
114 if canonical == "openssl" {
119 return resolve_openssl(arg0, tokens);
120 }
121 if matches!(canonical, "sudo" | "doas") {
122 return resolve_privilege_wrapper(arg0, tokens);
123 }
124 if let Some(names) = crate::registry::sub_archetypes(tokens) {
128 if !trusted_command_path(arg0.as_str()) {
129 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
130 }
131 let mut caps: Vec<Capability> = names
147 .iter()
148 .map(|n| {
149 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
150 Capability::worst("subcommand/flag declares an unknown archetype (§0)")
151 })
152 })
153 .collect();
154 if let Some(dest) = crate::registry::sub_destination_token(tokens) {
158 match destination_provenance(dest) {
159 Some(prov) => {
160 if let Some(base) = caps.first_mut() {
161 base.locus.provenance = prov;
162 }
163 }
164 None => {
165 return Some(worst(
166 "send target is a command transport (ext::…) — runs a local command, RCE (§4)",
167 ));
168 }
169 }
170 }
171 if let Some(path) = crate::registry::sub_output_path_token(tokens) {
176 caps.push(writes_export_file(classify_locus(path)));
177 }
178 if crate::registry::sub_loopback_localizes(tokens) {
197 for c in &mut caps {
198 if c.operation == Operation::Destroy {
199 continue;
200 }
201 c.locus.remote = RemoteReach::None;
202 c.network.direction = NetDirection::Loopback;
203 c.network.payload = NetPayload::None;
204 c.cost = Cost::None;
205 c.because = format!("{} — but the endpoint names this machine, so no remote is reached", c.because);
209 }
210 }
211 return Some(Profile::of(caps));
212 }
213 if let Some(names) = crate::registry::command_flag_archetypes(tokens) {
219 if !trusted_command_path(arg0.as_str()) {
220 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
221 }
222 let caps: Vec<Capability> = names
223 .iter()
224 .map(|n| {
225 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
226 Capability::worst("command flag declares an unknown archetype (§0)")
227 })
228 })
229 .collect();
230 return Some(Profile::of(caps));
231 }
232 let spec = crate::registry::command_behavior(canonical)?;
236 if !trusted_command_path(arg0.as_str()) {
242 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
243 }
244 Some(resolve_behavior(spec, tokens))
245}
246
247fn resolve_privilege_wrapper(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
253 if !trusted_command_path(arg0.as_str()) {
254 return Some(worst("sudo/doas invoked from a non-standard path — possible spoof (§0)"));
255 }
256 let mut i = 1;
257 let mut run_as_other = false;
258 'scan: while let Some(tok) = tokens.get(i) {
259 let t = tok.as_str();
260 if t == "--" {
261 i += 1;
262 break;
263 }
264 if !t.starts_with('-') || t == "-" {
265 break; }
267 if let Some(long) = t.strip_prefix("--") {
268 let (name, glued_val) = match long.split_once('=') {
269 Some((n, _)) => (n, true),
270 None => (long, false),
271 };
272 match name {
273 "login" | "shell" | "edit" => {
274 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
275 }
276 "user" | "other-user" => {
277 run_as_other = true;
278 if !glued_val { i += 1; }
279 }
280 "group" | "prompt" | "close-from" | "host" | "role" | "type"
281 | "command-timeout" | "chroot" | "chdir" | "preserve-env" => {
282 if !glued_val && name != "preserve-env" { i += 1; }
285 }
286 "background" | "stdin" | "non-interactive" | "reset-timestamp"
287 | "remove-timestamp" | "set-home" | "askpass" | "help" | "version"
288 | "validate" | "list" | "bell" => {}
289 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
290 }
291 } else {
292 let rest = &t[1..];
295 for (idx, c) in rest.char_indices() {
296 match c {
297 'i' | 's' | 'e' => {
298 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
299 }
300 'u' | 'U' | 'g' | 'p' | 'C' | 'h' | 'r' | 't' | 'T' | 'R' | 'D' => {
301 if c == 'u' || c == 'U' { run_as_other = true; }
302 if idx + c.len_utf8() == rest.len() { i += 1; } i += 1;
304 continue 'scan; }
306 'E' | 'H' | 'k' | 'K' | 'n' | 'b' | 'A' | 'S' | 'P' | 'B' | 'v' | 'l' => {}
307 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
308 }
309 }
310 }
311 i += 1;
312 }
313 let inner = &tokens[i.min(tokens.len())..];
317 if inner.is_empty() {
318 return None; }
320 let elevated = if run_as_other { Authority::OtherUser } else { Authority::Root };
321 let caps = resolve(inner)?
322 .capabilities
323 .into_iter()
324 .map(|mut c| {
325 c.authority = c.authority.max(elevated);
326 c
327 })
328 .collect();
329 Some(Profile::of(caps))
330}
331
332fn resolve_openssl(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
342 if !trusted_command_path(arg0.as_str()) {
343 return Some(worst("openssl invoked from a non-standard path — possible spoof (§0)"));
344 }
345 let sub = tokens.get(1)?.as_str();
346 let args = &tokens[2..];
347 let discloses = match sub {
348 "rsa" | "pkey" | "ec" | "dsa" => {
352 if openssl_flag(args, "-pubin") {
353 false
354 } else if openssl_flag(args, "-text") {
355 true } else if openssl_flag(args, "-pubout") {
357 false } else {
359 openssl_output_reaches_model(args)
360 }
361 }
362 "pkcs8" => openssl_flag(args, "-text") || openssl_output_reaches_model(args),
364 "pkcs12" => {
366 (openssl_flag(args, "-nodes") || openssl_flag(args, "-noenc"))
367 && openssl_output_reaches_model(args)
368 }
369 "enc" => openssl_flag(args, "-d") && openssl_output_reaches_model(args),
371 "smime" => openssl_flag(args, "-decrypt") && openssl_output_reaches_model(args),
372 "cms" => {
373 (openssl_flag(args, "-decrypt") || openssl_flag(args, "-EncryptedData_decrypt"))
374 && openssl_output_reaches_model(args)
375 }
376 _ => return None, };
378 if discloses {
379 let cap = crate::engine::archetype::archetype("decrypt-read")
380 .cloned()
381 .unwrap_or_else(|| Capability::worst("decrypt-read archetype missing (§0)"));
382 Some(Profile::of(vec![cap]))
383 } else {
384 None }
386}
387
388fn openssl_flag(args: &[Token], flag: &str) -> bool {
391 args.iter().any(|t| {
392 let s = t.as_str();
393 s == flag || (s.starts_with("--") && s.len() > 2 && &s[1..] == flag)
394 })
395}
396
397fn openssl_output_reaches_model(args: &[Token]) -> bool {
405 if openssl_flag(args, "-noout") {
406 return false;
407 }
408 let outs = openssl_flag_values(args, "-out");
409 !matches!(outs.as_slice(), [only] if out_value_is_plain_file(only))
413}
414
415fn out_value_is_plain_file(value: &str) -> bool {
426 if value.is_empty() || value.starts_with('-') {
427 return false;
428 }
429 let norm = collapse_path(value).to_ascii_lowercase();
430 let device_or_fd =
431 norm == "/dev" || norm.starts_with("/dev/") || (norm.starts_with("/proc/") && norm.contains("/fd/"));
432 !device_or_fd
433}
434
435fn collapse_path(p: &str) -> String {
439 let absolute = p.starts_with('/');
440 let mut stack: Vec<&str> = Vec::new();
441 for seg in p.split('/') {
442 match seg {
443 "" | "." => {}
444 ".." => {
445 if matches!(stack.last(), Some(&s) if s != "..") {
446 stack.pop();
447 } else if !absolute {
448 stack.push("..");
449 }
450 }
451 s => stack.push(s),
452 }
453 }
454 let joined = stack.join("/");
455 if absolute { format!("/{joined}") } else { joined }
456}
457
458fn openssl_flag_values<'a>(args: &'a [Token], flag: &str) -> Vec<&'a str> {
462 let twin = format!("-{flag}"); let mut out = Vec::new();
464 let mut i = 0;
465 while i < args.len() {
466 let s = args[i].as_str();
467 if let Some(v) = s
468 .strip_prefix(flag)
469 .or_else(|| s.strip_prefix(twin.as_str()))
470 .and_then(|r| r.strip_prefix('='))
471 {
472 out.push(v);
473 } else if (s == flag || s == twin)
474 && let Some(next) = args.get(i + 1)
475 {
476 out.push(next.as_str());
477 i += 1;
478 }
479 i += 1;
480 }
481 out
482}
483
484fn destination_provenance(dest: Option<&str>) -> Option<Provenance> {
488 let Some(tok) = dest else {
489 return Some(Provenance::Established);
490 };
491 if tok.starts_with("ext::") {
492 return None; }
494 if tok.contains('$') || tok.contains('`') {
497 return Some(Provenance::Opaque);
498 }
499 let literal = tok.contains("://")
502 || (tok.contains('@') && tok.contains(':'))
503 || tok.starts_with('/')
504 || tok.starts_with("./")
505 || tok.starts_with("../");
506 Some(if literal { Provenance::Literal } else { Provenance::Established })
507}
508
509fn resolve_behavior(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Profile {
515 use crate::registry::types::{BehaviorHook, PositionalRole};
516 if let Some(hook) = spec.hook {
517 return match hook {
518 BehaviorHook::Grep => {
522 let Some(g) = grep_operands(tokens) else {
523 return worst("grep: unrecognized flag or missing pattern — worst-cased (§0)");
524 };
525 let mut caps: Vec<Capability> = g
526 .pattern_files
527 .iter()
528 .map(|f| reads_content(read_locus(f), Scale::Single, "reads a grep -f pattern file"))
529 .collect();
530 caps.extend(reads_to_model(&g.files, g.scale));
531 Profile::of(caps)
532 }
533 BehaviorHook::Dd => resolve_dd(tokens),
538 BehaviorHook::Tar => resolve_tar(tokens),
539 BehaviorHook::Sed => resolve_sed(tokens),
540 };
541 }
542 if matches!(spec.positionals, PositionalRole::None) {
546 return match spec.operation {
547 Operation::Observe => {
548 let mut c = Capability::new(Operation::Observe);
549 c.disclosure.audience = DisclosureAudience::LocalProcess;
550 c.because = "behavior: prints its arguments to stdout; no fs/net/exec/secret".to_string();
551 Profile::of(vec![c])
552 }
553 _ => worst("behavior: none-operand role supports only observe (§0)"),
554 };
555 }
556 let long: Vec<&str> = spec.long.iter().map(String::as_str).collect();
557 let valued_long: Vec<&str> = spec.valued_long.iter().map(String::as_str).collect();
558 let Some(operands) = walk_positionals(&spec.short, &spec.valued_short, &long, &valued_long, spec.numeric_shorthand, tokens) else {
559 return worst("behavior: unrecognized flag — worst-cased (§0)");
560 };
561 let scale = behavior_scale(spec, &operands, tokens);
562 let flag_caps = path_flag_caps(spec, tokens);
564 match spec.positionals {
565 PositionalRole::Read => {
566 let mut caps = reads_to_model(&operands, scale);
567 caps.extend(flag_caps);
568 Profile::of(caps)
569 }
570 PositionalRole::Write => {
571 if operands.is_empty() {
572 return worst("behavior: write operation with no operand — worst-cased (§0)");
573 }
574 let mut caps: Vec<Capability> = operands
575 .iter()
576 .map(|p| match spec.operation {
577 Operation::Destroy => destroys(classify_locus(p), scale),
578 Operation::Create => creates(classify_locus(p), scale),
579 Operation::Mutate => mutates(classify_locus(p), scale, "behavior: in-place mutate"),
580 _ => Capability::worst("behavior: unsupported write operation — worst-cased (§0)"),
581 })
582 .collect();
583 caps.extend(flag_caps);
584 Profile::of(caps)
585 }
586 PositionalRole::Transfer => resolve_transfer(spec, operands, flag_caps, tokens),
587 PositionalRole::None | PositionalRole::PatternThenRead => {
590 worst("behavior: operand role not resolvable without a hook (§0)")
591 }
592 }
593}
594
595fn resolve_transfer(
600 spec: &crate::registry::types::BehaviorSpec,
601 operands: Vec<&str>,
602 flag_caps: Vec<Capability>,
603 tokens: &[Token],
604) -> Profile {
605 use crate::registry::types::TransferSource;
606 let Some(t) = &spec.transfer else {
607 return worst("behavior: transfer role without transfer spec — worst-cased (§0)");
608 };
609 let (sources, dest) = if let Some(d) = walk_value(&spec.valued_short, tokens, b't', "--target-directory") {
610 if operands.is_empty() {
611 return worst("behavior: transfer -t with no source operand — worst-cased (§0)");
612 }
613 (operands, d)
614 } else {
615 match operands.split_last() {
616 Some((last, rest)) if !rest.is_empty() => (rest.to_vec(), *last),
617 _ => return worst("behavior: transfer needs a source and a destination — worst-cased (§0)"),
618 }
619 };
620 let no_clobber = if t.clobber_flags.is_empty() {
621 t.no_clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
622 } else {
623 !t.clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
625 };
626 let recursive = t.recursive_flags.iter().any(|f| behavior_flag_present(tokens, f));
627 let transfer_scale = breadth_scale(&sources, recursive);
628 let source_writes = matches!(t.source, TransferSource::Relocate);
630 let mut prof = transfer_profile(
631 &sources,
632 dest,
633 transfer_scale,
634 source_writes,
635 |loc, sc| match t.source {
636 TransferSource::Observe => observes(loc, sc, "transfer reads the source at its locus"),
637 TransferSource::Relocate => relocates(loc, sc),
638 },
639 |loc, sc| overwrites(loc, sc, no_clobber),
640 );
641 prof.capabilities.extend(flag_caps);
642 prof
643}
644
645fn path_flag_caps(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Vec<Capability> {
649 use crate::registry::types::PathRole;
650 let mut caps = Vec::new();
651 for pf in &spec.path_flags {
652 let short = pf.short.unwrap_or(0);
653 let long = pf.long.as_deref().unwrap_or("");
654 if let Some(v) = walk_value(&spec.valued_short, tokens, short, long) {
655 caps.push(match pf.role {
656 PathRole::Read => observes(read_locus(v), Scale::Single, "behavior: a flag value is a read path"),
657 PathRole::Write => mutates(write_locus(v), Scale::Single, "behavior: a flag value is a write path"),
658 });
659 }
660 }
661 caps
662}
663
664fn behavior_scale(
667 spec: &crate::registry::types::BehaviorSpec,
668 operands: &[&str],
669 tokens: &[Token],
670) -> Scale {
671 use crate::registry::types::ScaleModel;
672 match spec.scale {
673 ScaleModel::Single => Scale::Single,
674 ScaleModel::Breadth => {
675 let recursive = spec.unbounded_flags.iter().any(|f| behavior_flag_present(tokens, f));
676 breadth_scale(operands, recursive)
677 }
678 }
679}
680
681fn behavior_flag_present(tokens: &[Token], flag: &str) -> bool {
684 if flag.starts_with("--") {
685 has_flag(tokens, None, Some(flag))
686 } else {
687 has_flag(tokens, Some(flag), None)
688 }
689}
690
691#[cfg(test)]
696pub(crate) const UNRESOLVED_CMD: &[&str] = &["safe-chains-unresolved-sentinel"];
697
698fn trusted_command_path(arg0: &str) -> bool {
702 const STD_BINS: &[&str] =
703 &["/usr/bin/", "/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/sbin/", "/usr/sbin/"];
704 !arg0.contains('/') || STD_BINS.iter().any(|p| arg0.starts_with(p))
705}
706
707struct GrepOperands<'a> {
714 files: Vec<&'a str>,
715 pattern_files: Vec<&'a str>,
716 scale: Scale,
717}
718
719fn grep_operands(tokens: &[Token]) -> Option<GrepOperands<'_>> {
722 let recursive = has_flag(tokens, Some("-r"), Some("--recursive"));
725 let scale = if recursive { Scale::Unbounded } else { Scale::Single };
726
727 let mut files = Vec::new(); let mut pattern_files = Vec::new(); let mut pattern_from_flag = false;
730 let mut unknown_flag = false;
731 let mut flags_done = false;
732 let mut i = 1;
733 while i < tokens.len() {
734 let t = tokens[i].as_str();
735 let next = tokens.get(i + 1).map(Token::as_str);
736 if !flags_done && t == "--" {
737 flags_done = true;
738 i += 1;
739 } else if flags_done || !t.starts_with('-') || t == "-" {
740 files.push(t);
741 i += 1;
742 } else if t.starts_with("--") {
743 if let Some(v) = t.strip_prefix("--file=") {
744 pattern_from_flag = true;
745 pattern_files.push(v);
746 i += 1;
747 } else if t == "--file" {
748 pattern_from_flag = true;
749 pattern_files.extend(next);
750 i += 2;
751 } else if t == "--regexp" {
752 pattern_from_flag = true;
753 i += 2;
754 } else if t.starts_with("--regexp=") {
755 pattern_from_flag = true;
756 i += 1;
757 } else if grep_long_known(t) {
758 i += 1;
759 } else if grep_long_dangerous(t) {
760 unknown_flag = true;
761 i += 1;
762 } else {
763 files.push(t);
767 i += 1;
768 }
769 } else {
770 match grep_short_cluster(t, next) {
771 GrepShort::Unrecognized => {
772 unknown_flag = true;
773 i += 1;
774 }
775 GrepShort::Standalone => i += 1,
776 GrepShort::Pattern { file, consumes_next } => {
777 pattern_files.extend(file);
778 pattern_from_flag = true;
779 i += if consumes_next { 2 } else { 1 };
780 }
781 GrepShort::SkipValue { consumes_next } => i += if consumes_next { 2 } else { 1 },
782 }
783 }
784 }
785
786 if unknown_flag {
787 return None; }
789 if files.is_empty() {
790 return None;
794 }
795
796 if !pattern_from_flag {
797 files.remove(0); }
799 if recursive && files.is_empty() {
800 files.push("."); }
802
803 Some(GrepOperands { files, pattern_files, scale })
804}
805
806enum GrepShort<'a> {
808 Unrecognized,
810 Standalone,
812 Pattern { file: Option<&'a str>, consumes_next: bool },
815 SkipValue { consumes_next: bool },
817}
818
819fn grep_short_cluster<'a>(cluster: &'a str, next: Option<&'a str>) -> GrepShort<'a> {
822 const BENIGN: &[u8] = b"ivnclLoqswxHhaIrzZEFGbUP";
827 let bytes = cluster.as_bytes();
828 let mut k = 1;
829 while k < bytes.len() {
830 if !bytes[k].is_ascii() {
832 return GrepShort::Unrecognized;
833 }
834 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
836 match bytes[k] {
837 b'f' => {
838 let file = if has { Some(glued) } else { next };
839 return GrepShort::Pattern { file, consumes_next: !has };
840 }
841 b'e' => return GrepShort::Pattern { file: None, consumes_next: !has },
842 b'm' | b'A' | b'B' | b'C' | b'd' => return GrepShort::SkipValue { consumes_next: !has },
843 b if BENIGN.contains(&b) => k += 1,
844 _ => return GrepShort::Unrecognized,
845 }
846 }
847 GrepShort::Standalone
848}
849
850fn grep_long_known(flag: &str) -> bool {
853 const KNOWN: &[&str] = &[
854 "--recursive", "--ignore-case", "--invert-match", "--line-number", "--count", "--files-with-matches", "--files-without-match",
857 "--only-matching", "--perl-regexp", "--word-regexp", "--line-regexp", "--fixed-strings",
858 "--extended-regexp", "--basic-regexp", "--with-filename", "--no-filename",
859 "--quiet", "--silent", "--no-messages", "--null", "--byte-offset", "--text",
860 "--color", "--colour", "--help", "--version", "--after-context", "--before-context",
861 "--context", "--max-count", "--include", "--exclude", "--exclude-dir",
862 "--include-dir", "--binary-files", "--devices", "--directories",
863 ];
864 let name = flag.split('=').next().unwrap_or(flag);
865 KNOWN.contains(&name)
866}
867
868fn grep_long_dangerous(flag: &str) -> bool {
873 let name = flag.split('=').next().unwrap_or(flag);
874 matches!(name, "--dereference-recursive")
875}
876
877fn resolve_dd(tokens: &[Token]) -> Profile {
886 const PARAMS: &[&str] = &[
887 "bs", "ibs", "obs", "cbs", "count", "skip", "seek", "conv", "iflag", "oflag", "status",
888 ];
889 let (mut input, mut output) = (None, None);
890 for t in &tokens[1..] {
891 let t = t.as_str();
892 if t == "--help" || t == "--version" {
893 continue;
894 }
895 let Some((key, val)) = t.split_once('=') else {
896 return worst("dd: non key=value operand — worst-cased (§0)");
897 };
898 match key {
899 "if" => input = Some(val),
900 "of" => output = Some(val),
901 k if PARAMS.contains(&k) => {}
902 _ => return worst("dd: unrecognized operand — worst-cased (§0)"),
903 }
904 }
905 let input_locus = input.map_or(LocalLocus::Process, read_locus);
909 match output {
910 Some(of) => Profile::of(vec![
912 observes(input_locus, Scale::Single, "dd reads its input (if=) into the output"),
913 overwrites(classify_locus(of), Scale::Single, false),
914 ]),
915 None => Profile::of(vec![reads_content(
917 input_locus,
918 Scale::Single,
919 "dd copies its input to stdout (→ the model)",
920 )]),
921 }
922}
923
924fn resolve_tar(tokens: &[Token]) -> Profile {
935 let mut p = TarParse::default();
936 let mut dir: Option<String> = None;
943 let mut i = 1;
944 while i < tokens.len() {
945 let t = tokens[i].as_str();
946 if t == "-C" || t == "--directory" {
947 dir = tokens.get(i + 1).map(|d| tar_bound(dir.as_deref(), d.as_str()));
948 i += 2;
949 continue;
950 }
951 if let Some(d) = t.strip_prefix("--directory=").or_else(|| t.strip_prefix("-C").filter(|d| !d.is_empty())) {
952 dir = Some(tar_bound(dir.as_deref(), d));
953 i += 1;
954 continue;
955 }
956 if let Some(long) = t.strip_prefix("--") {
957 p.long_option(long);
958 } else if let Some(cluster) = t.strip_prefix('-').filter(|c| !c.is_empty()) {
959 p.cluster(cluster);
960 } else if i == 1 {
961 p.cluster(t); } else {
963 p.positionals.push((dir.clone(), t));
964 }
965 i += 1;
966 }
967 p.into_profile()
968}
969
970type TarPositional<'a> = (Option<String>, &'a str);
973
974type TarRef<'a> = (Option<&'a str>, &'a str);
976
977fn tar_bound(dir: Option<&str>, path: &str) -> String {
981 match dir {
982 Some(d) if !path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-') => {
983 format!("{}/{}", d.trim_end_matches('/'), path)
984 }
985 _ => path.to_string(),
986 }
987}
988
989#[derive(Default)]
994struct TarParse<'a> {
995 mode: Option<u8>,
996 want_archive: bool,
997 reject: bool,
998 long_archive: Option<&'a str>,
999 positionals: Vec<TarPositional<'a>>,
1001}
1002
1003impl<'a> TarParse<'a> {
1004 fn cluster(&mut self, cluster: &str) {
1005 const NOVAL: &[u8] = b"vzjJZpkmOwhSlPa"; for b in cluster.bytes() {
1007 match b {
1008 b'c' | b'x' | b't' | b'r' | b'u' | b'A' | b'd' => self.mode = Some(b),
1009 b'f' => self.want_archive = true,
1010 b'C' | b'T' | b'X' | b'b' | b'H' | b'g' | b'K' | b'N' => self.reject = true,
1011 x if NOVAL.contains(&x) => {}
1012 _ => self.reject = true,
1013 }
1014 }
1015 }
1016
1017 fn long_option(&mut self, long: &'a str) {
1018 let name = long.split('=').next().unwrap_or(long);
1019 match name {
1020 "create" => self.mode = Some(b'c'),
1021 "extract" | "get" => self.mode = Some(b'x'),
1022 "list" => self.mode = Some(b't'),
1023 "append" => self.mode = Some(b'r'),
1024 "update" => self.mode = Some(b'u'),
1025 "file" => match long.split_once('=') {
1026 Some((_, v)) => self.long_archive = Some(v),
1027 None => self.want_archive = true,
1028 },
1029 "gzip" | "bzip2" | "xz" | "zstd" | "compress" | "verbose" | "preserve-permissions"
1030 | "same-permissions" | "to-stdout" | "help" | "version" | "dereference" | "totals" => {}
1031 _ => self.reject = true,
1032 }
1033 }
1034
1035 fn into_profile(self) -> Profile {
1036 let Some(mode) = self.mode.filter(|_| !self.reject) else {
1037 return worst("tar: unrecognized/unmodeled option — worst-cased (§0)");
1038 };
1039 let (archive, members): (Option<TarRef>, &[TarPositional]) =
1042 if let Some(a) = self.long_archive {
1043 (Some((None, a)), &self.positionals)
1044 } else if self.want_archive {
1045 match self.positionals.split_first() {
1046 Some((first, rest)) => (Some((first.0.as_deref(), first.1)), rest),
1047 None => return worst("tar: -f without an archive — worst-cased (§0)"),
1048 }
1049 } else {
1050 (None, &self.positionals) };
1052 let archive_file = archive.filter(|(_, a)| *a != "-");
1054
1055 match mode {
1056 b'c' | b'r' | b'u' => {
1057 let mut caps: Vec<Capability> = members
1058 .iter()
1059 .map(|(dir, m)| observes(read_locus(&tar_bound(dir.as_deref(), m)), Scale::Bounded, "tar reads a member into the archive"))
1060 .collect();
1061 if let Some((dir, a)) = archive_file {
1062 caps.push(overwrites(classify_locus(&tar_bound(dir, a)), Scale::Single, false));
1063 }
1064 if caps.is_empty() {
1065 return worst("tar create with no members — worst-cased (§0)");
1066 }
1067 Profile::of(caps)
1068 }
1069 b't' => {
1070 let loc = archive_file.map_or(LocalLocus::Process, |(dir, a)| classify_locus(&tar_bound(dir, a)));
1071 Profile::of(vec![reads_content(loc, Scale::Single, "tar lists the archive's members (names → the model)")])
1072 }
1073 _ => worst("tar extract writes an archive-controlled, ..-escapable path set — worst-cased (§0)"),
1075 }
1076 }
1077}
1078
1079fn resolve_sed(tokens: &[Token]) -> Profile {
1086 let script = crate::handlers::coreutils::sed::scan_sed(tokens);
1092 if script.exec || script.unknown {
1093 return worst("sed: script has an `e` exec or unmodeled command — worst-cased (§0, HP-7)");
1094 }
1095 if script.script_file {
1098 return worst("sed: -f runs a script file we can't inspect — worst-cased (§0)");
1099 }
1100 const BOOL: &[u8] = b"nrEsuz"; let mut in_place = false;
1102 let mut script_from_flag = false;
1103 let mut script_files: Vec<&str> = Vec::new(); let mut files: Vec<&str> = Vec::new();
1105 let mut flags_done = false;
1106 let mut i = 1;
1107 while i < tokens.len() {
1108 let t = tokens[i].as_str();
1109 let next = tokens.get(i + 1).map(Token::as_str);
1110 if !flags_done && t == "--" {
1111 flags_done = true;
1112 i += 1;
1113 } else if flags_done || t == "-" || !t.starts_with('-') {
1114 files.push(t);
1115 i += 1;
1116 } else if let Some(long) = t.strip_prefix("--") {
1117 match sed_long(long, next, &mut in_place, &mut script_from_flag, &mut script_files) {
1118 Some(consumed) => i += consumed,
1119 None => return worst("sed: unrecognized flag — worst-cased (§0)"),
1120 }
1121 } else {
1122 match sed_cluster(&t[1..], next, BOOL) {
1123 SedShort::Bad => return worst("sed: unrecognized flag — worst-cased (§0)"),
1124 SedShort::InPlace => {
1125 in_place = true;
1126 i += 1;
1127 }
1128 SedShort::Standalone => i += 1,
1129 SedShort::Script { consumes_next } => {
1130 script_from_flag = true;
1131 i += usize::from(consumes_next) + 1;
1132 }
1133 SedShort::ScriptFile { file, consumes_next } => {
1134 script_from_flag = true;
1135 script_files.extend(file);
1136 i += usize::from(consumes_next) + 1;
1137 }
1138 SedShort::SkipValue { consumes_next } => i += usize::from(consumes_next) + 1,
1139 }
1140 }
1141 }
1142 if !script_from_flag && !files.is_empty() {
1144 files.remove(0);
1145 }
1146 let scale = breadth_scale(&files, false);
1150 let mut caps: Vec<Capability> =
1151 script_files.iter().map(|f| observes(read_locus(f), Scale::Single, "sed reads an -f script file")).collect();
1152 caps.extend(script.writes.iter().map(|f| mutates(classify_locus(f), Scale::Single, "sed w/W writes a file")));
1155 caps.extend(script.reads.iter().map(|f| observes(read_locus(f), Scale::Single, "sed r/R reads a file")));
1156 if in_place {
1157 caps.extend(files.iter().map(|f| mutates(classify_locus(f), scale, "sed -i edits the file in place")));
1158 } else {
1159 caps.extend(reads_to_model(&files, scale));
1160 }
1161 Profile::of(caps)
1162}
1163
1164enum SedShort<'a> {
1166 Bad,
1167 Standalone,
1168 InPlace, Script { consumes_next: bool }, ScriptFile { file: Option<&'a str>, consumes_next: bool }, SkipValue { consumes_next: bool }, }
1173
1174fn sed_cluster<'a>(cluster: &'a str, next: Option<&'a str>, boolset: &[u8]) -> SedShort<'a> {
1175 let bytes = cluster.as_bytes();
1176 let mut k = 0;
1177 while k < bytes.len() {
1178 if !bytes[k].is_ascii() {
1181 return SedShort::Bad;
1182 }
1183 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
1185 match bytes[k] {
1186 b'i' => return SedShort::InPlace, b'e' => return SedShort::Script { consumes_next: !has },
1188 b'f' => {
1189 let file = if has { Some(glued) } else { next };
1190 return SedShort::ScriptFile { file, consumes_next: !has };
1191 }
1192 b'l' if has || next.is_some() => return SedShort::SkipValue { consumes_next: !has }, b if boolset.contains(&b) => k += 1,
1194 _ => return SedShort::Bad,
1195 }
1196 }
1197 SedShort::Standalone
1198}
1199
1200fn sed_long<'a>(
1202 long: &'a str,
1203 next: Option<&'a str>,
1204 in_place: &mut bool,
1205 script_from_flag: &mut bool,
1206 script_files: &mut Vec<&'a str>,
1207) -> Option<usize> {
1208 let name = long.split('=').next().unwrap_or(long);
1209 match name {
1210 "in-place" => *in_place = true, "expression" => {
1212 *script_from_flag = true;
1213 return Some(if long.contains('=') { 1 } else { 2 });
1214 }
1215 "file" => {
1216 *script_from_flag = true;
1217 match long.split_once('=') {
1218 Some((_, v)) => script_files.push(v),
1219 None => {
1220 script_files.extend(next);
1221 return Some(2);
1222 }
1223 }
1224 }
1225 "quiet" | "silent" | "regexp-extended" | "null-data" | "separate" | "unbuffered"
1226 | "posix" | "help" | "version" | "debug" | "follow-symlinks" | "sandbox"
1227 | "zero-terminated" | "line-length" => {}
1228 _ => return None,
1229 }
1230 Some(1)
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236
1237 fn toks(parts: &[&str]) -> Vec<Token> {
1238 parts.iter().map(|p| Token::from_test(p)).collect()
1239 }
1240
1241 fn level(name: &str) -> &'static crate::engine::level::Level {
1242 crate::engine::authoring::default_levels()
1243 .iter()
1244 .find(|l| l.name == name)
1245 .expect("level exists")
1246 }
1247
1248 fn inert() -> &'static crate::engine::level::Level {
1249 level("paranoid")
1250 }
1251
1252 fn read_local() -> &'static crate::engine::level::Level {
1253 level("reader")
1254 }
1255
1256 #[test]
1260 fn openssl_resolver_gates_model_disclosure_only() {
1261 let (dev, yolo) = (level("developer"), level("yolo"));
1262 for parts in [
1263 &["openssl", "rsa", "-in", "priv.pem"][..],
1264 &["openssl", "rsa", "-in", "priv.pem", "-pubout", "-text"], &["openssl", "rsa", "-in", "priv.pem", "-out", "/dev/stdout"], &["openssl", "rsa", "-in", "priv.pem", "-noout", "-text"],
1267 &["openssl", "pkcs8", "-in", "priv.pem"],
1268 &["openssl", "enc", "--d", "-k", "p", "-in", "c"], &["openssl", "cms", "-EncryptedData_decrypt", "-in", "m"],
1270 &["openssl", "pkcs12", "-in", "f.p12", "-noenc"],
1271 ] {
1272 let p = resolve(&toks(parts)).unwrap_or_else(|| panic!("resolves: {parts:?}"));
1273 assert!(
1274 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads),
1275 "secret=reads: {parts:?}",
1276 );
1277 assert!(!dev.admits(&p), "developer refuses: {parts:?}");
1278 assert!(yolo.admits(&p), "yolo admits: {parts:?}");
1279 }
1280 for parts in [
1281 &["openssl", "rsa", "-in", "priv.pem", "-pubout"][..],
1282 &["openssl", "rsa", "-in", "priv.pem", "-noout"], &["openssl", "rsa", "-in", "enc.pem", "-out", "clean.pem"], &["openssl", "pkey", "-in", "pub.pem", "-pubin", "-text"], &["openssl", "pkcs12", "-in", "f.p12", "-nodes", "-out", "k.pem"],
1286 &["openssl", "enc", "-e", "-in", "x", "-out", "x.enc", "-k", "p"],
1287 &["openssl", "x509", "-in", "c", "-noout", "-text"],
1288 ] {
1289 assert!(resolve(&toks(parts)).is_none(), "resolver abstains (→ legacy): {parts:?}");
1290 }
1291 }
1292
1293 #[test]
1298 fn openssl_output_destination_is_fail_closed() {
1299 let dev = level("developer");
1300 let reaches_model = |args: &[&str]| {
1301 let mut parts = vec!["openssl", "rsa", "-in", "priv.pem"];
1302 parts.extend_from_slice(args);
1303 match resolve(&toks(&parts)) {
1306 None => false,
1307 Some(p) => {
1308 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads) && !dev.admits(&p)
1309 }
1310 }
1311 };
1312 for evasion in [
1313 &["-out", "//dev/stdout"][..],
1314 &["-out", "/dev/./stdout"],
1315 &["-out", "//dev/fd/1"],
1316 &["-out", "/dev/fd//1"],
1317 &["-out=//dev/stdout"],
1318 &["-out", "/dev/stderr"],
1319 &["-out", "/foo/../dev/stdout"],
1320 &["-out", "dup.pem", "-out", "/dev/stdout"], &["-out", "-"],
1322 &["-provider-path", "-out", "-provider-path", "safe.pem"],
1326 &["-out", "-anything"],
1327 ] {
1328 assert!(reaches_model(evasion), "must read as model-reaching: {evasion:?}");
1329 }
1330 for diverted in [
1331 &["-out", "clean.pem"][..],
1332 &["-out", "./sub/key.pem"],
1333 &["-out", "devnotes.pem"], &["-out", "/home/u/key.pem"],
1335 &["-noout"],
1336 ] {
1337 assert!(!reaches_model(diverted), "must divert off the model: {diverted:?}");
1338 }
1339 }
1340
1341 #[test]
1342 fn echo_resolves_to_a_benign_inert_profile() {
1343 let p = resolve(&toks(&["echo", "hi"])).expect("echo has a resolver");
1344 assert_eq!(p.capabilities.len(), 1);
1345 let c = &p.capabilities[0];
1346 assert_eq!(c.operation, Operation::Observe);
1347 assert_eq!(c.locus.local, LocalLocus::Process);
1348 assert_eq!(c.disclosure.audience, DisclosureAudience::LocalProcess);
1349 assert!(!c.because.is_empty(), "a structural certification cites its reason");
1350 assert!(inert().admits(&p), "echo is fully certified and inert-safe");
1352 }
1353
1354 #[test]
1355 fn echo_flags_do_not_change_its_profile() {
1356 let bare = resolve(&toks(&["echo", "hi"])).expect("echo");
1357 let flagged = resolve(&toks(&["echo", "-n", "-e", "hi"])).expect("echo -n -e");
1358 assert_eq!(bare, flagged);
1359 assert!(inert().admits(&flagged));
1360 }
1361
1362 #[test]
1363 fn an_unresearched_command_has_no_resolver() {
1364 assert!(resolve(&toks(UNRESOLVED_CMD)).is_none(), "unresearched → caller worst-cases");
1365 assert!(resolve(&[]).is_none(), "empty tokens");
1366 }
1367
1368 #[test]
1369 fn cat_of_a_worktree_file_is_read_local() {
1370 let p = resolve(&toks(&["cat", "./notes.md"])).expect("cat");
1371 assert!(read_local().admits(&p), "cat ./notes.md");
1372 assert!(!inert().admits(&p), "reading a real file is above inert");
1373 }
1374
1375 #[test]
1376 fn cat_beyond_the_worktree_is_denied_by_locus() {
1377 for path in ["~/.ssh/id_rsa", "~/notes", "/etc/shadow", "$SECRET", "../outside", "/var/lib/mysql/data"] {
1379 let p = resolve(&toks(&["cat", path])).expect("cat");
1380 assert!(!read_local().admits(&p), "cat {path} is above read-local by locus");
1381 }
1382 }
1383
1384 #[test]
1385 fn cat_of_a_system_file_is_not_admitted() {
1386 for path in ["/etc/hosts", "/etc/os-release", "/usr/share/doc/x"] {
1388 let p = resolve(&toks(&["cat", path])).expect("cat");
1389 assert!(!read_local().admits(&p), "cat {path} is no longer auto-approved");
1390 }
1391 }
1392
1393 #[test]
1394 fn cat_stdin_is_process_scoped() {
1395 assert!(inert().admits(&resolve(&toks(&["cat"])).expect("cat")), "no operand → stdin");
1396 assert!(inert().admits(&resolve(&toks(&["cat", "-"])).expect("cat -")), "- → stdin");
1397 }
1398
1399 #[test]
1400 fn cat_reads_every_file_operand_and_one_home_read_sinks_it() {
1401 let p = resolve(&toks(&["cat", "-n", "a.txt", "src/b.rs"])).expect("cat");
1402 assert_eq!(p.capabilities.len(), 2, "-n is a flag; two files");
1403 assert!(read_local().admits(&p), "both worktree");
1404
1405 let mixed = resolve(&toks(&["cat", "a.txt", "~/.ssh/id_rsa"])).expect("cat");
1406 assert!(!read_local().admits(&mixed), "one home read sinks the whole profile");
1407 }
1408
1409 #[test]
1410 fn cat_double_dash_treats_the_rest_as_files() {
1411 let p = resolve(&toks(&["cat", "--", "-n"])).expect("cat");
1412 assert_eq!(p.capabilities.len(), 1, "-n after -- is a filename");
1413 assert!(read_local().admits(&p));
1414 }
1415
1416 #[test]
1417 fn head_tail_wc_read_like_cat_and_honor_numeric_shorthand() {
1418 use crate::engine::bridge::project;
1419 use crate::verdict::{SafetyLevel, Verdict};
1420 for cmd in [
1422 vec!["head", "README.md"],
1423 vec!["head", "-n", "5", "src/main.rs"],
1424 vec!["head", "-20", "src/main.rs"], vec!["tail", "-f", "./log.txt"], vec!["tail", "-n", "100", "./log.txt"],
1427 vec!["wc", "-l", "./notes.md"],
1428 ] {
1429 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Allowed(SafetyLevel::SafeRead), "{cmd:?}");
1430 }
1431 assert_eq!(project(&resolve(&toks(&["wc", "-c", "-"])).expect("wc")), Verdict::Allowed(SafetyLevel::Inert), "wc stdin");
1433 for cmd in [vec!["head", "~/.ssh/id_rsa"], vec!["tail", "/etc/shadow"], vec!["wc", "-l", "$SECRET"]] {
1434 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Denied, "{cmd:?} beyond worktree");
1435 }
1436 let p = resolve(&toks(&["head", "-20", "src/main.rs"])).expect("head");
1438 assert_eq!(p.capabilities.len(), 1, "-20 is the count, not a file");
1439 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from=list"])).expect("wc")), Verdict::Denied, "--files0-from");
1441 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from", "-"])).expect("wc")), Verdict::Denied, "--files0-from -");
1442 assert_eq!(project(&resolve(&toks(&["head", "-Z", "x"])).expect("head")), Verdict::Denied, "unknown flag");
1444 }
1445
1446 #[test]
1447 fn grep_reads_its_files_not_the_pattern() {
1448 let p = resolve(&toks(&["grep", "foo", "file.txt"])).expect("grep");
1449 assert_eq!(p.capabilities.len(), 1, "the pattern is not a file");
1450 assert!(read_local().admits(&p));
1451 }
1452
1453 #[test]
1454 fn grep_beyond_the_worktree_is_denied() {
1455 for args in [
1456 vec!["grep", "foo", "~/.ssh/config"],
1457 vec!["grep", "-r", "foo", "~"],
1458 vec!["grep", "foo", "$DIR"],
1459 ] {
1460 let p = resolve(&toks(&args)).expect("grep");
1461 assert!(!read_local().admits(&p), "{args:?}");
1462 }
1463 }
1464
1465 #[test]
1466 fn grep_recursive_is_unbounded_and_defaults_to_cwd() {
1467 let p = resolve(&toks(&["grep", "-r", "foo", "src/"])).expect("grep");
1468 assert!(p.capabilities.iter().all(|c| c.scale == Scale::Unbounded), "-r → unbounded");
1469 assert!(read_local().admits(&p), "recursive worktree search");
1470
1471 let cwd = resolve(&toks(&["grep", "-r", "foo"])).expect("grep");
1472 assert!(cwd.capabilities.iter().all(|c| c.locus.local == LocalLocus::Worktree), "cwd, not stdin");
1473 assert!(read_local().admits(&cwd));
1474 }
1475
1476 #[test]
1477 fn grep_e_and_f_supply_the_pattern_so_positionals_are_files() {
1478 let e = resolve(&toks(&["grep", "-e", "foo", "file.txt"])).expect("grep -e");
1480 assert_eq!(e.capabilities.len(), 1);
1481 assert!(read_local().admits(&e));
1482
1483 let f = resolve(&toks(&["grep", "-f", "patterns.txt", "file.txt"])).expect("grep -f");
1485 assert_eq!(f.capabilities.len(), 2, "patterns.txt + file.txt");
1486 assert!(read_local().admits(&f));
1487
1488 let home = resolve(&toks(&["grep", "-f", "~/.secret-patterns", "file.txt"])).expect("grep -f");
1489 assert!(!read_local().admits(&home), "a home pattern file is denied by locus");
1490
1491 let glued = resolve(&toks(&["grep", "-fpatterns.txt", "file.txt"])).expect("grep -f glued");
1493 assert_eq!(glued.capabilities.len(), 2, "glued -f value is still a read");
1494 let glued_home = resolve(&toks(&["grep", "-if~/.secrets", "x"])).expect("grep -if glued");
1495 assert!(!read_local().admits(&glued_home), "glued home pattern file denied by locus");
1496 }
1497
1498 #[test]
1499 fn grep_long_flags() {
1500 assert_eq!(resolve(&toks(&["grep", "--file", "p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1502 assert_eq!(resolve(&toks(&["grep", "--file=p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1503
1504 let r = resolve(&toks(&["grep", "--regexp", "foo", "f.txt"])).expect("grep");
1506 assert_eq!(r.capabilities.len(), 1);
1507 assert!(read_local().admits(&r));
1508
1509 let m = resolve(&toks(&["grep", "--max-count", "5", "foo", "f.txt"])).expect("grep");
1513 assert!(read_local().admits(&m), "--max-count 5 is fail-safe (imprecise)");
1514
1515 let pcre = resolve(&toks(&["grep", "--perl-regexp", "foo", "f"])).expect("grep");
1517 assert!(read_local().admits(&pcre), "grep --perl-regexp reads a file, it does not exec");
1518 }
1519
1520 #[test]
1521 fn grep_dash_patterns_are_search_patterns_not_flags() {
1522 for args in [
1527 vec!["grep", "-->", "file.txt"],
1528 vec!["grep", "---", "file.txt"],
1529 vec!["grep", "--some-pattern", "file.txt"],
1530 vec!["grep", "-rn", "-->", "src/"],
1531 vec!["grep", "-i", "-r", "-n", "-->", "src/"],
1532 ] {
1533 let p = resolve(&toks(&args)).expect("grep");
1534 assert!(read_local().admits(&p), "dash-pattern should read-local: {args:?}");
1535 assert!(!inert().admits(&p), "it still reads a file: {args:?}");
1536 }
1537 for args in [vec!["grep", "--dereference-recursive", "foo", "dir"]] {
1539 let p = resolve(&toks(&args)).expect("grep");
1540 assert!(!read_local().admits(&p), "dangerous long must worst-case: {args:?}");
1541 }
1542 for args in [
1544 vec!["grep", "-P", "foo", "f"],
1545 vec!["grep", "--perl-regexp", "foo", "f"],
1546 vec!["grep", "-oP", "foo", "f"],
1547 ] {
1548 let p = resolve(&toks(&args)).expect("grep");
1549 assert!(read_local().admits(&p), "grep PCRE flag should read-local: {args:?}");
1550 }
1551 }
1552
1553 #[test]
1554 fn grep_stdin_and_standalone_flags() {
1555 assert!(inert().admits(&resolve(&toks(&["grep", "foo"])).expect("grep")), "no file → stdin");
1556 let p = resolve(&toks(&["grep", "-i", "-n", "foo", "file.txt"])).expect("grep");
1557 assert_eq!(p.capabilities.len(), 1, "-i -n standalone; foo pattern; file.txt file");
1558 assert!(read_local().admits(&p));
1559 }
1560
1561 fn one_cap(cmd: &[&str]) -> Capability {
1564 let p = resolve(&toks(cmd)).expect("resolves");
1565 assert_eq!(p.capabilities.len(), 1, "{cmd:?} is a single-capability invocation");
1566 let mut c = p.capabilities[0].clone();
1567 c.because = String::new();
1568 c
1569 }
1570
1571 #[test]
1577 fn golden_profiles_cover_every_facet() {
1578 let mut echo = Capability::new(Operation::Observe);
1581 echo.disclosure.audience = DisclosureAudience::LocalProcess;
1582 assert_eq!(one_cap(&["echo", "hi"]), echo, "echo");
1583
1584 let mut cat = Capability::new(Operation::Observe);
1586 cat.locus.local = LocalLocus::Worktree;
1587 cat.disclosure.audience = DisclosureAudience::LocalProcess;
1588 assert_eq!(one_cap(&["cat", "./notes.md"]), cat, "cat ./notes.md");
1589
1590 let mut cat_home = cat.clone();
1592 cat_home.locus.local = LocalLocus::Machine;
1593 assert_eq!(one_cap(&["cat", "~/notes.txt"]), cat_home, "cat ~/notes.txt");
1594
1595 let mut cat_cred = cat.clone();
1597 cat_cred.locus.local = LocalLocus::Machine;
1598 assert_eq!(one_cap(&["cat", "~/.ssh/id_rsa"]), cat_cred, "cat ~/.ssh/id_rsa");
1599
1600 assert_eq!(one_cap(&["grep", "foo", "file.txt"]), cat, "grep foo file.txt");
1602
1603 let mut grep_r = cat.clone();
1605 grep_r.scale = Scale::Unbounded;
1606 assert_eq!(one_cap(&["grep", "-r", "foo", "src/"]), grep_r, "grep -r foo src/");
1607
1608 let mut rm = Capability::new(Operation::Destroy);
1610 rm.locus.local = LocalLocus::Worktree;
1611 rm.reversibility = Reversibility::Effortful;
1612 assert_eq!(one_cap(&["rm", "./x"]), rm, "rm ./x");
1613
1614 let mut mkdir = Capability::new(Operation::Create);
1616 mkdir.locus.local = LocalLocus::Worktree;
1617 mkdir.reversibility = Reversibility::Trivial;
1618 mkdir.persistence.level = PersistenceLevel::Data;
1619 assert_eq!(one_cap(&["mkdir", "./build"]), mkdir, "mkdir ./build");
1620
1621 assert_eq!(one_cap(&["touch", "./new.txt"]), mkdir, "touch ./new.txt");
1623
1624 let cp = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
1627 assert_eq!(cp.capabilities.len(), 2, "cp = source read + dest write");
1628 let mut src = Capability::new(Operation::Observe);
1629 src.locus.local = LocalLocus::Worktree; assert_eq!(clear_because(&cp.capabilities[0]), src, "cp source read");
1631 let mut dst = Capability::new(Operation::Create);
1632 dst.locus.local = LocalLocus::Worktree;
1633 dst.reversibility = Reversibility::Trivial; dst.persistence.level = PersistenceLevel::Data;
1635 assert_eq!(clear_because(&cp.capabilities[1]), dst, "cp -n dest write");
1636
1637 let mv = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
1640 let mut mv_src = Capability::new(Operation::Mutate);
1641 mv_src.locus.local = LocalLocus::Worktree;
1642 mv_src.reversibility = Reversibility::Trivial;
1643 assert_eq!(clear_because(&mv.capabilities[0]), mv_src, "mv source relocation");
1644 let mut mv_dst = Capability::new(Operation::Create);
1645 mv_dst.locus.local = LocalLocus::Worktree;
1646 mv_dst.reversibility = Reversibility::Recoverable;
1647 mv_dst.persistence.level = PersistenceLevel::Data;
1648 assert_eq!(clear_because(&mv.capabilities[1]), mv_dst, "mv dest write");
1649
1650 let ln = resolve(&toks(&["ln", "./a", "./b"])).expect("ln");
1653 let mut ln_tgt = Capability::new(Operation::Observe);
1654 ln_tgt.locus.local = LocalLocus::Worktree;
1655 assert_eq!(clear_because(&ln.capabilities[0]), ln_tgt, "ln target bridge");
1656 let mut ln_link = Capability::new(Operation::Create);
1657 ln_link.locus.local = LocalLocus::Worktree;
1658 ln_link.reversibility = Reversibility::Trivial;
1659 ln_link.persistence.level = PersistenceLevel::Data;
1660 assert_eq!(clear_because(&ln.capabilities[1]), ln_link, "ln link create");
1661 }
1662
1663 fn clear_because(c: &Capability) -> Capability {
1664 let mut c = c.clone();
1665 c.because = String::new();
1666 c
1667 }
1668
1669 #[test]
1670 fn mkdir_creates_in_the_worktree_but_not_beyond_it() {
1671 use crate::engine::bridge::project;
1672 use crate::verdict::{SafetyLevel, Verdict};
1673 for cmd in [vec!["mkdir", "./build"], vec!["mkdir", "-p", "a/b/c"], vec!["mkdir", "-m", "755", "./x"]] {
1675 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1676 }
1677 for cmd in [vec!["mkdir", "/etc/evil"], vec!["mkdir", "~/newdir"], vec!["mkdir", "$HOME/x"]] {
1679 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Denied, "{cmd:?}");
1680 }
1681 let g = resolve(&toks(&["mkdir", "-m755", "./x"])).expect("mkdir");
1683 assert_eq!(g.capabilities.len(), 1, "-m755 glued: only ./x is an operand");
1684 assert_eq!(g.capabilities[0].locus.local, LocalLocus::Worktree);
1685 assert_eq!(project(&resolve(&toks(&["mkdir", "-Q", "x"])).expect("mkdir")), Verdict::Denied, "unknown flag");
1687 assert_eq!(project(&resolve(&toks(&["mkdir"])).expect("mkdir")), Verdict::Denied, "no operand");
1688 }
1689
1690 #[test]
1691 fn cp_splits_source_and_dest_loci_and_overwrite_gates_the_level() {
1692 use crate::engine::bridge::project;
1693 use crate::verdict::{SafetyLevel, Verdict};
1694
1695 let plain = resolve(&toks(&["cp", "./a", "./b"])).expect("cp");
1699 assert_eq!(plain.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "dest overwrite");
1700 assert_eq!(project(&plain), Verdict::Allowed(SafetyLevel::SafeWrite), "cp ./a ./b");
1701 let nc = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
1702 assert_eq!(nc.capabilities.last().unwrap().reversibility, Reversibility::Trivial, "-n cannot clobber");
1703 assert_eq!(project(&nc), Verdict::Allowed(SafetyLevel::SafeWrite), "cp -n ./a ./b");
1704
1705 assert_eq!(project(&resolve(&toks(&["cp", "~/.ssh/id_rsa", "./x"])).expect("cp")), Verdict::Denied, "home source");
1708 assert_eq!(project(&resolve(&toks(&["cp", "/etc/shadow", "./x"])).expect("cp")), Verdict::Denied, "system source");
1709 assert_eq!(project(&resolve(&toks(&["cp", "./x", "~/backdoor"])).expect("cp")), Verdict::Denied, "home dest");
1711 assert_eq!(project(&resolve(&toks(&["cp", "./x", "/etc/cron.d/x"])).expect("cp")), Verdict::Denied, "system dest");
1712
1713 for form in [
1716 vec!["cp", "-t", "./dest", "./a", "./b"],
1717 vec!["cp", "--target-directory=./dest", "./a", "./b"],
1718 vec!["cp", "-t./dest", "./a", "./b"], ] {
1720 let t = resolve(&toks(&form)).expect("cp -t");
1721 assert_eq!(t.capabilities.len(), 3, "{form:?}: 2 sources + 1 dest");
1722 assert_eq!(project(&t), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
1723 }
1724 assert_eq!(project(&resolve(&toks(&["cp", "-t/etc", "./a"])).expect("cp")), Verdict::Denied, "cp -t/etc");
1726
1727 for form in [
1730 vec!["cp", "--backup", "./a", "./b"],
1731 vec!["cp", "--preserve", "./a", "./b"],
1732 vec!["cp", "--preserve=mode", "./a", "./b"],
1733 ] {
1734 let c = resolve(&toks(&form)).expect("cp");
1735 assert_eq!(c.capabilities.len(), 2, "{form:?}: source read + dest write");
1736 assert_eq!(project(&c), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
1737 }
1738
1739 assert_eq!(resolve(&toks(&["cp", "-r", "./a", "./b"])).expect("cp").capabilities[0].scale, Scale::Unbounded);
1741 assert_eq!(project(&resolve(&toks(&["cp", "./only"])).expect("cp")), Verdict::Denied, "no dest");
1742 assert_eq!(project(&resolve(&toks(&["cp", "-Q", "./a", "./b"])).expect("cp")), Verdict::Denied, "unknown flag");
1743 assert_eq!(project(&resolve(&toks(&["cp", "-t", "./dest"])).expect("cp")), Verdict::Denied, "-t no source");
1746 }
1747
1748 #[test]
1749 fn mv_relocates_within_the_worktree_and_gates_both_loci() {
1750 use crate::engine::bridge::project;
1751 use crate::verdict::{SafetyLevel, Verdict};
1752
1753 let m = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
1756 assert_eq!(m.capabilities[0].operation, Operation::Mutate, "source is a relocation, not a destroy");
1757 assert_eq!(m.capabilities[0].reversibility, Reversibility::Trivial, "mv back");
1758 assert_eq!(project(&m), Verdict::Allowed(SafetyLevel::SafeWrite), "mv ./a ./b");
1759
1760 assert_eq!(project(&resolve(&toks(&["mv", "~/.ssh/id_rsa", "./x"])).expect("mv")), Verdict::Denied, "source in home");
1762 assert_eq!(project(&resolve(&toks(&["mv", "./x", "~/exfil"])).expect("mv")), Verdict::Denied, "dest in home");
1763 assert_eq!(project(&resolve(&toks(&["mv", ".git/config", "./x"])).expect("mv")), Verdict::Denied, "mv .git/config");
1766 assert_eq!(project(&resolve(&toks(&["cp", ".git/config", "./x"])).expect("cp")), Verdict::Allowed(SafetyLevel::SafeWrite), "cp .git/config reads");
1767
1768 let cfg = "~/.config/safe-chains.toml";
1774 assert_eq!(
1775 resolve(&toks(&["mv", cfg, "./x"])).expect("mv").capabilities[0].locus.local,
1776 LocalLocus::Machine,
1777 "mv source removal gates at the WRITE face",
1778 );
1779 assert_eq!(
1780 resolve(&toks(&["cp", cfg, "./x"])).expect("cp").capabilities[0].locus.local,
1781 LocalLocus::WorktreeTrusted,
1782 "cp source read gates at the READ face",
1783 );
1784
1785 let t = resolve(&toks(&["mv", "-t", "./dest", "./a", "./b"])).expect("mv -t");
1787 assert_eq!(t.capabilities.len(), 3, "2 sources + 1 dest");
1788 assert_eq!(project(&resolve(&toks(&["mv", "./only"])).expect("mv")), Verdict::Denied, "no dest");
1789 assert_eq!(project(&resolve(&toks(&["mv", "-Q", "./a", "./b"])).expect("mv")), Verdict::Denied, "unknown flag");
1790 }
1791
1792 #[test]
1793 fn ln_is_cp_by_reference_and_gates_the_target_locus() {
1794 use crate::engine::bridge::project;
1795 use crate::verdict::{SafetyLevel, Verdict};
1796
1797 for cmd in [vec!["ln", "./a", "./b"], vec!["ln", "-s", "./target", "./link"]] {
1799 let p = resolve(&toks(&cmd)).expect("ln");
1800 assert_eq!(p.capabilities[0].operation, Operation::Observe, "target is a bridged read");
1801 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1802 }
1803 assert_eq!(project(&resolve(&toks(&["ln", "~/.ssh/id_rsa", "./x"])).expect("ln")), Verdict::Denied, "hard link to home credential");
1806 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/shadow", "./x"])).expect("ln")), Verdict::Denied, "symlink to secret");
1807 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/hosts", "./x"])).expect("ln")), Verdict::Denied, "symlink to system path");
1809 assert_eq!(project(&resolve(&toks(&["ln", "-s", "./a", "~/evil"])).expect("ln")), Verdict::Denied, "link into home");
1811 assert_eq!(resolve(&toks(&["ln", "-t", "./dir", "./a", "./b"])).expect("ln -t").capabilities.len(), 3);
1813 assert_eq!(project(&resolve(&toks(&["ln", "./only"])).expect("ln")), Verdict::Denied, "no link name");
1814 assert_eq!(project(&resolve(&toks(&["ln", "-Q", "./a", "./b"])).expect("ln")), Verdict::Denied, "unknown flag");
1815 let forced = resolve(&toks(&["ln", "-f", "./a", "./b"])).expect("ln -f");
1819 assert_eq!(project(&forced), Verdict::Allowed(SafetyLevel::SafeWrite), "ln -f worktree link");
1820 assert_eq!(forced.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "ln -f overwrites → recoverable");
1821 assert_eq!(
1822 resolve(&toks(&["ln", "./a", "./b"])).expect("ln").capabilities.last().unwrap().reversibility,
1823 Reversibility::Trivial,
1824 "ln default no-clobber → trivial",
1825 );
1826 }
1827
1828 #[test]
1829 fn dd_parses_key_value_operands_and_gates_both_sides() {
1830 use crate::engine::bridge::project;
1831 use crate::verdict::{SafetyLevel, Verdict};
1832
1833 assert_eq!(
1835 project(&resolve(&toks(&["dd", "if=./a", "of=./b", "bs=1M", "count=10"])).expect("dd")),
1836 Verdict::Allowed(SafetyLevel::SafeWrite),
1837 "dd worktree copy",
1838 );
1839 assert_eq!(project(&resolve(&toks(&["dd", "if=./notes"])).expect("dd")), Verdict::Allowed(SafetyLevel::SafeRead), "dd to stdout");
1841 assert_eq!(project(&resolve(&toks(&["dd"])).expect("dd")), Verdict::Allowed(SafetyLevel::Inert), "bare dd is stdin→stdout");
1842
1843 for cmd in [
1845 vec!["dd", "if=~/.ssh/id_rsa", "of=./x"], vec!["dd", "if=./x", "of=/dev/rdisk0"], vec!["dd", "if=./x", "of=/dev/sda"],
1848 vec!["dd", "if=./x", "of=~/backup"], vec!["dd", "if=~/.ssh/id_rsa"], ] {
1851 assert_eq!(project(&resolve(&toks(&cmd)).expect("dd")), Verdict::Denied, "{cmd:?}");
1852 }
1853 assert_eq!(project(&resolve(&toks(&["dd", "./file"])).expect("dd")), Verdict::Denied, "positional operand");
1855 assert_eq!(project(&resolve(&toks(&["dd", "exec=evil", "of=./x"])).expect("dd")), Verdict::Denied, "unknown key");
1856 }
1857
1858 #[test]
1859 fn tar_parses_dashless_bundles_and_splits_by_mode() {
1860 use crate::engine::bridge::project;
1861 use crate::verdict::{SafetyLevel, Verdict};
1862
1863 for cmd in [
1866 vec!["tar", "czf", "backup.tar", "./src"],
1867 vec!["tar", "-czf", "backup.tar", "./src"],
1868 vec!["tar", "--create", "--file=backup.tar", "./src"],
1869 ] {
1870 assert_eq!(project(&resolve(&toks(&cmd)).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1871 }
1872 assert_eq!(project(&resolve(&toks(&["tar", "tzf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeRead), "list");
1874
1875 assert_eq!(project(&resolve(&toks(&["tar", "czf", "-", "~/.ssh"])).expect("tar")), Verdict::Denied, "bundle secret to stdout");
1878 assert_eq!(project(&resolve(&toks(&["tar", "czf", "out.tar", "~/.aws"])).expect("tar")), Verdict::Denied, "bundle home member");
1879 assert_eq!(project(&resolve(&toks(&["tar", "cf", "~/backup.tar", "./src"])).expect("tar")), Verdict::Denied, "archive into home");
1881
1882 assert_eq!(project(&resolve(&toks(&["tar", "xzf", "release.tar"])).expect("tar")), Verdict::Denied, "extract");
1884 assert_eq!(project(&resolve(&toks(&["tar", "cf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "empty archive");
1887 assert_eq!(project(&resolve(&toks(&["tar", "-C", "/etc", "xf", "a.tar"])).expect("tar")), Verdict::Denied, "-C unmodeled");
1889 assert_eq!(project(&resolve(&toks(&["tar", "c"])).expect("tar")), Verdict::Denied, "create to stdout, no members");
1890 assert_eq!(project(&resolve(&toks(&["tar", "zf", "backup.tar"])).expect("tar")), Verdict::Denied, "no mode letter");
1891 }
1892
1893 #[test]
1894 fn sed_i_flips_read_to_write_and_locus_stops_system_wide_damage() {
1895 use crate::engine::bridge::project;
1896 use crate::verdict::{SafetyLevel, Verdict};
1897
1898 let read = resolve(&toks(&["sed", "s/x/y/", "./foo"])).expect("sed");
1900 assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
1901 assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "sed read");
1902 let edit = resolve(&toks(&["sed", "-i", "s/x/y/", "./foo"])).expect("sed");
1903 assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
1904 assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i worktree");
1905
1906 for cmd in [
1910 vec!["sed", "-i", "s/a/b/", "/etc/passwd"],
1911 vec!["sed", "-i", "s/a/b/", "/etc/hosts"],
1912 vec!["sed", "-i", "s/a/b/", "~/.bashrc"],
1913 vec!["sed", "-i", "s/a/b/", "$CONFIG"], vec!["sed", "-i", "s/a/b/", "../outside"], vec!["sed", "-i", "-e", "s/a/b/", "/etc/x"], ] {
1917 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?} must deny");
1918 }
1919
1920 let glob = resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed");
1923 assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
1924 assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i * (worktree)");
1925 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "a", "b", "c"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "multi-file");
1926
1927 assert_eq!(project(&resolve(&toks(&["sed", "-i.bak", "s/a/b/", "./foo"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "-i.bak");
1929 assert_eq!(project(&resolve(&toks(&["sed", "-f", "script.sed", "./foo"])).expect("sed")), Verdict::Denied, "-f script file unanalyzable");
1932 assert_eq!(project(&resolve(&toks(&["sed", "s/a/b/", "~/.ssh/id_rsa"])).expect("sed")), Verdict::Denied, "read home secret");
1934 assert_eq!(project(&resolve(&toks(&["sed", "-Q", "./foo"])).expect("sed")), Verdict::Denied, "unknown flag");
1935 }
1936
1937 #[test]
1938 fn sed_exec_command_is_worst_cased_at_parity_with_legacy() {
1939 use crate::engine::bridge::project;
1940 use crate::verdict::Verdict;
1941 for cmd in [
1944 vec!["sed", "s/test/touch tmp/e", "file"], vec!["sed", "-e", "s/x/cmd/e", "file"], vec!["sed", "s/x/cmd/ew", "file"], vec!["sed", "1e", "file"], vec!["sed", "e"], vec!["sed", "-e", "e"],
1950 vec!["sed", "1e reboot", "file"], vec!["sed", "p;e id", "file"], ] {
1953 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?}: exec must deny");
1954 }
1955 }
1959
1960 #[test]
1964 fn classify_locus_resolves_relative_operands_against_the_cwd_context() {
1965 use crate::engine::bridge::project;
1966 use crate::pathctx::PathCtx;
1967 use crate::verdict::{SafetyLevel, Verdict};
1968
1969 for p in ["*", "passwd", "config"] {
1971 assert_eq!(classify_locus(p), LocalLocus::Worktree, "{p}: no ctx → worktree");
1972 }
1973 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "no ctx: sed -i *");
1974
1975 let _g = crate::pathctx::enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
1977 for p in ["*", "hosts", "config", "cron.d"] {
1978 assert_eq!(classify_locus(p), LocalLocus::Machine, "{p}: cwd=/etc → machine");
1979 }
1980 assert_eq!(classify_locus("passwd"), LocalLocus::SystemIntegrity, "passwd: cwd=/etc → system-integrity");
1983 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Denied, "cwd=/etc: sed -i * denied");
1984 assert_eq!(project(&resolve(&toks(&["dd", "if=./x", "of=passwd"])).expect("dd")), Verdict::Denied, "cwd=/etc: dd of=passwd denied");
1985 assert_eq!(project(&resolve(&toks(&["cp", "./payload", "config"])).expect("cp")), Verdict::Denied, "cwd=/etc: cp denied");
1986 }
1987
1988 #[test]
1989 fn touch_creates_in_the_worktree_and_gates_the_reference_path() {
1990 use crate::engine::bridge::project;
1991 use crate::verdict::{SafetyLevel, Verdict};
1992 for cmd in [
1993 vec!["touch", "./new.txt"],
1994 vec!["touch", "-c", "existing"],
1995 vec!["touch", "-r", "ref.txt", "./out"], vec!["touch", "-d", "-1 day", "./out"], ] {
1998 assert_eq!(project(&resolve(&toks(&cmd)).expect("touch")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1999 }
2000 let p = resolve(&toks(&["touch", "-r", "ref.txt", "./out"])).expect("touch");
2004 assert_eq!(p.capabilities.len(), 2, "./out create + ref.txt read");
2005 assert!(p.capabilities.iter().any(|c| c.operation == Operation::Observe), "the -r reference is a read");
2006 assert_eq!(project(&resolve(&toks(&["touch", "-r", "~/.bashrc", "./out"])).expect("touch")), Verdict::Denied, "home reference");
2007 assert_eq!(project(&resolve(&toks(&["touch", "-r", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "system reference");
2008 assert_eq!(project(&resolve(&toks(&["touch", "--reference=/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long glued reference");
2009 assert_eq!(project(&resolve(&toks(&["touch", "--reference", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long spaced reference");
2010 assert_eq!(project(&resolve(&toks(&["touch", "-d", "-1 day", "/tmp/../etc/x"])).expect("touch")), Verdict::Denied, "operand still gated");
2012 assert_eq!(project(&resolve(&toks(&["touch", "/etc/x"])).expect("touch")), Verdict::Denied, "system path");
2014 assert_eq!(project(&resolve(&toks(&["touch", "-Z", "x"])).expect("touch")), Verdict::Denied, "unknown flag");
2015 assert_eq!(project(&resolve(&toks(&["touch"])).expect("touch")), Verdict::Denied, "no operand");
2016 }
2017
2018 #[test]
2019 fn worst_case_is_denied_even_by_a_permissive_yolo_shaped_level() {
2020 use crate::engine::level::{Clause, Level, OrdBound};
2021 let yolo = Level::new("yolo-ish")
2023 .allowing(Clause {
2024 local_locus: Some(OrdBound::at_most(LocalLocus::Machine)),
2025 ..Default::default()
2026 })
2027 .denying(Clause {
2028 operation: Some(vec![Operation::Destroy]),
2029 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
2030 ..Default::default()
2031 });
2032 let wc = Profile::of(vec![Capability::worst("test")]);
2033 assert!(!yolo.admits(&wc), "worst_case (locus=kernel) exceeds even a machine-capped allow");
2034 }
2035
2036 #[test]
2037 fn rm_within_the_worktree_projects_to_developer_but_beyond_it_denies() {
2038 use crate::engine::bridge::project;
2039 use crate::verdict::{SafetyLevel, Verdict};
2040 for cmd in [
2043 vec!["rm", "./stale.log"],
2044 vec!["rm", "-rf", "./node_modules"],
2045 vec!["rm", "a", "b", "c"],
2046 vec!["rm", "--interactive=always", "./x"], ] {
2048 let p = resolve(&toks(&cmd)).expect("rm resolves");
2049 assert!(p.capabilities.iter().all(|c| c.operation == Operation::Destroy), "{cmd:?} destroys");
2050 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?} → developer");
2051 }
2052 for cmd in [vec!["rm", "-rf", "/"], vec!["rm", "-rf", "~/notes"], vec!["rm", "/etc/hosts"]] {
2055 assert_eq!(project(&resolve(&toks(&cmd)).expect("rm")), Verdict::Denied, "{cmd:?} beyond worktree");
2056 }
2057 }
2058
2059 #[test]
2063 fn rm_rf_root_is_the_one_thing_even_yolo_denies() {
2064 let yolo = level("yolo");
2065 let root = resolve(&toks(&["rm", "-rf", "/"])).expect("rm");
2066 assert_eq!(root.capabilities[0].reversibility, Reversibility::Irreversible, "rm -rf / is irreversible");
2067 assert_eq!(root.capabilities[0].scale, Scale::Unbounded);
2068 assert!(!yolo.admits(&root), "rm -rf / denied even at yolo");
2069 assert!(!yolo.admits(&resolve(&toks(&["rm", "-rf", "~/notes"])).expect("rm")), "rm -rf ~ likewise");
2070 assert!(yolo.admits(&resolve(&toks(&["rm", "-rf", "./node_modules"])).expect("rm")), "recoverable worktree");
2072 assert!(yolo.admits(&resolve(&toks(&["rm", "/etc/hosts"])).expect("rm")), "single (bounded) system delete");
2073 }
2074
2075 #[test]
2080 fn a_subcommand_profile_resolves_to_its_archetype() {
2081 let p = resolve(&toks(&["koyeb", "apps", "delete", "myapp"])).expect("koyeb apps delete resolves");
2082 assert_eq!(p.capabilities.len(), 1);
2083 assert_eq!(
2084 &p.capabilities[0],
2085 crate::engine::archetype::archetype("remote-destroy-recoverable").unwrap(),
2086 "the sub resolves to its declared archetype's capability",
2087 );
2088 let create = resolve(&toks(&["koyeb", "apps", "create", "myapp"])).expect("resolves");
2090 assert_eq!(create.capabilities[0].operation, Operation::Create);
2091 assert!(resolve(&toks(&["koyeb", "apps", "list"])).is_none(), "untagged sub → engine abstains");
2093 }
2094
2095 #[test]
2100 fn an_escalating_flag_adds_a_capability_and_raises_the_tier() {
2101 let destroy = crate::engine::archetype::archetype("remote-destroy-irreversible").unwrap();
2102 let vcs_sync = {
2105 let mut c = crate::engine::archetype::archetype("vcs-sync").unwrap().clone();
2106 c.locus.provenance = Provenance::Established;
2107 c
2108 };
2109
2110 let base = resolve(&toks(&["git", "push", "origin", "main"])).expect("git push resolves");
2111 assert_eq!(base.capabilities, vec![vcs_sync.clone()], "base is vcs-sync, established destination");
2112
2113 let forced = resolve(&toks(&["git", "push", "--force"])).expect("resolves");
2114 assert_eq!(forced.capabilities.len(), 2);
2115 assert!(forced.capabilities.contains(&vcs_sync) && forced.capabilities.contains(destroy),
2116 "--force ADDS remote-destroy-irreversible to the vcs-sync base");
2117
2118 let network_admin = level("network-admin");
2121 assert!(network_admin.admits(&base), "git push is network-admin");
2122 assert!(!network_admin.admits(&forced), "git push --force escalated past network-admin");
2123 assert!(level("yolo").admits(&forced), "and lands at yolo");
2124
2125 assert_eq!(resolve(&toks(&["git", "push", "-f"])).unwrap().capabilities.len(), 2);
2127 }
2128
2129 #[test]
2133 fn git_push_destination_provenance_is_classified() {
2134 use crate::engine::bridge::project;
2135 use crate::verdict::Verdict;
2136
2137 let prov = |cmd: &[&str]| resolve(&toks(cmd)).expect("push resolves").capabilities[0].locus.provenance;
2138
2139 assert_eq!(prov(&["git", "push"]), Provenance::Established, "bare push = default remote");
2141 assert_eq!(prov(&["git", "push", "origin", "main"]), Provenance::Established, "remote name");
2142 assert_eq!(prov(&["git", "push", "--force", "origin"]), Provenance::Established, "flag then name");
2144 assert_eq!(prov(&["git", "push", "https://h/x.git", "main"]), Provenance::Literal, "url");
2146 assert_eq!(prov(&["git", "push", "git@h:x.git"]), Provenance::Literal, "scp-style");
2147 assert_eq!(prov(&["git", "push", "/srv/mirror.git"]), Provenance::Literal, "path");
2148 assert_eq!(prov(&["git", "push", "$REMOTE"]), Provenance::Opaque, "variable");
2150
2151 let net = level("network-admin");
2153 assert!(net.admits(&resolve(&toks(&["git", "push", "origin"])).unwrap()), "established at network-admin");
2154 assert!(net.admits(&resolve(&toks(&["git", "push", "https://h/x.git"])).unwrap()), "literal URL at network-admin");
2155 assert!(!net.admits(&resolve(&toks(&["git", "push", "$REMOTE"])).unwrap()), "opaque above network-admin");
2156 assert_eq!(project(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), Verdict::Denied, "ext:: is RCE");
2158 assert!(!net.admits(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), "ext:: not at network-admin");
2159
2160 assert_eq!(project(&resolve(&toks(&["git", "push", "--repo=ext::sh", "origin"])).unwrap()), Verdict::Denied, "--repo=ext:: is RCE");
2163 assert!(!net.admits(&resolve(&toks(&["git", "push", "--repo", "$VAR", "origin"])).unwrap()), "--repo $VAR is opaque");
2164 assert_eq!(prov(&["git", "push", "--repo=https://h/x.git", "main"]), Provenance::Literal, "--repo URL is literal");
2165 assert!(net.admits(&resolve(&toks(&["git", "push", "--repo=upstream", "main"])).unwrap()), "--repo=<remote name> is established");
2166 }
2167
2168 #[test]
2174 fn data_export_gates_its_output_file() {
2175 use crate::engine::bridge::project;
2176 use crate::verdict::{SafetyLevel, Verdict};
2177
2178 let stdout = resolve(&toks(&["supabase", "db", "dump", "--data-only"])).expect("dump resolves");
2180 assert_eq!(stdout.capabilities.len(), 1, "stdout dump = the remote read only");
2181 assert_eq!(stdout.capabilities[0].scale, Scale::Unbounded, "a dump records its volume");
2182 assert_eq!(project(&stdout), Verdict::Allowed(SafetyLevel::SafeRead), "bulk read auto-approves");
2183
2184 for cmd in [
2186 vec!["supabase", "db", "dump", "-f", "dump.sql"],
2187 vec!["supabase", "db", "dump", "--file=dump.sql", "--data-only"],
2188 ] {
2189 let p = resolve(&toks(&cmd)).expect("dump resolves");
2190 assert_eq!(p.capabilities.len(), 2, "{cmd:?}: the remote read + a local write");
2191 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2192 }
2193
2194 for cmd in [
2197 vec!["supabase", "db", "dump", "-f", "/etc/passwd"],
2198 vec!["supabase", "db", "dump", "--file=/etc/passwd"],
2199 vec!["supabase", "db", "dump", "-f/etc/passwd"],
2200 ] {
2201 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?} writes a system path");
2202 }
2203 }
2204
2205 #[test]
2209 fn sudo_elevates_the_wrapped_commands_authority() {
2210 use crate::engine::bridge::project;
2211 use crate::verdict::Verdict;
2212 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2213
2214 let read = resolve(&toks(&["sudo", "cat", "./notes.md"])).expect("sudo cat resolves");
2217 assert_eq!(read.capabilities[0].authority, Authority::Root, "authority lifted to root");
2218 assert!(!dev.admits(&read) && !net.admits(&read), "a root op is above the user-authority bands");
2219 assert!(local.admits(&read) && yolo.admits(&read), "a root read is local-admin");
2220
2221 assert_eq!(resolve(&toks(&["sudo", "-EH", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2223 assert_eq!(resolve(&toks(&["sudo", "-n", "-p", "pw", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2224
2225 assert_eq!(project(&resolve(&toks(&["sudo", "rm", "-rf", "/"])).unwrap()), Verdict::Denied, "sudo rm -rf / denied everywhere");
2227
2228 let other = resolve(&toks(&["sudo", "-u", "bob", "cat", "./x"])).expect("sudo -u resolves");
2230 assert_eq!(other.capabilities[0].authority, Authority::OtherUser, "-u = run as other user");
2231 assert!(!local.admits(&other) && yolo.admits(&other), "other-user is yolo-only");
2232 assert_eq!(resolve(&toks(&["sudo", "-ubob", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::OtherUser, "glued -ubob");
2233
2234 assert_eq!(project(&resolve(&toks(&["sudo", "-i"])).unwrap()), Verdict::Denied, "sudo -i is a root shell");
2236 assert!(!local.admits(&resolve(&toks(&["sudo", "-s", "bash"])).unwrap()), "root shell not local-admin");
2237
2238 assert_eq!(project(&resolve(&toks(&["sudo", "--nonsense", "cat", "./x"])).unwrap()), Verdict::Denied, "unknown option worst-cases");
2240
2241 assert!(resolve(&toks(&["sudo", "totallyunknowncmd", "x"])).is_none(), "unresolved inner → legacy denies");
2243 assert!(resolve(&toks(&["sudo", "-v"])).is_none(), "no inner command");
2245
2246 assert_eq!(resolve(&toks(&["doas", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2248
2249 assert!(resolve(&toks(&["doas", "-r"])).is_none(), "doas -r must not panic");
2252 assert!(resolve(&toks(&["sudo", "-u"])).is_none(), "sudo -u must not panic");
2253 assert_eq!(crate::command_verdict("doas -r"), Verdict::Denied, "doas -r denied, not crashed");
2254
2255 for cmd in ["sudo cat ./notes.md", "sudo rm -rf ./build", "sudo -EH cat ./x", "sudo -u bob ls"] {
2258 assert_eq!(crate::command_verdict(cmd), Verdict::Denied, "`{cmd}` must not auto-approve at the default band");
2259 }
2260 }
2261
2262 #[test]
2266 fn systemctl_service_management_is_local_admin() {
2267 use crate::verdict::Verdict;
2268 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2269
2270 for sub in ["restart", "start", "stop", "enable", "disable", "mask", "daemon-reload", "kill"] {
2272 let p = resolve(&toks(&["systemctl", sub, "nginx"])).unwrap_or_else(|| panic!("systemctl {sub} resolves"));
2273 assert!(!dev.admits(&p) && !net.admits(&p), "systemctl {sub} is above developer/network-admin");
2274 assert!(local.admits(&p) && yolo.admits(&p), "systemctl {sub} is local-admin");
2275 }
2276 assert!(crate::command_verdict("systemctl status nginx").is_allowed(), "status reads");
2278 assert_eq!(crate::command_verdict("systemctl reboot"), Verdict::Denied, "reboot omitted → denied");
2279
2280 let sudo_restart = resolve(&toks(&["sudo", "systemctl", "restart", "nginx"])).expect("resolves");
2282 assert!(local.admits(&sudo_restart), "sudo systemctl restart is local-admin");
2283 assert_eq!(crate::command_verdict("sudo systemctl restart nginx"), Verdict::Denied, "still not auto-approved at default");
2284 }
2285
2286 #[test]
2292 fn npm_install_is_classified_by_pinning_and_scripts_off() {
2293 let (dev, yolo) = (level("developer"), level("yolo"));
2294
2295 let safe = resolve(&toks(&["npm", "ci", "--ignore-scripts"])).expect("npm ci --ignore-scripts");
2297 assert!(dev.admits(&safe), "pinned, scripts-off ci is developer");
2298
2299 let scripts_on = resolve(&toks(&["npm", "ci"])).expect("npm ci");
2301 assert!(!dev.admits(&scripts_on), "ci without --ignore-scripts runs fetched code → above developer");
2302 assert!(yolo.admits(&scripts_on), "and lands at yolo");
2303
2304 for c in [&["npm", "install"][..], &["npm", "install", "left-pad"], &["npm", "i", "react"], &["npm", "install", "--ignore-scripts"]] {
2306 let p = resolve(&toks(c)).unwrap_or_else(|| panic!("{c:?} resolves"));
2307 assert!(!dev.admits(&p) && yolo.admits(&p), "{c:?}: floating install → supply-chain (yolo)");
2308 }
2309 }
2310
2311 #[test]
2312 fn rm_flag_and_operand_fail_closed() {
2313 use crate::engine::bridge::project;
2314 use crate::verdict::Verdict;
2315 for cmd in [
2316 vec!["rm", "--no-preserve-root", "-rf", "/"], vec!["rm", "-Z", "x"], vec!["rm"], vec!["./rm", "x"], ] {
2321 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?}");
2322 }
2323 }
2324
2325 #[test]
2326 fn rm_scale_and_force_semantics() {
2327 let cap = |cmd: &[&str]| resolve(&toks(cmd)).expect("rm").capabilities[0].clone();
2328 assert_eq!(cap(&["rm", "./x"]).scale, Scale::Single);
2329 assert_eq!(cap(&["rm", "a", "b"]).scale, Scale::Bounded, "multiple operands");
2330 assert_eq!(cap(&["rm", "*.log"]).scale, Scale::Bounded, "a glob");
2331 assert_eq!(cap(&["rm", "-r", "./dir"]).scale, Scale::Unbounded, "recursive");
2332 assert_eq!(cap(&["rm", "./x"]).reversibility, Reversibility::Effortful);
2334 assert_eq!(cap(&["rm", "-f", "./x"]).reversibility, Reversibility::Effortful, "-f is not a raiser");
2335 }
2336
2337 #[test]
2338 fn a_resolvable_name_from_a_non_standard_path_worst_cases() {
2339 for cmd in [vec!["./cat", "x"], vec!["/tmp/cat", "x"], vec!["~/bin/grep", "foo", "f"]] {
2341 let p = resolve(&toks(&cmd)).expect("resolvable name");
2342 assert!(!read_local().admits(&p), "{cmd:?} from a non-standard path must worst-case");
2343 }
2344 assert!(read_local().admits(&resolve(&toks(&["cat", "./notes.md"])).expect("cat")));
2346 assert!(read_local().admits(&resolve(&toks(&["/usr/bin/cat", "./notes.md"])).expect("cat")));
2347 assert!(resolve(&toks(&["/tmp/mytool", "x"])).is_none());
2349 }
2350
2351 #[test]
2352 fn unrecognized_flags_worst_case_fail_closed() {
2353 for cmd in [
2354 vec!["cat", "-Z", "./x"],
2355 vec!["cat", "--wat", "./x"],
2356 vec!["grep", "-Q", "foo", "f"], vec!["grep", "-R", "foo", "dir"], ] {
2359 let p = resolve(&toks(&cmd)).expect("resolver");
2360 assert!(!inert().admits(&p) && !read_local().admits(&p), "{cmd:?} must worst-case");
2361 }
2362 assert!(read_local().admits(&resolve(&toks(&["cat", "-nA", "./x"])).expect("cat")));
2364 assert!(read_local().admits(&resolve(&toks(&["grep", "-rin", "foo", "src/"])).expect("grep")));
2365 }
2366
2367 use proptest::prelude::*;
2368
2369 const TRANSFER_CMDS: &[&str] = &["cp", "mv", "ln"];
2374
2375 const HOT_PATHS: &[&str] = &["/etc/shadow", "~/.ssh/id_rsa", "$SECRET", "../out", "~/.aws"];
2378
2379 proptest! {
2380 #[test]
2386 fn transfer_commands_gate_both_operand_roles(
2387 cmd in prop::sample::select(TRANSFER_CMDS),
2388 hot in prop::sample::select(HOT_PATHS),
2389 ) {
2390 use crate::engine::bridge::project;
2391 use crate::verdict::Verdict;
2392 let hot_source = resolve(&toks(&[cmd, hot, "./safe"])).expect("resolves");
2393 prop_assert_eq!(project(&hot_source), Verdict::Denied, "{} hot SOURCE ({})", cmd, hot);
2394 let hot_dest = resolve(&toks(&[cmd, "./safe", hot])).expect("resolves");
2395 prop_assert_eq!(project(&hot_dest), Verdict::Denied, "{} hot DEST ({})", cmd, hot);
2396 }
2397
2398 #[test]
2404 fn sudo_family_flag_walk_never_panics(
2405 head in prop::sample::select(vec!["sudo", "doas"]),
2406 args in prop::collection::vec(
2407 prop_oneof![
2408 Just("-u".to_string()), Just("-r".to_string()), Just("-g".to_string()),
2409 Just("-i".to_string()), Just("-EH".to_string()), Just("-uEH".to_string()),
2410 Just("-uroot".to_string()), Just("--".to_string()), Just("-".to_string()),
2411 Just("root".to_string()), Just("cat".to_string()), Just("./x".to_string()),
2412 "-[a-zA-Z]{1,4}",
2413 ],
2414 0..6,
2415 ),
2416 ) {
2417 let parts: Vec<&str> =
2418 std::iter::once(head).chain(args.iter().map(String::as_str)).collect();
2419 let a = resolve(&toks(&parts)).is_some();
2420 let b = resolve(&toks(&parts)).is_some();
2421 prop_assert_eq!(a, b, "nondeterministic verdict for {:?}", parts);
2422 }
2423 }
2424
2425 const EXPECTED_BEHAVIOR_COMMANDS: &[&str] = &[
2435 "cat", "cp", "dd", "echo", "grep", "head", "ln", "mkdir", "mv", "rm", "sed", "tail",
2436 "tar", "touch", "wc",
2437 ];
2438
2439 #[test]
2443 fn behavior_command_roster_is_pinned() {
2444 use std::collections::BTreeSet;
2445 let actual: BTreeSet<&str> = crate::registry::toml_command_names()
2446 .into_iter()
2447 .filter(|n| crate::registry::command_behavior(n).is_some())
2448 .collect();
2449 let expected: BTreeSet<&str> = EXPECTED_BEHAVIOR_COMMANDS.iter().copied().collect();
2450 assert_eq!(
2451 actual, expected,
2452 "behavior-command roster drifted — a [command.behavior] block was added, dropped, or \
2453 typo'd. A dropped block silently reverts the command to its fail-open legacy path."
2454 );
2455 }
2456
2457 fn behavior_probes(cmd: &str, role: crate::registry::types::PositionalRole, hot: &str) -> Vec<Vec<String>> {
2460 use crate::registry::types::PositionalRole;
2461 let inv = |slots: &[&str]| -> Vec<String> {
2462 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
2463 };
2464 match role {
2465 PositionalRole::None => vec![],
2466 PositionalRole::Read | PositionalRole::Write => vec![inv(&["@"])],
2467 PositionalRole::PatternThenRead => vec![inv(&["PATTERN", "@"])],
2468 PositionalRole::Transfer => vec![inv(&["@", "./safe"]), inv(&["./safe", "@"])],
2469 }
2470 }
2471
2472 fn hook_probes(hook: crate::registry::types::BehaviorHook, cmd: &str, hot: &str) -> Vec<Vec<String>> {
2478 use crate::registry::types::BehaviorHook;
2479 let inv = |slots: &[&str]| -> Vec<String> {
2480 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
2481 };
2482 match hook {
2483 BehaviorHook::Grep => vec![],
2485 BehaviorHook::Dd => vec![inv(&["if=@", "of=./safe"]), inv(&["if=./safe", "of=@"])],
2486 BehaviorHook::Tar => vec![inv(&["cf", "./s.tar", "@"]), inv(&["cf", "@", "./s"]), inv(&["tf", "@"])],
2487 BehaviorHook::Sed => vec![inv(&["s/x/y/", "@"]), inv(&["-i", "s/x/y/", "@"])],
2488 }
2489 }
2490
2491 #[test]
2498 fn every_behavior_command_gates_hot_operands() {
2499 use crate::engine::bridge::project;
2500 use crate::registry::types::PositionalRole;
2501 use crate::verdict::Verdict;
2502
2503 let deny = |cmd: &[String], why: &str| {
2504 let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
2505 let profile = resolve(&toks(&refs)).expect("behavior command resolves");
2506 assert_eq!(project(&profile), Verdict::Denied, "{cmd:?}: {why}");
2507 };
2508
2509 let mut path_bearing = 0usize;
2510 let mut hook_bearing = 0usize;
2511 for name in crate::registry::toml_command_names() {
2512 let Some(b) = crate::registry::command_behavior(name) else { continue };
2513 if !matches!(b.positionals, PositionalRole::None) {
2514 path_bearing += 1;
2515 }
2516 if b.hook.is_some() {
2517 hook_bearing += 1;
2518 }
2519 for hot in HOT_PATHS {
2520 for cmd in behavior_probes(name, b.positionals, hot) {
2521 deny(&cmd, "touched hot path not gated");
2522 }
2523 if let Some(hook) = b.hook {
2527 for cmd in hook_probes(hook, name, hot) {
2528 deny(&cmd, "hook: touched hot path not gated");
2529 }
2530 }
2531 }
2532 let inv = |slots: &[&str]| -> Vec<String> {
2536 std::iter::once(name.to_string()).chain(slots.iter().map(|s| s.to_string())).collect()
2537 };
2538 match b.positionals {
2539 PositionalRole::Write => deny(&inv(&[".git/config"]), "write into worktree-trusted not gated"),
2540 PositionalRole::Transfer => deny(&inv(&["./safe", ".git/config"]), "transfer dest into worktree-trusted not gated"),
2541 _ => {}
2542 }
2543 }
2544 let count = |pred: fn(&crate::registry::types::BehaviorSpec) -> bool| {
2548 EXPECTED_BEHAVIOR_COMMANDS
2549 .iter()
2550 .filter(|n| crate::registry::command_behavior(n).is_some_and(pred))
2551 .count()
2552 };
2553 assert_eq!(
2554 path_bearing,
2555 count(|b| !matches!(b.positionals, PositionalRole::None)),
2556 "path-bearing behavior commands: saw {path_bearing}"
2557 );
2558 assert_eq!(hook_bearing, count(|b| b.hook.is_some()), "hook behavior commands: saw {hook_bearing}");
2559 }
2560
2561 #[test]
2569 fn every_hookless_behavior_command_worst_cases_unknown_flags() {
2570 use crate::engine::bridge::project;
2571 use crate::registry::types::{BehaviorHook, PositionalRole};
2572 use crate::verdict::Verdict;
2573
2574 let exempt = |b: &crate::registry::types::BehaviorSpec| {
2575 matches!(b.hook, Some(BehaviorHook::Grep)) || matches!(b.positionals, PositionalRole::None)
2576 };
2577 let mut checked = 0usize;
2578 for name in crate::registry::toml_command_names() {
2579 let Some(b) = crate::registry::command_behavior(name) else { continue };
2580 if exempt(b) {
2581 continue;
2582 }
2583 let profile = resolve(&toks(&[name, "--xyzzy-unknown-42", "./safe"])).expect("resolves");
2584 assert_eq!(project(&profile), Verdict::Denied, "{name}: unknown flag not worst-cased");
2585 checked += 1;
2586 }
2587 let expected = EXPECTED_BEHAVIOR_COMMANDS
2588 .iter()
2589 .filter(|n| crate::registry::command_behavior(n).is_some_and(|b| !exempt(b)))
2590 .count();
2591 assert_eq!(checked, expected, "declarative flag-walking behavior commands: saw {checked}");
2592 }
2593
2594 #[test]
2600 fn no_none_or_hook_command_declares_ungated_path_flags() {
2601 use crate::registry::types::PositionalRole;
2602 for name in crate::registry::toml_command_names() {
2603 let Some(b) = crate::registry::command_behavior(name) else { continue };
2604 if b.path_flags.is_empty() {
2605 continue;
2606 }
2607 assert!(
2608 b.hook.is_none() && !matches!(b.positionals, PositionalRole::None),
2609 "{name}: behavior path-flags are gated only on the Read/Write/Transfer path; on a \
2610 none-role or hook command they would be silently ungated (fail-open)"
2611 );
2612 }
2613 }
2614}