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::{hidden_peer_reach, is_substitution_value, is_unpinnable, reads_secret};
29
30pub(crate) fn loop_reprs(items: &[String]) -> Option<(String, String)> {
42 if items.is_empty() {
43 return None;
44 }
45 let faced: Vec<(String, LocalLocus, LocalLocus)> = items
46 .iter()
47 .map(|s| {
48 if crate::cst::check::is_opaque_value(s) {
49 ("$loop_sub".to_string(), LocalLocus::Machine, LocalLocus::Machine)
50 } else {
51 (s.clone(), read_locus(s), write_locus(s))
52 }
53 })
54 .collect();
55 let read_item = faced.iter().max_by_key(|(_, r, _)| *r).map(|(s, _, _)| s.clone())?;
56 let write_item = faced.iter().max_by_key(|(_, _, w)| *w).map(|(s, _, _)| s.clone())?;
57 let read_repr = crate::pathctx::expand_vars(&read_item, false).into_owned();
60 let write_repr = crate::pathctx::expand_vars(&write_item, true).into_owned();
61 Some((read_repr, write_repr))
62}
63
64pub(crate) fn read_content_verdict(path: &str) -> crate::verdict::Verdict {
68 let cap = reads_content(read_locus(path), Scale::Single, "reads a redirect source");
69 crate::engine::bridge::project(&Profile::of(vec![cap]))
70}
71
72pub(crate) fn write_target_verdict(path: &str) -> crate::verdict::Verdict {
75 let cap = overwrites(write_locus(path), Scale::Single, false);
76 crate::engine::bridge::project(&Profile::of(vec![cap]))
77}
78
79pub(crate) fn execute_file_verdict(path: &str) -> crate::verdict::Verdict {
86 if path.contains(['*', '?', '[']) {
91 return crate::engine::bridge::project(&worst("glob executor — the code that would run is unknown (§6)"));
92 }
93 let cap = executes(classify_locus(path), ExecutionTrust::CallerFile, "runs code from a named file");
94 crate::engine::bridge::project(&Profile::of(vec![cap]))
95}
96
97pub(crate) fn execute_project_verdict() -> crate::verdict::Verdict {
103 let cap = executes(LocalLocus::Worktree, ExecutionTrust::SelfCode, "runs the current project's own code");
104 crate::engine::bridge::project(&Profile::of(vec![cap]))
105}
106
107pub fn resolve(tokens: &[Token]) -> Option<Profile> {
112 let arg0 = tokens.first()?;
113 let canonical = crate::registry::canonical_name(arg0.command_name());
119 if canonical == "openssl" {
124 return resolve_openssl(arg0, tokens);
125 }
126 if matches!(canonical, "sudo" | "doas") {
127 return resolve_privilege_wrapper(arg0, tokens);
128 }
129 if let Some(names) = crate::registry::sub_archetypes(tokens) {
133 if !trusted_command_path(arg0.as_str()) {
134 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
135 }
136 let mut caps: Vec<Capability> = names
152 .iter()
153 .map(|n| {
154 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
155 Capability::worst("subcommand/flag declares an unknown archetype (§0)")
156 })
157 })
158 .collect();
159 if let Some(dest) = crate::registry::sub_destination_token(tokens) {
163 match destination_provenance(dest) {
164 Some(prov) => {
165 if let Some(base) = caps.first_mut() {
166 base.locus.provenance = prov;
167 }
168 }
169 None => {
170 return Some(worst(
171 "send target is a command transport (ext::…) — runs a local command, RCE (§4)",
172 ));
173 }
174 }
175 }
176 if let Some(path) = crate::registry::sub_output_path_token(tokens) {
181 caps.push(writes_export_file(classify_locus(path)));
182 }
183 if crate::registry::sub_loopback_localizes(tokens) {
202 for c in &mut caps {
203 if c.operation == Operation::Destroy {
204 continue;
205 }
206 c.locus.remote = RemoteReach::None;
207 c.network.direction = NetDirection::Loopback;
208 c.network.payload = NetPayload::None;
209 c.cost = Cost::None;
210 c.because = format!("{} — but the endpoint names this machine, so no remote is reached", c.because);
214 }
215 }
216 return Some(Profile::of(caps));
217 }
218 if let Some(names) = crate::registry::command_flag_archetypes(tokens) {
224 if !trusted_command_path(arg0.as_str()) {
225 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
226 }
227 let caps: Vec<Capability> = names
228 .iter()
229 .map(|n| {
230 crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
231 Capability::worst("command flag declares an unknown archetype (§0)")
232 })
233 })
234 .collect();
235 return Some(Profile::of(caps));
236 }
237 let spec = crate::registry::command_behavior(canonical)?;
241 if !trusted_command_path(arg0.as_str()) {
247 return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
248 }
249 Some(resolve_behavior(spec, tokens))
250}
251
252fn resolve_privilege_wrapper(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
258 if !trusted_command_path(arg0.as_str()) {
259 return Some(worst("sudo/doas invoked from a non-standard path — possible spoof (§0)"));
260 }
261 let mut i = 1;
262 let mut run_as_other = false;
263 'scan: while let Some(tok) = tokens.get(i) {
264 let t = tok.as_str();
265 if t == "--" {
266 i += 1;
267 break;
268 }
269 if !t.starts_with('-') || t == "-" {
270 break; }
272 if let Some(long) = t.strip_prefix("--") {
273 let (name, glued_val) = match long.split_once('=') {
274 Some((n, _)) => (n, true),
275 None => (long, false),
276 };
277 match name {
278 "login" | "shell" | "edit" => {
279 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
280 }
281 "user" | "other-user" => {
282 run_as_other = true;
283 if !glued_val { i += 1; }
284 }
285 "group" | "prompt" | "close-from" | "host" | "role" | "type"
286 | "command-timeout" | "chroot" | "chdir" | "preserve-env" => {
287 if !glued_val && name != "preserve-env" { i += 1; }
290 }
291 "background" | "stdin" | "non-interactive" | "reset-timestamp"
292 | "remove-timestamp" | "set-home" | "askpass" | "help" | "version"
293 | "validate" | "list" | "bell" => {}
294 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
295 }
296 } else {
297 let rest = &t[1..];
300 for (idx, c) in rest.char_indices() {
301 match c {
302 'i' | 's' | 'e' => {
303 return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
304 }
305 'u' | 'U' | 'g' | 'p' | 'C' | 'h' | 'r' | 't' | 'T' | 'R' | 'D' => {
306 if c == 'u' || c == 'U' { run_as_other = true; }
307 if idx + c.len_utf8() == rest.len() { i += 1; } i += 1;
309 continue 'scan; }
311 'E' | 'H' | 'k' | 'K' | 'n' | 'b' | 'A' | 'S' | 'P' | 'B' | 'v' | 'l' => {}
312 _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
313 }
314 }
315 }
316 i += 1;
317 }
318 let inner = &tokens[i.min(tokens.len())..];
322 if inner.is_empty() {
323 return None; }
325 let elevated = if run_as_other { Authority::OtherUser } else { Authority::Root };
326 let caps = resolve(inner)?
327 .capabilities
328 .into_iter()
329 .map(|mut c| {
330 c.authority = c.authority.max(elevated);
331 c
332 })
333 .collect();
334 Some(Profile::of(caps))
335}
336
337fn resolve_openssl(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
347 if !trusted_command_path(arg0.as_str()) {
348 return Some(worst("openssl invoked from a non-standard path — possible spoof (§0)"));
349 }
350 let sub = tokens.get(1)?.as_str();
351 let args = &tokens[2..];
352 let discloses = match sub {
353 "rsa" | "pkey" | "ec" | "dsa" => {
357 if openssl_flag(args, "-pubin") {
358 false
359 } else if openssl_flag(args, "-text") {
360 true } else if openssl_flag(args, "-pubout") {
362 false } else {
364 openssl_output_reaches_model(args)
365 }
366 }
367 "pkcs8" => openssl_flag(args, "-text") || openssl_output_reaches_model(args),
369 "pkcs12" => {
371 (openssl_flag(args, "-nodes") || openssl_flag(args, "-noenc"))
372 && openssl_output_reaches_model(args)
373 }
374 "enc" => openssl_flag(args, "-d") && openssl_output_reaches_model(args),
376 "smime" => openssl_flag(args, "-decrypt") && openssl_output_reaches_model(args),
377 "cms" => {
378 (openssl_flag(args, "-decrypt") || openssl_flag(args, "-EncryptedData_decrypt"))
379 && openssl_output_reaches_model(args)
380 }
381 _ => return None, };
383 if discloses {
384 let cap = crate::engine::archetype::archetype("decrypt-read")
385 .cloned()
386 .unwrap_or_else(|| Capability::worst("decrypt-read archetype missing (§0)"));
387 Some(Profile::of(vec![cap]))
388 } else {
389 None }
391}
392
393fn openssl_flag(args: &[Token], flag: &str) -> bool {
396 args.iter().any(|t| {
397 let s = t.as_str();
398 s == flag || (s.starts_with("--") && s.len() > 2 && &s[1..] == flag)
399 })
400}
401
402fn openssl_output_reaches_model(args: &[Token]) -> bool {
410 if openssl_flag(args, "-noout") {
411 return false;
412 }
413 let outs = openssl_flag_values(args, "-out");
414 !matches!(outs.as_slice(), [only] if out_value_is_plain_file(only))
418}
419
420fn out_value_is_plain_file(value: &str) -> bool {
431 if value.is_empty() || value.starts_with('-') {
432 return false;
433 }
434 let norm = collapse_path(value).to_ascii_lowercase();
435 let device_or_fd =
436 norm == "/dev" || norm.starts_with("/dev/") || (norm.starts_with("/proc/") && norm.contains("/fd/"));
437 !device_or_fd
438}
439
440fn collapse_path(p: &str) -> String {
444 let absolute = p.starts_with('/');
445 let mut stack: Vec<&str> = Vec::new();
446 for seg in p.split('/') {
447 match seg {
448 "" | "." => {}
449 ".." => {
450 if matches!(stack.last(), Some(&s) if s != "..") {
451 stack.pop();
452 } else if !absolute {
453 stack.push("..");
454 }
455 }
456 s => stack.push(s),
457 }
458 }
459 let joined = stack.join("/");
460 if absolute { format!("/{joined}") } else { joined }
461}
462
463fn openssl_flag_values<'a>(args: &'a [Token], flag: &str) -> Vec<&'a str> {
467 let twin = format!("-{flag}"); let mut out = Vec::new();
469 let mut i = 0;
470 while i < args.len() {
471 let s = args[i].as_str();
472 if let Some(v) = s
473 .strip_prefix(flag)
474 .or_else(|| s.strip_prefix(twin.as_str()))
475 .and_then(|r| r.strip_prefix('='))
476 {
477 out.push(v);
478 } else if (s == flag || s == twin)
479 && let Some(next) = args.get(i + 1)
480 {
481 out.push(next.as_str());
482 i += 1;
483 }
484 i += 1;
485 }
486 out
487}
488
489fn destination_provenance(dest: Option<&str>) -> Option<Provenance> {
493 let Some(tok) = dest else {
494 return Some(Provenance::Established);
495 };
496 if tok.starts_with("ext::") {
497 return None; }
499 if tok.contains('$') || tok.contains('`') {
502 return Some(Provenance::Opaque);
503 }
504 let literal = tok.contains("://")
507 || (tok.contains('@') && tok.contains(':'))
508 || tok.starts_with('/')
509 || tok.starts_with("./")
510 || tok.starts_with("../");
511 Some(if literal { Provenance::Literal } else { Provenance::Established })
512}
513
514fn resolve_behavior(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Profile {
520 use crate::registry::types::{BehaviorHook, PositionalRole};
521 if let Some(hook) = spec.hook {
522 return match hook {
523 BehaviorHook::Grep => {
527 let Some(g) = grep_operands(tokens) else {
528 return worst("grep: unrecognized flag or missing pattern — worst-cased (§0)");
529 };
530 let mut caps: Vec<Capability> = g
531 .pattern_files
532 .iter()
533 .map(|f| reads_content(read_locus(f), Scale::Single, "reads a grep -f pattern file"))
534 .collect();
535 caps.extend(reads_to_model(&g.files, g.scale));
536 Profile::of(caps)
537 }
538 BehaviorHook::Dd => resolve_dd(tokens),
543 BehaviorHook::Tar => resolve_tar(tokens),
544 BehaviorHook::Sed => resolve_sed(tokens),
545 BehaviorHook::Perl => resolve_perl(tokens),
546 };
547 }
548 if matches!(spec.positionals, PositionalRole::None) {
552 return match spec.operation {
553 Operation::Observe => {
554 let mut c = Capability::new(Operation::Observe);
555 c.disclosure.audience = DisclosureAudience::LocalProcess;
556 c.because = "behavior: prints its arguments to stdout; no fs/net/exec/secret".to_string();
557 Profile::of(vec![c])
558 }
559 _ => worst("behavior: none-operand role supports only observe (§0)"),
560 };
561 }
562 let long: Vec<&str> = spec.long.iter().map(String::as_str).collect();
563 let valued_long: Vec<&str> = spec.valued_long.iter().map(String::as_str).collect();
564 let Some(operands) = walk_positionals(&spec.short, &spec.valued_short, &long, &valued_long, spec.numeric_shorthand, tokens) else {
565 return worst("behavior: unrecognized flag — worst-cased (§0)");
566 };
567 let scale = behavior_scale(spec, &operands, tokens);
568 let flag_caps = path_flag_caps(spec, tokens);
570 match spec.positionals {
571 PositionalRole::Read => {
572 let mut caps = reads_to_model(&operands, scale);
573 caps.extend(flag_caps);
574 Profile::of(caps)
575 }
576 PositionalRole::Write => {
577 if operands.is_empty() {
578 return worst("behavior: write operation with no operand — worst-cased (§0)");
579 }
580 let mut caps: Vec<Capability> = operands
581 .iter()
582 .map(|p| match spec.operation {
583 Operation::Destroy => destroys(classify_locus(p), scale),
584 Operation::Create => creates(classify_locus(p), scale),
585 Operation::Mutate => mutates(classify_locus(p), scale, "behavior: in-place mutate"),
586 _ => Capability::worst("behavior: unsupported write operation — worst-cased (§0)"),
587 })
588 .collect();
589 caps.extend(flag_caps);
590 Profile::of(caps)
591 }
592 PositionalRole::Transfer => resolve_transfer(spec, operands, flag_caps, tokens),
593 PositionalRole::None | PositionalRole::PatternThenRead => {
596 worst("behavior: operand role not resolvable without a hook (§0)")
597 }
598 }
599}
600
601fn resolve_transfer(
606 spec: &crate::registry::types::BehaviorSpec,
607 operands: Vec<&str>,
608 flag_caps: Vec<Capability>,
609 tokens: &[Token],
610) -> Profile {
611 use crate::registry::types::TransferSource;
612 let Some(t) = &spec.transfer else {
613 return worst("behavior: transfer role without transfer spec — worst-cased (§0)");
614 };
615 let (sources, dest) = if let Some(d) = walk_value(&spec.valued_short, tokens, b't', "--target-directory") {
616 if operands.is_empty() {
617 return worst("behavior: transfer -t with no source operand — worst-cased (§0)");
618 }
619 (operands, d)
620 } else {
621 match operands.split_last() {
622 Some((last, rest)) if !rest.is_empty() => (rest.to_vec(), *last),
623 _ => return worst("behavior: transfer needs a source and a destination — worst-cased (§0)"),
624 }
625 };
626 let no_clobber = if t.clobber_flags.is_empty() {
627 t.no_clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
628 } else {
629 !t.clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
631 };
632 let recursive = t.recursive_flags.iter().any(|f| behavior_flag_present(tokens, f));
633 let transfer_scale = breadth_scale(&sources, recursive);
634 let source_writes = matches!(t.source, TransferSource::Relocate);
636 let mut prof = transfer_profile(
637 &sources,
638 dest,
639 transfer_scale,
640 source_writes,
641 |loc, sc| match t.source {
642 TransferSource::Observe => observes(loc, sc, "transfer reads the source at its locus"),
643 TransferSource::Relocate => relocates(loc, sc),
644 },
645 |loc, sc| overwrites(loc, sc, no_clobber),
646 );
647 prof.capabilities.extend(flag_caps);
648 prof
649}
650
651fn path_flag_caps(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Vec<Capability> {
655 use crate::registry::types::PathRole;
656 let mut caps = Vec::new();
657 for pf in &spec.path_flags {
658 let short = pf.short.unwrap_or(0);
659 let long = pf.long.as_deref().unwrap_or("");
660 if let Some(v) = walk_value(&spec.valued_short, tokens, short, long) {
661 caps.push(match pf.role {
662 PathRole::Read => observes(read_locus(v), Scale::Single, "behavior: a flag value is a read path"),
663 PathRole::Write => mutates(write_locus(v), Scale::Single, "behavior: a flag value is a write path"),
664 });
665 }
666 }
667 caps
668}
669
670fn behavior_scale(
673 spec: &crate::registry::types::BehaviorSpec,
674 operands: &[&str],
675 tokens: &[Token],
676) -> Scale {
677 use crate::registry::types::ScaleModel;
678 match spec.scale {
679 ScaleModel::Single => Scale::Single,
680 ScaleModel::Breadth => {
681 let recursive = spec.unbounded_flags.iter().any(|f| behavior_flag_present(tokens, f));
682 breadth_scale(operands, recursive)
683 }
684 }
685}
686
687fn behavior_flag_present(tokens: &[Token], flag: &str) -> bool {
690 if flag.starts_with("--") {
691 has_flag(tokens, None, Some(flag))
692 } else {
693 has_flag(tokens, Some(flag), None)
694 }
695}
696
697#[cfg(test)]
702pub(crate) const UNRESOLVED_CMD: &[&str] = &["safe-chains-unresolved-sentinel"];
703
704fn trusted_command_path(arg0: &str) -> bool {
708 const STD_BINS: &[&str] =
709 &["/usr/bin/", "/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/sbin/", "/usr/sbin/"];
710 !arg0.contains('/') || STD_BINS.iter().any(|p| arg0.starts_with(p))
711}
712
713struct GrepOperands<'a> {
720 files: Vec<&'a str>,
721 pattern_files: Vec<&'a str>,
722 scale: Scale,
723}
724
725fn grep_operands(tokens: &[Token]) -> Option<GrepOperands<'_>> {
728 let recursive = has_flag(tokens, Some("-r"), Some("--recursive"));
731 let scale = if recursive { Scale::Unbounded } else { Scale::Single };
732
733 let mut files = Vec::new(); let mut pattern_files = Vec::new(); let mut pattern_from_flag = false;
736 let mut unknown_flag = false;
737 let mut flags_done = false;
738 let mut i = 1;
739 while i < tokens.len() {
740 let t = tokens[i].as_str();
741 let next = tokens.get(i + 1).map(Token::as_str);
742 if !flags_done && t == "--" {
743 flags_done = true;
744 i += 1;
745 } else if flags_done || !t.starts_with('-') || t == "-" {
746 files.push(t);
747 i += 1;
748 } else if t.starts_with("--") {
749 if let Some(v) = t.strip_prefix("--file=") {
750 pattern_from_flag = true;
751 pattern_files.push(v);
752 i += 1;
753 } else if t == "--file" {
754 pattern_from_flag = true;
755 pattern_files.extend(next);
756 i += 2;
757 } else if t == "--regexp" {
758 pattern_from_flag = true;
759 i += 2;
760 } else if t.starts_with("--regexp=") {
761 pattern_from_flag = true;
762 i += 1;
763 } else if grep_long_known(t) {
764 i += 1;
765 } else if grep_long_dangerous(t) {
766 unknown_flag = true;
767 i += 1;
768 } else {
769 files.push(t);
773 i += 1;
774 }
775 } else {
776 match grep_short_cluster(t, next) {
777 GrepShort::Unrecognized => {
778 unknown_flag = true;
779 i += 1;
780 }
781 GrepShort::Standalone => i += 1,
782 GrepShort::Pattern { file, consumes_next } => {
783 pattern_files.extend(file);
784 pattern_from_flag = true;
785 i += if consumes_next { 2 } else { 1 };
786 }
787 GrepShort::SkipValue { consumes_next } => i += if consumes_next { 2 } else { 1 },
788 }
789 }
790 }
791
792 if unknown_flag {
793 return None; }
795 if files.is_empty() {
796 return None;
800 }
801
802 if !pattern_from_flag {
803 files.remove(0); }
805 if recursive && files.is_empty() {
806 files.push("."); }
808
809 Some(GrepOperands { files, pattern_files, scale })
810}
811
812enum GrepShort<'a> {
814 Unrecognized,
816 Standalone,
818 Pattern { file: Option<&'a str>, consumes_next: bool },
821 SkipValue { consumes_next: bool },
823}
824
825fn grep_short_cluster<'a>(cluster: &'a str, next: Option<&'a str>) -> GrepShort<'a> {
828 const BENIGN: &[u8] = b"ivnclLoqswxHhaIrzZEFGbUP";
833 let bytes = cluster.as_bytes();
834 let mut k = 1;
835 while k < bytes.len() {
836 if !bytes[k].is_ascii() {
838 return GrepShort::Unrecognized;
839 }
840 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
842 match bytes[k] {
843 b'f' => {
844 let file = if has { Some(glued) } else { next };
845 return GrepShort::Pattern { file, consumes_next: !has };
846 }
847 b'e' => return GrepShort::Pattern { file: None, consumes_next: !has },
848 b'm' | b'A' | b'B' | b'C' | b'd' => return GrepShort::SkipValue { consumes_next: !has },
849 b if BENIGN.contains(&b) => k += 1,
850 _ => return GrepShort::Unrecognized,
851 }
852 }
853 GrepShort::Standalone
854}
855
856fn grep_long_known(flag: &str) -> bool {
859 const KNOWN: &[&str] = &[
860 "--recursive", "--ignore-case", "--invert-match", "--line-number", "--count", "--files-with-matches", "--files-without-match",
863 "--only-matching", "--perl-regexp", "--word-regexp", "--line-regexp", "--fixed-strings",
864 "--extended-regexp", "--basic-regexp", "--with-filename", "--no-filename",
865 "--quiet", "--silent", "--no-messages", "--null", "--byte-offset", "--text",
866 "--color", "--colour", "--help", "--version", "--after-context", "--before-context",
867 "--context", "--max-count", "--include", "--exclude", "--exclude-dir",
868 "--include-dir", "--binary-files", "--devices", "--directories",
869 ];
870 let name = flag.split('=').next().unwrap_or(flag);
871 KNOWN.contains(&name)
872}
873
874fn grep_long_dangerous(flag: &str) -> bool {
879 let name = flag.split('=').next().unwrap_or(flag);
880 matches!(name, "--dereference-recursive")
881}
882
883fn resolve_dd(tokens: &[Token]) -> Profile {
892 const PARAMS: &[&str] = &[
893 "bs", "ibs", "obs", "cbs", "count", "skip", "seek", "conv", "iflag", "oflag", "status",
894 ];
895 let (mut input, mut output) = (None, None);
896 for t in &tokens[1..] {
897 let t = t.as_str();
898 if t == "--help" || t == "--version" {
899 continue;
900 }
901 let Some((key, val)) = t.split_once('=') else {
902 return worst("dd: non key=value operand — worst-cased (§0)");
903 };
904 match key {
905 "if" => input = Some(val),
906 "of" => output = Some(val),
907 k if PARAMS.contains(&k) => {}
908 _ => return worst("dd: unrecognized operand — worst-cased (§0)"),
909 }
910 }
911 let input_locus = input.map_or(LocalLocus::Process, read_locus);
915 match output {
916 Some(of) => Profile::of(vec![
918 observes(input_locus, Scale::Single, "dd reads its input (if=) into the output"),
919 overwrites(classify_locus(of), Scale::Single, false),
920 ]),
921 None => Profile::of(vec![reads_content(
923 input_locus,
924 Scale::Single,
925 "dd copies its input to stdout (→ the model)",
926 )]),
927 }
928}
929
930fn resolve_tar(tokens: &[Token]) -> Profile {
941 let mut p = TarParse::default();
942 let mut dir: Option<String> = None;
949 let mut i = 1;
950 while i < tokens.len() {
951 let t = tokens[i].as_str();
952 if t == "-C" || t == "--directory" {
953 dir = tokens.get(i + 1).map(|d| tar_bound(dir.as_deref(), d.as_str()));
954 i += 2;
955 continue;
956 }
957 if let Some(d) = t.strip_prefix("--directory=").or_else(|| t.strip_prefix("-C").filter(|d| !d.is_empty())) {
958 dir = Some(tar_bound(dir.as_deref(), d));
959 i += 1;
960 continue;
961 }
962 if let Some(long) = t.strip_prefix("--") {
963 p.long_option(long);
964 } else if let Some(cluster) = t.strip_prefix('-').filter(|c| !c.is_empty()) {
965 p.cluster(cluster);
966 } else if i == 1 {
967 p.cluster(t); } else {
969 p.positionals.push((dir.clone(), t));
970 }
971 i += 1;
972 }
973 p.into_profile()
974}
975
976type TarPositional<'a> = (Option<String>, &'a str);
979
980type TarRef<'a> = (Option<&'a str>, &'a str);
982
983fn tar_bound(dir: Option<&str>, path: &str) -> String {
987 match dir {
988 Some(d) if !path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-') => {
989 format!("{}/{}", d.trim_end_matches('/'), path)
990 }
991 _ => path.to_string(),
992 }
993}
994
995#[derive(Default)]
1000struct TarParse<'a> {
1001 mode: Option<u8>,
1002 want_archive: bool,
1003 reject: bool,
1004 long_archive: Option<&'a str>,
1005 positionals: Vec<TarPositional<'a>>,
1007}
1008
1009impl<'a> TarParse<'a> {
1010 fn cluster(&mut self, cluster: &str) {
1011 const NOVAL: &[u8] = b"vzjJZpkmOwhSlPa"; for b in cluster.bytes() {
1013 match b {
1014 b'c' | b'x' | b't' | b'r' | b'u' | b'A' | b'd' => self.mode = Some(b),
1015 b'f' => self.want_archive = true,
1016 b'C' | b'T' | b'X' | b'b' | b'H' | b'g' | b'K' | b'N' => self.reject = true,
1017 x if NOVAL.contains(&x) => {}
1018 _ => self.reject = true,
1019 }
1020 }
1021 }
1022
1023 fn long_option(&mut self, long: &'a str) {
1024 let name = long.split('=').next().unwrap_or(long);
1025 match name {
1026 "create" => self.mode = Some(b'c'),
1027 "extract" | "get" => self.mode = Some(b'x'),
1028 "list" => self.mode = Some(b't'),
1029 "append" => self.mode = Some(b'r'),
1030 "update" => self.mode = Some(b'u'),
1031 "file" => match long.split_once('=') {
1032 Some((_, v)) => self.long_archive = Some(v),
1033 None => self.want_archive = true,
1034 },
1035 "gzip" | "bzip2" | "xz" | "zstd" | "compress" | "verbose" | "preserve-permissions"
1036 | "same-permissions" | "to-stdout" | "help" | "version" | "dereference" | "totals" => {}
1037 _ => self.reject = true,
1038 }
1039 }
1040
1041 fn into_profile(self) -> Profile {
1042 let Some(mode) = self.mode.filter(|_| !self.reject) else {
1043 return worst("tar: unrecognized/unmodeled option — worst-cased (§0)");
1044 };
1045 let (archive, members): (Option<TarRef>, &[TarPositional]) =
1048 if let Some(a) = self.long_archive {
1049 (Some((None, a)), &self.positionals)
1050 } else if self.want_archive {
1051 match self.positionals.split_first() {
1052 Some((first, rest)) => (Some((first.0.as_deref(), first.1)), rest),
1053 None => return worst("tar: -f without an archive — worst-cased (§0)"),
1054 }
1055 } else {
1056 (None, &self.positionals) };
1058 let archive_file = archive.filter(|(_, a)| *a != "-");
1060
1061 match mode {
1062 b'c' | b'r' | b'u' => {
1063 let mut caps: Vec<Capability> = members
1064 .iter()
1065 .map(|(dir, m)| observes(read_locus(&tar_bound(dir.as_deref(), m)), Scale::Bounded, "tar reads a member into the archive"))
1066 .collect();
1067 if let Some((dir, a)) = archive_file {
1068 caps.push(overwrites(classify_locus(&tar_bound(dir, a)), Scale::Single, false));
1069 }
1070 if caps.is_empty() {
1071 return worst("tar create with no members — worst-cased (§0)");
1072 }
1073 Profile::of(caps)
1074 }
1075 b't' => {
1076 let loc = archive_file.map_or(LocalLocus::Process, |(dir, a)| classify_locus(&tar_bound(dir, a)));
1077 Profile::of(vec![reads_content(loc, Scale::Single, "tar lists the archive's members (names → the model)")])
1078 }
1079 _ => worst("tar extract writes an archive-controlled, ..-escapable path set — worst-cased (§0)"),
1081 }
1082 }
1083}
1084
1085fn resolve_sed(tokens: &[Token]) -> Profile {
1092 let script = crate::handlers::coreutils::sed::scan_sed(tokens);
1098 if script.exec || script.unknown {
1099 return worst("sed: script has an `e` exec or unmodeled command — worst-cased (§0, HP-7)");
1100 }
1101 if script.script_file {
1104 return worst("sed: -f runs a script file we can't inspect — worst-cased (§0)");
1105 }
1106 const BOOL: &[u8] = b"nrEsuz"; let mut in_place = false;
1108 let mut script_from_flag = false;
1109 let mut script_files: Vec<&str> = Vec::new(); let mut files: Vec<&str> = Vec::new();
1111 let mut flags_done = false;
1112 let mut i = 1;
1113 while i < tokens.len() {
1114 let t = tokens[i].as_str();
1115 let next = tokens.get(i + 1).map(Token::as_str);
1116 if !flags_done && t == "--" {
1117 flags_done = true;
1118 i += 1;
1119 } else if flags_done || t == "-" || !t.starts_with('-') {
1120 files.push(t);
1121 i += 1;
1122 } else if let Some(long) = t.strip_prefix("--") {
1123 match sed_long(long, next, &mut in_place, &mut script_from_flag, &mut script_files) {
1124 Some(consumed) => i += consumed,
1125 None => return worst("sed: unrecognized flag — worst-cased (§0)"),
1126 }
1127 } else {
1128 match sed_cluster(&t[1..], next, BOOL) {
1129 SedShort::Bad => return worst("sed: unrecognized flag — worst-cased (§0)"),
1130 SedShort::InPlace => {
1131 in_place = true;
1132 i += 1;
1133 }
1134 SedShort::Standalone => i += 1,
1135 SedShort::Script { consumes_next } => {
1136 script_from_flag = true;
1137 i += usize::from(consumes_next) + 1;
1138 }
1139 SedShort::ScriptFile { file, consumes_next } => {
1140 script_from_flag = true;
1141 script_files.extend(file);
1142 i += usize::from(consumes_next) + 1;
1143 }
1144 SedShort::SkipValue { consumes_next } => i += usize::from(consumes_next) + 1,
1145 }
1146 }
1147 }
1148 if !script_from_flag && !files.is_empty() {
1150 files.remove(0);
1151 }
1152 let scale = breadth_scale(&files, false);
1156 let mut caps: Vec<Capability> =
1157 script_files.iter().map(|f| observes(read_locus(f), Scale::Single, "sed reads an -f script file")).collect();
1158 caps.extend(script.writes.iter().map(|f| mutates(classify_locus(f), Scale::Single, "sed w/W writes a file")));
1161 caps.extend(script.reads.iter().map(|f| observes(read_locus(f), Scale::Single, "sed r/R reads a file")));
1162 if in_place {
1163 caps.extend(files.iter().map(|f| mutates(classify_locus(f), scale, "sed -i edits the file in place")));
1164 } else {
1165 caps.extend(reads_to_model(&files, scale));
1166 }
1167 Profile::of(caps)
1168}
1169
1170pub(crate) fn substitution_locus(script: &crate::cst::Script) -> Option<LocalLocus> {
1177 let [stmt] = script.0.as_slice() else { return None };
1181 let cmds = &stmt.pipeline.commands;
1182 let mut idx = cmds.len().checked_sub(1)?;
1183 loop {
1184 match stage_output_locus(cmds.get(idx)?)? {
1185 StageOutput::Locus(l) => return Some(l),
1186 StageOutput::PassThrough => idx = idx.checked_sub(1)?,
1187 }
1188 }
1189}
1190
1191enum StageOutput {
1192 Locus(LocalLocus),
1193 PassThrough,
1195}
1196
1197fn stage_output_locus(cmd: &crate::cst::Cmd) -> Option<StageOutput> {
1198 let crate::cst::Cmd::Simple(simple) = cmd else { return None };
1199 let words: Vec<String> = simple.words.iter().map(crate::cst::Word::eval).collect();
1200 use crate::registry::types::OutputLocus;
1201 let (name, args) = words.split_first()?;
1202 if !trusted_command_path(name) {
1205 return None;
1206 }
1207 let token = Token::from_raw(name.clone());
1208 let canonical = crate::registry::canonical_name(token.command_name());
1209 let rule = crate::registry::command_output_locus(canonical)?;
1210 if args.iter().any(|a| flag_present(a, &rule.invalidated_by)) {
1213 return None;
1214 }
1215
1216 match rule.locus_from {
1217 OutputLocus::Cwd => Some(StageOutput::Locus(read_locus("."))),
1220 OutputLocus::Operands => {
1223 if args.iter().any(|a| is_unpinnable(a)) {
1228 return None;
1229 }
1230 let roots = candidate_roots(args, &rule.valued);
1231 let worst = roots.iter().map(|r| read_locus(r)).max().unwrap_or_else(|| read_locus("."));
1233 Some(StageOutput::Locus(worst))
1234 }
1235 OutputLocus::Stdin => {
1238 if candidate_roots(args, &rule.valued).is_empty() {
1239 Some(StageOutput::PassThrough)
1240 } else {
1241 None
1242 }
1243 }
1244 }
1245}
1246
1247fn flag_present(arg: &str, flags: &[String]) -> bool {
1251 let head = arg.split('=').next().unwrap_or(arg);
1252 flags.iter().any(|f| {
1253 if head == f {
1254 return true;
1255 }
1256 match (f.strip_prefix('-'), arg.strip_prefix('-')) {
1257 (Some(letter), Some(cluster)) if f.len() == 2 && !arg.starts_with("--") => {
1258 cluster.contains(letter)
1259 }
1260 _ => false,
1261 }
1262 })
1263}
1264
1265fn candidate_roots<'a>(args: &'a [String], valued: &[String]) -> Vec<&'a str> {
1276 let mut roots = Vec::new();
1277 let mut skip_value = false;
1278 for a in args {
1279 if std::mem::take(&mut skip_value) {
1280 continue;
1281 }
1282 if a.starts_with('-') {
1283 let (head, glued_value) = match a.split_once('=') {
1287 Some((h, v)) => (h, Some(v)),
1288 None => (a.as_str(), None),
1289 };
1290 if valued.iter().any(|v| v == head) {
1291 skip_value = glued_value.is_none();
1292 continue;
1293 }
1294 let glued = glued_value.or_else(|| a.find(['/', '~']).map(|i| &a[i..]));
1298 if let Some(v) = glued.filter(|v| !v.is_empty()) {
1299 roots.push(v);
1300 }
1301 continue;
1302 }
1303 roots.push(a.as_str());
1304 }
1305 roots
1306}
1307
1308fn resolve_perl(tokens: &[Token]) -> Profile {
1309 use crate::handlers::perl::PerlCode;
1315 let Some(scan) = crate::handlers::perl::scan_perl(tokens) else {
1316 return worst("perl: unmodeled flag cluster — worst-cased (§0)");
1317 };
1318 match scan.code {
1319 PerlCode::None => {
1320 let mut c = Capability::new(Operation::Observe);
1321 c.disclosure.audience = DisclosureAudience::LocalProcess;
1322 c.because = "perl: reports its own version/usage".to_string();
1323 return Profile::of(vec![c]);
1324 }
1325 PerlCode::Opaque => return worst("perl: no inspectable -e/-E one-liner — worst-cased (§0)"),
1329 PerlCode::Inspectable => {}
1330 }
1331 let files: Vec<&str> = scan.files.iter().map(String::as_str).collect();
1334 let scale = breadth_scale(&files, false);
1335 let caps: Vec<Capability> = if scan.in_place {
1345 files.iter().map(|f| mutates(classify_locus(f), scale, "perl -i edits the file in place")).collect()
1346 } else {
1347 reads_to_model(&files, scale)
1348 };
1349 Profile::of(caps)
1350}
1351
1352enum SedShort<'a> {
1354 Bad,
1355 Standalone,
1356 InPlace, Script { consumes_next: bool }, ScriptFile { file: Option<&'a str>, consumes_next: bool }, SkipValue { consumes_next: bool }, }
1361
1362fn sed_cluster<'a>(cluster: &'a str, next: Option<&'a str>, boolset: &[u8]) -> SedShort<'a> {
1363 let bytes = cluster.as_bytes();
1364 let mut k = 0;
1365 while k < bytes.len() {
1366 if !bytes[k].is_ascii() {
1369 return SedShort::Bad;
1370 }
1371 let glued = &cluster[k + 1..]; let has = !glued.is_empty();
1373 match bytes[k] {
1374 b'i' => return SedShort::InPlace, b'e' => return SedShort::Script { consumes_next: !has },
1376 b'f' => {
1377 let file = if has { Some(glued) } else { next };
1378 return SedShort::ScriptFile { file, consumes_next: !has };
1379 }
1380 b'l' if has || next.is_some() => return SedShort::SkipValue { consumes_next: !has }, b if boolset.contains(&b) => k += 1,
1382 _ => return SedShort::Bad,
1383 }
1384 }
1385 SedShort::Standalone
1386}
1387
1388fn sed_long<'a>(
1390 long: &'a str,
1391 next: Option<&'a str>,
1392 in_place: &mut bool,
1393 script_from_flag: &mut bool,
1394 script_files: &mut Vec<&'a str>,
1395) -> Option<usize> {
1396 let name = long.split('=').next().unwrap_or(long);
1397 match name {
1398 "in-place" => *in_place = true, "expression" => {
1400 *script_from_flag = true;
1401 return Some(if long.contains('=') { 1 } else { 2 });
1402 }
1403 "file" => {
1404 *script_from_flag = true;
1405 match long.split_once('=') {
1406 Some((_, v)) => script_files.push(v),
1407 None => {
1408 script_files.extend(next);
1409 return Some(2);
1410 }
1411 }
1412 }
1413 "quiet" | "silent" | "regexp-extended" | "null-data" | "separate" | "unbuffered"
1414 | "posix" | "help" | "version" | "debug" | "follow-symlinks" | "sandbox"
1415 | "zero-terminated" | "line-length" => {}
1416 _ => return None,
1417 }
1418 Some(1)
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424
1425 fn toks(parts: &[&str]) -> Vec<Token> {
1426 parts.iter().map(|p| Token::from_test(p)).collect()
1427 }
1428
1429 fn level(name: &str) -> &'static crate::engine::level::Level {
1430 crate::engine::authoring::default_levels()
1431 .iter()
1432 .find(|l| l.name == name)
1433 .expect("level exists")
1434 }
1435
1436 fn inert() -> &'static crate::engine::level::Level {
1437 level("paranoid")
1438 }
1439
1440 fn read_local() -> &'static crate::engine::level::Level {
1441 level("reader")
1442 }
1443
1444 #[test]
1448 fn openssl_resolver_gates_model_disclosure_only() {
1449 let (dev, yolo) = (level("developer"), level("yolo"));
1450 for parts in [
1451 &["openssl", "rsa", "-in", "priv.pem"][..],
1452 &["openssl", "rsa", "-in", "priv.pem", "-pubout", "-text"], &["openssl", "rsa", "-in", "priv.pem", "-out", "/dev/stdout"], &["openssl", "rsa", "-in", "priv.pem", "-noout", "-text"],
1455 &["openssl", "pkcs8", "-in", "priv.pem"],
1456 &["openssl", "enc", "--d", "-k", "p", "-in", "c"], &["openssl", "cms", "-EncryptedData_decrypt", "-in", "m"],
1458 &["openssl", "pkcs12", "-in", "f.p12", "-noenc"],
1459 ] {
1460 let p = resolve(&toks(parts)).unwrap_or_else(|| panic!("resolves: {parts:?}"));
1461 assert!(
1462 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads),
1463 "secret=reads: {parts:?}",
1464 );
1465 assert!(!dev.admits(&p), "developer refuses: {parts:?}");
1466 assert!(yolo.admits(&p), "yolo admits: {parts:?}");
1467 }
1468 for parts in [
1469 &["openssl", "rsa", "-in", "priv.pem", "-pubout"][..],
1470 &["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"],
1474 &["openssl", "enc", "-e", "-in", "x", "-out", "x.enc", "-k", "p"],
1475 &["openssl", "x509", "-in", "c", "-noout", "-text"],
1476 ] {
1477 assert!(resolve(&toks(parts)).is_none(), "resolver abstains (→ legacy): {parts:?}");
1478 }
1479 }
1480
1481 #[test]
1486 fn openssl_output_destination_is_fail_closed() {
1487 let dev = level("developer");
1488 let reaches_model = |args: &[&str]| {
1489 let mut parts = vec!["openssl", "rsa", "-in", "priv.pem"];
1490 parts.extend_from_slice(args);
1491 match resolve(&toks(&parts)) {
1494 None => false,
1495 Some(p) => {
1496 p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads) && !dev.admits(&p)
1497 }
1498 }
1499 };
1500 for evasion in [
1501 &["-out", "//dev/stdout"][..],
1502 &["-out", "/dev/./stdout"],
1503 &["-out", "//dev/fd/1"],
1504 &["-out", "/dev/fd//1"],
1505 &["-out=//dev/stdout"],
1506 &["-out", "/dev/stderr"],
1507 &["-out", "/foo/../dev/stdout"],
1508 &["-out", "dup.pem", "-out", "/dev/stdout"], &["-out", "-"],
1510 &["-provider-path", "-out", "-provider-path", "safe.pem"],
1514 &["-out", "-anything"],
1515 ] {
1516 assert!(reaches_model(evasion), "must read as model-reaching: {evasion:?}");
1517 }
1518 for diverted in [
1519 &["-out", "clean.pem"][..],
1520 &["-out", "./sub/key.pem"],
1521 &["-out", "devnotes.pem"], &["-out", "/home/u/key.pem"],
1523 &["-noout"],
1524 ] {
1525 assert!(!reaches_model(diverted), "must divert off the model: {diverted:?}");
1526 }
1527 }
1528
1529 #[test]
1530 fn echo_resolves_to_a_benign_inert_profile() {
1531 let p = resolve(&toks(&["echo", "hi"])).expect("echo has a resolver");
1532 assert_eq!(p.capabilities.len(), 1);
1533 let c = &p.capabilities[0];
1534 assert_eq!(c.operation, Operation::Observe);
1535 assert_eq!(c.locus.local, LocalLocus::Process);
1536 assert_eq!(c.disclosure.audience, DisclosureAudience::LocalProcess);
1537 assert!(!c.because.is_empty(), "a structural certification cites its reason");
1538 assert!(inert().admits(&p), "echo is fully certified and inert-safe");
1540 }
1541
1542 #[test]
1543 fn echo_flags_do_not_change_its_profile() {
1544 let bare = resolve(&toks(&["echo", "hi"])).expect("echo");
1545 let flagged = resolve(&toks(&["echo", "-n", "-e", "hi"])).expect("echo -n -e");
1546 assert_eq!(bare, flagged);
1547 assert!(inert().admits(&flagged));
1548 }
1549
1550 #[test]
1551 fn an_unresearched_command_has_no_resolver() {
1552 assert!(resolve(&toks(UNRESOLVED_CMD)).is_none(), "unresearched → caller worst-cases");
1553 assert!(resolve(&[]).is_none(), "empty tokens");
1554 }
1555
1556 #[test]
1557 fn cat_of_a_worktree_file_is_read_local() {
1558 let p = resolve(&toks(&["cat", "./notes.md"])).expect("cat");
1559 assert!(read_local().admits(&p), "cat ./notes.md");
1560 assert!(!inert().admits(&p), "reading a real file is above inert");
1561 }
1562
1563 #[test]
1564 fn cat_beyond_the_worktree_is_denied_by_locus() {
1565 for path in ["~/.ssh/id_rsa", "~/notes", "/etc/shadow", "$SECRET", "../outside", "/var/lib/mysql/data"] {
1567 let p = resolve(&toks(&["cat", path])).expect("cat");
1568 assert!(!read_local().admits(&p), "cat {path} is above read-local by locus");
1569 }
1570 }
1571
1572 #[test]
1573 fn cat_of_a_system_file_is_not_admitted() {
1574 for path in ["/etc/hosts", "/etc/os-release", "/usr/share/doc/x"] {
1576 let p = resolve(&toks(&["cat", path])).expect("cat");
1577 assert!(!read_local().admits(&p), "cat {path} is no longer auto-approved");
1578 }
1579 }
1580
1581 #[test]
1582 fn cat_stdin_is_process_scoped() {
1583 assert!(inert().admits(&resolve(&toks(&["cat"])).expect("cat")), "no operand → stdin");
1584 assert!(inert().admits(&resolve(&toks(&["cat", "-"])).expect("cat -")), "- → stdin");
1585 }
1586
1587 #[test]
1588 fn cat_reads_every_file_operand_and_one_home_read_sinks_it() {
1589 let p = resolve(&toks(&["cat", "-n", "a.txt", "src/b.rs"])).expect("cat");
1590 assert_eq!(p.capabilities.len(), 2, "-n is a flag; two files");
1591 assert!(read_local().admits(&p), "both worktree");
1592
1593 let mixed = resolve(&toks(&["cat", "a.txt", "~/.ssh/id_rsa"])).expect("cat");
1594 assert!(!read_local().admits(&mixed), "one home read sinks the whole profile");
1595 }
1596
1597 #[test]
1598 fn cat_double_dash_treats_the_rest_as_files() {
1599 let p = resolve(&toks(&["cat", "--", "-n"])).expect("cat");
1600 assert_eq!(p.capabilities.len(), 1, "-n after -- is a filename");
1601 assert!(read_local().admits(&p));
1602 }
1603
1604 #[test]
1605 fn head_tail_wc_read_like_cat_and_honor_numeric_shorthand() {
1606 use crate::engine::bridge::project;
1607 use crate::verdict::{SafetyLevel, Verdict};
1608 for cmd in [
1610 vec!["head", "README.md"],
1611 vec!["head", "-n", "5", "src/main.rs"],
1612 vec!["head", "-20", "src/main.rs"], vec!["tail", "-f", "./log.txt"], vec!["tail", "-n", "100", "./log.txt"],
1615 vec!["wc", "-l", "./notes.md"],
1616 ] {
1617 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Allowed(SafetyLevel::SafeRead), "{cmd:?}");
1618 }
1619 assert_eq!(project(&resolve(&toks(&["wc", "-c", "-"])).expect("wc")), Verdict::Allowed(SafetyLevel::Inert), "wc stdin");
1621 for cmd in [vec!["head", "~/.ssh/id_rsa"], vec!["tail", "/etc/shadow"], vec!["wc", "-l", "$SECRET"]] {
1622 assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Denied, "{cmd:?} beyond worktree");
1623 }
1624 let p = resolve(&toks(&["head", "-20", "src/main.rs"])).expect("head");
1626 assert_eq!(p.capabilities.len(), 1, "-20 is the count, not a file");
1627 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from=list"])).expect("wc")), Verdict::Denied, "--files0-from");
1629 assert_eq!(project(&resolve(&toks(&["wc", "--files0-from", "-"])).expect("wc")), Verdict::Denied, "--files0-from -");
1630 assert_eq!(project(&resolve(&toks(&["head", "-Z", "x"])).expect("head")), Verdict::Denied, "unknown flag");
1632 }
1633
1634 #[test]
1635 fn grep_reads_its_files_not_the_pattern() {
1636 let p = resolve(&toks(&["grep", "foo", "file.txt"])).expect("grep");
1637 assert_eq!(p.capabilities.len(), 1, "the pattern is not a file");
1638 assert!(read_local().admits(&p));
1639 }
1640
1641 #[test]
1642 fn grep_beyond_the_worktree_is_denied() {
1643 for args in [
1644 vec!["grep", "foo", "~/.ssh/config"],
1645 vec!["grep", "-r", "foo", "~"],
1646 vec!["grep", "foo", "$DIR"],
1647 ] {
1648 let p = resolve(&toks(&args)).expect("grep");
1649 assert!(!read_local().admits(&p), "{args:?}");
1650 }
1651 }
1652
1653 #[test]
1654 fn grep_recursive_is_unbounded_and_defaults_to_cwd() {
1655 let p = resolve(&toks(&["grep", "-r", "foo", "src/"])).expect("grep");
1656 assert!(p.capabilities.iter().all(|c| c.scale == Scale::Unbounded), "-r → unbounded");
1657 assert!(read_local().admits(&p), "recursive worktree search");
1658
1659 let cwd = resolve(&toks(&["grep", "-r", "foo"])).expect("grep");
1660 assert!(cwd.capabilities.iter().all(|c| c.locus.local == LocalLocus::Worktree), "cwd, not stdin");
1661 assert!(read_local().admits(&cwd));
1662 }
1663
1664 #[test]
1665 fn grep_e_and_f_supply_the_pattern_so_positionals_are_files() {
1666 let e = resolve(&toks(&["grep", "-e", "foo", "file.txt"])).expect("grep -e");
1668 assert_eq!(e.capabilities.len(), 1);
1669 assert!(read_local().admits(&e));
1670
1671 let f = resolve(&toks(&["grep", "-f", "patterns.txt", "file.txt"])).expect("grep -f");
1673 assert_eq!(f.capabilities.len(), 2, "patterns.txt + file.txt");
1674 assert!(read_local().admits(&f));
1675
1676 let home = resolve(&toks(&["grep", "-f", "~/.secret-patterns", "file.txt"])).expect("grep -f");
1677 assert!(!read_local().admits(&home), "a home pattern file is denied by locus");
1678
1679 let glued = resolve(&toks(&["grep", "-fpatterns.txt", "file.txt"])).expect("grep -f glued");
1681 assert_eq!(glued.capabilities.len(), 2, "glued -f value is still a read");
1682 let glued_home = resolve(&toks(&["grep", "-if~/.secrets", "x"])).expect("grep -if glued");
1683 assert!(!read_local().admits(&glued_home), "glued home pattern file denied by locus");
1684 }
1685
1686 #[test]
1687 fn grep_long_flags() {
1688 assert_eq!(resolve(&toks(&["grep", "--file", "p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1690 assert_eq!(resolve(&toks(&["grep", "--file=p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1691
1692 let r = resolve(&toks(&["grep", "--regexp", "foo", "f.txt"])).expect("grep");
1694 assert_eq!(r.capabilities.len(), 1);
1695 assert!(read_local().admits(&r));
1696
1697 let m = resolve(&toks(&["grep", "--max-count", "5", "foo", "f.txt"])).expect("grep");
1701 assert!(read_local().admits(&m), "--max-count 5 is fail-safe (imprecise)");
1702
1703 let pcre = resolve(&toks(&["grep", "--perl-regexp", "foo", "f"])).expect("grep");
1705 assert!(read_local().admits(&pcre), "grep --perl-regexp reads a file, it does not exec");
1706 }
1707
1708 #[test]
1709 fn grep_dash_patterns_are_search_patterns_not_flags() {
1710 for args in [
1715 vec!["grep", "-->", "file.txt"],
1716 vec!["grep", "---", "file.txt"],
1717 vec!["grep", "--some-pattern", "file.txt"],
1718 vec!["grep", "-rn", "-->", "src/"],
1719 vec!["grep", "-i", "-r", "-n", "-->", "src/"],
1720 ] {
1721 let p = resolve(&toks(&args)).expect("grep");
1722 assert!(read_local().admits(&p), "dash-pattern should read-local: {args:?}");
1723 assert!(!inert().admits(&p), "it still reads a file: {args:?}");
1724 }
1725 let args = vec!["grep", "--dereference-recursive", "foo", "dir"];
1727 let p = resolve(&toks(&args)).expect("grep");
1728 assert!(!read_local().admits(&p), "dangerous long must worst-case: {args:?}");
1729 for args in [
1731 vec!["grep", "-P", "foo", "f"],
1732 vec!["grep", "--perl-regexp", "foo", "f"],
1733 vec!["grep", "-oP", "foo", "f"],
1734 ] {
1735 let p = resolve(&toks(&args)).expect("grep");
1736 assert!(read_local().admits(&p), "grep PCRE flag should read-local: {args:?}");
1737 }
1738 }
1739
1740 #[test]
1741 fn grep_stdin_and_standalone_flags() {
1742 assert!(inert().admits(&resolve(&toks(&["grep", "foo"])).expect("grep")), "no file → stdin");
1743 let p = resolve(&toks(&["grep", "-i", "-n", "foo", "file.txt"])).expect("grep");
1744 assert_eq!(p.capabilities.len(), 1, "-i -n standalone; foo pattern; file.txt file");
1745 assert!(read_local().admits(&p));
1746 }
1747
1748 fn one_cap(cmd: &[&str]) -> Capability {
1751 let p = resolve(&toks(cmd)).expect("resolves");
1752 assert_eq!(p.capabilities.len(), 1, "{cmd:?} is a single-capability invocation");
1753 let mut c = p.capabilities[0].clone();
1754 c.because = String::new();
1755 c
1756 }
1757
1758 #[test]
1764 fn golden_profiles_cover_every_facet() {
1765 let mut echo = Capability::new(Operation::Observe);
1768 echo.disclosure.audience = DisclosureAudience::LocalProcess;
1769 assert_eq!(one_cap(&["echo", "hi"]), echo, "echo");
1770
1771 let mut cat = Capability::new(Operation::Observe);
1773 cat.locus.local = LocalLocus::Worktree;
1774 cat.disclosure.audience = DisclosureAudience::LocalProcess;
1775 assert_eq!(one_cap(&["cat", "./notes.md"]), cat, "cat ./notes.md");
1776
1777 let mut cat_home = cat.clone();
1779 cat_home.locus.local = LocalLocus::Machine;
1780 assert_eq!(one_cap(&["cat", "~/notes.txt"]), cat_home, "cat ~/notes.txt");
1781
1782 let mut cat_cred = cat.clone();
1784 cat_cred.locus.local = LocalLocus::Machine;
1785 assert_eq!(one_cap(&["cat", "~/.ssh/id_rsa"]), cat_cred, "cat ~/.ssh/id_rsa");
1786
1787 assert_eq!(one_cap(&["grep", "foo", "file.txt"]), cat, "grep foo file.txt");
1789
1790 let mut grep_r = cat.clone();
1792 grep_r.scale = Scale::Unbounded;
1793 assert_eq!(one_cap(&["grep", "-r", "foo", "src/"]), grep_r, "grep -r foo src/");
1794
1795 let mut rm = Capability::new(Operation::Destroy);
1797 rm.locus.local = LocalLocus::Worktree;
1798 rm.reversibility = Reversibility::Effortful;
1799 assert_eq!(one_cap(&["rm", "./x"]), rm, "rm ./x");
1800
1801 let mut mkdir = Capability::new(Operation::Create);
1803 mkdir.locus.local = LocalLocus::Worktree;
1804 mkdir.reversibility = Reversibility::Trivial;
1805 mkdir.persistence.level = PersistenceLevel::Data;
1806 assert_eq!(one_cap(&["mkdir", "./build"]), mkdir, "mkdir ./build");
1807
1808 assert_eq!(one_cap(&["touch", "./new.txt"]), mkdir, "touch ./new.txt");
1810
1811 let cp = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
1814 assert_eq!(cp.capabilities.len(), 2, "cp = source read + dest write");
1815 let mut src = Capability::new(Operation::Observe);
1816 src.locus.local = LocalLocus::Worktree; assert_eq!(clear_because(&cp.capabilities[0]), src, "cp source read");
1818 let mut dst = Capability::new(Operation::Create);
1819 dst.locus.local = LocalLocus::Worktree;
1820 dst.reversibility = Reversibility::Trivial; dst.persistence.level = PersistenceLevel::Data;
1822 assert_eq!(clear_because(&cp.capabilities[1]), dst, "cp -n dest write");
1823
1824 let mv = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
1827 let mut mv_src = Capability::new(Operation::Mutate);
1828 mv_src.locus.local = LocalLocus::Worktree;
1829 mv_src.reversibility = Reversibility::Trivial;
1830 assert_eq!(clear_because(&mv.capabilities[0]), mv_src, "mv source relocation");
1831 let mut mv_dst = Capability::new(Operation::Create);
1832 mv_dst.locus.local = LocalLocus::Worktree;
1833 mv_dst.reversibility = Reversibility::Recoverable;
1834 mv_dst.persistence.level = PersistenceLevel::Data;
1835 assert_eq!(clear_because(&mv.capabilities[1]), mv_dst, "mv dest write");
1836
1837 let ln = resolve(&toks(&["ln", "./a", "./b"])).expect("ln");
1840 let mut ln_tgt = Capability::new(Operation::Observe);
1841 ln_tgt.locus.local = LocalLocus::Worktree;
1842 assert_eq!(clear_because(&ln.capabilities[0]), ln_tgt, "ln target bridge");
1843 let mut ln_link = Capability::new(Operation::Create);
1844 ln_link.locus.local = LocalLocus::Worktree;
1845 ln_link.reversibility = Reversibility::Trivial;
1846 ln_link.persistence.level = PersistenceLevel::Data;
1847 assert_eq!(clear_because(&ln.capabilities[1]), ln_link, "ln link create");
1848 }
1849
1850 fn clear_because(c: &Capability) -> Capability {
1851 let mut c = c.clone();
1852 c.because = String::new();
1853 c
1854 }
1855
1856 #[test]
1857 fn mkdir_creates_in_the_worktree_but_not_beyond_it() {
1858 use crate::engine::bridge::project;
1859 use crate::verdict::{SafetyLevel, Verdict};
1860 for cmd in [vec!["mkdir", "./build"], vec!["mkdir", "-p", "a/b/c"], vec!["mkdir", "-m", "755", "./x"]] {
1862 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1863 }
1864 for cmd in [vec!["mkdir", "/etc/evil"], vec!["mkdir", "~/newdir"], vec!["mkdir", "$HOME/x"]] {
1866 assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Denied, "{cmd:?}");
1867 }
1868 let g = resolve(&toks(&["mkdir", "-m755", "./x"])).expect("mkdir");
1870 assert_eq!(g.capabilities.len(), 1, "-m755 glued: only ./x is an operand");
1871 assert_eq!(g.capabilities[0].locus.local, LocalLocus::Worktree);
1872 assert_eq!(project(&resolve(&toks(&["mkdir", "-Q", "x"])).expect("mkdir")), Verdict::Denied, "unknown flag");
1874 assert_eq!(project(&resolve(&toks(&["mkdir"])).expect("mkdir")), Verdict::Denied, "no operand");
1875 }
1876
1877 #[test]
1878 fn cp_splits_source_and_dest_loci_and_overwrite_gates_the_level() {
1879 use crate::engine::bridge::project;
1880 use crate::verdict::{SafetyLevel, Verdict};
1881
1882 let plain = resolve(&toks(&["cp", "./a", "./b"])).expect("cp");
1886 assert_eq!(plain.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "dest overwrite");
1887 assert_eq!(project(&plain), Verdict::Allowed(SafetyLevel::SafeWrite), "cp ./a ./b");
1888 let nc = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
1889 assert_eq!(nc.capabilities.last().unwrap().reversibility, Reversibility::Trivial, "-n cannot clobber");
1890 assert_eq!(project(&nc), Verdict::Allowed(SafetyLevel::SafeWrite), "cp -n ./a ./b");
1891
1892 assert_eq!(project(&resolve(&toks(&["cp", "~/.ssh/id_rsa", "./x"])).expect("cp")), Verdict::Denied, "home source");
1895 assert_eq!(project(&resolve(&toks(&["cp", "/etc/shadow", "./x"])).expect("cp")), Verdict::Denied, "system source");
1896 assert_eq!(project(&resolve(&toks(&["cp", "./x", "~/backdoor"])).expect("cp")), Verdict::Denied, "home dest");
1898 assert_eq!(project(&resolve(&toks(&["cp", "./x", "/etc/cron.d/x"])).expect("cp")), Verdict::Denied, "system dest");
1899
1900 for form in [
1903 vec!["cp", "-t", "./dest", "./a", "./b"],
1904 vec!["cp", "--target-directory=./dest", "./a", "./b"],
1905 vec!["cp", "-t./dest", "./a", "./b"], ] {
1907 let t = resolve(&toks(&form)).expect("cp -t");
1908 assert_eq!(t.capabilities.len(), 3, "{form:?}: 2 sources + 1 dest");
1909 assert_eq!(project(&t), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
1910 }
1911 assert_eq!(project(&resolve(&toks(&["cp", "-t/etc", "./a"])).expect("cp")), Verdict::Denied, "cp -t/etc");
1913
1914 for form in [
1917 vec!["cp", "--backup", "./a", "./b"],
1918 vec!["cp", "--preserve", "./a", "./b"],
1919 vec!["cp", "--preserve=mode", "./a", "./b"],
1920 ] {
1921 let c = resolve(&toks(&form)).expect("cp");
1922 assert_eq!(c.capabilities.len(), 2, "{form:?}: source read + dest write");
1923 assert_eq!(project(&c), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
1924 }
1925
1926 assert_eq!(resolve(&toks(&["cp", "-r", "./a", "./b"])).expect("cp").capabilities[0].scale, Scale::Unbounded);
1928 assert_eq!(project(&resolve(&toks(&["cp", "./only"])).expect("cp")), Verdict::Denied, "no dest");
1929 assert_eq!(project(&resolve(&toks(&["cp", "-Q", "./a", "./b"])).expect("cp")), Verdict::Denied, "unknown flag");
1930 assert_eq!(project(&resolve(&toks(&["cp", "-t", "./dest"])).expect("cp")), Verdict::Denied, "-t no source");
1933 }
1934
1935 #[test]
1936 fn mv_relocates_within_the_worktree_and_gates_both_loci() {
1937 use crate::engine::bridge::project;
1938 use crate::verdict::{SafetyLevel, Verdict};
1939
1940 let m = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
1943 assert_eq!(m.capabilities[0].operation, Operation::Mutate, "source is a relocation, not a destroy");
1944 assert_eq!(m.capabilities[0].reversibility, Reversibility::Trivial, "mv back");
1945 assert_eq!(project(&m), Verdict::Allowed(SafetyLevel::SafeWrite), "mv ./a ./b");
1946
1947 assert_eq!(project(&resolve(&toks(&["mv", "~/.ssh/id_rsa", "./x"])).expect("mv")), Verdict::Denied, "source in home");
1949 assert_eq!(project(&resolve(&toks(&["mv", "./x", "~/exfil"])).expect("mv")), Verdict::Denied, "dest in home");
1950 assert_eq!(project(&resolve(&toks(&["mv", ".git/config", "./x"])).expect("mv")), Verdict::Denied, "mv .git/config");
1953 assert_eq!(project(&resolve(&toks(&["cp", ".git/config", "./x"])).expect("cp")), Verdict::Allowed(SafetyLevel::SafeWrite), "cp .git/config reads");
1954
1955 let cfg = "~/.config/safe-chains.toml";
1961 assert_eq!(
1962 resolve(&toks(&["mv", cfg, "./x"])).expect("mv").capabilities[0].locus.local,
1963 LocalLocus::Machine,
1964 "mv source removal gates at the WRITE face",
1965 );
1966 assert_eq!(
1967 resolve(&toks(&["cp", cfg, "./x"])).expect("cp").capabilities[0].locus.local,
1968 LocalLocus::WorktreeTrusted,
1969 "cp source read gates at the READ face",
1970 );
1971
1972 let t = resolve(&toks(&["mv", "-t", "./dest", "./a", "./b"])).expect("mv -t");
1974 assert_eq!(t.capabilities.len(), 3, "2 sources + 1 dest");
1975 assert_eq!(project(&resolve(&toks(&["mv", "./only"])).expect("mv")), Verdict::Denied, "no dest");
1976 assert_eq!(project(&resolve(&toks(&["mv", "-Q", "./a", "./b"])).expect("mv")), Verdict::Denied, "unknown flag");
1977 }
1978
1979 #[test]
1980 fn ln_is_cp_by_reference_and_gates_the_target_locus() {
1981 use crate::engine::bridge::project;
1982 use crate::verdict::{SafetyLevel, Verdict};
1983
1984 for cmd in [vec!["ln", "./a", "./b"], vec!["ln", "-s", "./target", "./link"]] {
1986 let p = resolve(&toks(&cmd)).expect("ln");
1987 assert_eq!(p.capabilities[0].operation, Operation::Observe, "target is a bridged read");
1988 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1989 }
1990 assert_eq!(project(&resolve(&toks(&["ln", "~/.ssh/id_rsa", "./x"])).expect("ln")), Verdict::Denied, "hard link to home credential");
1993 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/shadow", "./x"])).expect("ln")), Verdict::Denied, "symlink to secret");
1994 assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/hosts", "./x"])).expect("ln")), Verdict::Denied, "symlink to system path");
1996 assert_eq!(project(&resolve(&toks(&["ln", "-s", "./a", "~/evil"])).expect("ln")), Verdict::Denied, "link into home");
1998 assert_eq!(resolve(&toks(&["ln", "-t", "./dir", "./a", "./b"])).expect("ln -t").capabilities.len(), 3);
2000 assert_eq!(project(&resolve(&toks(&["ln", "./only"])).expect("ln")), Verdict::Denied, "no link name");
2001 assert_eq!(project(&resolve(&toks(&["ln", "-Q", "./a", "./b"])).expect("ln")), Verdict::Denied, "unknown flag");
2002 let forced = resolve(&toks(&["ln", "-f", "./a", "./b"])).expect("ln -f");
2006 assert_eq!(project(&forced), Verdict::Allowed(SafetyLevel::SafeWrite), "ln -f worktree link");
2007 assert_eq!(forced.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "ln -f overwrites → recoverable");
2008 assert_eq!(
2009 resolve(&toks(&["ln", "./a", "./b"])).expect("ln").capabilities.last().unwrap().reversibility,
2010 Reversibility::Trivial,
2011 "ln default no-clobber → trivial",
2012 );
2013 }
2014
2015 #[test]
2016 fn dd_parses_key_value_operands_and_gates_both_sides() {
2017 use crate::engine::bridge::project;
2018 use crate::verdict::{SafetyLevel, Verdict};
2019
2020 assert_eq!(
2022 project(&resolve(&toks(&["dd", "if=./a", "of=./b", "bs=1M", "count=10"])).expect("dd")),
2023 Verdict::Allowed(SafetyLevel::SafeWrite),
2024 "dd worktree copy",
2025 );
2026 assert_eq!(project(&resolve(&toks(&["dd", "if=./notes"])).expect("dd")), Verdict::Allowed(SafetyLevel::SafeRead), "dd to stdout");
2028 assert_eq!(project(&resolve(&toks(&["dd"])).expect("dd")), Verdict::Allowed(SafetyLevel::Inert), "bare dd is stdin→stdout");
2029
2030 for cmd in [
2032 vec!["dd", "if=~/.ssh/id_rsa", "of=./x"], vec!["dd", "if=./x", "of=/dev/rdisk0"], vec!["dd", "if=./x", "of=/dev/sda"],
2035 vec!["dd", "if=./x", "of=~/backup"], vec!["dd", "if=~/.ssh/id_rsa"], ] {
2038 assert_eq!(project(&resolve(&toks(&cmd)).expect("dd")), Verdict::Denied, "{cmd:?}");
2039 }
2040 assert_eq!(project(&resolve(&toks(&["dd", "./file"])).expect("dd")), Verdict::Denied, "positional operand");
2042 assert_eq!(project(&resolve(&toks(&["dd", "exec=evil", "of=./x"])).expect("dd")), Verdict::Denied, "unknown key");
2043 }
2044
2045 #[test]
2046 fn tar_parses_dashless_bundles_and_splits_by_mode() {
2047 use crate::engine::bridge::project;
2048 use crate::verdict::{SafetyLevel, Verdict};
2049
2050 for cmd in [
2053 vec!["tar", "czf", "backup.tar", "./src"],
2054 vec!["tar", "-czf", "backup.tar", "./src"],
2055 vec!["tar", "--create", "--file=backup.tar", "./src"],
2056 ] {
2057 assert_eq!(project(&resolve(&toks(&cmd)).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2058 }
2059 assert_eq!(project(&resolve(&toks(&["tar", "tzf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeRead), "list");
2061
2062 assert_eq!(project(&resolve(&toks(&["tar", "czf", "-", "~/.ssh"])).expect("tar")), Verdict::Denied, "bundle secret to stdout");
2065 assert_eq!(project(&resolve(&toks(&["tar", "czf", "out.tar", "~/.aws"])).expect("tar")), Verdict::Denied, "bundle home member");
2066 assert_eq!(project(&resolve(&toks(&["tar", "cf", "~/backup.tar", "./src"])).expect("tar")), Verdict::Denied, "archive into home");
2068
2069 assert_eq!(project(&resolve(&toks(&["tar", "xzf", "release.tar"])).expect("tar")), Verdict::Denied, "extract");
2071 assert_eq!(project(&resolve(&toks(&["tar", "cf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "empty archive");
2074 assert_eq!(project(&resolve(&toks(&["tar", "-C", "/etc", "xf", "a.tar"])).expect("tar")), Verdict::Denied, "-C unmodeled");
2076 assert_eq!(project(&resolve(&toks(&["tar", "c"])).expect("tar")), Verdict::Denied, "create to stdout, no members");
2077 assert_eq!(project(&resolve(&toks(&["tar", "zf", "backup.tar"])).expect("tar")), Verdict::Denied, "no mode letter");
2078 }
2079
2080 #[cfg(test)]
2087 fn sub_locus(line: &str) -> Option<LocalLocus> {
2088 let script = crate::cst::parse(line).expect("parses");
2089 substitution_locus(&script)
2090 }
2091
2092 #[test]
2101 fn every_output_claim_is_bounded_by_its_roots() {
2102 use crate::registry::types::OutputLocus;
2103
2104 let mut probed = 0usize;
2105 for name in crate::registry::toml_command_names() {
2106 let Some(spec) = crate::registry::command_output_locus(name) else { continue };
2107 probed += 1;
2108 match spec.locus_from {
2109 OutputLocus::Operands => {
2110 let hot_roots: Vec<&str> =
2115 HOT_PATHS.iter().copied().chain(["~", "~/.ssh"]).collect();
2116 for hot in hot_roots {
2117 for line in [
2120 format!("{name} pat {hot}"),
2121 format!("{name} --base-directory {hot} pat"),
2122 format!("{name} --search-path={hot} pat"),
2123 format!("{name} -E{hot} pat"),
2124 ] {
2125 let got = sub_locus(&line);
2126 let want = read_locus(hot);
2127 assert!(
2128 got.is_none_or(|l| l >= want),
2129 "`{line}`: reported {got:?}, but reading {hot} is {want:?}",
2130 );
2131 }
2132 }
2133 assert_eq!(sub_locus(&format!("{name} pat $(hostname)")), None, "{name}: nested sub");
2135 }
2136 OutputLocus::Cwd => {
2139 assert_eq!(sub_locus(name), Some(read_locus(".")), "{name}: bare");
2140 }
2141 OutputLocus::Stdin => {
2144 for hot in HOT_PATHS {
2145 assert_eq!(
2146 sub_locus(&format!("{name} {hot}")),
2147 None,
2148 "{name}: a file operand makes it print contents, not paths",
2149 );
2150 }
2151 }
2152 }
2153 for flag in &spec.invalidated_by {
2155 let line = format!("{name} {flag} pat");
2156 assert_eq!(sub_locus(&line), None, "`{line}`: {flag} is declared invalidating");
2157 }
2158 }
2159 assert!(probed > 0, "no command declares [command.output] — the guard is vacuous");
2160 }
2161
2162 #[test]
2167 fn output_valued_flags_agree_across_spellings() {
2168 use crate::registry::types::OutputLocus;
2169 let mut checked = 0usize;
2170 for name in crate::registry::toml_command_names() {
2171 let Some(spec) = crate::registry::command_output_locus(name) else { continue };
2172 for flag in &spec.valued {
2173 if spec.invalidated_by.contains(flag) {
2175 continue;
2176 }
2177 let producer = match spec.locus_from {
2188 OutputLocus::Stdin => "fd a app/ | ",
2189 _ => "",
2190 };
2191 for tail in ["", " /etc/hosts"] {
2192 let separated = sub_locus(&format!("{producer}{name} {flag} 5{tail}"));
2193 let glued = sub_locus(&format!("{producer}{name} {flag}=5{tail}"));
2194 assert_eq!(
2195 separated, glued,
2196 "{name} {flag} (tail {tail:?}): separated {separated:?}, glued {glued:?}",
2197 );
2198 checked += 1;
2199 }
2200 }
2201 }
2202 assert!(checked > 0, "no output claim declares a valued flag — the guard is vacuous");
2203 }
2204
2205 #[test]
2209 fn undeclared_commands_get_no_output_claim() {
2210 for line in ["echo /etc/shadow", "hostname", "cat ./f", "ls", "git rev-parse --show-toplevel"] {
2213 assert_eq!(sub_locus(line), None, "`{line}` must have no output claim");
2214 }
2215 assert!(!crate::is_safe_command("cat $(echo /etc/shadow)"), "echo must not bound its output");
2216 }
2217
2218 #[test]
2219 fn perl_i_worktree_vs_system() {
2220 use crate::engine::bridge::project;
2221 use crate::verdict::{SafetyLevel, Verdict};
2222
2223 let read = resolve(&toks(&["perl", "-pe", "s/x/y/", "./foo"])).expect("perl");
2225 assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2226 assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "perl read");
2227
2228 let edit = resolve(&toks(&["perl", "-pi", "-e", "s/x/y/", "./foo"])).expect("perl");
2230 assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2231 assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i worktree");
2232 let glued = resolve(&toks(&["perl", "-i.bak", "-pe", "s/x/y/", "./foo"])).expect("perl");
2233 assert_eq!(glued.capabilities[0].operation, Operation::Mutate, "-i.bak is still in-place");
2234
2235 for cmd in [
2238 vec!["perl", "-pe", "s/a/b/", "/etc/shadow"],
2239 vec!["perl", "-ne", "print", "~/.ssh/id_rsa"],
2240 vec!["perl", "-pe", "s/a/b/", "/etc/passwd"],
2241 vec!["perl", "-pe", "s/a/b/", "$CONFIG"], vec!["perl", "-pi", "-e", "s/a/b/", "/etc/hosts"],
2243 vec!["perl", "-pi", "-e", "s/a/b/", "~/.bashrc"],
2244 vec!["perl", "-pi", "-e", "s/a/b/", "../outside"],
2245 ] {
2246 assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2247 }
2248
2249 for cmd in [
2252 vec!["perl", "./script.pl"],
2253 vec!["perl", "-n", "./file.txt"],
2254 vec!["perl", "-e", "system(\"rm -rf /\")", "./foo"],
2255 vec!["perl", "-pie", "s/a/b/", "./foo"], ] {
2257 assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2258 }
2259
2260 let glob = resolve(&toks(&["perl", "-pi", "-e", "s/a/b/", "*"])).expect("perl");
2262 assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2263 assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i * (worktree)");
2264 }
2265
2266 #[test]
2267 fn sed_i_flips_read_to_write_and_locus_stops_system_wide_damage() {
2268 use crate::engine::bridge::project;
2269 use crate::verdict::{SafetyLevel, Verdict};
2270
2271 let read = resolve(&toks(&["sed", "s/x/y/", "./foo"])).expect("sed");
2273 assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2274 assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "sed read");
2275 let edit = resolve(&toks(&["sed", "-i", "s/x/y/", "./foo"])).expect("sed");
2276 assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2277 assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i worktree");
2278
2279 for cmd in [
2283 vec!["sed", "-i", "s/a/b/", "/etc/passwd"],
2284 vec!["sed", "-i", "s/a/b/", "/etc/hosts"],
2285 vec!["sed", "-i", "s/a/b/", "~/.bashrc"],
2286 vec!["sed", "-i", "s/a/b/", "$CONFIG"], vec!["sed", "-i", "s/a/b/", "../outside"], vec!["sed", "-i", "-e", "s/a/b/", "/etc/x"], ] {
2290 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?} must deny");
2291 }
2292
2293 let glob = resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed");
2296 assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2297 assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i * (worktree)");
2298 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "a", "b", "c"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "multi-file");
2299
2300 assert_eq!(project(&resolve(&toks(&["sed", "-i.bak", "s/a/b/", "./foo"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "-i.bak");
2302 assert_eq!(project(&resolve(&toks(&["sed", "-f", "script.sed", "./foo"])).expect("sed")), Verdict::Denied, "-f script file unanalyzable");
2305 assert_eq!(project(&resolve(&toks(&["sed", "s/a/b/", "~/.ssh/id_rsa"])).expect("sed")), Verdict::Denied, "read home secret");
2307 assert_eq!(project(&resolve(&toks(&["sed", "-Q", "./foo"])).expect("sed")), Verdict::Denied, "unknown flag");
2308 }
2309
2310 #[test]
2311 fn sed_exec_command_is_worst_cased_at_parity_with_legacy() {
2312 use crate::engine::bridge::project;
2313 use crate::verdict::Verdict;
2314 for cmd in [
2317 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"],
2323 vec!["sed", "1e reboot", "file"], vec!["sed", "p;e id", "file"], ] {
2326 assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?}: exec must deny");
2327 }
2328 }
2332
2333 #[test]
2337 fn classify_locus_resolves_relative_operands_against_the_cwd_context() {
2338 use crate::engine::bridge::project;
2339 use crate::pathctx::PathCtx;
2340 use crate::verdict::{SafetyLevel, Verdict};
2341
2342 for p in ["*", "passwd", "config"] {
2344 assert_eq!(classify_locus(p), LocalLocus::Worktree, "{p}: no ctx → worktree");
2345 }
2346 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "no ctx: sed -i *");
2347
2348 let _g = crate::pathctx::enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
2350 for p in ["*", "hosts", "config", "cron.d"] {
2351 assert_eq!(classify_locus(p), LocalLocus::Machine, "{p}: cwd=/etc → machine");
2352 }
2353 assert_eq!(classify_locus("passwd"), LocalLocus::SystemIntegrity, "passwd: cwd=/etc → system-integrity");
2356 assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Denied, "cwd=/etc: sed -i * denied");
2357 assert_eq!(project(&resolve(&toks(&["dd", "if=./x", "of=passwd"])).expect("dd")), Verdict::Denied, "cwd=/etc: dd of=passwd denied");
2358 assert_eq!(project(&resolve(&toks(&["cp", "./payload", "config"])).expect("cp")), Verdict::Denied, "cwd=/etc: cp denied");
2359 }
2360
2361 #[test]
2362 fn touch_creates_in_the_worktree_and_gates_the_reference_path() {
2363 use crate::engine::bridge::project;
2364 use crate::verdict::{SafetyLevel, Verdict};
2365 for cmd in [
2366 vec!["touch", "./new.txt"],
2367 vec!["touch", "-c", "existing"],
2368 vec!["touch", "-r", "ref.txt", "./out"], vec!["touch", "-d", "-1 day", "./out"], ] {
2371 assert_eq!(project(&resolve(&toks(&cmd)).expect("touch")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2372 }
2373 let p = resolve(&toks(&["touch", "-r", "ref.txt", "./out"])).expect("touch");
2377 assert_eq!(p.capabilities.len(), 2, "./out create + ref.txt read");
2378 assert!(p.capabilities.iter().any(|c| c.operation == Operation::Observe), "the -r reference is a read");
2379 assert_eq!(project(&resolve(&toks(&["touch", "-r", "~/.bashrc", "./out"])).expect("touch")), Verdict::Denied, "home reference");
2380 assert_eq!(project(&resolve(&toks(&["touch", "-r", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "system reference");
2381 assert_eq!(project(&resolve(&toks(&["touch", "--reference=/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long glued reference");
2382 assert_eq!(project(&resolve(&toks(&["touch", "--reference", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long spaced reference");
2383 assert_eq!(project(&resolve(&toks(&["touch", "-d", "-1 day", "/tmp/../etc/x"])).expect("touch")), Verdict::Denied, "operand still gated");
2385 assert_eq!(project(&resolve(&toks(&["touch", "/etc/x"])).expect("touch")), Verdict::Denied, "system path");
2387 assert_eq!(project(&resolve(&toks(&["touch", "-Z", "x"])).expect("touch")), Verdict::Denied, "unknown flag");
2388 assert_eq!(project(&resolve(&toks(&["touch"])).expect("touch")), Verdict::Denied, "no operand");
2389 }
2390
2391 #[test]
2392 fn worst_case_is_denied_even_by_a_permissive_yolo_shaped_level() {
2393 use crate::engine::level::{Clause, Level, OrdBound};
2394 let yolo = Level::new("yolo-ish")
2396 .allowing(Clause {
2397 local_locus: Some(OrdBound::at_most(LocalLocus::Machine)),
2398 ..Default::default()
2399 })
2400 .denying(Clause {
2401 operation: Some(vec![Operation::Destroy]),
2402 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
2403 ..Default::default()
2404 });
2405 let wc = Profile::of(vec![Capability::worst("test")]);
2406 assert!(!yolo.admits(&wc), "worst_case (locus=kernel) exceeds even a machine-capped allow");
2407 }
2408
2409 #[test]
2410 fn rm_within_the_worktree_projects_to_developer_but_beyond_it_denies() {
2411 use crate::engine::bridge::project;
2412 use crate::verdict::{SafetyLevel, Verdict};
2413 for cmd in [
2416 vec!["rm", "./stale.log"],
2417 vec!["rm", "-rf", "./node_modules"],
2418 vec!["rm", "a", "b", "c"],
2419 vec!["rm", "--interactive=always", "./x"], ] {
2421 let p = resolve(&toks(&cmd)).expect("rm resolves");
2422 assert!(p.capabilities.iter().all(|c| c.operation == Operation::Destroy), "{cmd:?} destroys");
2423 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?} → developer");
2424 }
2425 for cmd in [vec!["rm", "-rf", "/"], vec!["rm", "-rf", "~/notes"], vec!["rm", "/etc/hosts"]] {
2428 assert_eq!(project(&resolve(&toks(&cmd)).expect("rm")), Verdict::Denied, "{cmd:?} beyond worktree");
2429 }
2430 }
2431
2432 #[test]
2436 fn rm_rf_root_is_the_one_thing_even_yolo_denies() {
2437 let yolo = level("yolo");
2438 let root = resolve(&toks(&["rm", "-rf", "/"])).expect("rm");
2439 assert_eq!(root.capabilities[0].reversibility, Reversibility::Irreversible, "rm -rf / is irreversible");
2440 assert_eq!(root.capabilities[0].scale, Scale::Unbounded);
2441 assert!(!yolo.admits(&root), "rm -rf / denied even at yolo");
2442 assert!(!yolo.admits(&resolve(&toks(&["rm", "-rf", "~/notes"])).expect("rm")), "rm -rf ~ likewise");
2443 assert!(yolo.admits(&resolve(&toks(&["rm", "-rf", "./node_modules"])).expect("rm")), "recoverable worktree");
2445 assert!(yolo.admits(&resolve(&toks(&["rm", "/etc/hosts"])).expect("rm")), "single (bounded) system delete");
2446 }
2447
2448 #[test]
2453 fn a_subcommand_profile_resolves_to_its_archetype() {
2454 let p = resolve(&toks(&["koyeb", "apps", "delete", "myapp"])).expect("koyeb apps delete resolves");
2455 assert_eq!(p.capabilities.len(), 1);
2456 assert_eq!(
2457 &p.capabilities[0],
2458 crate::engine::archetype::archetype("remote-destroy-recoverable").unwrap(),
2459 "the sub resolves to its declared archetype's capability",
2460 );
2461 let create = resolve(&toks(&["koyeb", "apps", "create", "myapp"])).expect("resolves");
2463 assert_eq!(create.capabilities[0].operation, Operation::Create);
2464 assert!(resolve(&toks(&["koyeb", "apps", "list"])).is_none(), "untagged sub → engine abstains");
2466 }
2467
2468 #[test]
2473 fn an_escalating_flag_adds_a_capability_and_raises_the_tier() {
2474 let destroy = crate::engine::archetype::archetype("remote-destroy-irreversible").unwrap();
2475 let vcs_sync = {
2478 let mut c = crate::engine::archetype::archetype("vcs-sync").unwrap().clone();
2479 c.locus.provenance = Provenance::Established;
2480 c
2481 };
2482
2483 let base = resolve(&toks(&["git", "push", "origin", "main"])).expect("git push resolves");
2484 assert_eq!(base.capabilities, vec![vcs_sync.clone()], "base is vcs-sync, established destination");
2485
2486 let forced = resolve(&toks(&["git", "push", "--force"])).expect("resolves");
2487 assert_eq!(forced.capabilities.len(), 2);
2488 assert!(forced.capabilities.contains(&vcs_sync) && forced.capabilities.contains(destroy),
2489 "--force ADDS remote-destroy-irreversible to the vcs-sync base");
2490
2491 let network_admin = level("network-admin");
2494 assert!(network_admin.admits(&base), "git push is network-admin");
2495 assert!(!network_admin.admits(&forced), "git push --force escalated past network-admin");
2496 assert!(level("yolo").admits(&forced), "and lands at yolo");
2497
2498 assert_eq!(resolve(&toks(&["git", "push", "-f"])).unwrap().capabilities.len(), 2);
2500 }
2501
2502 #[test]
2506 fn git_push_destination_provenance_is_classified() {
2507 use crate::engine::bridge::project;
2508 use crate::verdict::Verdict;
2509
2510 let prov = |cmd: &[&str]| resolve(&toks(cmd)).expect("push resolves").capabilities[0].locus.provenance;
2511
2512 assert_eq!(prov(&["git", "push"]), Provenance::Established, "bare push = default remote");
2514 assert_eq!(prov(&["git", "push", "origin", "main"]), Provenance::Established, "remote name");
2515 assert_eq!(prov(&["git", "push", "--force", "origin"]), Provenance::Established, "flag then name");
2517 assert_eq!(prov(&["git", "push", "https://h/x.git", "main"]), Provenance::Literal, "url");
2519 assert_eq!(prov(&["git", "push", "git@h:x.git"]), Provenance::Literal, "scp-style");
2520 assert_eq!(prov(&["git", "push", "/srv/mirror.git"]), Provenance::Literal, "path");
2521 assert_eq!(prov(&["git", "push", "$REMOTE"]), Provenance::Opaque, "variable");
2523
2524 let net = level("network-admin");
2526 assert!(net.admits(&resolve(&toks(&["git", "push", "origin"])).unwrap()), "established at network-admin");
2527 assert!(net.admits(&resolve(&toks(&["git", "push", "https://h/x.git"])).unwrap()), "literal URL at network-admin");
2528 assert!(!net.admits(&resolve(&toks(&["git", "push", "$REMOTE"])).unwrap()), "opaque above network-admin");
2529 assert_eq!(project(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), Verdict::Denied, "ext:: is RCE");
2531 assert!(!net.admits(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), "ext:: not at network-admin");
2532
2533 assert_eq!(project(&resolve(&toks(&["git", "push", "--repo=ext::sh", "origin"])).unwrap()), Verdict::Denied, "--repo=ext:: is RCE");
2536 assert!(!net.admits(&resolve(&toks(&["git", "push", "--repo", "$VAR", "origin"])).unwrap()), "--repo $VAR is opaque");
2537 assert_eq!(prov(&["git", "push", "--repo=https://h/x.git", "main"]), Provenance::Literal, "--repo URL is literal");
2538 assert!(net.admits(&resolve(&toks(&["git", "push", "--repo=upstream", "main"])).unwrap()), "--repo=<remote name> is established");
2539 }
2540
2541 #[test]
2547 fn data_export_gates_its_output_file() {
2548 use crate::engine::bridge::project;
2549 use crate::verdict::{SafetyLevel, Verdict};
2550
2551 let stdout = resolve(&toks(&["supabase", "db", "dump", "--data-only"])).expect("dump resolves");
2553 assert_eq!(stdout.capabilities.len(), 1, "stdout dump = the remote read only");
2554 assert_eq!(stdout.capabilities[0].scale, Scale::Unbounded, "a dump records its volume");
2555 assert_eq!(project(&stdout), Verdict::Allowed(SafetyLevel::SafeRead), "bulk read auto-approves");
2556
2557 for cmd in [
2559 vec!["supabase", "db", "dump", "-f", "dump.sql"],
2560 vec!["supabase", "db", "dump", "--file=dump.sql", "--data-only"],
2561 ] {
2562 let p = resolve(&toks(&cmd)).expect("dump resolves");
2563 assert_eq!(p.capabilities.len(), 2, "{cmd:?}: the remote read + a local write");
2564 assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2565 }
2566
2567 for cmd in [
2570 vec!["supabase", "db", "dump", "-f", "/etc/passwd"],
2571 vec!["supabase", "db", "dump", "--file=/etc/passwd"],
2572 vec!["supabase", "db", "dump", "-f/etc/passwd"],
2573 ] {
2574 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?} writes a system path");
2575 }
2576 }
2577
2578 #[test]
2582 fn sudo_elevates_the_wrapped_commands_authority() {
2583 use crate::engine::bridge::project;
2584 use crate::verdict::Verdict;
2585 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2586
2587 let read = resolve(&toks(&["sudo", "cat", "./notes.md"])).expect("sudo cat resolves");
2590 assert_eq!(read.capabilities[0].authority, Authority::Root, "authority lifted to root");
2591 assert!(!dev.admits(&read) && !net.admits(&read), "a root op is above the user-authority bands");
2592 assert!(local.admits(&read) && yolo.admits(&read), "a root read is local-admin");
2593
2594 assert_eq!(resolve(&toks(&["sudo", "-EH", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2596 assert_eq!(resolve(&toks(&["sudo", "-n", "-p", "pw", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2597
2598 assert_eq!(project(&resolve(&toks(&["sudo", "rm", "-rf", "/"])).unwrap()), Verdict::Denied, "sudo rm -rf / denied everywhere");
2600
2601 let other = resolve(&toks(&["sudo", "-u", "bob", "cat", "./x"])).expect("sudo -u resolves");
2603 assert_eq!(other.capabilities[0].authority, Authority::OtherUser, "-u = run as other user");
2604 assert!(!local.admits(&other) && yolo.admits(&other), "other-user is yolo-only");
2605 assert_eq!(resolve(&toks(&["sudo", "-ubob", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::OtherUser, "glued -ubob");
2606
2607 assert_eq!(project(&resolve(&toks(&["sudo", "-i"])).unwrap()), Verdict::Denied, "sudo -i is a root shell");
2609 assert!(!local.admits(&resolve(&toks(&["sudo", "-s", "bash"])).unwrap()), "root shell not local-admin");
2610
2611 assert_eq!(project(&resolve(&toks(&["sudo", "--nonsense", "cat", "./x"])).unwrap()), Verdict::Denied, "unknown option worst-cases");
2613
2614 assert!(resolve(&toks(&["sudo", "totallyunknowncmd", "x"])).is_none(), "unresolved inner → legacy denies");
2616 assert!(resolve(&toks(&["sudo", "-v"])).is_none(), "no inner command");
2618
2619 assert_eq!(resolve(&toks(&["doas", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2621
2622 assert!(resolve(&toks(&["doas", "-r"])).is_none(), "doas -r must not panic");
2625 assert!(resolve(&toks(&["sudo", "-u"])).is_none(), "sudo -u must not panic");
2626 assert_eq!(crate::command_verdict("doas -r"), Verdict::Denied, "doas -r denied, not crashed");
2627
2628 for cmd in ["sudo cat ./notes.md", "sudo rm -rf ./build", "sudo -EH cat ./x", "sudo -u bob ls"] {
2631 assert_eq!(crate::command_verdict(cmd), Verdict::Denied, "`{cmd}` must not auto-approve at the default band");
2632 }
2633 }
2634
2635 #[test]
2639 fn systemctl_service_management_is_local_admin() {
2640 use crate::verdict::Verdict;
2641 let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2642
2643 for sub in ["restart", "start", "stop", "enable", "disable", "mask", "daemon-reload", "kill"] {
2645 let p = resolve(&toks(&["systemctl", sub, "nginx"])).unwrap_or_else(|| panic!("systemctl {sub} resolves"));
2646 assert!(!dev.admits(&p) && !net.admits(&p), "systemctl {sub} is above developer/network-admin");
2647 assert!(local.admits(&p) && yolo.admits(&p), "systemctl {sub} is local-admin");
2648 }
2649 assert!(crate::command_verdict("systemctl status nginx").is_allowed(), "status reads");
2651 assert_eq!(crate::command_verdict("systemctl reboot"), Verdict::Denied, "reboot omitted → denied");
2652
2653 let sudo_restart = resolve(&toks(&["sudo", "systemctl", "restart", "nginx"])).expect("resolves");
2655 assert!(local.admits(&sudo_restart), "sudo systemctl restart is local-admin");
2656 assert_eq!(crate::command_verdict("sudo systemctl restart nginx"), Verdict::Denied, "still not auto-approved at default");
2657 }
2658
2659 #[test]
2665 fn npm_install_is_classified_by_pinning_and_scripts_off() {
2666 let (dev, yolo) = (level("developer"), level("yolo"));
2667
2668 let safe = resolve(&toks(&["npm", "ci", "--ignore-scripts"])).expect("npm ci --ignore-scripts");
2670 assert!(dev.admits(&safe), "pinned, scripts-off ci is developer");
2671
2672 let scripts_on = resolve(&toks(&["npm", "ci"])).expect("npm ci");
2674 assert!(!dev.admits(&scripts_on), "ci without --ignore-scripts runs fetched code → above developer");
2675 assert!(yolo.admits(&scripts_on), "and lands at yolo");
2676
2677 for c in [&["npm", "install"][..], &["npm", "install", "left-pad"], &["npm", "i", "react"], &["npm", "install", "--ignore-scripts"]] {
2679 let p = resolve(&toks(c)).unwrap_or_else(|| panic!("{c:?} resolves"));
2680 assert!(!dev.admits(&p) && yolo.admits(&p), "{c:?}: floating install → supply-chain (yolo)");
2681 }
2682 }
2683
2684 #[test]
2685 fn rm_flag_and_operand_fail_closed() {
2686 use crate::engine::bridge::project;
2687 use crate::verdict::Verdict;
2688 for cmd in [
2689 vec!["rm", "--no-preserve-root", "-rf", "/"], vec!["rm", "-Z", "x"], vec!["rm"], vec!["./rm", "x"], ] {
2694 assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?}");
2695 }
2696 }
2697
2698 #[test]
2699 fn rm_scale_and_force_semantics() {
2700 let cap = |cmd: &[&str]| resolve(&toks(cmd)).expect("rm").capabilities[0].clone();
2701 assert_eq!(cap(&["rm", "./x"]).scale, Scale::Single);
2702 assert_eq!(cap(&["rm", "a", "b"]).scale, Scale::Bounded, "multiple operands");
2703 assert_eq!(cap(&["rm", "*.log"]).scale, Scale::Bounded, "a glob");
2704 assert_eq!(cap(&["rm", "-r", "./dir"]).scale, Scale::Unbounded, "recursive");
2705 assert_eq!(cap(&["rm", "./x"]).reversibility, Reversibility::Effortful);
2707 assert_eq!(cap(&["rm", "-f", "./x"]).reversibility, Reversibility::Effortful, "-f is not a raiser");
2708 }
2709
2710 #[test]
2711 fn a_resolvable_name_from_a_non_standard_path_worst_cases() {
2712 for cmd in [vec!["./cat", "x"], vec!["/tmp/cat", "x"], vec!["~/bin/grep", "foo", "f"]] {
2714 let p = resolve(&toks(&cmd)).expect("resolvable name");
2715 assert!(!read_local().admits(&p), "{cmd:?} from a non-standard path must worst-case");
2716 }
2717 assert!(read_local().admits(&resolve(&toks(&["cat", "./notes.md"])).expect("cat")));
2719 assert!(read_local().admits(&resolve(&toks(&["/usr/bin/cat", "./notes.md"])).expect("cat")));
2720 assert!(resolve(&toks(&["/tmp/mytool", "x"])).is_none());
2722 }
2723
2724 #[test]
2725 fn unrecognized_flags_worst_case_fail_closed() {
2726 for cmd in [
2727 vec!["cat", "-Z", "./x"],
2728 vec!["cat", "--wat", "./x"],
2729 vec!["grep", "-Q", "foo", "f"], vec!["grep", "-R", "foo", "dir"], ] {
2732 let p = resolve(&toks(&cmd)).expect("resolver");
2733 assert!(!inert().admits(&p) && !read_local().admits(&p), "{cmd:?} must worst-case");
2734 }
2735 assert!(read_local().admits(&resolve(&toks(&["cat", "-nA", "./x"])).expect("cat")));
2737 assert!(read_local().admits(&resolve(&toks(&["grep", "-rin", "foo", "src/"])).expect("grep")));
2738 }
2739
2740 use proptest::prelude::*;
2741
2742 const TRANSFER_CMDS: &[&str] = &["cp", "mv", "ln"];
2747
2748 const HOT_PATHS: &[&str] = &["/etc/shadow", "~/.ssh/id_rsa", "$SECRET", "../out", "~/.aws"];
2751
2752 proptest! {
2753 #[test]
2759 fn transfer_commands_gate_both_operand_roles(
2760 cmd in prop::sample::select(TRANSFER_CMDS),
2761 hot in prop::sample::select(HOT_PATHS),
2762 ) {
2763 use crate::engine::bridge::project;
2764 use crate::verdict::Verdict;
2765 let hot_source = resolve(&toks(&[cmd, hot, "./safe"])).expect("resolves");
2766 prop_assert_eq!(project(&hot_source), Verdict::Denied, "{} hot SOURCE ({})", cmd, hot);
2767 let hot_dest = resolve(&toks(&[cmd, "./safe", hot])).expect("resolves");
2768 prop_assert_eq!(project(&hot_dest), Verdict::Denied, "{} hot DEST ({})", cmd, hot);
2769 }
2770
2771 #[test]
2777 fn sudo_family_flag_walk_never_panics(
2778 head in prop::sample::select(vec!["sudo", "doas"]),
2779 args in prop::collection::vec(
2780 prop_oneof![
2781 Just("-u".to_string()), Just("-r".to_string()), Just("-g".to_string()),
2782 Just("-i".to_string()), Just("-EH".to_string()), Just("-uEH".to_string()),
2783 Just("-uroot".to_string()), Just("--".to_string()), Just("-".to_string()),
2784 Just("root".to_string()), Just("cat".to_string()), Just("./x".to_string()),
2785 "-[a-zA-Z]{1,4}",
2786 ],
2787 0..6,
2788 ),
2789 ) {
2790 let parts: Vec<&str> =
2791 std::iter::once(head).chain(args.iter().map(String::as_str)).collect();
2792 let a = resolve(&toks(&parts)).is_some();
2793 let b = resolve(&toks(&parts)).is_some();
2794 prop_assert_eq!(a, b, "nondeterministic verdict for {:?}", parts);
2795 }
2796 }
2797
2798 const EXPECTED_BEHAVIOR_COMMANDS: &[&str] = &[
2808 "cat", "cp", "dd", "echo", "grep", "head", "ln", "mkdir", "mv", "perl", "rm", "sed",
2809 "tail", "tar", "touch", "wc",
2810 ];
2811
2812 #[test]
2816 fn behavior_command_roster_is_pinned() {
2817 use std::collections::BTreeSet;
2818 let actual: BTreeSet<&str> = crate::registry::toml_command_names()
2819 .into_iter()
2820 .filter(|n| crate::registry::command_behavior(n).is_some())
2821 .collect();
2822 let expected: BTreeSet<&str> = EXPECTED_BEHAVIOR_COMMANDS.iter().copied().collect();
2823 assert_eq!(
2824 actual, expected,
2825 "behavior-command roster drifted — a [command.behavior] block was added, dropped, or \
2826 typo'd. A dropped block silently reverts the command to its fail-open legacy path."
2827 );
2828 }
2829
2830 fn behavior_probes(cmd: &str, role: crate::registry::types::PositionalRole, hot: &str) -> Vec<Vec<String>> {
2833 use crate::registry::types::PositionalRole;
2834 let inv = |slots: &[&str]| -> Vec<String> {
2835 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
2836 };
2837 match role {
2838 PositionalRole::None => vec![],
2839 PositionalRole::Read | PositionalRole::Write => vec![inv(&["@"])],
2840 PositionalRole::PatternThenRead => vec![inv(&["PATTERN", "@"])],
2841 PositionalRole::Transfer => vec![inv(&["@", "./safe"]), inv(&["./safe", "@"])],
2842 }
2843 }
2844
2845 fn hook_probes(hook: crate::registry::types::BehaviorHook, cmd: &str, hot: &str) -> Vec<Vec<String>> {
2851 use crate::registry::types::BehaviorHook;
2852 let inv = |slots: &[&str]| -> Vec<String> {
2853 std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
2854 };
2855 match hook {
2856 BehaviorHook::Grep => vec![],
2858 BehaviorHook::Dd => vec![inv(&["if=@", "of=./safe"]), inv(&["if=./safe", "of=@"])],
2859 BehaviorHook::Tar => vec![inv(&["cf", "./s.tar", "@"]), inv(&["cf", "@", "./s"]), inv(&["tf", "@"])],
2860 BehaviorHook::Sed => vec![inv(&["s/x/y/", "@"]), inv(&["-i", "s/x/y/", "@"])],
2861 BehaviorHook::Perl => {
2862 vec![inv(&["-pe", "s/x/y/", "@"]), inv(&["-pi", "-e", "s/x/y/", "@"])]
2863 }
2864 }
2865 }
2866
2867 #[test]
2874 fn every_behavior_command_gates_hot_operands() {
2875 use crate::engine::bridge::project;
2876 use crate::registry::types::PositionalRole;
2877 use crate::verdict::Verdict;
2878
2879 let deny = |cmd: &[String], why: &str| {
2880 let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
2881 let profile = resolve(&toks(&refs)).expect("behavior command resolves");
2882 assert_eq!(project(&profile), Verdict::Denied, "{cmd:?}: {why}");
2883 };
2884
2885 let mut path_bearing = 0usize;
2886 let mut hook_bearing = 0usize;
2887 for name in crate::registry::toml_command_names() {
2888 let Some(b) = crate::registry::command_behavior(name) else { continue };
2889 if !matches!(b.positionals, PositionalRole::None) {
2890 path_bearing += 1;
2891 }
2892 if b.hook.is_some() {
2893 hook_bearing += 1;
2894 }
2895 for hot in HOT_PATHS {
2896 for cmd in behavior_probes(name, b.positionals, hot) {
2897 deny(&cmd, "touched hot path not gated");
2898 }
2899 if let Some(hook) = b.hook {
2903 for cmd in hook_probes(hook, name, hot) {
2904 deny(&cmd, "hook: touched hot path not gated");
2905 }
2906 }
2907 }
2908 let inv = |slots: &[&str]| -> Vec<String> {
2912 std::iter::once(name.to_string()).chain(slots.iter().map(|s| s.to_string())).collect()
2913 };
2914 match b.positionals {
2915 PositionalRole::Write => deny(&inv(&[".git/config"]), "write into worktree-trusted not gated"),
2916 PositionalRole::Transfer => deny(&inv(&["./safe", ".git/config"]), "transfer dest into worktree-trusted not gated"),
2917 _ => {}
2918 }
2919 }
2920 let count = |pred: fn(&crate::registry::types::BehaviorSpec) -> bool| {
2924 EXPECTED_BEHAVIOR_COMMANDS
2925 .iter()
2926 .filter(|n| crate::registry::command_behavior(n).is_some_and(pred))
2927 .count()
2928 };
2929 assert_eq!(
2930 path_bearing,
2931 count(|b| !matches!(b.positionals, PositionalRole::None)),
2932 "path-bearing behavior commands: saw {path_bearing}"
2933 );
2934 assert_eq!(hook_bearing, count(|b| b.hook.is_some()), "hook behavior commands: saw {hook_bearing}");
2935 }
2936
2937 #[test]
2945 fn every_hookless_behavior_command_worst_cases_unknown_flags() {
2946 use crate::engine::bridge::project;
2947 use crate::registry::types::{BehaviorHook, PositionalRole};
2948 use crate::verdict::Verdict;
2949
2950 let exempt = |b: &crate::registry::types::BehaviorSpec| {
2951 matches!(b.hook, Some(BehaviorHook::Grep)) || matches!(b.positionals, PositionalRole::None)
2952 };
2953 let mut checked = 0usize;
2954 for name in crate::registry::toml_command_names() {
2955 let Some(b) = crate::registry::command_behavior(name) else { continue };
2956 if exempt(b) {
2957 continue;
2958 }
2959 let profile = resolve(&toks(&[name, "--xyzzy-unknown-42", "./safe"])).expect("resolves");
2960 assert_eq!(project(&profile), Verdict::Denied, "{name}: unknown flag not worst-cased");
2961 checked += 1;
2962 }
2963 let expected = EXPECTED_BEHAVIOR_COMMANDS
2964 .iter()
2965 .filter(|n| crate::registry::command_behavior(n).is_some_and(|b| !exempt(b)))
2966 .count();
2967 assert_eq!(checked, expected, "declarative flag-walking behavior commands: saw {checked}");
2968 }
2969
2970 #[test]
2976 fn no_none_or_hook_command_declares_ungated_path_flags() {
2977 use crate::registry::types::PositionalRole;
2978 for name in crate::registry::toml_command_names() {
2979 let Some(b) = crate::registry::command_behavior(name) else { continue };
2980 if b.path_flags.is_empty() {
2981 continue;
2982 }
2983 assert!(
2984 b.hook.is_none() && !matches!(b.positionals, PositionalRole::None),
2985 "{name}: behavior path-flags are gated only on the Read/Write/Transfer path; on a \
2986 none-role or hook command they would be silently ungated (fail-open)"
2987 );
2988 }
2989 }
2990}