Skip to main content

edict/commands/protocol/
shell.rs

1//! Shell-safe primitives for protocol guidance rendering.
2//!
3//! Single-quote escaping, identifier validation, and command builder helpers.
4//! The renderer layer composes these rather than duplicating quoting logic.
5
6use std::fmt::Write;
7
8/// Escape a string for safe inclusion in a single-quoted shell argument.
9///
10/// The POSIX approach: wrap in single quotes, and for any embedded single
11/// quote, end the current quoting, insert an escaped single quote, and
12/// restart quoting: `'` → `'\''`.
13///
14/// Returns the string with surrounding single quotes.
15pub fn shell_escape(s: &str) -> String {
16    let mut out = String::with_capacity(s.len() + 2);
17    out.push('\'');
18    for ch in s.chars() {
19        if ch == '\'' {
20            out.push_str("'\\''");
21        } else {
22            out.push(ch);
23        }
24    }
25    out.push('\'');
26    out
27}
28
29/// Validate a bone ID (e.g., `bd-3cqv`, `bn-m80`).
30///
31/// Bone ID prefixes vary by project, so we validate the format
32/// (short alphanumeric with hyphens) without hardcoding a prefix.
33pub fn validate_bone_id(id: &str) -> Result<(), ValidationError> {
34    if id.is_empty() {
35        return Err(ValidationError::Empty("bone ID"));
36    }
37    let valid = id.len() <= 20
38        && id.contains('-')
39        && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
40    if !valid {
41        return Err(ValidationError::InvalidFormat {
42            field: "bone ID",
43            value: id.to_string(),
44            expected: "<prefix>-[a-z0-9]+",
45        });
46    }
47    Ok(())
48}
49
50/// Validate a workspace name.
51pub fn validate_workspace_name(name: &str) -> Result<(), ValidationError> {
52    if name.is_empty() {
53        return Err(ValidationError::Empty("workspace name"));
54    }
55    if name.len() > 64 {
56        return Err(ValidationError::TooLong {
57            field: "workspace name",
58            max: 64,
59            actual: name.len(),
60        });
61    }
62    let valid = name
63        .chars()
64        .next()
65        .map(|c| c.is_ascii_alphanumeric())
66        .unwrap_or(false)
67        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
68    if !valid {
69        return Err(ValidationError::InvalidFormat {
70            field: "workspace name",
71            value: name.to_string(),
72            expected: "[a-z0-9][a-z0-9-]*, max 64 chars",
73        });
74    }
75    Ok(())
76}
77
78/// Validate an identifier (agent name, project name).
79/// Must be non-empty and contain no shell metacharacters.
80pub fn validate_identifier(field: &'static str, value: &str) -> Result<(), ValidationError> {
81    if value.is_empty() {
82        return Err(ValidationError::Empty(field));
83    }
84    let has_unsafe = value.chars().any(|c| {
85        matches!(
86            c,
87            ' ' | '\t'
88                | '\n'
89                | '\r'
90                | '\''
91                | '"'
92                | '`'
93                | '$'
94                | '\\'
95                | '!'
96                | '&'
97                | '|'
98                | ';'
99                | '('
100                | ')'
101                | '{'
102                | '}'
103                | '<'
104                | '>'
105                | '*'
106                | '?'
107                | '['
108                | ']'
109                | '#'
110                | '~'
111                | '\0'
112        )
113    });
114    if has_unsafe {
115        return Err(ValidationError::UnsafeChars {
116            field,
117            value: value.to_string(),
118        });
119    }
120    Ok(())
121}
122
123/// Validation error for shell-rendered values.
124#[derive(Debug, Clone)]
125pub enum ValidationError {
126    Empty(&'static str),
127    TooLong {
128        field: &'static str,
129        max: usize,
130        actual: usize,
131    },
132    InvalidFormat {
133        field: &'static str,
134        value: String,
135        expected: &'static str,
136    },
137    UnsafeChars {
138        field: &'static str,
139        value: String,
140    },
141}
142
143impl std::fmt::Display for ValidationError {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            ValidationError::Empty(field) => write!(f, "{field} cannot be empty"),
147            ValidationError::TooLong {
148                field, max, actual, ..
149            } => {
150                write!(f, "{field} too long ({actual} chars, max {max})")
151            }
152            ValidationError::InvalidFormat {
153                field,
154                value,
155                expected,
156            } => {
157                write!(f, "invalid {field} '{value}', expected {expected}")
158            }
159            ValidationError::UnsafeChars { field, value } => {
160                write!(f, "{field} '{value}' contains shell metacharacters")
161            }
162        }
163    }
164}
165
166impl std::error::Error for ValidationError {}
167
168/// Validate a review ID (e.g., `cr-2rnh`).
169pub fn validate_review_id(id: &str) -> Result<(), ValidationError> {
170    if id.is_empty() {
171        return Err(ValidationError::Empty("review ID"));
172    }
173    let valid =
174        id.starts_with("cr-") && id.len() > 3 && id[3..].chars().all(|c| c.is_ascii_alphanumeric());
175    if !valid {
176        return Err(ValidationError::InvalidFormat {
177            field: "review ID",
178            value: id.to_string(),
179            expected: "cr-[a-z0-9]+",
180        });
181    }
182    Ok(())
183}
184
185/// Ensure a structural value is safe for direct shell interpolation.
186///
187/// Structural values (bone IDs, workspace names, project names, statuses, labels)
188/// are expected to be pre-validated identifiers. As defense-in-depth, if a value
189/// contains shell metacharacters, it is escaped rather than interpolated raw.
190fn safe_ident(value: &str) -> std::borrow::Cow<'_, str> {
191    if !value.is_empty()
192        && value
193            .chars()
194            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':'))
195    {
196        std::borrow::Cow::Borrowed(value)
197    } else {
198        std::borrow::Cow::Owned(shell_escape(value))
199    }
200}
201
202// --- Command builders ---
203// These produce shell-safe command strings. All dynamic values are validated
204// or escaped before inclusion. Structural identifiers pass through safe_ident()
205// for defense-in-depth against unvalidated callers.
206
207/// Build: `bus claims stake --agent <agent> "bone://<project>/<id>" -m "<memo>"`
208pub fn claims_stake_cmd(agent: &str, uri: &str, memo: &str) -> String {
209    validate_identifier("agent", agent).expect("invalid agent name");
210    let agent_safe = safe_ident(agent);
211    let mut cmd = String::new();
212    write!(
213        cmd,
214        "bus claims stake --agent {} {}",
215        agent_safe,
216        shell_escape(uri)
217    )
218    .unwrap();
219    if !memo.is_empty() {
220        write!(cmd, " -m {}", shell_escape(memo)).unwrap();
221    }
222    cmd
223}
224
225/// Build: `bus claims release --agent <agent> "<uri>"`
226#[allow(dead_code)]
227pub fn claims_release_cmd(agent: &str, uri: &str) -> String {
228    validate_identifier("agent", agent).expect("invalid agent name");
229    let agent_safe = safe_ident(agent);
230    format!(
231        "bus claims release --agent {} {}",
232        agent_safe,
233        shell_escape(uri)
234    )
235}
236
237/// Build: `bus claims release --agent <agent> --all`
238pub fn claims_release_all_cmd(agent: &str) -> String {
239    validate_identifier("agent", agent).expect("invalid agent name");
240    let agent_safe = safe_ident(agent);
241    format!("bus claims release --agent {} --all", agent_safe)
242}
243
244/// Build: `bus send --agent <agent> <project> '<message>' -L <label>`
245pub fn bus_send_cmd(agent: &str, project: &str, message: &str, label: &str) -> String {
246    validate_identifier("agent", agent).expect("invalid agent name");
247    let agent_safe = safe_ident(agent);
248
249    // Validate project name before use
250    if let Err(_) = validate_identifier("project", project) {
251        // If validation fails, force escaping instead of raw interpolation
252        let mut cmd = String::new();
253        write!(
254            cmd,
255            "bus send --agent {} {} {}",
256            agent_safe,
257            shell_escape(project),
258            shell_escape(message)
259        )
260        .unwrap();
261        if !label.is_empty() {
262            write!(cmd, " -L {}", shell_escape(label)).unwrap();
263        }
264        return cmd;
265    }
266
267    let mut cmd = String::new();
268    write!(
269        cmd,
270        "bus send --agent {} {} {}",
271        agent_safe,
272        safe_ident(project),
273        shell_escape(message)
274    )
275    .unwrap();
276    if !label.is_empty() {
277        // Apply same validate+escape fallback as project parameter
278        if validate_identifier("label", label).is_ok() {
279            write!(cmd, " -L {}", safe_ident(label)).unwrap();
280        } else {
281            write!(cmd, " -L {}", shell_escape(label)).unwrap();
282        }
283    }
284    cmd
285}
286
287/// Build: `maw exec default -- bn do <id>`
288#[allow(dead_code)]
289pub fn bn_do_cmd(bone_id: &str) -> String {
290    // Validate bone_id before use - escape if validation fails
291    let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
292        safe_ident(bone_id)
293    } else {
294        std::borrow::Cow::Owned(shell_escape(bone_id))
295    };
296
297    format!("maw exec default -- bn do {}", bone_id_safe)
298}
299
300/// Build: `maw exec default -- bn bone comment add <id> '<message>'`
301#[allow(dead_code)]
302pub fn bn_comment_cmd(bone_id: &str, message: &str) -> String {
303    // Validate bone_id before use
304    let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
305        safe_ident(bone_id)
306    } else {
307        std::borrow::Cow::Owned(shell_escape(bone_id))
308    };
309
310    format!(
311        "maw exec default -- bn bone comment add {} {}",
312        bone_id_safe,
313        shell_escape(message)
314    )
315}
316
317/// Build: `maw exec default -- bn done <id> --reason '<reason>'`
318pub fn bn_done_cmd(bone_id: &str, reason: &str) -> String {
319    // Validate bone_id before use
320    let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
321        safe_ident(bone_id)
322    } else {
323        std::borrow::Cow::Owned(shell_escape(bone_id))
324    };
325
326    let mut cmd = format!("maw exec default -- bn done {}", bone_id_safe);
327    if !reason.is_empty() {
328        write!(cmd, " --reason {}", shell_escape(reason)).unwrap();
329    }
330    cmd
331}
332
333/// Build: `maw ws create --random`
334pub fn ws_create_cmd() -> String {
335    "maw ws create --random".to_string()
336}
337
338/// Build: `maw ws merge <ws> --destroy --message <msg>`
339///
340/// `message` is required — maw enforces explicit commit messages.
341/// Use conventional commit prefix: `feat:`, `fix:`, `chore:`, etc.
342pub fn ws_merge_cmd(workspace: &str, message: &str) -> String {
343    // Validate workspace name before use
344    let workspace_safe = if validate_workspace_name(workspace).is_ok() {
345        safe_ident(workspace)
346    } else {
347        std::borrow::Cow::Owned(shell_escape(workspace))
348    };
349
350    format!(
351        "maw ws merge {} --destroy --message {}",
352        workspace_safe,
353        shell_escape(message)
354    )
355}
356
357/// Build: `maw exec <ws> -- crit reviews create --agent <agent> --title '<title>' --reviewers <reviewers>`
358pub fn crit_create_cmd(workspace: &str, agent: &str, title: &str, reviewers: &str) -> String {
359    validate_identifier("agent", agent).expect("invalid agent name");
360    let agent_safe = safe_ident(agent);
361
362    // Validate workspace and reviewers before use
363    let workspace_safe = if validate_workspace_name(workspace).is_ok() {
364        safe_ident(workspace)
365    } else {
366        std::borrow::Cow::Owned(shell_escape(workspace))
367    };
368
369    let reviewers_safe = if validate_identifier("reviewers", reviewers).is_ok() {
370        safe_ident(reviewers)
371    } else {
372        std::borrow::Cow::Owned(shell_escape(reviewers))
373    };
374
375    format!(
376        "maw exec {} -- crit reviews create --agent {} --title {} --reviewers {}",
377        workspace_safe,
378        agent_safe,
379        shell_escape(title),
380        reviewers_safe
381    )
382}
383
384/// Build: `maw exec <ws> -- crit reviews request <id> --reviewers <reviewers> --agent <agent>`
385pub fn crit_request_cmd(workspace: &str, review_id: &str, reviewers: &str, agent: &str) -> String {
386    validate_identifier("agent", agent).expect("invalid agent name");
387    let agent_safe = safe_ident(agent);
388
389    // Validate all identifiers before use
390    let workspace_safe = if validate_workspace_name(workspace).is_ok() {
391        safe_ident(workspace)
392    } else {
393        std::borrow::Cow::Owned(shell_escape(workspace))
394    };
395
396    let review_id_safe = if validate_review_id(review_id).is_ok() {
397        safe_ident(review_id)
398    } else {
399        std::borrow::Cow::Owned(shell_escape(review_id))
400    };
401
402    let reviewers_safe = if validate_identifier("reviewers", reviewers).is_ok() {
403        safe_ident(reviewers)
404    } else {
405        std::borrow::Cow::Owned(shell_escape(reviewers))
406    };
407
408    format!(
409        "maw exec {} -- crit reviews request {} --reviewers {} --agent {}",
410        workspace_safe, review_id_safe, reviewers_safe, agent_safe
411    )
412}
413
414/// Build: `maw exec <ws> -- crit review <id>`
415pub fn crit_show_cmd(workspace: &str, review_id: &str) -> String {
416    // Validate workspace and review_id before use
417    let workspace_safe = if validate_workspace_name(workspace).is_ok() {
418        safe_ident(workspace)
419    } else {
420        std::borrow::Cow::Owned(shell_escape(workspace))
421    };
422
423    let review_id_safe = if validate_review_id(review_id).is_ok() {
424        safe_ident(review_id)
425    } else {
426        std::borrow::Cow::Owned(shell_escape(review_id))
427    };
428
429    format!(
430        "maw exec {} -- crit review {}",
431        workspace_safe, review_id_safe
432    )
433}
434
435/// Build: `bus statuses clear --agent <agent>`
436pub fn bus_statuses_clear_cmd(agent: &str) -> String {
437    validate_identifier("agent", agent).expect("invalid agent name");
438    let agent_safe = safe_ident(agent);
439    format!("bus statuses clear --agent {}", agent_safe)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    // --- shell_escape tests ---
447
448    #[test]
449    fn escape_empty() {
450        assert_eq!(shell_escape(""), "''");
451    }
452
453    #[test]
454    fn escape_simple() {
455        assert_eq!(shell_escape("hello"), "'hello'");
456    }
457
458    #[test]
459    fn escape_with_spaces() {
460        assert_eq!(shell_escape("hello world"), "'hello world'");
461    }
462
463    #[test]
464    fn escape_single_quotes() {
465        assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
466    }
467
468    #[test]
469    fn escape_double_quotes() {
470        assert_eq!(shell_escape(r#"say "hi""#), r#"'say "hi"'"#);
471    }
472
473    #[test]
474    fn escape_backslashes() {
475        assert_eq!(shell_escape(r"path\to\file"), r"'path\to\file'");
476    }
477
478    #[test]
479    fn escape_newlines() {
480        assert_eq!(shell_escape("line1\nline2"), "'line1\nline2'");
481    }
482
483    #[test]
484    fn escape_dollar_variables() {
485        assert_eq!(shell_escape("$HOME"), "'$HOME'");
486    }
487
488    #[test]
489    fn escape_backticks() {
490        assert_eq!(shell_escape("`whoami`"), "'`whoami`'");
491    }
492
493    #[test]
494    fn escape_unicode() {
495        assert_eq!(shell_escape("hello 🌍"), "'hello 🌍'");
496    }
497
498    #[test]
499    fn escape_multiple_single_quotes() {
500        assert_eq!(shell_escape("it's Bob's"), "'it'\\''s Bob'\\''s'");
501    }
502
503    #[test]
504    fn escape_all_metacharacters() {
505        assert_eq!(shell_escape("$(rm -rf /)"), "'$(rm -rf /)'");
506    }
507
508    // --- validate_bone_id tests ---
509
510    #[test]
511    fn valid_bone_id() {
512        assert!(validate_bone_id("bd-3cqv").is_ok());
513        assert!(validate_bone_id("bd-abc123").is_ok());
514        assert!(validate_bone_id("bd-a").is_ok());
515        // Other project prefixes
516        assert!(validate_bone_id("bn-m80").is_ok());
517        assert!(validate_bone_id("bm-xyz").is_ok());
518        assert!(validate_bone_id("xx-3cqv").is_ok());
519    }
520
521    #[test]
522    fn invalid_bone_id_empty() {
523        assert!(validate_bone_id("").is_err());
524    }
525
526    #[test]
527    fn invalid_bone_id_no_hyphen() {
528        assert!(validate_bone_id("3cqv").is_err());
529        assert!(validate_bone_id("abcdef").is_err());
530    }
531
532    #[test]
533    fn invalid_bone_id_special_chars() {
534        assert!(validate_bone_id("bd-abc def").is_err());
535        assert!(validate_bone_id("bd-abc;rm").is_err());
536        assert!(validate_bone_id("bd-abc/def").is_err());
537    }
538
539    // --- validate_review_id tests ---
540
541    #[test]
542    fn valid_review_id() {
543        assert!(validate_review_id("cr-2rnh").is_ok());
544        assert!(validate_review_id("cr-abc123").is_ok());
545        assert!(validate_review_id("cr-a").is_ok());
546    }
547
548    #[test]
549    fn invalid_review_id_empty() {
550        assert!(validate_review_id("").is_err());
551    }
552
553    #[test]
554    fn invalid_review_id_no_prefix() {
555        assert!(validate_review_id("2rnh").is_err());
556        assert!(validate_review_id("bd-3cqv").is_err());
557    }
558
559    #[test]
560    fn invalid_review_id_special_chars() {
561        assert!(validate_review_id("cr-abc-def").is_err());
562        assert!(validate_review_id("cr-").is_err());
563    }
564
565    // --- safe_ident tests ---
566
567    #[test]
568    fn safe_ident_passes_clean_values() {
569        assert_eq!(safe_ident("bd-3cqv").as_ref(), "bd-3cqv");
570        assert_eq!(safe_ident("frost-castle").as_ref(), "frost-castle");
571        assert_eq!(safe_ident("in_progress").as_ref(), "in_progress");
572        assert_eq!(safe_ident("edict-dev").as_ref(), "edict-dev");
573    }
574
575    #[test]
576    fn safe_ident_escapes_unsafe_values() {
577        // Spaces get escaped
578        assert_eq!(safe_ident("bad name").as_ref(), "'bad name'");
579        // Shell metacharacters get escaped
580        assert_eq!(safe_ident("$(rm -rf)").as_ref(), "'$(rm -rf)'");
581        // Empty gets escaped
582        assert_eq!(safe_ident("").as_ref(), "''");
583    }
584
585    // --- validate_workspace_name tests ---
586
587    #[test]
588    fn valid_workspace_names() {
589        assert!(validate_workspace_name("default").is_ok());
590        assert!(validate_workspace_name("frost-castle").is_ok());
591        assert!(validate_workspace_name("a").is_ok());
592        assert!(validate_workspace_name("ws-123-test").is_ok());
593    }
594
595    #[test]
596    fn invalid_workspace_empty() {
597        assert!(validate_workspace_name("").is_err());
598    }
599
600    #[test]
601    fn invalid_workspace_starts_with_dash() {
602        assert!(validate_workspace_name("-foo").is_err());
603    }
604
605    #[test]
606    fn invalid_workspace_special_chars() {
607        assert!(validate_workspace_name("ws name").is_err());
608        assert!(validate_workspace_name("ws_name").is_err());
609        assert!(validate_workspace_name("ws.name").is_err());
610    }
611
612    #[test]
613    fn invalid_workspace_too_long() {
614        let long_name: String = "a".repeat(65);
615        assert!(validate_workspace_name(&long_name).is_err());
616    }
617
618    #[test]
619    fn workspace_exactly_64_chars() {
620        let name: String = "a".repeat(64);
621        assert!(validate_workspace_name(&name).is_ok());
622    }
623
624    // --- validate_identifier tests ---
625
626    #[test]
627    fn valid_identifiers() {
628        assert!(validate_identifier("agent", "edict-dev").is_ok());
629        assert!(validate_identifier("project", "myproject").is_ok());
630        assert!(validate_identifier("agent", "my-agent-123").is_ok());
631    }
632
633    #[test]
634    fn invalid_identifier_empty() {
635        assert!(validate_identifier("agent", "").is_err());
636    }
637
638    #[test]
639    fn invalid_identifier_shell_metacharacters() {
640        assert!(validate_identifier("agent", "foo bar").is_err());
641        assert!(validate_identifier("agent", "foo;rm").is_err());
642        assert!(validate_identifier("agent", "$(whoami)").is_err());
643        assert!(validate_identifier("agent", "foo`bar`").is_err());
644        assert!(validate_identifier("agent", "foo'bar").is_err());
645        assert!(validate_identifier("agent", "foo\"bar").is_err());
646        assert!(validate_identifier("agent", "a|b").is_err());
647        assert!(validate_identifier("agent", "a&b").is_err());
648    }
649
650    // --- Command builder tests ---
651
652    #[test]
653    fn claims_stake_basic() {
654        let cmd = claims_stake_cmd("crimson-storm", "bone://myproject/bd-abc", "bd-abc");
655        assert_eq!(
656            cmd,
657            "bus claims stake --agent crimson-storm 'bone://myproject/bd-abc' -m 'bd-abc'"
658        );
659    }
660
661    #[test]
662    fn claims_stake_no_memo() {
663        let cmd = claims_stake_cmd("crimson-storm", "bone://myproject/bd-abc", "");
664        assert_eq!(
665            cmd,
666            "bus claims stake --agent crimson-storm 'bone://myproject/bd-abc'"
667        );
668    }
669
670    #[test]
671    fn claims_release_basic() {
672        let cmd = claims_release_cmd("crimson-storm", "bone://myproject/bd-abc");
673        assert_eq!(
674            cmd,
675            "bus claims release --agent crimson-storm 'bone://myproject/bd-abc'"
676        );
677    }
678
679    #[test]
680    fn claims_release_all() {
681        let cmd = claims_release_all_cmd("crimson-storm");
682        assert_eq!(cmd, "bus claims release --agent crimson-storm --all");
683    }
684
685    #[test]
686    fn bus_send_basic() {
687        let cmd = bus_send_cmd(
688            "crimson-storm",
689            "myproject",
690            "Task claimed: bd-abc",
691            "task-claim",
692        );
693        assert_eq!(
694            cmd,
695            "bus send --agent crimson-storm myproject 'Task claimed: bd-abc' -L task-claim"
696        );
697    }
698
699    #[test]
700    fn bus_send_with_quotes_in_message() {
701        let cmd = bus_send_cmd("crimson-storm", "myproject", "it's done", "task-done");
702        assert_eq!(
703            cmd,
704            "bus send --agent crimson-storm myproject 'it'\\''s done' -L task-done"
705        );
706    }
707
708    #[test]
709    fn bus_send_no_label() {
710        let cmd = bus_send_cmd("crimson-storm", "myproject", "hello", "");
711        assert_eq!(cmd, "bus send --agent crimson-storm myproject 'hello'");
712    }
713
714    #[test]
715    fn bn_do_basic() {
716        let cmd = bn_do_cmd("bd-abc");
717        assert_eq!(cmd, "maw exec default -- bn do bd-abc");
718    }
719
720    #[test]
721    fn bn_comment_with_escaping() {
722        let cmd = bn_comment_cmd("bd-abc", "Started work in ws/frost-castle/");
723        assert_eq!(
724            cmd,
725            "maw exec default -- bn bone comment add bd-abc 'Started work in ws/frost-castle/'"
726        );
727    }
728
729    #[test]
730    fn bn_done_basic() {
731        let cmd = bn_done_cmd("bd-abc", "Completed");
732        assert_eq!(
733            cmd,
734            "maw exec default -- bn done bd-abc --reason 'Completed'"
735        );
736    }
737
738    #[test]
739    fn bn_done_no_reason() {
740        let cmd = bn_done_cmd("bd-abc", "");
741        assert_eq!(cmd, "maw exec default -- bn done bd-abc");
742    }
743
744    #[test]
745    fn ws_merge_with_message() {
746        let cmd = ws_merge_cmd("frost-castle", "feat: add login flow");
747        assert_eq!(
748            cmd,
749            "maw ws merge frost-castle --destroy --message 'feat: add login flow'"
750        );
751    }
752
753    #[test]
754    fn crit_create_with_escaping() {
755        let cmd = crit_create_cmd(
756            "frost-castle",
757            "crimson-storm",
758            "feat: add login",
759            "myproject-security",
760        );
761        assert_eq!(
762            cmd,
763            "maw exec frost-castle -- crit reviews create --agent crimson-storm --title 'feat: add login' --reviewers myproject-security"
764        );
765    }
766
767    #[test]
768    fn crit_request_basic() {
769        let cmd = crit_request_cmd(
770            "frost-castle",
771            "cr-123",
772            "myproject-security",
773            "crimson-storm",
774        );
775        assert_eq!(
776            cmd,
777            "maw exec frost-castle -- crit reviews request cr-123 --reviewers myproject-security --agent crimson-storm"
778        );
779    }
780
781    #[test]
782    fn crit_show_basic() {
783        let cmd = crit_show_cmd("frost-castle", "cr-123");
784        assert_eq!(cmd, "maw exec frost-castle -- crit review cr-123");
785    }
786
787    // --- Deterministic output tests ---
788
789    #[test]
790    fn command_builders_are_deterministic() {
791        // Same inputs always produce same output
792        let cmd1 = bus_send_cmd("crimson-storm", "proj", "msg", "label");
793        let cmd2 = bus_send_cmd("crimson-storm", "proj", "msg", "label");
794        assert_eq!(cmd1, cmd2);
795    }
796
797    // --- Injection resistance tests ---
798
799    #[test]
800    fn escape_prevents_command_injection() {
801        // Malicious input with embedded quotes gets properly escaped
802        let malicious = "done'; rm -rf /; echo '";
803        let escaped = shell_escape(malicious);
804        // The escaped value starts and ends with single quotes
805        assert!(escaped.starts_with('\''));
806        assert!(escaped.ends_with('\''));
807        // Embedded single quotes are broken out with \'
808        assert!(escaped.contains("\\'"));
809        // When used in a command, the entire escaped value appears as one arg
810        let cmd = bn_comment_cmd("bd-abc", malicious);
811        assert!(cmd.contains(&escaped));
812        // Roundtrip: the escaped form should decode back to the original
813        // (verified by the start/end quotes and \' escaping pattern)
814    }
815
816    #[test]
817    fn escape_prevents_variable_expansion() {
818        let msg = "Status: $HOME/.secret";
819        let escaped = shell_escape(msg);
820        assert_eq!(escaped, "'Status: $HOME/.secret'");
821    }
822}