1use super::facet::*;
13use crate::parse::{Token, has_flag};
14
15mod capability;
16mod flags;
17pub(crate) mod 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::{FrozenWrite, anchoring_of, frozen_write_kind, names_credential_store};
29pub(crate) use locus::is_unpinnable;
30
31pub(crate) fn loop_reprs(items: &[String]) -> Option<(String, String)> {
43 if items.is_empty() {
44 return None;
45 }
46 let faced: Vec<(String, LocalLocus, LocalLocus)> = items
47 .iter()
48 .map(|s| {
49 if crate::cst::check::is_opaque_value(s) {
50 ("$loop_sub".to_string(), LocalLocus::Machine, LocalLocus::Machine)
51 } else {
52 (s.clone(), read_locus(s), write_locus(s))
53 }
54 })
55 .collect();
56 let read_item = faced.iter().max_by_key(|(_, r, _)| *r).map(|(s, _, _)| s.clone())?;
57 let write_item = faced.iter().max_by_key(|(_, _, w)| *w).map(|(s, _, _)| s.clone())?;
58 let read_repr = crate::pathctx::expand_vars(&read_item, false).into_owned();
61 let write_repr = crate::pathctx::expand_vars(&write_item, true).into_owned();
62 Some((read_repr, write_repr))
63}
64
65pub(crate) fn read_content_verdict(path: &str) -> crate::verdict::Verdict {
69 let cap = reads_content(read_locus(path), Scale::Single, "reads a redirect source");
70 crate::engine::bridge::project(&Profile::of(vec![cap]))
71}
72
73pub(crate) fn write_target_verdict(path: &str) -> crate::verdict::Verdict {
76 let cap = overwrites(write_locus(path), Scale::Single, false);
77 crate::engine::bridge::project(&Profile::of(vec![cap]))
78}
79
80pub(crate) fn rebind_is_stricter_than_write(path: &str) -> bool {
88 let rebind = overwrites(locus::rebind_locus(path), Scale::Single, false);
89 let refused = !crate::engine::bridge::project(&Profile::of(vec![rebind])).is_allowed();
90 refused && write_target_verdict(path).is_allowed()
91}
92
93pub(crate) fn worst_path_element(
108 value: &str,
109 judge: fn(&str) -> crate::verdict::Verdict,
110 split_list: bool,
111) -> crate::verdict::Verdict {
112 let mut worst = judge(value);
113 if split_list && value.contains(':') && !value.contains("://") {
114 for element in value.split(':').filter(|s| !s.is_empty()) {
115 worst = worst.combine(judge(element));
116 }
117 }
118 worst
119}
120
121
122pub(crate) fn execute_file_verdict(path: &str) -> crate::verdict::Verdict {
129 if path.contains(['*', '?', '[']) {
134 return crate::engine::bridge::project(&worst("glob executor — the code that would run is unknown (§6)"));
135 }
136 if path.contains(crate::cst::eval::PROCSUB_SENTINEL) {
145 return crate::engine::bridge::project(&worst(
146 "process-substitution executor — the code that would run is a command's output (§6)",
147 ));
148 }
149 if crate::engine::resolve::locus::is_url(path) {
156 return crate::engine::bridge::project(&worst(
157 "URL executor — the code that would run is not a workspace file (§6)",
158 ));
159 }
160 if path.split_whitespace().count() > 1 {
173 let mut worst_seen = None;
174 for token in path.split_whitespace() {
175 if token.starts_with('-') {
176 return crate::engine::bridge::project(&worst(
177 "executor value is a command line, not a path — a non-path token means the \
178 code that would run is unknown",
179 ));
180 }
181 let v = execute_file_verdict(token);
182 worst_seen = Some(match worst_seen {
183 None => v,
184 Some(prev) => crate::verdict::Verdict::combine(prev, v),
185 });
186 }
187 return worst_seen.unwrap_or_else(|| {
188 crate::engine::bridge::project(&worst("empty executor value"))
189 });
190 }
191 let cap = executes(classify_locus(path), ExecutionTrust::CallerFile, "runs code from a named file");
192 crate::engine::bridge::project(&Profile::of(vec![cap]))
193}
194
195pub(crate) fn execute_project_verdict() -> crate::verdict::Verdict {
201 let cap = executes(LocalLocus::Worktree, ExecutionTrust::SelfCode, "runs the current project's own code");
202 crate::engine::bridge::project(&Profile::of(vec![cap]))
203}
204
205pub fn resolve(tokens: &[Token]) -> Option<Profile> {
210 let arg0 = tokens.first()?;
211 let canonical = crate::registry::canonical_name(arg0.command_name());
217 if canonical == "openssl" {
222 return resolve_openssl(arg0, tokens);
223 }
224 if matches!(canonical, "sudo" | "doas") {
225 return resolve_privilege_wrapper(arg0, tokens);
226 }
227 if let Some(names) = crate::registry::sub_archetypes(tokens) {
231 if !trusted_command_path(arg0.as_str()) {
232 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
233 }
234 let mut caps: Vec<Capability> = names
250 .iter()
251 .map(|n| {
252 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
253 Capability::worst("subcommand/flag declares an unknown archetype (§0)")
254 })
255 })
256 .collect();
257 if let Some(dest) = crate::registry::sub_destination_token(tokens) {
261 match destination_provenance(dest) {
262 Some(prov) => {
263 if let Some(base) = caps.first_mut() {
264 base.locus.provenance = prov;
265 }
266 }
267 None => {
268 return Some(worst(
269 "send target is a command transport (ext::…) — runs a local command, RCE (§4)",
270 ));
271 }
272 }
273 }
274 if let Some(path) = crate::registry::sub_output_path_token(tokens) {
279 caps.push(writes_export_file(classify_locus(path)));
280 }
281 if crate::registry::sub_loopback_localizes(tokens) {
300 for c in &mut caps {
301 if c.operation == Operation::Destroy {
302 continue;
303 }
304 c.locus.remote = RemoteReach::None;
305 c.network.direction = NetDirection::Loopback;
306 c.network.payload = NetPayload::None;
307 c.cost = Cost::None;
308 c.because = format!("{} — but the endpoint names this machine, so no remote is reached", c.because);
312 }
313 }
314 return Some(Profile::of(caps));
315 }
316 if let Some(names) = crate::registry::command_flag_archetypes(tokens) {
322 if !trusted_command_path(arg0.as_str()) {
323 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
324 }
325 let caps: Vec<Capability> = names
326 .iter()
327 .map(|n| {
328 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
329 Capability::worst("command flag declares an unknown archetype (§0)")
330 })
331 })
332 .collect();
333 return Some(Profile::of(caps));
334 }
335 let spec = crate::registry::command_behavior(canonical)?;
339 if !trusted_command_path(arg0.as_str()) {
345 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
346 }
347 Some(resolve_behavior(spec, tokens))
348}
349
350fn resolve_privilege_wrapper(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
356 if !trusted_command_path(arg0.as_str()) {
357 return Some(worst("sudo/doas invoked from a non-standard path — possible spoof (§0)"));
358 }
359 let mut i = 1;
360 let mut run_as_other = false;
361 'scan: while let Some(tok) = tokens.get(i) {
362 let t = tok.as_str();
363 if t == "--" {
364 i += 1;
365 break;
366 }
367 if !t.starts_with('-') || t == "-" {
368 break; }
370 if let Some(long) = t.strip_prefix("--") {
371 let (name, glued_val) = match long.split_once('=') {
372 Some((n, _)) => (n, true),
373 None => (long, false),
374 };
375 match name {
376 "login" | "shell" | "edit" => {
377 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
378 }
379 "user" | "other-user" => {
380 run_as_other = true;
381 if !glued_val { i += 1; }
382 }
383 "group" | "prompt" | "close-from" | "host" | "role" | "type"
384 | "command-timeout" | "chroot" | "chdir" | "preserve-env" => {
385 if !glued_val && name != "preserve-env" { i += 1; }
388 }
389 "background" | "stdin" | "non-interactive" | "reset-timestamp"
390 | "remove-timestamp" | "set-home" | "askpass" | "help" | "version"
391 | "validate" | "list" | "bell" => {}
392 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
393 }
394 } else {
395 let rest = &t[1..];
398 for (idx, c) in rest.char_indices() {
399 match c {
400 'i' | 's' | 'e' => {
401 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
402 }
403 'u' | 'U' | 'g' | 'p' | 'C' | 'h' | 'r' | 't' | 'T' | 'R' | 'D' => {
404 if c == 'u' || c == 'U' { run_as_other = true; }
405 if idx + c.len_utf8() == rest.len() { i += 1; } i += 1;
407 continue 'scan; }
409 'E' | 'H' | 'k' | 'K' | 'n' | 'b' | 'A' | 'S' | 'P' | 'B' | 'v' | 'l' => {}
410 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
411 }
412 }
413 }
414 i += 1;
415 }
416 let inner = &tokens[i.min(tokens.len())..];
420 if inner.is_empty() {
421 return None; }
423 let elevated = if run_as_other { Authority::OtherUser } else { Authority::Root };
424 let caps = resolve(inner)?
425 .capabilities
426 .into_iter()
427 .map(|mut c| {
428 c.authority = c.authority.max(elevated);
429 c
430 })
431 .collect();
432 Some(Profile::of(caps))
433}
434
435fn resolve_openssl(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
445 if !trusted_command_path(arg0.as_str()) {
446 return Some(worst("openssl invoked from a non-standard path — possible spoof (§0)"));
447 }
448 let sub = tokens.get(1)?.as_str();
449 let args = &tokens[2..];
450 let discloses = match sub {
451 "rsa" | "pkey" | "ec" | "dsa" => {
455 if openssl_flag(args, "-pubin") {
456 false
457 } else if openssl_flag(args, "-text") {
458 true } else if openssl_flag(args, "-pubout") {
460 false } else {
462 openssl_output_reaches_model(args)
463 }
464 }
465 "pkcs8" => openssl_flag(args, "-text") || openssl_output_reaches_model(args),
467 "pkcs12" => {
469 (openssl_flag(args, "-nodes") || openssl_flag(args, "-noenc"))
470 && openssl_output_reaches_model(args)
471 }
472 "enc" => openssl_flag(args, "-d") && openssl_output_reaches_model(args),
474 "smime" => openssl_flag(args, "-decrypt") && openssl_output_reaches_model(args),
475 "cms" => {
476 (openssl_flag(args, "-decrypt") || openssl_flag(args, "-EncryptedData_decrypt"))
477 && openssl_output_reaches_model(args)
478 }
479 _ => return None, };
481 if discloses {
482 let cap = crate::engine::archetype::archetype("decrypt-read")
483 .cloned()
484 .unwrap_or_else(|| Capability::worst("decrypt-read archetype missing (§0)"));
485 Some(Profile::of(vec![cap]))
486 } else {
487 None }
489}
490
491fn openssl_flag(args: &[Token], flag: &str) -> bool {
494 args.iter().any(|t| {
495 let s = t.as_str();
496 s == flag || (s.starts_with("--") && s.len() > 2 && &s[1..] == flag)
497 })
498}
499
500fn openssl_output_reaches_model(args: &[Token]) -> bool {
508 if openssl_flag(args, "-noout") {
509 return false;
510 }
511 let outs = openssl_flag_values(args, "-out");
512 !matches!(outs.as_slice(), [only] if out_value_is_plain_file(only))
516}
517
518fn out_value_is_plain_file(value: &str) -> bool {
529 if value.is_empty() || value.starts_with('-') {
530 return false;
531 }
532 let norm = collapse_path(value).to_ascii_lowercase();
533 let device_or_fd =
534 norm == "/dev" || norm.starts_with("/dev/") || (norm.starts_with("/proc/") && norm.contains("/fd/"));
535 !device_or_fd
536}
537
538fn collapse_path(p: &str) -> String {
542 let absolute = p.starts_with('/');
543 let mut stack: Vec<&str> = Vec::new();
544 for seg in p.split('/') {
545 match seg {
546 "" | "." => {}
547 ".." => {
548 if matches!(stack.last(), Some(&s) if s != "..") {
549 stack.pop();
550 } else if !absolute {
551 stack.push("..");
552 }
553 }
554 s => stack.push(s),
555 }
556 }
557 let joined = stack.join("/");
558 if absolute { format!("/{joined}") } else { joined }
559}
560
561fn openssl_flag_values<'a>(args: &'a [Token], flag: &str) -> Vec<&'a str> {
565 let twin = format!("-{flag}"); let mut out = Vec::new();
567 let mut i = 0;
568 while i < args.len() {
569 let s = args[i].as_str();
570 if let Some(v) = s
571 .strip_prefix(flag)
572 .or_else(|| s.strip_prefix(twin.as_str()))
573 .and_then(|r| r.strip_prefix('='))
574 {
575 out.push(v);
576 } else if (s == flag || s == twin)
577 && let Some(next) = args.get(i + 1)
578 {
579 out.push(next.as_str());
580 i += 1;
581 }
582 i += 1;
583 }
584 out
585}
586
587fn destination_provenance(dest: Option<&str>) -> Option<Provenance> {
591 let Some(tok) = dest else {
592 return Some(Provenance::Established);
593 };
594 if tok.starts_with("ext::") {
595 return None; }
597 if tok.contains('$') || tok.contains('`') {
600 return Some(Provenance::Opaque);
601 }
602 let literal = tok.contains("://")
605 || (tok.contains('@') && tok.contains(':'))
606 || tok.starts_with('/')
607 || tok.starts_with("./")
608 || tok.starts_with("../");
609 Some(if literal { Provenance::Literal } else { Provenance::Established })
610}
611
612fn resolve_behavior(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Profile {
618 use crate::registry::types::{BehaviorHook, PositionalRole};
619 if let Some(hook) = spec.hook {
620 return match hook {
621 BehaviorHook::Grep => {
625 let Some(g) = grep_operands(tokens) else {
626 return worst("grep: unrecognized flag or missing pattern — worst-cased (§0)");
627 };
628 let mut caps: Vec<Capability> = g
629 .pattern_files
630 .iter()
631 .map(|f| reads_content(read_locus(f), Scale::Single, "reads a grep -f pattern file"))
632 .collect();
633 caps.extend(reads_to_model(&g.files, g.scale));
634 Profile::of(caps)
635 }
636 BehaviorHook::Dd => resolve_dd(tokens),
641 BehaviorHook::Tar => resolve_tar(tokens),
642 BehaviorHook::Sed => resolve_sed(tokens),
643 BehaviorHook::Perl => resolve_perl(tokens),
644 };
645 }
646 if matches!(spec.positionals, PositionalRole::None) {
650 return match spec.operation {
651 Operation::Observe => {
652 let mut c = Capability::new(Operation::Observe);
653 c.disclosure.audience = DisclosureAudience::LocalProcess;
654 c.because = "behavior: prints its arguments to stdout; no fs/net/exec/secret".to_string();
655 Profile::of(vec![c])
656 }
657 _ => worst("behavior: none-operand role supports only observe (§0)"),
658 };
659 }
660 let long: Vec<&str> = spec.long.iter().map(String::as_str).collect();
661 let valued_long: Vec<&str> = spec.valued_long.iter().map(String::as_str).collect();
662 let Some(operands) = walk_positionals(&spec.short, &spec.valued_short, &long, &valued_long, spec.numeric_shorthand, tokens) else {
663 return worst("behavior: unrecognized flag — worst-cased (§0)");
664 };
665 let scale = behavior_scale(spec, &operands, tokens);
666 let flag_caps = path_flag_caps(spec, tokens);
668 match spec.positionals {
669 PositionalRole::Read => {
670 let mut caps = reads_to_model(&operands, scale);
671 caps.extend(flag_caps);
672 Profile::of(caps)
673 }
674 PositionalRole::Write => {
675 if operands.is_empty() {
676 if tokens.iter().skip(1).any(|t| matches!(t.as_str(), "--help" | "--version")) {
685 let mut c = Capability::new(Operation::Observe);
686 c.disclosure.audience = DisclosureAudience::LocalProcess;
687 c.because = "behavior: prints usage and exits; nothing is written".to_string();
688 return Profile::of(vec![c]);
689 }
690 return worst("behavior: write operation with no operand — worst-cased (§0)");
691 }
692 let mut caps: Vec<Capability> = operands
693 .iter()
694 .map(|p| match spec.operation {
695 Operation::Destroy => destroys(locus::rebind_locus(p), scale),
698 Operation::Create => creates(classify_locus(p), scale),
699 Operation::Mutate => mutates(classify_locus(p), scale, "behavior: in-place mutate"),
700 _ => Capability::worst("behavior: unsupported write operation — worst-cased (§0)"),
701 })
702 .collect();
703 caps.extend(flag_caps);
704 Profile::of(caps)
705 }
706 PositionalRole::Transfer => resolve_transfer(spec, operands, flag_caps, tokens),
707 PositionalRole::None | PositionalRole::PatternThenRead => {
710 worst("behavior: operand role not resolvable without a hook (§0)")
711 }
712 }
713}
714
715fn resolve_transfer(
720 spec: &crate::registry::types::BehaviorSpec,
721 operands: Vec<&str>,
722 flag_caps: Vec<Capability>,
723 tokens: &[Token],
724) -> Profile {
725 use crate::registry::types::TransferSource;
726 let Some(t) = &spec.transfer else {
727 return worst("behavior: transfer role without transfer spec — worst-cased (§0)");
728 };
729 let (sources, dest, dest_is_container) = if let Some(d) = walk_value(&spec.valued_short, tokens, b't', "--target-directory") {
734 if operands.is_empty() {
735 return worst("behavior: transfer -t with no source operand — worst-cased (§0)");
736 }
737 (operands, d, true)
738 } else {
739 match operands.split_last() {
740 Some((last, rest)) if !rest.is_empty() => (rest.to_vec(), *last, rest.len() >= 2),
741 _ => return worst("behavior: transfer needs a source and a destination — worst-cased (§0)"),
742 }
743 };
744 let no_clobber = if t.clobber_flags.is_empty() {
745 t.no_clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
746 } else {
747 !t.clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
749 };
750 let recursive = t.recursive_flags.iter().any(|f| behavior_flag_present(tokens, f));
751 let transfer_scale = breadth_scale(&sources, recursive);
752 let source_face = match t.source {
756 TransferSource::Relocate => locus::Face::Rebind,
757 TransferSource::Observe => locus::Face::Read,
758 };
759 let dest_face = if t.rebinds_destination && !dest_is_container {
767 locus::Face::Rebind
768 } else {
769 locus::Face::Write
770 };
771 let mut prof = transfer_profile(
772 &sources,
773 dest,
774 transfer_scale,
775 source_face,
776 dest_face,
777 |loc, sc| match t.source {
778 TransferSource::Observe => observes(loc, sc, "transfer reads the source at its locus"),
779 TransferSource::Relocate => relocates(loc, sc),
780 },
781 |loc, sc| overwrites(loc, sc, no_clobber),
782 );
783 prof.capabilities.extend(flag_caps);
784 prof
785}
786
787fn path_flag_caps(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Vec<Capability> {
791 use crate::registry::types::PathRole;
792 let mut caps = Vec::new();
793 for pf in &spec.path_flags {
794 let short = pf.short.unwrap_or(0);
795 let long = pf.long.as_deref().unwrap_or("");
796 if let Some(v) = walk_value(&spec.valued_short, tokens, short, long) {
797 caps.push(match pf.role {
798 PathRole::Read => observes(read_locus(v), Scale::Single, "behavior: a flag value is a read path"),
799 PathRole::Write => mutates(write_locus(v), Scale::Single, "behavior: a flag value is a write path"),
800 });
801 }
802 }
803 caps
804}
805
806fn behavior_scale(
809 spec: &crate::registry::types::BehaviorSpec,
810 operands: &[&str],
811 tokens: &[Token],
812) -> Scale {
813 use crate::registry::types::ScaleModel;
814 match spec.scale {
815 ScaleModel::Single => Scale::Single,
816 ScaleModel::Breadth => {
817 let recursive = spec.unbounded_flags.iter().any(|f| behavior_flag_present(tokens, f));
818 breadth_scale(operands, recursive)
819 }
820 }
821}
822
823fn behavior_flag_present(tokens: &[Token], flag: &str) -> bool {
826 if flag.starts_with("--") {
827 has_flag(tokens, None, Some(flag))
828 } else {
829 has_flag(tokens, Some(flag), None)
830 }
831}
832
833#[cfg(test)]
838pub(crate) const UNRESOLVED_CMD: &[&str] = &["safe-chains-unresolved-sentinel"];
839
840fn trusted_command_path(arg0: &str) -> bool {
844 const STD_BINS: &[&str] =
845 &["/usr/bin/", "/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/sbin/", "/usr/sbin/"];
846 !arg0.contains('/') || STD_BINS.iter().any(|p| arg0.starts_with(p))
847}
848
849struct GrepOperands<'a> {
856 files: Vec<&'a str>,
857 pattern_files: Vec<&'a str>,
858 scale: Scale,
859}
860
861fn grep_operands(tokens: &[Token]) -> Option<GrepOperands<'_>> {
864 let recursive = has_flag(tokens, Some("-r"), Some("--recursive"));
867 let scale = if recursive { Scale::Unbounded } else { Scale::Single };
868
869 let mut files = Vec::new(); let mut pattern_files = Vec::new(); let mut pattern_from_flag = false;
872 let mut unknown_flag = false;
873 let mut flags_done = false;
874 let mut i = 1;
875 while i < tokens.len() {
876 let t = tokens[i].as_str();
877 let next = tokens.get(i + 1).map(Token::as_str);
878 if !flags_done && t == "--" {
879 flags_done = true;
880 i += 1;
881 } else if flags_done || !t.starts_with('-') || t == "-" {
882 files.push(t);
883 i += 1;
884 } else if t.starts_with("--") {
885 if let Some(v) = t.strip_prefix("--file=") {
886 pattern_from_flag = true;
887 pattern_files.push(v);
888 i += 1;
889 } else if t == "--file" {
890 pattern_from_flag = true;
891 pattern_files.extend(next);
892 i += 2;
893 } else if t == "--regexp" {
894 pattern_from_flag = true;
895 i += 2;
896 } else if t.starts_with("--regexp=") {
897 pattern_from_flag = true;
898 i += 1;
899 } else if grep_long_known(t) {
900 i += 1;
901 } else if grep_long_dangerous(t) {
902 unknown_flag = true;
903 i += 1;
904 } else {
905 files.push(t);
909 i += 1;
910 }
911 } else {
912 match grep_short_cluster(t, next) {
913 GrepShort::Unrecognized => {
914 unknown_flag = true;
915 i += 1;
916 }
917 GrepShort::Standalone => i += 1,
918 GrepShort::Pattern { file, consumes_next } => {
919 pattern_files.extend(file);
920 pattern_from_flag = true;
921 i += if consumes_next { 2 } else { 1 };
922 }
923 GrepShort::SkipValue { consumes_next } => i += if consumes_next { 2 } else { 1 },
924 }
925 }
926 }
927
928 if unknown_flag {
929 return None; }
931 if files.is_empty() {
932 return None;
936 }
937
938 if !pattern_from_flag {
939 files.remove(0); }
941 if recursive && files.is_empty() {
942 files.push("."); }
944
945 Some(GrepOperands { files, pattern_files, scale })
946}
947
948enum GrepShort<'a> {
950 Unrecognized,
952 Standalone,
954 Pattern { file: Option<&'a str>, consumes_next: bool },
957 SkipValue { consumes_next: bool },
959}
960
961fn grep_short_cluster<'a>(cluster: &'a str, next: Option<&'a str>) -> GrepShort<'a> {
964 const BENIGN: &[u8] = b"ivnclLoqswxHhaIrzZEFGbUP";
969 let bytes = cluster.as_bytes();
970 let mut k = 1;
971 while k < bytes.len() {
972 if !bytes[k].is_ascii() {
974 return GrepShort::Unrecognized;
975 }
976 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
978 match bytes[k] {
979 b'f' => {
980 let file = if has { Some(glued) } else { next };
981 return GrepShort::Pattern { file, consumes_next: !has };
982 }
983 b'e' => return GrepShort::Pattern { file: None, consumes_next: !has },
984 b'm' | b'A' | b'B' | b'C' | b'd' => return GrepShort::SkipValue { consumes_next: !has },
985 b if BENIGN.contains(&b) => k += 1,
986 _ => return GrepShort::Unrecognized,
987 }
988 }
989 GrepShort::Standalone
990}
991
992fn grep_long_known(flag: &str) -> bool {
995 const KNOWN: &[&str] = &[
996 "--recursive", "--ignore-case", "--invert-match", "--line-number", "--count", "--files-with-matches", "--files-without-match",
999 "--only-matching", "--perl-regexp", "--word-regexp", "--line-regexp", "--fixed-strings",
1000 "--extended-regexp", "--basic-regexp", "--with-filename", "--no-filename",
1001 "--quiet", "--silent", "--no-messages", "--null", "--byte-offset", "--text",
1002 "--color", "--colour", "--help", "--version", "--after-context", "--before-context",
1003 "--context", "--max-count", "--include", "--exclude", "--exclude-dir",
1004 "--include-dir", "--binary-files", "--devices", "--directories",
1005 ];
1006 let name = flag.split('=').next().unwrap_or(flag);
1007 KNOWN.contains(&name)
1008}
1009
1010fn grep_long_dangerous(flag: &str) -> bool {
1015 let name = flag.split('=').next().unwrap_or(flag);
1016 matches!(name, "--dereference-recursive")
1017}
1018
1019fn resolve_dd(tokens: &[Token]) -> Profile {
1028 const PARAMS: &[&str] = &[
1029 "bs", "ibs", "obs", "cbs", "count", "skip", "seek", "conv", "iflag", "oflag", "status",
1030 ];
1031 let (mut input, mut output) = (None, None);
1032 for t in &tokens[1..] {
1033 let t = t.as_str();
1034 if t == "--help" || t == "--version" {
1035 continue;
1036 }
1037 let Some((key, val)) = t.split_once('=') else {
1038 return worst("dd: non key=value operand — worst-cased (§0)");
1039 };
1040 match key {
1041 "if" => input = Some(val),
1042 "of" => output = Some(val),
1043 k if PARAMS.contains(&k) => {}
1044 _ => return worst("dd: unrecognized operand — worst-cased (§0)"),
1045 }
1046 }
1047 let input_locus = input.map_or(LocalLocus::Process, read_locus);
1051 match output {
1052 Some(of) => Profile::of(vec![
1054 observes(input_locus, Scale::Single, "dd reads its input (if=) into the output"),
1055 overwrites(classify_locus(of), Scale::Single, false),
1056 ]),
1057 None => Profile::of(vec![reads_content(
1059 input_locus,
1060 Scale::Single,
1061 "dd copies its input to stdout (→ the model)",
1062 )]),
1063 }
1064}
1065
1066fn resolve_tar(tokens: &[Token]) -> Profile {
1077 let mut p = TarParse::default();
1078 let mut dir: Option<String> = None;
1085 let mut i = 1;
1086 while i < tokens.len() {
1087 let t = tokens[i].as_str();
1088 if t == "-C" || t == "--directory" {
1089 dir = tokens.get(i + 1).map(|d| tar_bound(dir.as_deref(), d.as_str()));
1090 i += 2;
1091 continue;
1092 }
1093 if let Some(d) = t.strip_prefix("--directory=").or_else(|| t.strip_prefix("-C").filter(|d| !d.is_empty())) {
1094 dir = Some(tar_bound(dir.as_deref(), d));
1095 i += 1;
1096 continue;
1097 }
1098 if let Some(long) = t.strip_prefix("--") {
1099 p.long_option(long);
1100 } else if let Some(cluster) = t.strip_prefix('-').filter(|c| !c.is_empty()) {
1101 p.cluster(cluster);
1102 } else if i == 1 {
1103 p.cluster(t); } else {
1105 p.positionals.push((dir.clone(), t));
1106 }
1107 i += 1;
1108 }
1109 p.into_profile()
1110}
1111
1112type TarPositional<'a> = (Option<String>, &'a str);
1115
1116type TarRef<'a> = (Option<&'a str>, &'a str);
1118
1119fn tar_bound(dir: Option<&str>, path: &str) -> String {
1123 match dir {
1124 Some(d) if !path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-') => {
1125 format!("{}/{}", d.trim_end_matches('/'), path)
1126 }
1127 _ => path.to_string(),
1128 }
1129}
1130
1131#[derive(Default)]
1136struct TarParse<'a> {
1137 mode: Option<u8>,
1138 want_archive: bool,
1139 reject: bool,
1140 long_archive: Option<&'a str>,
1141 positionals: Vec<TarPositional<'a>>,
1143}
1144
1145impl<'a> TarParse<'a> {
1146 fn cluster(&mut self, cluster: &str) {
1147 const NOVAL: &[u8] = b"vzjJZpkmOwhSlPa"; for b in cluster.bytes() {
1149 match b {
1150 b'c' | b'x' | b't' | b'r' | b'u' | b'A' | b'd' => self.mode = Some(b),
1151 b'f' => self.want_archive = true,
1152 b'C' | b'T' | b'X' | b'b' | b'H' | b'g' | b'K' | b'N' => self.reject = true,
1153 x if NOVAL.contains(&x) => {}
1154 _ => self.reject = true,
1155 }
1156 }
1157 }
1158
1159 fn long_option(&mut self, long: &'a str) {
1160 let name = long.split('=').next().unwrap_or(long);
1161 match name {
1162 "create" => self.mode = Some(b'c'),
1163 "extract" | "get" => self.mode = Some(b'x'),
1164 "list" => self.mode = Some(b't'),
1165 "append" => self.mode = Some(b'r'),
1166 "update" => self.mode = Some(b'u'),
1167 "file" => match long.split_once('=') {
1168 Some((_, v)) => self.long_archive = Some(v),
1169 None => self.want_archive = true,
1170 },
1171 "gzip" | "bzip2" | "xz" | "zstd" | "compress" | "verbose" | "preserve-permissions"
1172 | "same-permissions" | "to-stdout" | "help" | "version" | "dereference" | "totals" => {}
1173 _ => self.reject = true,
1174 }
1175 }
1176
1177 fn into_profile(self) -> Profile {
1178 let Some(mode) = self.mode.filter(|_| !self.reject) else {
1179 return worst("tar: unrecognized/unmodeled option — worst-cased (§0)");
1180 };
1181 let (archive, members): (Option<TarRef>, &[TarPositional]) =
1184 if let Some(a) = self.long_archive {
1185 (Some((None, a)), &self.positionals)
1186 } else if self.want_archive {
1187 match self.positionals.split_first() {
1188 Some((first, rest)) => (Some((first.0.as_deref(), first.1)), rest),
1189 None => return worst("tar: -f without an archive — worst-cased (§0)"),
1190 }
1191 } else {
1192 (None, &self.positionals) };
1194 let archive_file = archive.filter(|(_, a)| *a != "-");
1196
1197 match mode {
1198 b'c' | b'r' | b'u' => {
1199 let mut caps: Vec<Capability> = members
1200 .iter()
1201 .map(|(dir, m)| observes(read_locus(&tar_bound(dir.as_deref(), m)), Scale::Bounded, "tar reads a member into the archive"))
1202 .collect();
1203 if let Some((dir, a)) = archive_file {
1204 caps.push(overwrites(classify_locus(&tar_bound(dir, a)), Scale::Single, false));
1205 }
1206 if caps.is_empty() {
1207 return worst("tar create with no members — worst-cased (§0)");
1208 }
1209 Profile::of(caps)
1210 }
1211 b't' => {
1212 let loc = archive_file.map_or(LocalLocus::Process, |(dir, a)| classify_locus(&tar_bound(dir, a)));
1213 Profile::of(vec![reads_content(loc, Scale::Single, "tar lists the archive's members (names → the model)")])
1214 }
1215 _ => worst("tar extract writes an archive-controlled, ..-escapable path set — worst-cased (§0)"),
1217 }
1218 }
1219}
1220
1221fn resolve_sed(tokens: &[Token]) -> Profile {
1228 let script = crate::handlers::coreutils::sed::scan_sed(tokens);
1234 if script.exec || script.unknown {
1235 return worst("sed: script has an `e` exec or unmodeled command — worst-cased (§0, HP-7)");
1236 }
1237 if script.script_file {
1240 return worst("sed: -f runs a script file we can't inspect — worst-cased (§0)");
1241 }
1242 const BOOL: &[u8] = b"nrEsuz"; let mut in_place = false;
1244 let mut script_from_flag = false;
1245 let mut script_files: Vec<&str> = Vec::new(); let mut files: Vec<&str> = Vec::new();
1247 let mut flags_done = false;
1248 let mut i = 1;
1249 while i < tokens.len() {
1250 let t = tokens[i].as_str();
1251 let next = tokens.get(i + 1).map(Token::as_str);
1252 if !flags_done && t == "--" {
1253 flags_done = true;
1254 i += 1;
1255 } else if flags_done || t == "-" || !t.starts_with('-') {
1256 files.push(t);
1257 i += 1;
1258 } else if let Some(long) = t.strip_prefix("--") {
1259 match sed_long(long, next, &mut in_place, &mut script_from_flag, &mut script_files) {
1260 Some(consumed) => i += consumed,
1261 None => return worst("sed: unrecognized flag — worst-cased (§0)"),
1262 }
1263 } else {
1264 match sed_cluster(&t[1..], next, BOOL) {
1265 SedShort::Bad => return worst("sed: unrecognized flag — worst-cased (§0)"),
1266 SedShort::InPlace => {
1267 in_place = true;
1268 i += 1;
1269 }
1270 SedShort::Standalone => i += 1,
1271 SedShort::Script { consumes_next } => {
1272 script_from_flag = true;
1273 i += usize::from(consumes_next) + 1;
1274 }
1275 SedShort::ScriptFile { file, consumes_next } => {
1276 script_from_flag = true;
1277 script_files.extend(file);
1278 i += usize::from(consumes_next) + 1;
1279 }
1280 SedShort::SkipValue { consumes_next } => i += usize::from(consumes_next) + 1,
1281 }
1282 }
1283 }
1284 if !script_from_flag && !files.is_empty() {
1286 files.remove(0);
1287 }
1288 let scale = breadth_scale(&files, false);
1292 let mut caps: Vec<Capability> =
1293 script_files.iter().map(|f| observes(read_locus(f), Scale::Single, "sed reads an -f script file")).collect();
1294 caps.extend(script.writes.iter().map(|f| mutates(classify_locus(f), Scale::Single, "sed w/W writes a file")));
1297 caps.extend(script.reads.iter().map(|f| observes(read_locus(f), Scale::Single, "sed r/R reads a file")));
1298 if in_place {
1299 caps.extend(files.iter().map(|f| mutates(classify_locus(f), scale, "sed -i edits the file in place")));
1300 } else {
1301 caps.extend(reads_to_model(&files, scale));
1302 }
1303 Profile::of(caps)
1304}
1305
1306pub(crate) fn substitution_claim(script: &crate::cst::Script) -> Option<SubClaim> {
1313 let [stmt] = script.0.as_slice() else { return None };
1317 let cmds = &stmt.pipeline.commands;
1318 let mut idx = cmds.len().checked_sub(1)?;
1319 loop {
1320 match stage_output_locus(cmds.get(idx)?)? {
1321 StageOutput::Locus(l) => return Some(SubClaim::Locus(l)),
1322 StageOutput::Atom => return Some(SubClaim::Atom),
1325 StageOutput::PassThrough => idx = idx.checked_sub(1)?,
1326 }
1327 }
1328}
1329
1330enum StageOutput {
1331 Locus(LocalLocus),
1332 Atom,
1334 PassThrough,
1336}
1337
1338pub(crate) enum SubClaim {
1343 Locus(LocalLocus),
1344 Atom,
1345}
1346
1347fn stage_output_locus(cmd: &crate::cst::Cmd) -> Option<StageOutput> {
1348 let crate::cst::Cmd::Simple(simple) = cmd else { return None };
1349 let words: Vec<String> = simple.words.iter().map(crate::cst::Word::eval).collect();
1350 use crate::registry::types::OutputLocus;
1351 let (name, args) = words.split_first()?;
1352 if !trusted_command_path(name) {
1355 return None;
1356 }
1357 let token = Token::from_raw(name.clone());
1358 let canonical = crate::registry::canonical_name(token.command_name());
1359 let (rule, args) = match crate::registry::sub_output_locus(canonical, args) {
1362 Some((rule, rest)) => (rule, rest),
1363 None => (crate::registry::command_output_locus(canonical)?, args),
1364 };
1365 if !rule.requires.is_empty() && !rule.requires.iter().any(|r| args.iter().any(|a| flag_present(a, std::slice::from_ref(r)))) {
1369 return None;
1370 }
1371 if args.iter().any(|a| a == "--help" || a == "--version") {
1385 return None;
1386 }
1387 if args.iter().any(|a| flag_present(a, &rule.invalidated_by)) {
1390 return None;
1391 }
1392
1393 match rule.locus_from {
1394 OutputLocus::Atom => Some(StageOutput::Atom),
1400 OutputLocus::Cwd => Some(StageOutput::Locus(read_locus("."))),
1403 OutputLocus::Operands => {
1406 if args.iter().any(|a| is_unpinnable(a)) {
1411 return None;
1412 }
1413 let roots = candidate_roots(args, &rule.valued);
1414 let worst = roots.iter().map(|r| read_locus(r)).max().unwrap_or_else(|| read_locus("."));
1416 Some(StageOutput::Locus(worst))
1417 }
1418 OutputLocus::Stdin => {
1421 if candidate_roots(args, &rule.valued).is_empty() {
1422 Some(StageOutput::PassThrough)
1423 } else {
1424 None
1425 }
1426 }
1427 }
1428}
1429
1430fn flag_present(arg: &str, flags: &[String]) -> bool {
1434 let head = arg.split('=').next().unwrap_or(arg);
1435 flags.iter().any(|f| {
1436 if head == f {
1437 return true;
1438 }
1439 match (f.strip_prefix('-'), arg.strip_prefix('-')) {
1440 (Some(letter), Some(cluster)) if f.len() == 2 && !arg.starts_with("--") => {
1441 cluster.contains(letter)
1442 }
1443 _ => false,
1444 }
1445 })
1446}
1447
1448fn candidate_roots<'a>(args: &'a [String], valued: &[String]) -> Vec<&'a str> {
1459 let mut roots = Vec::new();
1460 let mut skip_value = false;
1461 for a in args {
1462 if std::mem::take(&mut skip_value) {
1463 continue;
1464 }
1465 if a.starts_with('-') {
1466 let (head, glued_value) = match a.split_once('=') {
1470 Some((h, v)) => (h, Some(v)),
1471 None => (a.as_str(), None),
1472 };
1473 if valued.iter().any(|v| v == head) {
1474 skip_value = glued_value.is_none();
1475 continue;
1476 }
1477 let glued = glued_value.or_else(|| a.find(['/', '~']).map(|i| &a[i..]));
1481 if let Some(v) = glued.filter(|v| !v.is_empty()) {
1482 roots.push(v);
1483 }
1484 continue;
1485 }
1486 roots.push(a.as_str());
1487 }
1488 roots
1489}
1490
1491fn resolve_perl(tokens: &[Token]) -> Profile {
1492 use crate::handlers::perl::PerlCode;
1498 let Some(scan) = crate::handlers::perl::scan_perl(tokens) else {
1499 return worst("perl: unmodeled flag cluster — worst-cased (§0)");
1500 };
1501 match scan.code {
1502 PerlCode::None => {
1503 let mut c = Capability::new(Operation::Observe);
1504 c.disclosure.audience = DisclosureAudience::LocalProcess;
1505 c.because = "perl: reports its own version/usage".to_string();
1506 return Profile::of(vec![c]);
1507 }
1508 PerlCode::Opaque => return worst("perl: no inspectable -e/-E one-liner — worst-cased (§0)"),
1512 PerlCode::Inspectable => {}
1513 }
1514 let files: Vec<&str> = scan.files.iter().map(String::as_str).collect();
1517 let scale = breadth_scale(&files, false);
1518 let caps: Vec<Capability> = if scan.in_place {
1528 files.iter().map(|f| mutates(classify_locus(f), scale, "perl -i edits the file in place")).collect()
1529 } else {
1530 reads_to_model(&files, scale)
1531 };
1532 Profile::of(caps)
1533}
1534
1535enum SedShort<'a> {
1537 Bad,
1538 Standalone,
1539 InPlace, Script { consumes_next: bool }, ScriptFile { file: Option<&'a str>, consumes_next: bool }, SkipValue { consumes_next: bool }, }
1544
1545fn sed_cluster<'a>(cluster: &'a str, next: Option<&'a str>, boolset: &[u8]) -> SedShort<'a> {
1546 let bytes = cluster.as_bytes();
1547 let mut k = 0;
1548 while k < bytes.len() {
1549 if !bytes[k].is_ascii() {
1552 return SedShort::Bad;
1553 }
1554 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
1556 match bytes[k] {
1557 b'i' => return SedShort::InPlace, b'e' => return SedShort::Script { consumes_next: !has },
1559 b'f' => {
1560 let file = if has { Some(glued) } else { next };
1561 return SedShort::ScriptFile { file, consumes_next: !has };
1562 }
1563 b'l' if has || next.is_some() => return SedShort::SkipValue { consumes_next: !has }, b if boolset.contains(&b) => k += 1,
1565 _ => return SedShort::Bad,
1566 }
1567 }
1568 SedShort::Standalone
1569}
1570
1571fn sed_long<'a>(
1573 long: &'a str,
1574 next: Option<&'a str>,
1575 in_place: &mut bool,
1576 script_from_flag: &mut bool,
1577 script_files: &mut Vec<&'a str>,
1578) -> Option<usize> {
1579 let name = long.split('=').next().unwrap_or(long);
1580 match name {
1581 "in-place" => *in_place = true, "expression" => {
1583 *script_from_flag = true;
1584 return Some(if long.contains('=') { 1 } else { 2 });
1585 }
1586 "file" => {
1587 *script_from_flag = true;
1588 match long.split_once('=') {
1589 Some((_, v)) => script_files.push(v),
1590 None => {
1591 script_files.extend(next);
1592 return Some(2);
1593 }
1594 }
1595 }
1596 "quiet" | "silent" | "regexp-extended" | "null-data" | "separate" | "unbuffered"
1597 | "posix" | "help" | "version" | "debug" | "follow-symlinks" | "sandbox"
1598 | "zero-terminated" | "line-length" => {}
1599 _ => return None,
1600 }
1601 Some(1)
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607
1608 fn toks(parts: &[&str]) -> Vec<Token> {
1609 parts.iter().map(|p| Token::from_test(p)).collect()
1610 }
1611
1612 fn level(name: &str) -> &'static crate::engine::level::Level {
1613 crate::engine::authoring::default_levels()
1614 .iter()
1615 .find(|l| l.name == name)
1616 .expect("level exists")
1617 }
1618
1619 fn inert() -> &'static crate::engine::level::Level {
1620 level("paranoid")
1621 }
1622
1623 fn read_local() -> &'static crate::engine::level::Level {
1624 level("reader")
1625 }
1626
1627 #[test]
1631 fn openssl_resolver_gates_model_disclosure_only() {
1632 let (dev, yolo) = (level("developer"), level("yolo"));
1633 for parts in [
1634 &["openssl", "rsa", "-in", "priv.pem"][..],
1635 &["openssl", "rsa", "-in", "priv.pem", "-pubout", "-text"], &["openssl", "rsa", "-in", "priv.pem", "-out", "/dev/stdout"], &["openssl", "rsa", "-in", "priv.pem", "-noout", "-text"],
1638 &["openssl", "pkcs8", "-in", "priv.pem"],
1639 &["openssl", "enc", "--d", "-k", "p", "-in", "c"], &["openssl", "cms", "-EncryptedData_decrypt", "-in", "m"],
1641 &["openssl", "pkcs12", "-in", "f.p12", "-noenc"],
1642 ] {
1643 let p = resolve(&toks(parts)).unwrap_or_else(|| panic!("resolves: {parts:?}"));
1644 assert!(
1645 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads),
1646 "secret=reads: {parts:?}",
1647 );
1648 assert!(!dev.admits(&p), "developer refuses: {parts:?}");
1649 assert!(yolo.admits(&p), "yolo admits: {parts:?}");
1650 }
1651 for parts in [
1652 &["openssl", "rsa", "-in", "priv.pem", "-pubout"][..],
1653 &["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"],
1657 &["openssl", "enc", "-e", "-in", "x", "-out", "x.enc", "-k", "p"],
1658 &["openssl", "x509", "-in", "c", "-noout", "-text"],
1659 ] {
1660 assert!(resolve(&toks(parts)).is_none(), "resolver abstains (→ legacy): {parts:?}");
1661 }
1662 }
1663
1664 #[test]
1669 fn openssl_output_destination_is_fail_closed() {
1670 let dev = level("developer");
1671 let reaches_model = |args: &[&str]| {
1672 let mut parts = vec!["openssl", "rsa", "-in", "priv.pem"];
1673 parts.extend_from_slice(args);
1674 match resolve(&toks(&parts)) {
1677 None => false,
1678 Some(p) => {
1679 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads) && !dev.admits(&p)
1680 }
1681 }
1682 };
1683 for evasion in [
1684 &["-out", "//dev/stdout"][..],
1685 &["-out", "/dev/./stdout"],
1686 &["-out", "//dev/fd/1"],
1687 &["-out", "/dev/fd//1"],
1688 &["-out=//dev/stdout"],
1689 &["-out", "/dev/stderr"],
1690 &["-out", "/foo/../dev/stdout"],
1691 &["-out", "dup.pem", "-out", "/dev/stdout"], &["-out", "-"],
1693 &["-provider-path", "-out", "-provider-path", "safe.pem"],
1697 &["-out", "-anything"],
1698 ] {
1699 assert!(reaches_model(evasion), "must read as model-reaching: {evasion:?}");
1700 }
1701 for diverted in [
1702 &["-out", "clean.pem"][..],
1703 &["-out", "./sub/key.pem"],
1704 &["-out", "devnotes.pem"], &["-out", "/home/u/key.pem"],
1706 &["-noout"],
1707 ] {
1708 assert!(!reaches_model(diverted), "must divert off the model: {diverted:?}");
1709 }
1710 }
1711
1712 #[test]
1713 fn echo_resolves_to_a_benign_inert_profile() {
1714 let p = resolve(&toks(&["echo", "hi"])).expect("echo has a resolver");
1715 assert_eq!(p.capabilities.len(), 1);
1716 let c = &p.capabilities[0];
1717 assert_eq!(c.operation, Operation::Observe);
1718 assert_eq!(c.locus.local, LocalLocus::Process);
1719 assert_eq!(c.disclosure.audience, DisclosureAudience::LocalProcess);
1720 assert!(!c.because.is_empty(), "a structural certification cites its reason");
1721 assert!(inert().admits(&p), "echo is fully certified and inert-safe");
1723 }
1724
1725 #[test]
1726 fn echo_flags_do_not_change_its_profile() {
1727 let bare = resolve(&toks(&["echo", "hi"])).expect("echo");
1728 let flagged = resolve(&toks(&["echo", "-n", "-e", "hi"])).expect("echo -n -e");
1729 assert_eq!(bare, flagged);
1730 assert!(inert().admits(&flagged));
1731 }
1732
1733 #[test]
1734 fn an_unresearched_command_has_no_resolver() {
1735 assert!(resolve(&toks(UNRESOLVED_CMD)).is_none(), "unresearched → caller worst-cases");
1736 assert!(resolve(&[]).is_none(), "empty tokens");
1737 }
1738
1739 #[test]
1740 fn cat_of_a_worktree_file_is_read_local() {
1741 let p = resolve(&toks(&["cat", "./notes.md"])).expect("cat");
1742 assert!(read_local().admits(&p), "cat ./notes.md");
1743 assert!(!inert().admits(&p), "reading a real file is above inert");
1744 }
1745
1746 #[test]
1747 fn cat_beyond_the_worktree_is_denied_by_locus() {
1748 for path in ["~/.ssh/id_rsa", "~/notes", "/etc/shadow", "$SECRET", "../outside", "/var/lib/mysql/data"] {
1750 let p = resolve(&toks(&["cat", path])).expect("cat");
1751 assert!(!read_local().admits(&p), "cat {path} is above read-local by locus");
1752 }
1753 }
1754
1755 #[test]
1756 fn cat_of_machine_config_is_not_admitted() {
1757 for path in ["/etc/hosts", "/etc/os-release", "/usr/local/etc/nginx/nginx.conf", "/var/log/auth.log"] {
1760 let p = resolve(&toks(&["cat", path])).expect("cat");
1761 assert!(!read_local().admits(&p), "cat {path} is no longer auto-approved");
1762 }
1763 }
1764
1765 #[test]
1773 fn package_content_is_readable_but_never_writable() {
1774 for path in [
1775 "/usr/share/doc/x",
1776 "/usr/share/man/man1/git.1",
1777 "/usr/local/share/doc/x/README",
1778 "/opt/homebrew/lib/node_modules/npm/package.json",
1779 "/Library/Developer/CommandLineTools/usr/include/stdio.h",
1780 "/nix/store/abc/share/doc/README",
1781 "~/.cargo/registry/src/idx/serde-1.0/README.md",
1782 "~/.rustup/toolchains/stable/lib/rustlib/src/core/src/lib.rs",
1783 "~/go/pkg/mod/github.com/x/y@v1/README.md",
1784 "~/.local/share/mise/installs/node/22/README.md",
1785 ] {
1786 let read = resolve(&toks(&["cat", path])).expect("cat");
1787 assert!(read_local().admits(&read), "reading package content {path} should be admitted");
1788 let write = resolve(&toks(&["rm", "-rf", path])).expect("rm");
1789 assert!(
1790 !read_local().admits(&write),
1791 "package content {path} must NOT be writable — this widens disclosure only"
1792 );
1793 }
1794 }
1795
1796 #[test]
1802 fn an_admit_prefix_can_never_widen_the_credential_shield() {
1803 for path in [
1804 "/usr/share/.ssh/id_rsa",
1805 "/usr/local/lib/.aws/credentials",
1806 "/opt/homebrew/share/.gnupg/secring.gpg",
1807 "~/.cargo/registry/.ssh/id_ed25519",
1808 "/nix/store/x/.aws/credentials",
1809 ] {
1810 let p = resolve(&toks(&["cat", path])).expect("cat");
1811 assert!(!read_local().admits(&p), "an admit prefix widened the shield at {path}");
1812 }
1813 }
1814
1815 #[test]
1816 fn cat_stdin_is_process_scoped() {
1817 assert!(inert().admits(&resolve(&toks(&["cat"])).expect("cat")), "no operand → stdin");
1818 assert!(inert().admits(&resolve(&toks(&["cat", "-"])).expect("cat -")), "- → stdin");
1819 }
1820
1821 #[test]
1822 fn cat_reads_every_file_operand_and_one_home_read_sinks_it() {
1823 let p = resolve(&toks(&["cat", "-n", "a.txt", "src/b.rs"])).expect("cat");
1824 assert_eq!(p.capabilities.len(), 2, "-n is a flag; two files");
1825 assert!(read_local().admits(&p), "both worktree");
1826
1827 let mixed = resolve(&toks(&["cat", "a.txt", "~/.ssh/id_rsa"])).expect("cat");
1828 assert!(!read_local().admits(&mixed), "one home read sinks the whole profile");
1829 }
1830
1831 #[test]
1832 fn cat_double_dash_treats_the_rest_as_files() {
1833 let p = resolve(&toks(&["cat", "--", "-n"])).expect("cat");
1834 assert_eq!(p.capabilities.len(), 1, "-n after -- is a filename");
1835 assert!(read_local().admits(&p));
1836 }
1837
1838 #[test]
1839 fn head_tail_wc_read_like_cat_and_honor_numeric_shorthand() {
1840 use crate::engine::bridge::project;
1841 use crate::verdict::{SafetyLevel, Verdict};
1842 for cmd in [
1844 vec!["head", "README.md"],
1845 vec!["head", "-n", "5", "src/main.rs"],
1846 vec!["head", "-20", "src/main.rs"], vec!["tail", "-f", "./log.txt"], vec!["tail", "-n", "100", "./log.txt"],
1849 vec!["wc", "-l", "./notes.md"],
1850 ] {
1851 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Allowed(SafetyLevel::SafeRead), "{cmd:?}");
1852 }
1853 assert_eq!(project(&resolve(&toks(&["wc", "-c", "-"])).expect("wc")), Verdict::Allowed(SafetyLevel::Inert), "wc stdin");
1855 for cmd in [vec!["head", "~/.ssh/id_rsa"], vec!["tail", "/etc/shadow"], vec!["wc", "-l", "$SECRET"]] {
1856 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Denied, "{cmd:?} beyond worktree");
1857 }
1858 let p = resolve(&toks(&["head", "-20", "src/main.rs"])).expect("head");
1860 assert_eq!(p.capabilities.len(), 1, "-20 is the count, not a file");
1861 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from=list"])).expect("wc")), Verdict::Denied, "--files0-from");
1863 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from", "-"])).expect("wc")), Verdict::Denied, "--files0-from -");
1864 assert_eq!(project(&resolve(&toks(&["head", "-Z", "x"])).expect("head")), Verdict::Denied, "unknown flag");
1866 }
1867
1868 #[test]
1869 fn grep_reads_its_files_not_the_pattern() {
1870 let p = resolve(&toks(&["grep", "foo", "file.txt"])).expect("grep");
1871 assert_eq!(p.capabilities.len(), 1, "the pattern is not a file");
1872 assert!(read_local().admits(&p));
1873 }
1874
1875 #[test]
1876 fn grep_beyond_the_worktree_is_denied() {
1877 for args in [
1878 vec!["grep", "foo", "~/.ssh/config"],
1879 vec!["grep", "-r", "foo", "~"],
1880 vec!["grep", "foo", "$DIR"],
1881 ] {
1882 let p = resolve(&toks(&args)).expect("grep");
1883 assert!(!read_local().admits(&p), "{args:?}");
1884 }
1885 }
1886
1887 #[test]
1888 fn grep_recursive_is_unbounded_and_defaults_to_cwd() {
1889 let p = resolve(&toks(&["grep", "-r", "foo", "src/"])).expect("grep");
1890 assert!(p.capabilities.iter().all(|c| c.scale == Scale::Unbounded), "-r → unbounded");
1891 assert!(read_local().admits(&p), "recursive worktree search");
1892
1893 let cwd = resolve(&toks(&["grep", "-r", "foo"])).expect("grep");
1894 assert!(cwd.capabilities.iter().all(|c| c.locus.local == LocalLocus::Worktree), "cwd, not stdin");
1895 assert!(read_local().admits(&cwd));
1896 }
1897
1898 #[test]
1899 fn grep_e_and_f_supply_the_pattern_so_positionals_are_files() {
1900 let e = resolve(&toks(&["grep", "-e", "foo", "file.txt"])).expect("grep -e");
1902 assert_eq!(e.capabilities.len(), 1);
1903 assert!(read_local().admits(&e));
1904
1905 let f = resolve(&toks(&["grep", "-f", "patterns.txt", "file.txt"])).expect("grep -f");
1907 assert_eq!(f.capabilities.len(), 2, "patterns.txt + file.txt");
1908 assert!(read_local().admits(&f));
1909
1910 let home = resolve(&toks(&["grep", "-f", "~/.secret-patterns", "file.txt"])).expect("grep -f");
1911 assert!(!read_local().admits(&home), "a home pattern file is denied by locus");
1912
1913 let glued = resolve(&toks(&["grep", "-fpatterns.txt", "file.txt"])).expect("grep -f glued");
1915 assert_eq!(glued.capabilities.len(), 2, "glued -f value is still a read");
1916 let glued_home = resolve(&toks(&["grep", "-if~/.secrets", "x"])).expect("grep -if glued");
1917 assert!(!read_local().admits(&glued_home), "glued home pattern file denied by locus");
1918 }
1919
1920 #[test]
1921 fn grep_long_flags() {
1922 assert_eq!(resolve(&toks(&["grep", "--file", "p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1924 assert_eq!(resolve(&toks(&["grep", "--file=p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1925
1926 let r = resolve(&toks(&["grep", "--regexp", "foo", "f.txt"])).expect("grep");
1928 assert_eq!(r.capabilities.len(), 1);
1929 assert!(read_local().admits(&r));
1930
1931 let m = resolve(&toks(&["grep", "--max-count", "5", "foo", "f.txt"])).expect("grep");
1935 assert!(read_local().admits(&m), "--max-count 5 is fail-safe (imprecise)");
1936
1937 let pcre = resolve(&toks(&["grep", "--perl-regexp", "foo", "f"])).expect("grep");
1939 assert!(read_local().admits(&pcre), "grep --perl-regexp reads a file, it does not exec");
1940 }
1941
1942 #[test]
1943 fn grep_dash_patterns_are_search_patterns_not_flags() {
1944 for args in [
1949 vec!["grep", "-->", "file.txt"],
1950 vec!["grep", "---", "file.txt"],
1951 vec!["grep", "--some-pattern", "file.txt"],
1952 vec!["grep", "-rn", "-->", "src/"],
1953 vec!["grep", "-i", "-r", "-n", "-->", "src/"],
1954 ] {
1955 let p = resolve(&toks(&args)).expect("grep");
1956 assert!(read_local().admits(&p), "dash-pattern should read-local: {args:?}");
1957 assert!(!inert().admits(&p), "it still reads a file: {args:?}");
1958 }
1959 let args = vec!["grep", "--dereference-recursive", "foo", "dir"];
1961 let p = resolve(&toks(&args)).expect("grep");
1962 assert!(!read_local().admits(&p), "dangerous long must worst-case: {args:?}");
1963 for args in [
1965 vec!["grep", "-P", "foo", "f"],
1966 vec!["grep", "--perl-regexp", "foo", "f"],
1967 vec!["grep", "-oP", "foo", "f"],
1968 ] {
1969 let p = resolve(&toks(&args)).expect("grep");
1970 assert!(read_local().admits(&p), "grep PCRE flag should read-local: {args:?}");
1971 }
1972 }
1973
1974 #[test]
1975 fn grep_stdin_and_standalone_flags() {
1976 assert!(inert().admits(&resolve(&toks(&["grep", "foo"])).expect("grep")), "no file → stdin");
1977 let p = resolve(&toks(&["grep", "-i", "-n", "foo", "file.txt"])).expect("grep");
1978 assert_eq!(p.capabilities.len(), 1, "-i -n standalone; foo pattern; file.txt file");
1979 assert!(read_local().admits(&p));
1980 }
1981
1982 fn one_cap(cmd: &[&str]) -> Capability {
1985 let p = resolve(&toks(cmd)).expect("resolves");
1986 assert_eq!(p.capabilities.len(), 1, "{cmd:?} is a single-capability invocation");
1987 let mut c = p.capabilities[0].clone();
1988 c.because = String::new();
1989 c
1990 }
1991
1992 #[test]
1998 fn golden_profiles_cover_every_facet() {
1999 let mut echo = Capability::new(Operation::Observe);
2002 echo.disclosure.audience = DisclosureAudience::LocalProcess;
2003 assert_eq!(one_cap(&["echo", "hi"]), echo, "echo");
2004
2005 let mut cat = Capability::new(Operation::Observe);
2007 cat.locus.local = LocalLocus::Worktree;
2008 cat.disclosure.audience = DisclosureAudience::LocalProcess;
2009 assert_eq!(one_cap(&["cat", "./notes.md"]), cat, "cat ./notes.md");
2010
2011 let mut cat_home = cat.clone();
2013 cat_home.locus.local = LocalLocus::Machine;
2014 assert_eq!(one_cap(&["cat", "~/notes.txt"]), cat_home, "cat ~/notes.txt");
2015
2016 let mut cat_cred = cat.clone();
2018 cat_cred.locus.local = LocalLocus::Machine;
2019 assert_eq!(one_cap(&["cat", "~/.ssh/id_rsa"]), cat_cred, "cat ~/.ssh/id_rsa");
2020
2021 assert_eq!(one_cap(&["grep", "foo", "file.txt"]), cat, "grep foo file.txt");
2023
2024 let mut grep_r = cat.clone();
2026 grep_r.scale = Scale::Unbounded;
2027 assert_eq!(one_cap(&["grep", "-r", "foo", "src/"]), grep_r, "grep -r foo src/");
2028
2029 let mut rm = Capability::new(Operation::Destroy);
2031 rm.locus.local = LocalLocus::Worktree;
2032 rm.reversibility = Reversibility::Effortful;
2033 assert_eq!(one_cap(&["rm", "./x"]), rm, "rm ./x");
2034
2035 let mut mkdir = Capability::new(Operation::Create);
2037 mkdir.locus.local = LocalLocus::Worktree;
2038 mkdir.reversibility = Reversibility::Trivial;
2039 mkdir.persistence.level = PersistenceLevel::Data;
2040 assert_eq!(one_cap(&["mkdir", "./build"]), mkdir, "mkdir ./build");
2041
2042 assert_eq!(one_cap(&["touch", "./new.txt"]), mkdir, "touch ./new.txt");
2044
2045 let cp = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
2048 assert_eq!(cp.capabilities.len(), 2, "cp = source read + dest write");
2049 let mut src = Capability::new(Operation::Observe);
2050 src.locus.local = LocalLocus::Worktree; assert_eq!(clear_because(&cp.capabilities[0]), src, "cp source read");
2052 let mut dst = Capability::new(Operation::Create);
2053 dst.locus.local = LocalLocus::Worktree;
2054 dst.reversibility = Reversibility::Trivial; dst.persistence.level = PersistenceLevel::Data;
2056 assert_eq!(clear_because(&cp.capabilities[1]), dst, "cp -n dest write");
2057
2058 let mv = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
2061 let mut mv_src = Capability::new(Operation::Mutate);
2062 mv_src.locus.local = LocalLocus::Worktree;
2063 mv_src.reversibility = Reversibility::Trivial;
2064 assert_eq!(clear_because(&mv.capabilities[0]), mv_src, "mv source relocation");
2065 let mut mv_dst = Capability::new(Operation::Create);
2066 mv_dst.locus.local = LocalLocus::Worktree;
2067 mv_dst.reversibility = Reversibility::Recoverable;
2068 mv_dst.persistence.level = PersistenceLevel::Data;
2069 assert_eq!(clear_because(&mv.capabilities[1]), mv_dst, "mv dest write");
2070
2071 let ln = resolve(&toks(&["ln", "./a", "./b"])).expect("ln");
2074 let mut ln_tgt = Capability::new(Operation::Observe);
2075 ln_tgt.locus.local = LocalLocus::Worktree;
2076 assert_eq!(clear_because(&ln.capabilities[0]), ln_tgt, "ln target bridge");
2077 let mut ln_link = Capability::new(Operation::Create);
2078 ln_link.locus.local = LocalLocus::Worktree;
2079 ln_link.reversibility = Reversibility::Trivial;
2080 ln_link.persistence.level = PersistenceLevel::Data;
2081 assert_eq!(clear_because(&ln.capabilities[1]), ln_link, "ln link create");
2082 }
2083
2084 fn clear_because(c: &Capability) -> Capability {
2085 let mut c = c.clone();
2086 c.because = String::new();
2087 c
2088 }
2089
2090 #[test]
2091 fn mkdir_creates_in_the_worktree_but_not_beyond_it() {
2092 use crate::engine::bridge::project;
2093 use crate::verdict::{SafetyLevel, Verdict};
2094 for cmd in [vec!["mkdir", "./build"], vec!["mkdir", "-p", "a/b/c"], vec!["mkdir", "-m", "755", "./x"]] {
2096 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2097 }
2098 for cmd in [vec!["mkdir", "/etc/evil"], vec!["mkdir", "~/newdir"], vec!["mkdir", "$HOME/x"]] {
2100 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Denied, "{cmd:?}");
2101 }
2102 let g = resolve(&toks(&["mkdir", "-m755", "./x"])).expect("mkdir");
2104 assert_eq!(g.capabilities.len(), 1, "-m755 glued: only ./x is an operand");
2105 assert_eq!(g.capabilities[0].locus.local, LocalLocus::Worktree);
2106 assert_eq!(project(&resolve(&toks(&["mkdir", "-Q", "x"])).expect("mkdir")), Verdict::Denied, "unknown flag");
2108 assert_eq!(project(&resolve(&toks(&["mkdir"])).expect("mkdir")), Verdict::Denied, "no operand");
2109 }
2110
2111 #[test]
2112 fn cp_splits_source_and_dest_loci_and_overwrite_gates_the_level() {
2113 use crate::engine::bridge::project;
2114 use crate::verdict::{SafetyLevel, Verdict};
2115
2116 let plain = resolve(&toks(&["cp", "./a", "./b"])).expect("cp");
2120 assert_eq!(plain.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "dest overwrite");
2121 assert_eq!(project(&plain), Verdict::Allowed(SafetyLevel::SafeWrite), "cp ./a ./b");
2122 let nc = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
2123 assert_eq!(nc.capabilities.last().unwrap().reversibility, Reversibility::Trivial, "-n cannot clobber");
2124 assert_eq!(project(&nc), Verdict::Allowed(SafetyLevel::SafeWrite), "cp -n ./a ./b");
2125
2126 assert_eq!(project(&resolve(&toks(&["cp", "~/.ssh/id_rsa", "./x"])).expect("cp")), Verdict::Denied, "home source");
2129 assert_eq!(project(&resolve(&toks(&["cp", "/etc/shadow", "./x"])).expect("cp")), Verdict::Denied, "system source");
2130 assert_eq!(project(&resolve(&toks(&["cp", "./x", "~/backdoor"])).expect("cp")), Verdict::Denied, "home dest");
2132 assert_eq!(project(&resolve(&toks(&["cp", "./x", "/etc/cron.d/x"])).expect("cp")), Verdict::Denied, "system dest");
2133
2134 for form in [
2137 vec!["cp", "-t", "./dest", "./a", "./b"],
2138 vec!["cp", "--target-directory=./dest", "./a", "./b"],
2139 vec!["cp", "-t./dest", "./a", "./b"], ] {
2141 let t = resolve(&toks(&form)).expect("cp -t");
2142 assert_eq!(t.capabilities.len(), 3, "{form:?}: 2 sources + 1 dest");
2143 assert_eq!(project(&t), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
2144 }
2145 assert_eq!(project(&resolve(&toks(&["cp", "-t/etc", "./a"])).expect("cp")), Verdict::Denied, "cp -t/etc");
2147
2148 for form in [
2151 vec!["cp", "--backup", "./a", "./b"],
2152 vec!["cp", "--preserve", "./a", "./b"],
2153 vec!["cp", "--preserve=mode", "./a", "./b"],
2154 ] {
2155 let c = resolve(&toks(&form)).expect("cp");
2156 assert_eq!(c.capabilities.len(), 2, "{form:?}: source read + dest write");
2157 assert_eq!(project(&c), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
2158 }
2159
2160 assert_eq!(resolve(&toks(&["cp", "-r", "./a", "./b"])).expect("cp").capabilities[0].scale, Scale::Unbounded);
2162 assert_eq!(project(&resolve(&toks(&["cp", "./only"])).expect("cp")), Verdict::Denied, "no dest");
2163 assert_eq!(project(&resolve(&toks(&["cp", "-Q", "./a", "./b"])).expect("cp")), Verdict::Denied, "unknown flag");
2164 assert_eq!(project(&resolve(&toks(&["cp", "-t", "./dest"])).expect("cp")), Verdict::Denied, "-t no source");
2167 }
2168
2169 #[test]
2170 fn mv_relocates_within_the_worktree_and_gates_both_loci() {
2171 use crate::engine::bridge::project;
2172 use crate::verdict::{SafetyLevel, Verdict};
2173
2174 let m = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
2177 assert_eq!(m.capabilities[0].operation, Operation::Mutate, "source is a relocation, not a destroy");
2178 assert_eq!(m.capabilities[0].reversibility, Reversibility::Trivial, "mv back");
2179 assert_eq!(project(&m), Verdict::Allowed(SafetyLevel::SafeWrite), "mv ./a ./b");
2180
2181 assert_eq!(project(&resolve(&toks(&["mv", "~/.ssh/id_rsa", "./x"])).expect("mv")), Verdict::Denied, "source in home");
2183 assert_eq!(project(&resolve(&toks(&["mv", "./x", "~/exfil"])).expect("mv")), Verdict::Denied, "dest in home");
2184 assert_eq!(project(&resolve(&toks(&["mv", ".git/config", "./x"])).expect("mv")), Verdict::Denied, "mv .git/config");
2187 assert_eq!(project(&resolve(&toks(&["cp", ".git/config", "./x"])).expect("cp")), Verdict::Allowed(SafetyLevel::SafeWrite), "cp .git/config reads");
2188
2189 let cfg = "~/.config/safe-chains.toml";
2196 assert_eq!(
2197 resolve(&toks(&["mv", cfg, "./x"])).expect("mv").capabilities[0].locus.local,
2198 LocalLocus::SystemIntegrity,
2199 "mv source removal gates at the REBIND face",
2200 );
2201 assert_eq!(
2202 resolve(&toks(&["cp", cfg, "./x"])).expect("cp").capabilities[0].locus.local,
2203 LocalLocus::WorktreeTrusted,
2204 "cp source read gates at the READ face",
2205 );
2206
2207 let t = resolve(&toks(&["mv", "-t", "./dest", "./a", "./b"])).expect("mv -t");
2209 assert_eq!(t.capabilities.len(), 3, "2 sources + 1 dest");
2210 assert_eq!(project(&resolve(&toks(&["mv", "./only"])).expect("mv")), Verdict::Denied, "no dest");
2211 assert_eq!(project(&resolve(&toks(&["mv", "-Q", "./a", "./b"])).expect("mv")), Verdict::Denied, "unknown flag");
2212 }
2213
2214 #[test]
2215 fn ln_is_cp_by_reference_and_gates_the_target_locus() {
2216 use crate::engine::bridge::project;
2217 use crate::verdict::{SafetyLevel, Verdict};
2218
2219 for cmd in [vec!["ln", "./a", "./b"], vec!["ln", "-s", "./target", "./link"]] {
2221 let p = resolve(&toks(&cmd)).expect("ln");
2222 assert_eq!(p.capabilities[0].operation, Operation::Observe, "target is a bridged read");
2223 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2224 }
2225 assert_eq!(project(&resolve(&toks(&["ln", "~/.ssh/id_rsa", "./x"])).expect("ln")), Verdict::Denied, "hard link to home credential");
2228 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/shadow", "./x"])).expect("ln")), Verdict::Denied, "symlink to secret");
2229 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/hosts", "./x"])).expect("ln")), Verdict::Denied, "symlink to system path");
2231 assert_eq!(project(&resolve(&toks(&["ln", "-s", "./a", "~/evil"])).expect("ln")), Verdict::Denied, "link into home");
2233 assert_eq!(resolve(&toks(&["ln", "-t", "./dir", "./a", "./b"])).expect("ln -t").capabilities.len(), 3);
2235 assert_eq!(project(&resolve(&toks(&["ln", "./only"])).expect("ln")), Verdict::Denied, "no link name");
2236 assert_eq!(project(&resolve(&toks(&["ln", "-Q", "./a", "./b"])).expect("ln")), Verdict::Denied, "unknown flag");
2237 let forced = resolve(&toks(&["ln", "-f", "./a", "./b"])).expect("ln -f");
2241 assert_eq!(project(&forced), Verdict::Allowed(SafetyLevel::SafeWrite), "ln -f worktree link");
2242 assert_eq!(forced.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "ln -f overwrites → recoverable");
2243 assert_eq!(
2244 resolve(&toks(&["ln", "./a", "./b"])).expect("ln").capabilities.last().unwrap().reversibility,
2245 Reversibility::Trivial,
2246 "ln default no-clobber → trivial",
2247 );
2248 }
2249
2250 #[test]
2251 fn dd_parses_key_value_operands_and_gates_both_sides() {
2252 use crate::engine::bridge::project;
2253 use crate::verdict::{SafetyLevel, Verdict};
2254
2255 assert_eq!(
2257 project(&resolve(&toks(&["dd", "if=./a", "of=./b", "bs=1M", "count=10"])).expect("dd")),
2258 Verdict::Allowed(SafetyLevel::SafeWrite),
2259 "dd worktree copy",
2260 );
2261 assert_eq!(project(&resolve(&toks(&["dd", "if=./notes"])).expect("dd")), Verdict::Allowed(SafetyLevel::SafeRead), "dd to stdout");
2263 assert_eq!(project(&resolve(&toks(&["dd"])).expect("dd")), Verdict::Allowed(SafetyLevel::Inert), "bare dd is stdin→stdout");
2264
2265 for cmd in [
2267 vec!["dd", "if=~/.ssh/id_rsa", "of=./x"], vec!["dd", "if=./x", "of=/dev/rdisk0"], vec!["dd", "if=./x", "of=/dev/sda"],
2270 vec!["dd", "if=./x", "of=~/backup"], vec!["dd", "if=~/.ssh/id_rsa"], ] {
2273 assert_eq!(project(&resolve(&toks(&cmd)).expect("dd")), Verdict::Denied, "{cmd:?}");
2274 }
2275 assert_eq!(project(&resolve(&toks(&["dd", "./file"])).expect("dd")), Verdict::Denied, "positional operand");
2277 assert_eq!(project(&resolve(&toks(&["dd", "exec=evil", "of=./x"])).expect("dd")), Verdict::Denied, "unknown key");
2278 }
2279
2280 #[test]
2281 fn tar_parses_dashless_bundles_and_splits_by_mode() {
2282 use crate::engine::bridge::project;
2283 use crate::verdict::{SafetyLevel, Verdict};
2284
2285 for cmd in [
2288 vec!["tar", "czf", "backup.tar", "./src"],
2289 vec!["tar", "-czf", "backup.tar", "./src"],
2290 vec!["tar", "--create", "--file=backup.tar", "./src"],
2291 ] {
2292 assert_eq!(project(&resolve(&toks(&cmd)).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2293 }
2294 assert_eq!(project(&resolve(&toks(&["tar", "tzf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeRead), "list");
2296
2297 assert_eq!(project(&resolve(&toks(&["tar", "czf", "-", "~/.ssh"])).expect("tar")), Verdict::Denied, "bundle secret to stdout");
2300 assert_eq!(project(&resolve(&toks(&["tar", "czf", "out.tar", "~/.aws"])).expect("tar")), Verdict::Denied, "bundle home member");
2301 assert_eq!(project(&resolve(&toks(&["tar", "cf", "~/backup.tar", "./src"])).expect("tar")), Verdict::Denied, "archive into home");
2303
2304 assert_eq!(project(&resolve(&toks(&["tar", "xzf", "release.tar"])).expect("tar")), Verdict::Denied, "extract");
2306 assert_eq!(project(&resolve(&toks(&["tar", "cf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "empty archive");
2309 assert_eq!(project(&resolve(&toks(&["tar", "-C", "/etc", "xf", "a.tar"])).expect("tar")), Verdict::Denied, "-C unmodeled");
2311 assert_eq!(project(&resolve(&toks(&["tar", "c"])).expect("tar")), Verdict::Denied, "create to stdout, no members");
2312 assert_eq!(project(&resolve(&toks(&["tar", "zf", "backup.tar"])).expect("tar")), Verdict::Denied, "no mode letter");
2313 }
2314
2315 #[cfg(test)]
2322 fn sub_locus(line: &str) -> Option<LocalLocus> {
2323 let script = crate::cst::parse(line).expect("parses");
2324 match substitution_claim(&script)? {
2325 SubClaim::Locus(l) => Some(l),
2326 SubClaim::Atom => None,
2329 }
2330 }
2331
2332 #[test]
2343 fn no_output_claim_survives_a_help_or_version_flag() {
2344 let mut probed = 0usize;
2345 for (scope, spec) in crate::registry::output_claims() {
2346 let scope = match spec.requires.first() {
2349 Some(required) => format!("{scope} {required}"),
2350 None => scope,
2351 };
2352 for flag in ["--help", "--version"] {
2353 let line = format!("{scope} {flag}");
2354 let Some(script) = crate::cst::parse(&line) else { continue };
2355 probed += 1;
2356 assert!(
2357 substitution_claim(&script).is_none(),
2358 "`{line}` still carries an output claim, but --help/--version print prose \
2359 rather than the command's data, so the claim does not describe it"
2360 );
2361 }
2362 }
2363 assert!(probed > 0, "nothing declares an output claim; this guard would be vacuous");
2364 }
2365
2366 #[test]
2376 fn a_sub_claim_follows_every_trusted_spelling_of_its_command() {
2377 let bare = sub_locus("git diff --name-only");
2378 assert!(bare.is_some(), "precondition: bare `git diff --name-only` should carry a claim");
2379 for spelling in ["/usr/bin/git", "/opt/homebrew/bin/git"] {
2380 assert_eq!(
2381 sub_locus(&format!("{spelling} diff --name-only")),
2382 bare,
2383 "`{spelling} diff --name-only` disagrees with the bare spelling",
2384 );
2385 }
2386 for untrusted in ["./git", "/tmp/git", "../git"] {
2387 assert_eq!(
2388 sub_locus(&format!("{untrusted} diff --name-only")),
2389 None,
2390 "`{untrusted}` is not a trusted path to git and must earn no output claim",
2391 );
2392 }
2393 }
2394
2395 #[test]
2400 fn a_required_flag_is_necessary_for_its_claim() {
2401 let mut probed = 0usize;
2402 for (scope, spec) in crate::registry::output_claims() {
2403 if spec.requires.is_empty() {
2404 continue;
2405 }
2406 probed += 1;
2407 let Some(script) = crate::cst::parse(&scope) else { continue };
2408 assert_eq!(
2409 substitution_claim(&script).map(|_| ()),
2410 None,
2411 "`{scope}` carries an output claim without any of {:?}, which the declaration says \
2412 are required for the output to be paths at all",
2413 spec.requires,
2414 );
2415 for required in &spec.requires {
2417 let line = format!("{scope} {required}");
2418 let Some(script) = crate::cst::parse(&line) else { continue };
2419 assert!(
2420 substitution_claim(&script).is_some(),
2421 "`{line}` carries no output claim, but `{required}` is declared as one of the \
2422 flags that makes the claim hold"
2423 );
2424 }
2425 }
2426 assert!(probed > 0, "nothing declares `requires`; this guard would be vacuous");
2427 }
2428
2429 #[test]
2438 fn every_output_claim_is_bounded_by_its_roots() {
2439 use crate::registry::types::OutputLocus;
2440
2441 let mut probed = 0usize;
2442 for (scope, spec) in crate::registry::output_claims() {
2443 probed += 1;
2444 let name = match spec.requires.first() {
2447 Some(required) => format!("{scope} {required}"),
2448 None => scope.clone(),
2449 };
2450 let name = name.as_str();
2451 match spec.locus_from {
2452 OutputLocus::Atom => {
2478 const ARGUED: &[&str] = &["seq"];
2479 assert!(
2480 ARGUED.contains(&name),
2481 "command '{name}' declares `locus_from = \"atom\"`, which asserts that no \
2482 word it prints can contain a separator. That cannot be checked here \
2483 without running the command, so it must be argued in the command's TOML \
2484 (what it prints, and which flags reshape it into `invalidated_by`) and \
2485 then listed in ARGUED."
2486 );
2487 }
2488 OutputLocus::Operands => {
2489 let hot_roots: Vec<&str> =
2494 HOT_PATHS.iter().copied().chain(["~", "~/.ssh"]).collect();
2495 for hot in hot_roots {
2496 for line in [
2499 format!("{name} pat {hot}"),
2500 format!("{name} --base-directory {hot} pat"),
2501 format!("{name} --search-path={hot} pat"),
2502 format!("{name} -E{hot} pat"),
2503 ] {
2504 let got = sub_locus(&line);
2505 let want = read_locus(hot);
2506 assert!(
2507 got.is_none_or(|l| l >= want),
2508 "`{line}`: reported {got:?}, but reading {hot} is {want:?}",
2509 );
2510 }
2511 }
2512 assert_eq!(sub_locus(&format!("{name} pat $(hostname)")), None, "{name}: nested sub");
2514 }
2515 OutputLocus::Cwd => {
2518 assert_eq!(sub_locus(name), Some(read_locus(".")), "{name}: bare");
2519 }
2520 OutputLocus::Stdin => {
2523 for hot in HOT_PATHS {
2524 assert_eq!(
2525 sub_locus(&format!("{name} {hot}")),
2526 None,
2527 "{name}: a file operand makes it print contents, not paths",
2528 );
2529 }
2530 }
2531 }
2532 for flag in &spec.invalidated_by {
2534 let line = format!("{name} {flag} pat");
2535 assert_eq!(sub_locus(&line), None, "`{line}`: {flag} is declared invalidating");
2536 }
2537 }
2538 assert!(probed > 0, "no command declares [command.output] — the guard is vacuous");
2539 assert!(
2544 crate::registry::output_claims().iter().any(|(scope, _)| scope.contains(' ')),
2545 "no sub-scoped output claim was enumerated, so `[command.sub.output]` is unprobed",
2546 );
2547 }
2548
2549 #[test]
2554 fn output_valued_flags_agree_across_spellings() {
2555 use crate::registry::types::OutputLocus;
2556 let mut checked = 0usize;
2557 for (scope, spec) in crate::registry::output_claims() {
2558 let name = match spec.requires.first() {
2560 Some(required) => format!("{scope} {required}"),
2561 None => scope.clone(),
2562 };
2563 let name = name.as_str();
2564 for flag in &spec.valued {
2565 if spec.invalidated_by.contains(flag) {
2567 continue;
2568 }
2569 let producer = match spec.locus_from {
2580 OutputLocus::Stdin => "fd a app/ | ",
2581 _ => "",
2582 };
2583 for tail in ["", " /etc/hosts"] {
2584 let separated = sub_locus(&format!("{producer}{name} {flag} 5{tail}"));
2585 let glued = sub_locus(&format!("{producer}{name} {flag}=5{tail}"));
2586 assert_eq!(
2587 separated, glued,
2588 "{name} {flag} (tail {tail:?}): separated {separated:?}, glued {glued:?}",
2589 );
2590 checked += 1;
2591 }
2592 }
2593 }
2594 assert!(checked > 0, "no output claim declares a valued flag — the guard is vacuous");
2595 }
2596
2597 #[test]
2601 fn undeclared_commands_get_no_output_claim() {
2602 for line in ["echo /etc/shadow", "hostname", "cat ./f", "ls", "git rev-parse --show-toplevel"] {
2605 assert_eq!(sub_locus(line), None, "`{line}` must have no output claim");
2606 }
2607 assert!(!crate::is_safe_command("cat $(echo /etc/shadow)"), "echo must not bound its output");
2608 }
2609
2610 #[test]
2611 fn perl_i_worktree_vs_system() {
2612 use crate::engine::bridge::project;
2613 use crate::verdict::{SafetyLevel, Verdict};
2614
2615 let read = resolve(&toks(&["perl", "-pe", "s/x/y/", "./foo"])).expect("perl");
2617 assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2618 assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "perl read");
2619
2620 let edit = resolve(&toks(&["perl", "-pi", "-e", "s/x/y/", "./foo"])).expect("perl");
2622 assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2623 assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i worktree");
2624 let glued = resolve(&toks(&["perl", "-i.bak", "-pe", "s/x/y/", "./foo"])).expect("perl");
2625 assert_eq!(glued.capabilities[0].operation, Operation::Mutate, "-i.bak is still in-place");
2626
2627 for cmd in [
2630 vec!["perl", "-pe", "s/a/b/", "/etc/shadow"],
2631 vec!["perl", "-ne", "print", "~/.ssh/id_rsa"],
2632 vec!["perl", "-pe", "s/a/b/", "/etc/passwd"],
2633 vec!["perl", "-pe", "s/a/b/", "$CONFIG"], vec!["perl", "-pi", "-e", "s/a/b/", "/etc/hosts"],
2635 vec!["perl", "-pi", "-e", "s/a/b/", "~/.bashrc"],
2636 vec!["perl", "-pi", "-e", "s/a/b/", "../outside"],
2637 ] {
2638 assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2639 }
2640
2641 for cmd in [
2644 vec!["perl", "./script.pl"],
2645 vec!["perl", "-n", "./file.txt"],
2646 vec!["perl", "-e", "system(\"rm -rf /\")", "./foo"],
2647 vec!["perl", "-pie", "s/a/b/", "./foo"], ] {
2649 assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2650 }
2651
2652 let glob = resolve(&toks(&["perl", "-pi", "-e", "s/a/b/", "*"])).expect("perl");
2654 assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2655 assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i * (worktree)");
2656 }
2657
2658 #[test]
2659 fn sed_i_flips_read_to_write_and_locus_stops_system_wide_damage() {
2660 use crate::engine::bridge::project;
2661 use crate::verdict::{SafetyLevel, Verdict};
2662
2663 let read = resolve(&toks(&["sed", "s/x/y/", "./foo"])).expect("sed");
2665 assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2666 assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "sed read");
2667 let edit = resolve(&toks(&["sed", "-i", "s/x/y/", "./foo"])).expect("sed");
2668 assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2669 assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i worktree");
2670
2671 for cmd in [
2675 vec!["sed", "-i", "s/a/b/", "/etc/passwd"],
2676 vec!["sed", "-i", "s/a/b/", "/etc/hosts"],
2677 vec!["sed", "-i", "s/a/b/", "~/.bashrc"],
2678 vec!["sed", "-i", "s/a/b/", "$CONFIG"], vec!["sed", "-i", "s/a/b/", "../outside"], vec!["sed", "-i", "-e", "s/a/b/", "/etc/x"], ] {
2682 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?} must deny");
2683 }
2684
2685 let glob = resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed");
2688 assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2689 assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i * (worktree)");
2690 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "a", "b", "c"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "multi-file");
2691
2692 assert_eq!(project(&resolve(&toks(&["sed", "-i.bak", "s/a/b/", "./foo"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "-i.bak");
2694 assert_eq!(project(&resolve(&toks(&["sed", "-f", "script.sed", "./foo"])).expect("sed")), Verdict::Denied, "-f script file unanalyzable");
2697 assert_eq!(project(&resolve(&toks(&["sed", "s/a/b/", "~/.ssh/id_rsa"])).expect("sed")), Verdict::Denied, "read home secret");
2699 assert_eq!(project(&resolve(&toks(&["sed", "-Q", "./foo"])).expect("sed")), Verdict::Denied, "unknown flag");
2700 }
2701
2702 #[test]
2703 fn sed_exec_command_is_worst_cased_at_parity_with_legacy() {
2704 use crate::engine::bridge::project;
2705 use crate::verdict::Verdict;
2706 for cmd in [
2709 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"],
2715 vec!["sed", "1e reboot", "file"], vec!["sed", "p;e id", "file"], ] {
2718 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?}: exec must deny");
2719 }
2720 }
2724
2725 #[test]
2729 fn classify_locus_resolves_relative_operands_against_the_cwd_context() {
2730 use crate::engine::bridge::project;
2731 use crate::pathctx::PathCtx;
2732 use crate::verdict::{SafetyLevel, Verdict};
2733
2734 for p in ["*", "passwd", "config"] {
2736 assert_eq!(classify_locus(p), LocalLocus::Worktree, "{p}: no ctx → worktree");
2737 }
2738 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "no ctx: sed -i *");
2739
2740 let _g = crate::pathctx::enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
2742 for p in ["*", "hosts", "config", "cron.d"] {
2743 assert_eq!(classify_locus(p), LocalLocus::Machine, "{p}: cwd=/etc → machine");
2744 }
2745 assert_eq!(classify_locus("passwd"), LocalLocus::SystemIntegrity, "passwd: cwd=/etc → system-integrity");
2748 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Denied, "cwd=/etc: sed -i * denied");
2749 assert_eq!(project(&resolve(&toks(&["dd", "if=./x", "of=passwd"])).expect("dd")), Verdict::Denied, "cwd=/etc: dd of=passwd denied");
2750 assert_eq!(project(&resolve(&toks(&["cp", "./payload", "config"])).expect("cp")), Verdict::Denied, "cwd=/etc: cp denied");
2751 }
2752
2753 #[test]
2754 fn touch_creates_in_the_worktree_and_gates_the_reference_path() {
2755 use crate::engine::bridge::project;
2756 use crate::verdict::{SafetyLevel, Verdict};
2757 for cmd in [
2758 vec!["touch", "./new.txt"],
2759 vec!["touch", "-c", "existing"],
2760 vec!["touch", "-r", "ref.txt", "./out"], vec!["touch", "-d", "-1 day", "./out"], ] {
2763 assert_eq!(project(&resolve(&toks(&cmd)).expect("touch")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2764 }
2765 let p = resolve(&toks(&["touch", "-r", "ref.txt", "./out"])).expect("touch");
2769 assert_eq!(p.capabilities.len(), 2, "./out create + ref.txt read");
2770 assert!(p.capabilities.iter().any(|c| c.operation == Operation::Observe), "the -r reference is a read");
2771 assert_eq!(project(&resolve(&toks(&["touch", "-r", "~/.bashrc", "./out"])).expect("touch")), Verdict::Denied, "home reference");
2772 assert_eq!(project(&resolve(&toks(&["touch", "-r", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "system reference");
2773 assert_eq!(project(&resolve(&toks(&["touch", "--reference=/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long glued reference");
2774 assert_eq!(project(&resolve(&toks(&["touch", "--reference", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long spaced reference");
2775 assert_eq!(project(&resolve(&toks(&["touch", "-d", "-1 day", "/tmp/../etc/x"])).expect("touch")), Verdict::Denied, "operand still gated");
2777 assert_eq!(project(&resolve(&toks(&["touch", "/etc/x"])).expect("touch")), Verdict::Denied, "system path");
2779 assert_eq!(project(&resolve(&toks(&["touch", "-Z", "x"])).expect("touch")), Verdict::Denied, "unknown flag");
2780 assert_eq!(project(&resolve(&toks(&["touch"])).expect("touch")), Verdict::Denied, "no operand");
2781 }
2782
2783 #[test]
2784 fn worst_case_is_denied_even_by_a_permissive_yolo_shaped_level() {
2785 use crate::engine::level::{Clause, Level, OrdBound};
2786 let yolo = Level::new("yolo-ish")
2788 .allowing(Clause {
2789 local_locus: Some(OrdBound::at_most(LocalLocus::Machine)),
2790 ..Default::default()
2791 })
2792 .denying(Clause {
2793 operation: Some(vec![Operation::Destroy]),
2794 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
2795 ..Default::default()
2796 });
2797 let wc = Profile::of(vec![Capability::worst("test")]);
2798 assert!(!yolo.admits(&wc), "worst_case (locus=kernel) exceeds even a machine-capped allow");
2799 }
2800
2801 #[test]
2802 fn rm_within_the_worktree_projects_to_developer_but_beyond_it_denies() {
2803 use crate::engine::bridge::project;
2804 use crate::verdict::{SafetyLevel, Verdict};
2805 for cmd in [
2808 vec!["rm", "./stale.log"],
2809 vec!["rm", "-rf", "./node_modules"],
2810 vec!["rm", "a", "b", "c"],
2811 vec!["rm", "--interactive=always", "./x"], ] {
2813 let p = resolve(&toks(&cmd)).expect("rm resolves");
2814 assert!(p.capabilities.iter().all(|c| c.operation == Operation::Destroy), "{cmd:?} destroys");
2815 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?} → developer");
2816 }
2817 for cmd in [vec!["rm", "-rf", "/"], vec!["rm", "-rf", "~/notes"], vec!["rm", "/etc/hosts"]] {
2820 assert_eq!(project(&resolve(&toks(&cmd)).expect("rm")), Verdict::Denied, "{cmd:?} beyond worktree");
2821 }
2822 }
2823
2824 #[test]
2828 fn rm_rf_root_is_the_one_thing_even_yolo_denies() {
2829 let yolo = level("yolo");
2830 let root = resolve(&toks(&["rm", "-rf", "/"])).expect("rm");
2831 assert_eq!(root.capabilities[0].reversibility, Reversibility::Irreversible, "rm -rf / is irreversible");
2832 assert_eq!(root.capabilities[0].scale, Scale::Unbounded);
2833 assert!(!yolo.admits(&root), "rm -rf / denied even at yolo");
2834 assert!(!yolo.admits(&resolve(&toks(&["rm", "-rf", "~/notes"])).expect("rm")), "rm -rf ~ likewise");
2835 assert!(yolo.admits(&resolve(&toks(&["rm", "-rf", "./node_modules"])).expect("rm")), "recoverable worktree");
2837 assert!(yolo.admits(&resolve(&toks(&["rm", "/etc/hosts"])).expect("rm")), "single (bounded) system delete");
2838 }
2839
2840 #[test]
2845 fn a_subcommand_profile_resolves_to_its_archetype() {
2846 let p = resolve(&toks(&["koyeb", "apps", "delete", "myapp"])).expect("koyeb apps delete resolves");
2847 assert_eq!(p.capabilities.len(), 1);
2848 assert_eq!(
2849 &p.capabilities[0],
2850 crate::engine::archetype::archetype("remote-destroy-recoverable").unwrap(),
2851 "the sub resolves to its declared archetype's capability",
2852 );
2853 let create = resolve(&toks(&["koyeb", "apps", "create", "myapp"])).expect("resolves");
2855 assert_eq!(create.capabilities[0].operation, Operation::Create);
2856 assert!(resolve(&toks(&["koyeb", "apps", "list"])).is_none(), "untagged sub → engine abstains");
2858 }
2859
2860 #[test]
2865 fn an_escalating_flag_adds_a_capability_and_raises_the_tier() {
2866 let destroy = crate::engine::archetype::archetype("remote-destroy-irreversible").unwrap();
2867 let vcs_sync = {
2870 let mut c = crate::engine::archetype::archetype("vcs-sync").unwrap().clone();
2871 c.locus.provenance = Provenance::Established;
2872 c
2873 };
2874
2875 let base = resolve(&toks(&["git", "push", "origin", "main"])).expect("git push resolves");
2876 assert_eq!(base.capabilities, vec![vcs_sync.clone()], "base is vcs-sync, established destination");
2877
2878 let forced = resolve(&toks(&["git", "push", "--force"])).expect("resolves");
2879 assert_eq!(forced.capabilities.len(), 2);
2880 assert!(forced.capabilities.contains(&vcs_sync) && forced.capabilities.contains(destroy),
2881 "--force ADDS remote-destroy-irreversible to the vcs-sync base");
2882
2883 let network_admin = level("network-admin");
2886 assert!(network_admin.admits(&base), "git push is network-admin");
2887 assert!(!network_admin.admits(&forced), "git push --force escalated past network-admin");
2888 assert!(level("yolo").admits(&forced), "and lands at yolo");
2889
2890 assert_eq!(resolve(&toks(&["git", "push", "-f"])).unwrap().capabilities.len(), 2);
2892 }
2893
2894 #[test]
2898 fn git_push_destination_provenance_is_classified() {
2899 use crate::engine::bridge::project;
2900 use crate::verdict::Verdict;
2901
2902 let prov = |cmd: &[&str]| resolve(&toks(cmd)).expect("push resolves").capabilities[0].locus.provenance;
2903
2904 assert_eq!(prov(&["git", "push"]), Provenance::Established, "bare push = default remote");
2906 assert_eq!(prov(&["git", "push", "origin", "main"]), Provenance::Established, "remote name");
2907 assert_eq!(prov(&["git", "push", "--force", "origin"]), Provenance::Established, "flag then name");
2909 assert_eq!(prov(&["git", "push", "https://h/x.git", "main"]), Provenance::Literal, "url");
2911 assert_eq!(prov(&["git", "push", "git@h:x.git"]), Provenance::Literal, "scp-style");
2912 assert_eq!(prov(&["git", "push", "/srv/mirror.git"]), Provenance::Literal, "path");
2913 assert_eq!(prov(&["git", "push", "$REMOTE"]), Provenance::Opaque, "variable");
2915
2916 let net = level("network-admin");
2918 assert!(net.admits(&resolve(&toks(&["git", "push", "origin"])).unwrap()), "established at network-admin");
2919 assert!(net.admits(&resolve(&toks(&["git", "push", "https://h/x.git"])).unwrap()), "literal URL at network-admin");
2920 assert!(!net.admits(&resolve(&toks(&["git", "push", "$REMOTE"])).unwrap()), "opaque above network-admin");
2921 assert_eq!(project(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), Verdict::Denied, "ext:: is RCE");
2923 assert!(!net.admits(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), "ext:: not at network-admin");
2924
2925 assert_eq!(project(&resolve(&toks(&["git", "push", "--repo=ext::sh", "origin"])).unwrap()), Verdict::Denied, "--repo=ext:: is RCE");
2928 assert!(!net.admits(&resolve(&toks(&["git", "push", "--repo", "$VAR", "origin"])).unwrap()), "--repo $VAR is opaque");
2929 assert_eq!(prov(&["git", "push", "--repo=https://h/x.git", "main"]), Provenance::Literal, "--repo URL is literal");
2930 assert!(net.admits(&resolve(&toks(&["git", "push", "--repo=upstream", "main"])).unwrap()), "--repo=<remote name> is established");
2931 }
2932
2933 #[test]
2939 fn data_export_gates_its_output_file() {
2940 use crate::engine::bridge::project;
2941 use crate::verdict::{SafetyLevel, Verdict};
2942
2943 let stdout = resolve(&toks(&["supabase", "db", "dump", "--data-only"])).expect("dump resolves");
2945 assert_eq!(stdout.capabilities.len(), 1, "stdout dump = the remote read only");
2946 assert_eq!(stdout.capabilities[0].scale, Scale::Unbounded, "a dump records its volume");
2947 assert_eq!(project(&stdout), Verdict::Allowed(SafetyLevel::SafeRead), "bulk read auto-approves");
2948
2949 for cmd in [
2951 vec!["supabase", "db", "dump", "-f", "dump.sql"],
2952 vec!["supabase", "db", "dump", "--file=dump.sql", "--data-only"],
2953 ] {
2954 let p = resolve(&toks(&cmd)).expect("dump resolves");
2955 assert_eq!(p.capabilities.len(), 2, "{cmd:?}: the remote read + a local write");
2956 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2957 }
2958
2959 for cmd in [
2962 vec!["supabase", "db", "dump", "-f", "/etc/passwd"],
2963 vec!["supabase", "db", "dump", "--file=/etc/passwd"],
2964 vec!["supabase", "db", "dump", "-f/etc/passwd"],
2965 ] {
2966 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?} writes a system path");
2967 }
2968 }
2969
2970 #[test]
2974 fn sudo_elevates_the_wrapped_commands_authority() {
2975 use crate::engine::bridge::project;
2976 use crate::verdict::Verdict;
2977 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2978
2979 let read = resolve(&toks(&["sudo", "cat", "./notes.md"])).expect("sudo cat resolves");
2982 assert_eq!(read.capabilities[0].authority, Authority::Root, "authority lifted to root");
2983 assert!(!dev.admits(&read) && !net.admits(&read), "a root op is above the user-authority bands");
2984 assert!(local.admits(&read) && yolo.admits(&read), "a root read is local-admin");
2985
2986 assert_eq!(resolve(&toks(&["sudo", "-EH", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2988 assert_eq!(resolve(&toks(&["sudo", "-n", "-p", "pw", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2989
2990 assert_eq!(project(&resolve(&toks(&["sudo", "rm", "-rf", "/"])).unwrap()), Verdict::Denied, "sudo rm -rf / denied everywhere");
2992
2993 let other = resolve(&toks(&["sudo", "-u", "bob", "cat", "./x"])).expect("sudo -u resolves");
2995 assert_eq!(other.capabilities[0].authority, Authority::OtherUser, "-u = run as other user");
2996 assert!(!local.admits(&other) && yolo.admits(&other), "other-user is yolo-only");
2997 assert_eq!(resolve(&toks(&["sudo", "-ubob", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::OtherUser, "glued -ubob");
2998
2999 assert_eq!(project(&resolve(&toks(&["sudo", "-i"])).unwrap()), Verdict::Denied, "sudo -i is a root shell");
3001 assert!(!local.admits(&resolve(&toks(&["sudo", "-s", "bash"])).unwrap()), "root shell not local-admin");
3002
3003 assert_eq!(project(&resolve(&toks(&["sudo", "--nonsense", "cat", "./x"])).unwrap()), Verdict::Denied, "unknown option worst-cases");
3005
3006 assert!(resolve(&toks(&["sudo", "totallyunknowncmd", "x"])).is_none(), "unresolved inner → legacy denies");
3008 assert!(resolve(&toks(&["sudo", "-v"])).is_none(), "no inner command");
3010
3011 assert_eq!(resolve(&toks(&["doas", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
3013
3014 assert!(resolve(&toks(&["doas", "-r"])).is_none(), "doas -r must not panic");
3017 assert!(resolve(&toks(&["sudo", "-u"])).is_none(), "sudo -u must not panic");
3018 assert_eq!(crate::command_verdict("doas -r"), Verdict::Denied, "doas -r denied, not crashed");
3019
3020 for cmd in ["sudo cat ./notes.md", "sudo rm -rf ./build", "sudo -EH cat ./x", "sudo -u bob ls"] {
3023 assert_eq!(crate::command_verdict(cmd), Verdict::Denied, "`{cmd}` must not auto-approve at the default band");
3024 }
3025 }
3026
3027 #[test]
3031 fn systemctl_service_management_is_local_admin() {
3032 use crate::verdict::Verdict;
3033 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
3034
3035 for sub in ["restart", "start", "stop", "enable", "disable", "mask", "daemon-reload", "kill"] {
3037 let p = resolve(&toks(&["systemctl", sub, "nginx"])).unwrap_or_else(|| panic!("systemctl {sub} resolves"));
3038 assert!(!dev.admits(&p) && !net.admits(&p), "systemctl {sub} is above developer/network-admin");
3039 assert!(local.admits(&p) && yolo.admits(&p), "systemctl {sub} is local-admin");
3040 }
3041 assert!(crate::command_verdict("systemctl status nginx").is_allowed(), "status reads");
3043 assert_eq!(crate::command_verdict("systemctl reboot"), Verdict::Denied, "reboot omitted → denied");
3044
3045 let sudo_restart = resolve(&toks(&["sudo", "systemctl", "restart", "nginx"])).expect("resolves");
3047 assert!(local.admits(&sudo_restart), "sudo systemctl restart is local-admin");
3048 assert_eq!(crate::command_verdict("sudo systemctl restart nginx"), Verdict::Denied, "still not auto-approved at default");
3049 }
3050
3051 #[test]
3057 fn npm_install_is_classified_by_pinning_and_scripts_off() {
3058 let (dev, yolo) = (level("developer"), level("yolo"));
3059
3060 let safe = resolve(&toks(&["npm", "ci", "--ignore-scripts"])).expect("npm ci --ignore-scripts");
3062 assert!(dev.admits(&safe), "pinned, scripts-off ci is developer");
3063
3064 let scripts_on = resolve(&toks(&["npm", "ci"])).expect("npm ci");
3066 assert!(!dev.admits(&scripts_on), "ci without --ignore-scripts runs fetched code → above developer");
3067 assert!(yolo.admits(&scripts_on), "and lands at yolo");
3068
3069 for c in [&["npm", "install"][..], &["npm", "install", "left-pad"], &["npm", "i", "react"], &["npm", "install", "--ignore-scripts"]] {
3071 let p = resolve(&toks(c)).unwrap_or_else(|| panic!("{c:?} resolves"));
3072 assert!(!dev.admits(&p) && yolo.admits(&p), "{c:?}: floating install → supply-chain (yolo)");
3073 }
3074 }
3075
3076 #[test]
3077 fn rm_flag_and_operand_fail_closed() {
3078 use crate::engine::bridge::project;
3079 use crate::verdict::Verdict;
3080 for cmd in [
3081 vec!["rm", "--no-preserve-root", "-rf", "/"], vec!["rm", "-Z", "x"], vec!["rm"], vec!["./rm", "x"], ] {
3086 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?}");
3087 }
3088 }
3089
3090 #[test]
3091 fn rm_scale_and_force_semantics() {
3092 let cap = |cmd: &[&str]| resolve(&toks(cmd)).expect("rm").capabilities[0].clone();
3093 assert_eq!(cap(&["rm", "./x"]).scale, Scale::Single);
3094 assert_eq!(cap(&["rm", "a", "b"]).scale, Scale::Bounded, "multiple operands");
3095 assert_eq!(cap(&["rm", "*.log"]).scale, Scale::Bounded, "a glob");
3096 assert_eq!(cap(&["rm", "-r", "./dir"]).scale, Scale::Unbounded, "recursive");
3097 assert_eq!(cap(&["rm", "./x"]).reversibility, Reversibility::Effortful);
3099 assert_eq!(cap(&["rm", "-f", "./x"]).reversibility, Reversibility::Effortful, "-f is not a raiser");
3100 }
3101
3102 #[test]
3103 fn a_resolvable_name_from_a_non_standard_path_worst_cases() {
3104 for cmd in [vec!["./cat", "x"], vec!["/tmp/cat", "x"], vec!["~/bin/grep", "foo", "f"]] {
3106 let p = resolve(&toks(&cmd)).expect("resolvable name");
3107 assert!(!read_local().admits(&p), "{cmd:?} from a non-standard path must worst-case");
3108 }
3109 assert!(read_local().admits(&resolve(&toks(&["cat", "./notes.md"])).expect("cat")));
3111 assert!(read_local().admits(&resolve(&toks(&["/usr/bin/cat", "./notes.md"])).expect("cat")));
3112 assert!(resolve(&toks(&["/tmp/mytool", "x"])).is_none());
3114 }
3115
3116 #[test]
3117 fn unrecognized_flags_worst_case_fail_closed() {
3118 for cmd in [
3119 vec!["cat", "-Z", "./x"],
3120 vec!["cat", "--wat", "./x"],
3121 vec!["grep", "-Q", "foo", "f"], vec!["grep", "-R", "foo", "dir"], ] {
3124 let p = resolve(&toks(&cmd)).expect("resolver");
3125 assert!(!inert().admits(&p) && !read_local().admits(&p), "{cmd:?} must worst-case");
3126 }
3127 assert!(read_local().admits(&resolve(&toks(&["cat", "-nA", "./x"])).expect("cat")));
3129 assert!(read_local().admits(&resolve(&toks(&["grep", "-rin", "foo", "src/"])).expect("grep")));
3130 }
3131
3132 use proptest::prelude::*;
3133
3134 const TRANSFER_CMDS: &[&str] = &["cp", "mv", "ln"];
3139
3140 const HOT_PATHS: &[&str] = &["/etc/shadow", "~/.ssh/id_rsa", "$SECRET", "../out", "~/.aws"];
3143
3144 proptest! {
3145 #[test]
3151 fn transfer_commands_gate_both_operand_roles(
3152 cmd in prop::sample::select(TRANSFER_CMDS),
3153 hot in prop::sample::select(HOT_PATHS),
3154 ) {
3155 use crate::engine::bridge::project;
3156 use crate::verdict::Verdict;
3157 let hot_source = resolve(&toks(&[cmd, hot, "./safe"])).expect("resolves");
3158 prop_assert_eq!(project(&hot_source), Verdict::Denied, "{} hot SOURCE ({})", cmd, hot);
3159 let hot_dest = resolve(&toks(&[cmd, "./safe", hot])).expect("resolves");
3160 prop_assert_eq!(project(&hot_dest), Verdict::Denied, "{} hot DEST ({})", cmd, hot);
3161 }
3162
3163 #[test]
3169 fn sudo_family_flag_walk_never_panics(
3170 head in prop::sample::select(vec!["sudo", "doas"]),
3171 args in prop::collection::vec(
3172 prop_oneof![
3173 Just("-u".to_string()), Just("-r".to_string()), Just("-g".to_string()),
3174 Just("-i".to_string()), Just("-EH".to_string()), Just("-uEH".to_string()),
3175 Just("-uroot".to_string()), Just("--".to_string()), Just("-".to_string()),
3176 Just("root".to_string()), Just("cat".to_string()), Just("./x".to_string()),
3177 "-[a-zA-Z]{1,4}",
3178 ],
3179 0..6,
3180 ),
3181 ) {
3182 let parts: Vec<&str> =
3183 std::iter::once(head).chain(args.iter().map(String::as_str)).collect();
3184 let a = resolve(&toks(&parts)).is_some();
3185 let b = resolve(&toks(&parts)).is_some();
3186 prop_assert_eq!(a, b, "nondeterministic verdict for {:?}", parts);
3187 }
3188 }
3189
3190 const EXPECTED_BEHAVIOR_COMMANDS: &[&str] = &[
3200 "cat", "cp", "dd", "echo", "grep", "head", "ln", "mkdir", "mv", "perl", "rm", "rmdir",
3201 "sed", "tail", "tar", "touch", "wc",
3202 ];
3203
3204 #[test]
3208 fn behavior_command_roster_is_pinned() {
3209 use std::collections::BTreeSet;
3210 let actual: BTreeSet<&str> = crate::registry::toml_command_names()
3211 .into_iter()
3212 .filter(|n| crate::registry::command_behavior(n).is_some())
3213 .collect();
3214 let expected: BTreeSet<&str> = EXPECTED_BEHAVIOR_COMMANDS.iter().copied().collect();
3215 assert_eq!(
3216 actual, expected,
3217 "behavior-command roster drifted — a [command.behavior] block was added, dropped, or \
3218 typo'd. A dropped block silently reverts the command to its fail-open legacy path."
3219 );
3220 }
3221
3222 fn behavior_probes(cmd: &str, role: crate::registry::types::PositionalRole, hot: &str) -> Vec<Vec<String>> {
3225 use crate::registry::types::PositionalRole;
3226 let inv = |slots: &[&str]| -> Vec<String> {
3227 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
3228 };
3229 match role {
3230 PositionalRole::None => vec![],
3231 PositionalRole::Read | PositionalRole::Write => vec![inv(&["@"])],
3232 PositionalRole::PatternThenRead => vec![inv(&["PATTERN", "@"])],
3233 PositionalRole::Transfer => vec![inv(&["@", "./safe"]), inv(&["./safe", "@"])],
3234 }
3235 }
3236
3237 fn hook_probes(hook: crate::registry::types::BehaviorHook, cmd: &str, hot: &str) -> Vec<Vec<String>> {
3243 use crate::registry::types::BehaviorHook;
3244 let inv = |slots: &[&str]| -> Vec<String> {
3245 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
3246 };
3247 match hook {
3248 BehaviorHook::Grep => vec![],
3250 BehaviorHook::Dd => vec![inv(&["if=@", "of=./safe"]), inv(&["if=./safe", "of=@"])],
3251 BehaviorHook::Tar => vec![inv(&["cf", "./s.tar", "@"]), inv(&["cf", "@", "./s"]), inv(&["tf", "@"])],
3252 BehaviorHook::Sed => vec![inv(&["s/x/y/", "@"]), inv(&["-i", "s/x/y/", "@"])],
3253 BehaviorHook::Perl => {
3254 vec![inv(&["-pe", "s/x/y/", "@"]), inv(&["-pi", "-e", "s/x/y/", "@"])]
3255 }
3256 }
3257 }
3258
3259 #[test]
3266 fn every_behavior_command_gates_hot_operands() {
3267 use crate::engine::bridge::project;
3268 use crate::registry::types::PositionalRole;
3269 use crate::verdict::Verdict;
3270
3271 let deny = |cmd: &[String], why: &str| {
3272 let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
3273 let profile = resolve(&toks(&refs)).expect("behavior command resolves");
3274 assert_eq!(project(&profile), Verdict::Denied, "{cmd:?}: {why}");
3275 };
3276
3277 let mut path_bearing = 0usize;
3278 let mut hook_bearing = 0usize;
3279 for name in crate::registry::toml_command_names() {
3280 let Some(b) = crate::registry::command_behavior(name) else { continue };
3281 if !matches!(b.positionals, PositionalRole::None) {
3282 path_bearing += 1;
3283 }
3284 if b.hook.is_some() {
3285 hook_bearing += 1;
3286 }
3287 for hot in HOT_PATHS {
3288 for cmd in behavior_probes(name, b.positionals, hot) {
3289 deny(&cmd, "touched hot path not gated");
3290 }
3291 if let Some(hook) = b.hook {
3295 for cmd in hook_probes(hook, name, hot) {
3296 deny(&cmd, "hook: touched hot path not gated");
3297 }
3298 }
3299 }
3300 let inv = |slots: &[&str]| -> Vec<String> {
3304 std::iter::once(name.to_string()).chain(slots.iter().map(|s| s.to_string())).collect()
3305 };
3306 match b.positionals {
3307 PositionalRole::Write => deny(&inv(&[".git/config"]), "write into worktree-trusted not gated"),
3308 PositionalRole::Transfer => deny(&inv(&["./safe", ".git/config"]), "transfer dest into worktree-trusted not gated"),
3309 _ => {}
3310 }
3311 }
3312 let count = |pred: fn(&crate::registry::types::BehaviorSpec) -> bool| {
3316 EXPECTED_BEHAVIOR_COMMANDS
3317 .iter()
3318 .filter(|n| crate::registry::command_behavior(n).is_some_and(pred))
3319 .count()
3320 };
3321 assert_eq!(
3322 path_bearing,
3323 count(|b| !matches!(b.positionals, PositionalRole::None)),
3324 "path-bearing behavior commands: saw {path_bearing}"
3325 );
3326 assert_eq!(hook_bearing, count(|b| b.hook.is_some()), "hook behavior commands: saw {hook_bearing}");
3327 }
3328
3329 #[test]
3337 fn every_hookless_behavior_command_worst_cases_unknown_flags() {
3338 use crate::engine::bridge::project;
3339 use crate::registry::types::{BehaviorHook, PositionalRole};
3340 use crate::verdict::Verdict;
3341
3342 let exempt = |b: &crate::registry::types::BehaviorSpec| {
3343 matches!(b.hook, Some(BehaviorHook::Grep)) || matches!(b.positionals, PositionalRole::None)
3344 };
3345 let mut checked = 0usize;
3346 for name in crate::registry::toml_command_names() {
3347 let Some(b) = crate::registry::command_behavior(name) else { continue };
3348 if exempt(b) {
3349 continue;
3350 }
3351 let profile = resolve(&toks(&[name, "--xyzzy-unknown-42", "./safe"])).expect("resolves");
3352 assert_eq!(project(&profile), Verdict::Denied, "{name}: unknown flag not worst-cased");
3353 checked += 1;
3354 }
3355 let expected = EXPECTED_BEHAVIOR_COMMANDS
3356 .iter()
3357 .filter(|n| crate::registry::command_behavior(n).is_some_and(|b| !exempt(b)))
3358 .count();
3359 assert_eq!(checked, expected, "declarative flag-walking behavior commands: saw {checked}");
3360 }
3361
3362 #[test]
3368 fn no_none_or_hook_command_declares_ungated_path_flags() {
3369 use crate::registry::types::PositionalRole;
3370 for name in crate::registry::toml_command_names() {
3371 let Some(b) = crate::registry::command_behavior(name) else { continue };
3372 if b.path_flags.is_empty() {
3373 continue;
3374 }
3375 assert!(
3376 b.hook.is_none() && !matches!(b.positionals, PositionalRole::None),
3377 "{name}: behavior path-flags are gated only on the Read/Write/Transfer path; on a \
3378 none-role or hook command they would be silently ungated (fail-open)"
3379 );
3380 }
3381 }
3382}