bash_validation.rs 28 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. //! Bash command validation submodules.
  2. //!
  3. //! Ports the upstream `BashTool` validation pipeline:
  4. //! - `readOnlyValidation` — block write-like commands in read-only mode
  5. //! - `destructiveCommandWarning` — flag dangerous destructive commands
  6. //! - `modeValidation` — enforce permission mode constraints on commands
  7. //! - `sedValidation` — validate sed expressions before execution
  8. //! - `pathValidation` — detect suspicious path patterns
  9. //! - `commandSemantics` — classify command intent
  10. use std::path::Path;
  11. use crate::permissions::PermissionMode;
  12. /// Result of validating a bash command before execution.
  13. #[derive(Debug, Clone, PartialEq, Eq)]
  14. pub enum ValidationResult {
  15. /// Command is safe to execute.
  16. Allow,
  17. /// Command should be blocked with the given reason.
  18. Block { reason: String },
  19. /// Command requires user confirmation with the given warning.
  20. Warn { message: String },
  21. }
  22. /// Semantic classification of a bash command's intent.
  23. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  24. pub enum CommandIntent {
  25. /// Read-only operations: ls, cat, grep, find, etc.
  26. ReadOnly,
  27. /// File system writes: cp, mv, mkdir, touch, tee, etc.
  28. Write,
  29. /// Destructive operations: rm, shred, truncate, etc.
  30. Destructive,
  31. /// Network operations: curl, wget, ssh, etc.
  32. Network,
  33. /// Process management: kill, pkill, etc.
  34. ProcessManagement,
  35. /// Package management: apt, brew, pip, npm, etc.
  36. PackageManagement,
  37. /// System administration: sudo, chmod, chown, mount, etc.
  38. SystemAdmin,
  39. /// Unknown or unclassifiable command.
  40. Unknown,
  41. }
  42. // ---------------------------------------------------------------------------
  43. // readOnlyValidation
  44. // ---------------------------------------------------------------------------
  45. /// Commands that perform write operations and should be blocked in read-only mode.
  46. const WRITE_COMMANDS: &[&str] = &[
  47. "cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp", "ln", "install", "tee",
  48. "truncate", "shred", "mkfifo", "mknod", "dd",
  49. ];
  50. /// Commands that modify system state and should be blocked in read-only mode.
  51. const STATE_MODIFYING_COMMANDS: &[&str] = &[
  52. "apt",
  53. "apt-get",
  54. "yum",
  55. "dnf",
  56. "pacman",
  57. "brew",
  58. "pip",
  59. "pip3",
  60. "npm",
  61. "yarn",
  62. "pnpm",
  63. "bun",
  64. "cargo",
  65. "gem",
  66. "go",
  67. "rustup",
  68. "docker",
  69. "systemctl",
  70. "service",
  71. "mount",
  72. "umount",
  73. "kill",
  74. "pkill",
  75. "killall",
  76. "reboot",
  77. "shutdown",
  78. "halt",
  79. "poweroff",
  80. "useradd",
  81. "userdel",
  82. "usermod",
  83. "groupadd",
  84. "groupdel",
  85. "crontab",
  86. "at",
  87. ];
  88. /// Shell redirection operators that indicate writes.
  89. const WRITE_REDIRECTIONS: &[&str] = &[">", ">>", ">&"];
  90. /// Validate that a command is allowed under read-only mode.
  91. ///
  92. /// Corresponds to upstream `tools/BashTool/readOnlyValidation.ts`.
  93. #[must_use]
  94. pub fn validate_read_only(command: &str, mode: PermissionMode) -> ValidationResult {
  95. if mode != PermissionMode::ReadOnly {
  96. return ValidationResult::Allow;
  97. }
  98. let first_command = extract_first_command(command);
  99. // Check for write commands.
  100. for &write_cmd in WRITE_COMMANDS {
  101. if first_command == write_cmd {
  102. return ValidationResult::Block {
  103. reason: format!(
  104. "Command '{write_cmd}' modifies the filesystem and is not allowed in read-only mode"
  105. ),
  106. };
  107. }
  108. }
  109. // Check for state-modifying commands.
  110. for &state_cmd in STATE_MODIFYING_COMMANDS {
  111. if first_command == state_cmd {
  112. return ValidationResult::Block {
  113. reason: format!(
  114. "Command '{state_cmd}' modifies system state and is not allowed in read-only mode"
  115. ),
  116. };
  117. }
  118. }
  119. // Check for sudo wrapping write commands.
  120. if first_command == "sudo" {
  121. let inner = extract_sudo_inner(command);
  122. if !inner.is_empty() {
  123. let inner_result = validate_read_only(inner, mode);
  124. if inner_result != ValidationResult::Allow {
  125. return inner_result;
  126. }
  127. }
  128. }
  129. // Check for write redirections.
  130. for &redir in WRITE_REDIRECTIONS {
  131. if command.contains(redir) {
  132. return ValidationResult::Block {
  133. reason: format!(
  134. "Command contains write redirection '{redir}' which is not allowed in read-only mode"
  135. ),
  136. };
  137. }
  138. }
  139. // Check for git commands that modify state.
  140. if first_command == "git" {
  141. return validate_git_read_only(command);
  142. }
  143. ValidationResult::Allow
  144. }
  145. /// Git subcommands that are read-only safe.
  146. const GIT_READ_ONLY_SUBCOMMANDS: &[&str] = &[
  147. "status",
  148. "log",
  149. "diff",
  150. "show",
  151. "branch",
  152. "tag",
  153. "stash",
  154. "remote",
  155. "fetch",
  156. "ls-files",
  157. "ls-tree",
  158. "cat-file",
  159. "rev-parse",
  160. "describe",
  161. "shortlog",
  162. "blame",
  163. "bisect",
  164. "reflog",
  165. "config",
  166. ];
  167. fn validate_git_read_only(command: &str) -> ValidationResult {
  168. let parts: Vec<&str> = command.split_whitespace().collect();
  169. // Skip past "git" and any flags (e.g., "git -C /path")
  170. let subcommand = parts.iter().skip(1).find(|p| !p.starts_with('-'));
  171. match subcommand {
  172. Some(&sub) if GIT_READ_ONLY_SUBCOMMANDS.contains(&sub) => ValidationResult::Allow,
  173. Some(&sub) => ValidationResult::Block {
  174. reason: format!(
  175. "Git subcommand '{sub}' modifies repository state and is not allowed in read-only mode"
  176. ),
  177. },
  178. None => ValidationResult::Allow, // bare "git" is fine
  179. }
  180. }
  181. // ---------------------------------------------------------------------------
  182. // destructiveCommandWarning
  183. // ---------------------------------------------------------------------------
  184. /// Patterns that indicate potentially destructive commands.
  185. const DESTRUCTIVE_PATTERNS: &[(&str, &str)] = &[
  186. (
  187. "rm -rf /",
  188. "Recursive forced deletion at root — this will destroy the system",
  189. ),
  190. ("rm -rf ~", "Recursive forced deletion of home directory"),
  191. (
  192. "rm -rf *",
  193. "Recursive forced deletion of all files in current directory",
  194. ),
  195. ("rm -rf .", "Recursive forced deletion of current directory"),
  196. (
  197. "mkfs",
  198. "Filesystem creation will destroy existing data on the device",
  199. ),
  200. (
  201. "dd if=",
  202. "Direct disk write — can overwrite partitions or devices",
  203. ),
  204. ("> /dev/sd", "Writing to raw disk device"),
  205. (
  206. "chmod -R 777",
  207. "Recursively setting world-writable permissions",
  208. ),
  209. ("chmod -R 000", "Recursively removing all permissions"),
  210. (":(){ :|:& };:", "Fork bomb — will crash the system"),
  211. ];
  212. /// Commands that are always destructive regardless of arguments.
  213. const ALWAYS_DESTRUCTIVE_COMMANDS: &[&str] = &["shred", "wipefs"];
  214. /// Warn if a command looks destructive.
  215. ///
  216. /// Corresponds to upstream `tools/BashTool/destructiveCommandWarning.ts`.
  217. #[must_use]
  218. pub fn check_destructive(command: &str) -> ValidationResult {
  219. // Check known destructive patterns.
  220. for &(pattern, warning) in DESTRUCTIVE_PATTERNS {
  221. if command.contains(pattern) {
  222. return ValidationResult::Warn {
  223. message: format!("Destructive command detected: {warning}"),
  224. };
  225. }
  226. }
  227. // Check always-destructive commands.
  228. let first = extract_first_command(command);
  229. for &cmd in ALWAYS_DESTRUCTIVE_COMMANDS {
  230. if first == cmd {
  231. return ValidationResult::Warn {
  232. message: format!(
  233. "Command '{cmd}' is inherently destructive and may cause data loss"
  234. ),
  235. };
  236. }
  237. }
  238. // Check for "rm -rf" with broad targets.
  239. if command.contains("rm ") && command.contains("-r") && command.contains("-f") {
  240. // Already handled the most dangerous patterns above.
  241. // Flag any remaining "rm -rf" as a warning.
  242. return ValidationResult::Warn {
  243. message: "Recursive forced deletion detected — verify the target path is correct"
  244. .to_string(),
  245. };
  246. }
  247. ValidationResult::Allow
  248. }
  249. // ---------------------------------------------------------------------------
  250. // modeValidation
  251. // ---------------------------------------------------------------------------
  252. /// Validate that a command is consistent with the given permission mode.
  253. ///
  254. /// Corresponds to upstream `tools/BashTool/modeValidation.ts`.
  255. #[must_use]
  256. pub fn validate_mode(command: &str, mode: PermissionMode) -> ValidationResult {
  257. match mode {
  258. PermissionMode::ReadOnly => validate_read_only(command, mode),
  259. PermissionMode::WorkspaceWrite => {
  260. // In workspace-write mode, check for system-level destructive
  261. // operations that go beyond workspace scope.
  262. if command_targets_outside_workspace(command) {
  263. return ValidationResult::Warn {
  264. message:
  265. "Command appears to target files outside the workspace — requires elevated permission"
  266. .to_string(),
  267. };
  268. }
  269. ValidationResult::Allow
  270. }
  271. PermissionMode::DangerFullAccess | PermissionMode::Allow | PermissionMode::Prompt => {
  272. ValidationResult::Allow
  273. }
  274. }
  275. }
  276. /// Heuristic: does the command reference absolute paths outside typical workspace dirs?
  277. fn command_targets_outside_workspace(command: &str) -> bool {
  278. let system_paths = [
  279. "/etc/", "/usr/", "/var/", "/boot/", "/sys/", "/proc/", "/dev/", "/sbin/", "/lib/", "/opt/",
  280. ];
  281. let first = extract_first_command(command);
  282. let is_write_cmd = WRITE_COMMANDS.contains(&first.as_str())
  283. || STATE_MODIFYING_COMMANDS.contains(&first.as_str());
  284. if !is_write_cmd {
  285. return false;
  286. }
  287. for sys_path in &system_paths {
  288. if command.contains(sys_path) {
  289. return true;
  290. }
  291. }
  292. false
  293. }
  294. // ---------------------------------------------------------------------------
  295. // sedValidation
  296. // ---------------------------------------------------------------------------
  297. /// Validate sed expressions for safety.
  298. ///
  299. /// Corresponds to upstream `tools/BashTool/sedValidation.ts`.
  300. #[must_use]
  301. pub fn validate_sed(command: &str, mode: PermissionMode) -> ValidationResult {
  302. let first = extract_first_command(command);
  303. if first != "sed" {
  304. return ValidationResult::Allow;
  305. }
  306. // In read-only mode, block sed -i (in-place editing).
  307. if mode == PermissionMode::ReadOnly && command.contains(" -i") {
  308. return ValidationResult::Block {
  309. reason: "sed -i (in-place editing) is not allowed in read-only mode".to_string(),
  310. };
  311. }
  312. ValidationResult::Allow
  313. }
  314. // ---------------------------------------------------------------------------
  315. // pathValidation
  316. // ---------------------------------------------------------------------------
  317. /// Validate that command paths don't include suspicious traversal patterns.
  318. ///
  319. /// Corresponds to upstream `tools/BashTool/pathValidation.ts`.
  320. #[must_use]
  321. pub fn validate_paths(command: &str, workspace: &Path) -> ValidationResult {
  322. // Check for directory traversal attempts.
  323. if command.contains("../") {
  324. let workspace_str = workspace.to_string_lossy();
  325. // Allow traversal if it resolves within workspace (heuristic).
  326. if !command.contains(&*workspace_str) {
  327. return ValidationResult::Warn {
  328. message: "Command contains directory traversal pattern '../' — verify the target path resolves within the workspace".to_string(),
  329. };
  330. }
  331. }
  332. // Check for home directory references that could escape workspace.
  333. if command.contains("~/") || command.contains("$HOME") {
  334. return ValidationResult::Warn {
  335. message:
  336. "Command references home directory — verify it stays within the workspace scope"
  337. .to_string(),
  338. };
  339. }
  340. ValidationResult::Allow
  341. }
  342. // ---------------------------------------------------------------------------
  343. // commandSemantics
  344. // ---------------------------------------------------------------------------
  345. /// Commands that are read-only (no filesystem or state modification).
  346. const SEMANTIC_READ_ONLY_COMMANDS: &[&str] = &[
  347. "ls",
  348. "cat",
  349. "head",
  350. "tail",
  351. "less",
  352. "more",
  353. "wc",
  354. "sort",
  355. "uniq",
  356. "grep",
  357. "egrep",
  358. "fgrep",
  359. "find",
  360. "which",
  361. "whereis",
  362. "whatis",
  363. "man",
  364. "info",
  365. "file",
  366. "stat",
  367. "du",
  368. "df",
  369. "free",
  370. "uptime",
  371. "uname",
  372. "hostname",
  373. "whoami",
  374. "id",
  375. "groups",
  376. "env",
  377. "printenv",
  378. "echo",
  379. "printf",
  380. "date",
  381. "cal",
  382. "bc",
  383. "expr",
  384. "test",
  385. "true",
  386. "false",
  387. "pwd",
  388. "tree",
  389. "diff",
  390. "cmp",
  391. "md5sum",
  392. "sha256sum",
  393. "sha1sum",
  394. "xxd",
  395. "od",
  396. "hexdump",
  397. "strings",
  398. "readlink",
  399. "realpath",
  400. "basename",
  401. "dirname",
  402. "seq",
  403. "yes",
  404. "tput",
  405. "column",
  406. "jq",
  407. "yq",
  408. "xargs",
  409. "tr",
  410. "cut",
  411. "paste",
  412. "awk",
  413. "sed",
  414. ];
  415. /// Commands that perform network operations.
  416. const NETWORK_COMMANDS: &[&str] = &[
  417. "curl",
  418. "wget",
  419. "ssh",
  420. "scp",
  421. "rsync",
  422. "ftp",
  423. "sftp",
  424. "nc",
  425. "ncat",
  426. "telnet",
  427. "ping",
  428. "traceroute",
  429. "dig",
  430. "nslookup",
  431. "host",
  432. "whois",
  433. "ifconfig",
  434. "ip",
  435. "netstat",
  436. "ss",
  437. "nmap",
  438. ];
  439. /// Commands that manage processes.
  440. const PROCESS_COMMANDS: &[&str] = &[
  441. "kill", "pkill", "killall", "ps", "top", "htop", "bg", "fg", "jobs", "nohup", "disown", "wait",
  442. "nice", "renice",
  443. ];
  444. /// Commands that manage packages.
  445. const PACKAGE_COMMANDS: &[&str] = &[
  446. "apt", "apt-get", "yum", "dnf", "pacman", "brew", "pip", "pip3", "npm", "yarn", "pnpm", "bun",
  447. "cargo", "gem", "go", "rustup", "snap", "flatpak",
  448. ];
  449. /// Commands that require system administrator privileges.
  450. const SYSTEM_ADMIN_COMMANDS: &[&str] = &[
  451. "sudo",
  452. "su",
  453. "chroot",
  454. "mount",
  455. "umount",
  456. "fdisk",
  457. "parted",
  458. "lsblk",
  459. "blkid",
  460. "systemctl",
  461. "service",
  462. "journalctl",
  463. "dmesg",
  464. "modprobe",
  465. "insmod",
  466. "rmmod",
  467. "iptables",
  468. "ufw",
  469. "firewall-cmd",
  470. "sysctl",
  471. "crontab",
  472. "at",
  473. "useradd",
  474. "userdel",
  475. "usermod",
  476. "groupadd",
  477. "groupdel",
  478. "passwd",
  479. "visudo",
  480. ];
  481. /// Classify the semantic intent of a bash command.
  482. ///
  483. /// Corresponds to upstream `tools/BashTool/commandSemantics.ts`.
  484. #[must_use]
  485. pub fn classify_command(command: &str) -> CommandIntent {
  486. let first = extract_first_command(command);
  487. classify_by_first_command(&first, command)
  488. }
  489. fn classify_by_first_command(first: &str, command: &str) -> CommandIntent {
  490. if SEMANTIC_READ_ONLY_COMMANDS.contains(&first) {
  491. if first == "sed" && command.contains(" -i") {
  492. return CommandIntent::Write;
  493. }
  494. return CommandIntent::ReadOnly;
  495. }
  496. if ALWAYS_DESTRUCTIVE_COMMANDS.contains(&first) || first == "rm" {
  497. return CommandIntent::Destructive;
  498. }
  499. if WRITE_COMMANDS.contains(&first) {
  500. return CommandIntent::Write;
  501. }
  502. if NETWORK_COMMANDS.contains(&first) {
  503. return CommandIntent::Network;
  504. }
  505. if PROCESS_COMMANDS.contains(&first) {
  506. return CommandIntent::ProcessManagement;
  507. }
  508. if PACKAGE_COMMANDS.contains(&first) {
  509. return CommandIntent::PackageManagement;
  510. }
  511. if SYSTEM_ADMIN_COMMANDS.contains(&first) {
  512. return CommandIntent::SystemAdmin;
  513. }
  514. if first == "git" {
  515. return classify_git_command(command);
  516. }
  517. CommandIntent::Unknown
  518. }
  519. fn classify_git_command(command: &str) -> CommandIntent {
  520. let parts: Vec<&str> = command.split_whitespace().collect();
  521. let subcommand = parts.iter().skip(1).find(|p| !p.starts_with('-'));
  522. match subcommand {
  523. Some(&sub) if GIT_READ_ONLY_SUBCOMMANDS.contains(&sub) => CommandIntent::ReadOnly,
  524. _ => CommandIntent::Write,
  525. }
  526. }
  527. // ---------------------------------------------------------------------------
  528. // Pipeline: run all validations
  529. // ---------------------------------------------------------------------------
  530. /// Run the full validation pipeline on a bash command.
  531. ///
  532. /// Returns the first non-Allow result, or Allow if all validations pass.
  533. #[must_use]
  534. pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -> ValidationResult {
  535. // 1. Mode-level validation (includes read-only checks).
  536. let result = validate_mode(command, mode);
  537. if result != ValidationResult::Allow {
  538. return result;
  539. }
  540. // 2. Sed-specific validation.
  541. let result = validate_sed(command, mode);
  542. if result != ValidationResult::Allow {
  543. return result;
  544. }
  545. // 3. Destructive command warnings.
  546. let result = check_destructive(command);
  547. if result != ValidationResult::Allow {
  548. return result;
  549. }
  550. // 4. Path validation.
  551. validate_paths(command, workspace)
  552. }
  553. // ---------------------------------------------------------------------------
  554. // Helpers
  555. // ---------------------------------------------------------------------------
  556. /// Extract the first bare command from a pipeline/chain, stripping env vars and sudo.
  557. fn extract_first_command(command: &str) -> String {
  558. let trimmed = command.trim();
  559. // Skip leading environment variable assignments (KEY=val cmd ...).
  560. let mut remaining = trimmed;
  561. loop {
  562. let next = remaining.trim_start();
  563. if let Some(eq_pos) = next.find('=') {
  564. let before_eq = &next[..eq_pos];
  565. // Valid env var name: alphanumeric + underscore, no spaces.
  566. if !before_eq.is_empty()
  567. && before_eq
  568. .chars()
  569. .all(|c| c.is_ascii_alphanumeric() || c == '_')
  570. {
  571. // Skip past the value (might be quoted).
  572. let after_eq = &next[eq_pos + 1..];
  573. if let Some(space) = find_end_of_value(after_eq) {
  574. remaining = &after_eq[space..];
  575. continue;
  576. }
  577. // No space found means value goes to end of string — no actual command.
  578. return String::new();
  579. }
  580. }
  581. break;
  582. }
  583. remaining
  584. .split_whitespace()
  585. .next()
  586. .unwrap_or("")
  587. .to_string()
  588. }
  589. /// Extract the command following "sudo" (skip sudo flags).
  590. fn extract_sudo_inner(command: &str) -> &str {
  591. let parts: Vec<&str> = command.split_whitespace().collect();
  592. let sudo_idx = parts.iter().position(|&p| p == "sudo");
  593. match sudo_idx {
  594. Some(idx) => {
  595. // Skip flags after sudo.
  596. let rest = &parts[idx + 1..];
  597. for &part in rest {
  598. if !part.starts_with('-') {
  599. // Found the inner command — return from here to end.
  600. let offset = command.find(part).unwrap_or(0);
  601. return &command[offset..];
  602. }
  603. }
  604. ""
  605. }
  606. None => "",
  607. }
  608. }
  609. /// Find the end of a value in `KEY=value rest` (handles basic quoting).
  610. fn find_end_of_value(s: &str) -> Option<usize> {
  611. let s = s.trim_start();
  612. if s.is_empty() {
  613. return None;
  614. }
  615. let first = s.as_bytes()[0];
  616. if first == b'"' || first == b'\'' {
  617. let quote = first;
  618. let mut i = 1;
  619. while i < s.len() {
  620. if s.as_bytes()[i] == quote && (i == 0 || s.as_bytes()[i - 1] != b'\\') {
  621. // Skip past quote.
  622. i += 1;
  623. // Find next whitespace.
  624. while i < s.len() && !s.as_bytes()[i].is_ascii_whitespace() {
  625. i += 1;
  626. }
  627. return if i < s.len() { Some(i) } else { None };
  628. }
  629. i += 1;
  630. }
  631. None
  632. } else {
  633. s.find(char::is_whitespace)
  634. }
  635. }
  636. // ---------------------------------------------------------------------------
  637. // Tests
  638. // ---------------------------------------------------------------------------
  639. #[cfg(test)]
  640. mod tests {
  641. use super::*;
  642. use std::path::PathBuf;
  643. // --- readOnlyValidation ---
  644. #[test]
  645. fn blocks_rm_in_read_only() {
  646. assert!(matches!(
  647. validate_read_only("rm -rf /tmp/x", PermissionMode::ReadOnly),
  648. ValidationResult::Block { reason } if reason.contains("rm")
  649. ));
  650. }
  651. #[test]
  652. fn allows_rm_in_workspace_write() {
  653. assert_eq!(
  654. validate_read_only("rm -rf /tmp/x", PermissionMode::WorkspaceWrite),
  655. ValidationResult::Allow
  656. );
  657. }
  658. #[test]
  659. fn blocks_write_redirections_in_read_only() {
  660. assert!(matches!(
  661. validate_read_only("echo hello > file.txt", PermissionMode::ReadOnly),
  662. ValidationResult::Block { reason } if reason.contains("redirection")
  663. ));
  664. }
  665. #[test]
  666. fn allows_read_commands_in_read_only() {
  667. assert_eq!(
  668. validate_read_only("ls -la", PermissionMode::ReadOnly),
  669. ValidationResult::Allow
  670. );
  671. assert_eq!(
  672. validate_read_only("cat /etc/hosts", PermissionMode::ReadOnly),
  673. ValidationResult::Allow
  674. );
  675. assert_eq!(
  676. validate_read_only("grep -r pattern .", PermissionMode::ReadOnly),
  677. ValidationResult::Allow
  678. );
  679. }
  680. #[test]
  681. fn blocks_sudo_write_in_read_only() {
  682. assert!(matches!(
  683. validate_read_only("sudo rm -rf /tmp/x", PermissionMode::ReadOnly),
  684. ValidationResult::Block { reason } if reason.contains("rm")
  685. ));
  686. }
  687. #[test]
  688. fn blocks_git_push_in_read_only() {
  689. assert!(matches!(
  690. validate_read_only("git push origin main", PermissionMode::ReadOnly),
  691. ValidationResult::Block { reason } if reason.contains("push")
  692. ));
  693. }
  694. #[test]
  695. fn allows_git_status_in_read_only() {
  696. assert_eq!(
  697. validate_read_only("git status", PermissionMode::ReadOnly),
  698. ValidationResult::Allow
  699. );
  700. }
  701. #[test]
  702. fn blocks_package_install_in_read_only() {
  703. assert!(matches!(
  704. validate_read_only("npm install express", PermissionMode::ReadOnly),
  705. ValidationResult::Block { reason } if reason.contains("npm")
  706. ));
  707. }
  708. // --- destructiveCommandWarning ---
  709. #[test]
  710. fn warns_rm_rf_root() {
  711. assert!(matches!(
  712. check_destructive("rm -rf /"),
  713. ValidationResult::Warn { message } if message.contains("root")
  714. ));
  715. }
  716. #[test]
  717. fn warns_rm_rf_home() {
  718. assert!(matches!(
  719. check_destructive("rm -rf ~"),
  720. ValidationResult::Warn { message } if message.contains("home")
  721. ));
  722. }
  723. #[test]
  724. fn warns_shred() {
  725. assert!(matches!(
  726. check_destructive("shred /dev/sda"),
  727. ValidationResult::Warn { message } if message.contains("destructive")
  728. ));
  729. }
  730. #[test]
  731. fn warns_fork_bomb() {
  732. assert!(matches!(
  733. check_destructive(":(){ :|:& };:"),
  734. ValidationResult::Warn { message } if message.contains("Fork bomb")
  735. ));
  736. }
  737. #[test]
  738. fn allows_safe_commands() {
  739. assert_eq!(check_destructive("ls -la"), ValidationResult::Allow);
  740. assert_eq!(check_destructive("echo hello"), ValidationResult::Allow);
  741. }
  742. // --- modeValidation ---
  743. #[test]
  744. fn workspace_write_warns_system_paths() {
  745. assert!(matches!(
  746. validate_mode("cp file.txt /etc/config", PermissionMode::WorkspaceWrite),
  747. ValidationResult::Warn { message } if message.contains("outside the workspace")
  748. ));
  749. }
  750. #[test]
  751. fn workspace_write_allows_local_writes() {
  752. assert_eq!(
  753. validate_mode("cp file.txt ./backup/", PermissionMode::WorkspaceWrite),
  754. ValidationResult::Allow
  755. );
  756. }
  757. // --- sedValidation ---
  758. #[test]
  759. fn blocks_sed_inplace_in_read_only() {
  760. assert!(matches!(
  761. validate_sed("sed -i 's/old/new/' file.txt", PermissionMode::ReadOnly),
  762. ValidationResult::Block { reason } if reason.contains("sed -i")
  763. ));
  764. }
  765. #[test]
  766. fn allows_sed_stdout_in_read_only() {
  767. assert_eq!(
  768. validate_sed("sed 's/old/new/' file.txt", PermissionMode::ReadOnly),
  769. ValidationResult::Allow
  770. );
  771. }
  772. // --- pathValidation ---
  773. #[test]
  774. fn warns_directory_traversal() {
  775. let workspace = PathBuf::from("/workspace/project");
  776. assert!(matches!(
  777. validate_paths("cat ../../../etc/passwd", &workspace),
  778. ValidationResult::Warn { message } if message.contains("traversal")
  779. ));
  780. }
  781. #[test]
  782. fn warns_home_directory_reference() {
  783. let workspace = PathBuf::from("/workspace/project");
  784. assert!(matches!(
  785. validate_paths("cat ~/.ssh/id_rsa", &workspace),
  786. ValidationResult::Warn { message } if message.contains("home directory")
  787. ));
  788. }
  789. // --- commandSemantics ---
  790. #[test]
  791. fn classifies_read_only_commands() {
  792. assert_eq!(classify_command("ls -la"), CommandIntent::ReadOnly);
  793. assert_eq!(classify_command("cat file.txt"), CommandIntent::ReadOnly);
  794. assert_eq!(
  795. classify_command("grep -r pattern ."),
  796. CommandIntent::ReadOnly
  797. );
  798. assert_eq!(
  799. classify_command("find . -name '*.rs'"),
  800. CommandIntent::ReadOnly
  801. );
  802. }
  803. #[test]
  804. fn classifies_write_commands() {
  805. assert_eq!(classify_command("cp a.txt b.txt"), CommandIntent::Write);
  806. assert_eq!(classify_command("mv old.txt new.txt"), CommandIntent::Write);
  807. assert_eq!(classify_command("mkdir -p /tmp/dir"), CommandIntent::Write);
  808. }
  809. #[test]
  810. fn classifies_destructive_commands() {
  811. assert_eq!(
  812. classify_command("rm -rf /tmp/x"),
  813. CommandIntent::Destructive
  814. );
  815. assert_eq!(
  816. classify_command("shred /dev/sda"),
  817. CommandIntent::Destructive
  818. );
  819. }
  820. #[test]
  821. fn classifies_network_commands() {
  822. assert_eq!(
  823. classify_command("curl https://example.com"),
  824. CommandIntent::Network
  825. );
  826. assert_eq!(classify_command("wget file.zip"), CommandIntent::Network);
  827. }
  828. #[test]
  829. fn classifies_sed_inplace_as_write() {
  830. assert_eq!(
  831. classify_command("sed -i 's/old/new/' file.txt"),
  832. CommandIntent::Write
  833. );
  834. }
  835. #[test]
  836. fn classifies_sed_stdout_as_read_only() {
  837. assert_eq!(
  838. classify_command("sed 's/old/new/' file.txt"),
  839. CommandIntent::ReadOnly
  840. );
  841. }
  842. #[test]
  843. fn classifies_git_status_as_read_only() {
  844. assert_eq!(classify_command("git status"), CommandIntent::ReadOnly);
  845. assert_eq!(
  846. classify_command("git log --oneline"),
  847. CommandIntent::ReadOnly
  848. );
  849. }
  850. #[test]
  851. fn classifies_git_push_as_write() {
  852. assert_eq!(
  853. classify_command("git push origin main"),
  854. CommandIntent::Write
  855. );
  856. }
  857. // --- validate_command (full pipeline) ---
  858. #[test]
  859. fn pipeline_blocks_write_in_read_only() {
  860. let workspace = PathBuf::from("/workspace");
  861. assert!(matches!(
  862. validate_command("rm -rf /tmp/x", PermissionMode::ReadOnly, &workspace),
  863. ValidationResult::Block { .. }
  864. ));
  865. }
  866. #[test]
  867. fn pipeline_warns_destructive_in_write_mode() {
  868. let workspace = PathBuf::from("/workspace");
  869. assert!(matches!(
  870. validate_command("rm -rf /", PermissionMode::WorkspaceWrite, &workspace),
  871. ValidationResult::Warn { .. }
  872. ));
  873. }
  874. #[test]
  875. fn pipeline_allows_safe_read_in_read_only() {
  876. let workspace = PathBuf::from("/workspace");
  877. assert_eq!(
  878. validate_command("ls -la", PermissionMode::ReadOnly, &workspace),
  879. ValidationResult::Allow
  880. );
  881. }
  882. // --- extract_first_command ---
  883. #[test]
  884. fn extracts_command_from_env_prefix() {
  885. assert_eq!(extract_first_command("FOO=bar ls -la"), "ls");
  886. assert_eq!(extract_first_command("A=1 B=2 echo hello"), "echo");
  887. }
  888. #[test]
  889. fn extracts_plain_command() {
  890. assert_eq!(extract_first_command("grep -r pattern ."), "grep");
  891. }
  892. }