sofos 0.4.0

An interactive AI coding agent for your terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
//! Structural and path-policy checks for the bash executor.
//!
//! Two layers: free functions that look at command text in isolation
//! (parent-traversal detection, shell-boundary-aware operator search)
//! and methods on [`BashExecutor`] that combine those checks with the
//! workspace's permission state — including the per-command rejection
//! messages the executor surfaces to the model.

use crate::config::{LOCAL_CONFIG_FILE, config_files_hint};
use crate::error::{Result, SofosError};
use crate::tools::ToolName;
use crate::tools::bash::BashExecutor;
use crate::tools::permissions::command_parse::{
    COMPOUND_HEADERS_NO_BODY, COMPOUND_HEADERS_WITH_BODY, COMPOUND_KEYWORDS, is_env_assignment,
};
use crate::tools::permissions::{CommandPermission, PermissionManager, grant_dir_for_path};
use crate::tools::utils::{is_absolute_path, lexically_normalize, normalize_command_whitespace};
use std::path::PathBuf;

/// Return true when a command argument looks like a parent-directory
/// reference (`..`, `../foo`, `foo/..`, `foo/../bar`). Substring matches
/// inside opaque tokens — like git revision ranges (`HEAD~5..HEAD`) or
/// regex patterns (`\.\.\.`) — are intentionally NOT flagged: blocking
/// them was what stopped the AI from running legitimate git diagnostics
/// when a file ended up corrupted.
///
/// The command is split on whitespace and also on `=` / `:`, so that
/// flag-embedded traversals (`--include=../secret.h`) and PATH-style
/// assignments (`PATH=/usr/bin:../foo`) surface their `..` fragment as
/// its own token rather than hiding inside an opaque `KEY=VALUE`
/// string. Git range syntax (`HEAD~5..HEAD`, `HEAD^:path`) survives
/// the split because neither `..` nor `^` are delimiters here.
pub(super) fn has_path_traversal(command: &str) -> bool {
    let split = |c: char| c.is_whitespace() || matches!(c, '=' | ':');
    for raw in command.split(split).filter(|t| !t.is_empty()) {
        // Reduce to the bytes the shell passes, so a `..` hidden by quotes
        // (`".""."/x`) or a backslash (`\../x`) surfaces — without flagging a
        // quoted regex like `'s/\.\.//'` whose backslashes are literal.
        let t = reduce_for_traversal(raw);
        if t == ".." || t.starts_with("../") || t.ends_with("/..") || t.contains("/../") {
            return true;
        }
    }
    false
}

/// Reduce a shell word to the bytes it expands to, for spotting a `..` path
/// component. Quotes and grouping chars are dropped; an unquoted backslash
/// escapes the next char, while a quoted backslash stays literal.
fn reduce_for_traversal(raw: &str) -> String {
    let mut out = String::with_capacity(raw.len());
    let mut in_single = false;
    let mut in_double = false;
    let mut chars = raw.chars();
    while let Some(c) = chars.next() {
        match c {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            '\\' if !in_single && !in_double => {
                if let Some(next) = chars.next() {
                    out.push(next);
                }
            }
            '"' | '\'' | '`' | '(' | ')' | '{' | '}' | '[' | ']' | ';' | ',' => {}
            _ => out.push(c),
        }
    }
    out
}

/// Detect shell command and process substitution outside of single
/// quotes. Returns the marker that triggered the match so the rejection
/// message can name it. Single quotes suppress substitution in POSIX
/// shells, so `echo '$(rm bad)'` is literal text; double quotes do not
/// suppress it. A backslash outside single quotes escapes the next
/// byte, so `\$(foo)` is literal. Arithmetic expansion `$((expr))` is
/// not flagged because it evaluates a numeric expression rather than
/// running a command, but a substitution nested inside arithmetic is
/// still caught when the scanner reaches the inner `$(`.
pub(super) fn detect_command_substitution(command: &str) -> Option<&'static str> {
    let bytes = command.as_bytes();
    let mut i = 0;
    let mut in_single = false;
    let mut in_double = false;
    while i < bytes.len() {
        let b = bytes[i];
        if !in_single && b == b'\\' {
            i = i.saturating_add(2);
            continue;
        }
        if !in_double && b == b'\'' {
            in_single = !in_single;
            i += 1;
            continue;
        }
        if !in_single && b == b'"' {
            in_double = !in_double;
            i += 1;
            continue;
        }
        if !in_single {
            if b == b'`' {
                return Some("`");
            }
            if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
                if i + 2 < bytes.len() && bytes[i + 2] == b'(' {
                    i += 3;
                    continue;
                }
                return Some("$(");
            }
            if (b == b'<' || b == b'>') && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
                return Some(if b == b'<' { "<(" } else { ">(" });
            }
        }
        i += 1;
    }
    None
}

/// Detect bash ANSI-C quoting (`$'...'`) used outside other quoting. The
/// shell decodes the backslash escapes inside such a token into bytes, so
/// `$'\x67it'` becomes `git` and `git $'\x70ush'` runs `git push` — a
/// spelling neither the structural git gate nor the command classifier
/// would otherwise recognise. Any command using this form is refused, so a
/// dangerous program name or git subcommand cannot be hidden behind it.
/// A `$'` inside single or double quotes is an ordinary dollar before a
/// quote and is left alone, as the shell treats it.
pub(super) fn detect_ansi_c_quoting(command: &str) -> bool {
    let bytes = command.as_bytes();
    let mut i = 0;
    let mut in_single = false;
    let mut in_double = false;
    while i < bytes.len() {
        let b = bytes[i];
        if !in_single && b == b'\\' {
            i = i.saturating_add(2);
            continue;
        }
        if !in_double && b == b'\'' {
            in_single = !in_single;
            i += 1;
            continue;
        }
        if !in_single && b == b'"' {
            in_double = !in_double;
            i += 1;
            continue;
        }
        if !in_single && !in_double && b == b'$' && bytes.get(i + 1) == Some(&b'\'') {
            return true;
        }
        i += 1;
    }
    false
}

/// Programs that run one of their arguments as a command, so a dangerous
/// `git` call can hide behind them — `env git push`, `timeout 5 git push`,
/// `nice git push`, `xargs git push`. We look past the launcher to the git
/// call rather than stopping at the wrapper's name.
const GIT_LAUNCHERS: &[&str] = &[
    "env", "nice", "time", "timeout", "command", "nohup", "setsid", "stdbuf", "xargs", "ionice",
    "taskset", "chrt",
];

/// Shells that run the string after `-c` as a command, so `sh -c "git
/// push"` is inspected by re-parsing that string.
const GIT_SHELLS: &[&str] = &["sh", "bash", "dash", "zsh", "ksh"];

/// Git global options that take their value as the following word, so the
/// real subcommand is the token after the value. Matched case-sensitively
/// (`-C` changes directory, `-c` sets config) and only without `=`, since
/// the `=` form keeps option and value in one token. Any other leading
/// `-` token is treated as possibly value-taking too (see
/// [`git_subcommand_candidates`]), so a future option cannot hide the verb.
const GIT_GLOBAL_VALUE_OPTIONS: &[&str] = &[
    "-C",
    "-c",
    "--git-dir",
    "--work-tree",
    "--namespace",
    "--super-prefix",
    "--config-env",
    "--attr-source",
    "--shallow-file",
];

/// Git global options that take no value. Listed so the subcommand scan
/// does not also explore the "consumes the next word" branch for them,
/// which would step over the real subcommand into its arguments and
/// mis-read a pathspec named like a verb (`git --no-pager log -- rm`).
const GIT_GLOBAL_NOARG_OPTIONS: &[&str] = &[
    "-p",
    "-P",
    "--paginate",
    "--no-pager",
    "--bare",
    "--no-replace-objects",
    "--literal-pathspecs",
    "--glob-pathspecs",
    "--noglob-pathspecs",
    "--icase-pathspecs",
    "--no-optional-locks",
    "--no-advice",
];

/// Bound on how deep we follow `sh -c "..."` / launcher nesting.
const GIT_NESTING_LIMIT: u8 = 8;

/// Finish the current word: emit it, or drop it when it is a redirection
/// target the shell would strip from the argument vector.
fn flush_word(words: &mut Vec<String>, cur: &mut String, in_word: &mut bool, drop_next: &mut bool) {
    if !*in_word {
        return;
    }
    if *drop_next {
        cur.clear();
        *drop_next = false;
    } else {
        words.push(std::mem::take(cur));
    }
    *in_word = false;
}

/// Split a shell segment into words the way the shell would, honouring
/// single quotes, double quotes, and backslash escapes and removing those
/// quoting characters. Subshell parentheses end the current word so
/// `(git push)` yields `git`/`push`; brace groups are handled separately
/// because `{` is a standalone keyword token. Redirections (`< file`,
/// `2> out`, `git<file`) are dropped, operator and target both, so the
/// words match the argument vector the shell hands the program — otherwise
/// `git <file push` would look like the subcommand was `<file`. Expansions
/// are not performed — `$(...)` and backticks are rejected upstream by
/// `detect_command_substitution` — so a `$` or backtick is kept literally.
/// `git -C 'a b' push` yields four words with `a b` intact rather than
/// splitting the quoted value.
fn shell_words(segment: &str) -> Vec<String> {
    let mut words = Vec::new();
    let mut cur = String::new();
    let mut in_word = false;
    // The next finished word is a redirection target to drop.
    let mut drop_next = false;
    let mut chars = segment.chars().peekable();
    let mut quote: Option<char> = None;
    while let Some(c) = chars.next() {
        match quote {
            Some('\'') => {
                if c == '\'' {
                    quote = None;
                } else {
                    cur.push(c);
                }
            }
            Some(_) => match c {
                '"' => quote = None,
                '\\' => match chars.peek() {
                    Some(&n) if matches!(n, '"' | '\\' | '$' | '`') => {
                        cur.push(n);
                        chars.next();
                    }
                    _ => cur.push('\\'),
                },
                _ => cur.push(c),
            },
            None => match c {
                '\'' | '"' => {
                    quote = Some(c);
                    in_word = true;
                }
                '\\' => {
                    if let Some(n) = chars.next() {
                        cur.push(n);
                    }
                    in_word = true;
                }
                '<' | '>' => {
                    // A bare fd number right before the operator (`2>`,
                    // `0<`) is part of the redirection, not a word.
                    if in_word && cur.chars().all(|c| c.is_ascii_digit()) {
                        cur.clear();
                        in_word = false;
                    } else {
                        flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next);
                    }
                    // Swallow the rest of the operator: >> << <> >& <& >|.
                    while matches!(chars.peek(), Some('<' | '>' | '&' | '|')) {
                        chars.next();
                    }
                    drop_next = true;
                }
                '(' | ')' => flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next),
                _ if c.is_whitespace() => {
                    flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next)
                }
                _ => {
                    cur.push(c);
                    in_word = true;
                }
            },
        }
    }
    flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next);
    words
}

/// Index of the program token in `words`, past leading env-assignments and
/// shell keywords (`FOO=bar`, `then`, `do`, `if`, …). `None` when the
/// segment carries no command of its own — a `for VAR in …` header.
fn command_base_index(words: &[String]) -> Option<usize> {
    let mut i = 0;
    while let Some(tok) = words.get(i) {
        if COMPOUND_HEADERS_NO_BODY.contains(&tok.as_str()) {
            return None;
        }
        if is_env_assignment(tok)
            || COMPOUND_KEYWORDS.contains(&tok.as_str())
            || COMPOUND_HEADERS_WITH_BODY.contains(&tok.as_str())
        {
            i += 1;
        } else {
            break;
        }
    }
    (i < words.len()).then_some(i)
}

/// Append the argument list (everything after the `git` program token) of
/// every git invocation reachable in `words`, looking through leading
/// env-assignments, launcher programs, and `sh -c "..."` wrappers.
fn collect_git_invocations(words: &[String], depth: u8, out: &mut Vec<Vec<String>>) {
    if depth == 0 {
        return;
    }
    let Some(base) = command_base_index(words) else {
        return;
    };
    let rest = &words[base + 1..];
    if base_is_git(&words[base]) {
        out.push(rest.to_vec());
        return;
    }
    let name = program_name(&words[base]);
    if GIT_SHELLS.contains(&name.as_str()) {
        for payload in shell_c_payloads(rest) {
            collect_git_invocations(&shell_words(&payload), depth - 1, out);
        }
        return;
    }
    if GIT_LAUNCHERS.contains(&name.as_str()) {
        // `env -S "git push"` / `env --split-string=...` re-splits its
        // string argument into a fresh command line, so re-parse it.
        if name == "env" {
            for payload in env_split_string_payloads(rest) {
                collect_git_invocations(&shell_words(&payload), depth - 1, out);
            }
        }
        for (i, tok) in rest.iter().enumerate() {
            if base_is_git(tok) {
                out.push(rest[i + 1..].to_vec());
            } else if GIT_SHELLS.contains(&program_name(tok).as_str()) {
                for payload in shell_c_payloads(&rest[i + 1..]) {
                    collect_git_invocations(&shell_words(&payload), depth - 1, out);
                }
            }
        }
    }
}

/// The command strings `env` runs via `-S` / `--split-string`, in the
/// separate-word, glued (`-S"git push"`), and `=`-joined forms. Each is a
/// command line `env` re-splits and runs, so it is re-parsed like a shell.
fn env_split_string_payloads(args: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    let mut i = 0;
    while let Some(tok) = args.get(i) {
        if tok == "-S" || tok == "--split-string" {
            if let Some(payload) = args.get(i + 1) {
                out.push(payload.clone());
            }
            i += 2;
        } else if let Some(payload) = tok
            .strip_prefix("--split-string=")
            .or_else(|| tok.strip_prefix("-S").filter(|s| !s.is_empty()))
        {
            out.push(payload.to_string());
            i += 1;
        } else {
            i += 1;
        }
    }
    out
}

/// Lower-cased program name with any directory prefix removed, so
/// `/usr/bin/env` and `env` compare equal against the launcher sets.
fn program_name(token: &str) -> String {
    token
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or(token)
        .to_ascii_lowercase()
}

/// The command strings a shell runs via `-c`. `sh -c "git push"` yields
/// `["git push"]`.
fn shell_c_payloads(args: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    let mut i = 0;
    while i < args.len() {
        if args[i] == "-c" {
            if let Some(payload) = args.get(i + 1) {
                out.push(payload.clone());
            }
            i += 2;
        } else {
            i += 1;
        }
    }
    out
}

/// Indices into `args` that git could dispatch as the subcommand. A
/// leading global option may or may not consume the next word as its
/// value; known value-taking options consume it, the `=` form does not,
/// and every other `-` token is explored both ways so a value-taking
/// option we don't know about cannot push the real verb out of view.
fn git_subcommand_candidates(args: &[String]) -> Vec<usize> {
    let mut seen = vec![false; args.len()];
    let mut out = Vec::new();
    let mut stack = vec![0usize];
    while let Some(i) = stack.pop() {
        if i >= args.len() || seen[i] {
            continue;
        }
        seen[i] = true;
        let tok = &args[i];
        if tok == "--" {
            stack.push(i + 1);
        } else if !tok.starts_with('-') || tok == "-" {
            out.push(i);
        } else if tok.contains('=') {
            stack.push(i + 1);
        } else if GIT_GLOBAL_VALUE_OPTIONS.contains(&tok.as_str()) {
            stack.push(i + 2);
        } else if GIT_GLOBAL_NOARG_OPTIONS.contains(&tok.as_str()) {
            stack.push(i + 1);
        } else {
            stack.push(i + 1);
            stack.push(i + 2);
        }
    }
    out
}

/// Whether one git invocation's argument list runs a destructive or
/// networked operation, recognised regardless of leading global options.
fn git_args_are_dangerous(args: &[String]) -> bool {
    if git_args_use_exec_capable_config(args) {
        return true;
    }
    git_subcommand_candidates(args)
        .into_iter()
        .any(|i| git_subcommand_is_dangerous(&args[i].to_ascii_lowercase(), &args[i + 1..]))
}

/// Whether a git subcommand (lower-cased) with the arguments that follow it
/// is one Sofos refuses to run for the model: it reaches the network,
/// rewrites history, or destroys working-tree state. File-recovery forms
/// (`git restore`, `git checkout -- <path>`) are intentionally absent so
/// the model can roll back a botched edit.
fn git_subcommand_is_dangerous(verb: &str, rest: &[String]) -> bool {
    let has = |flags: &[&str]| {
        rest.iter()
            .any(|t| flags.contains(&t.to_ascii_lowercase().as_str()))
    };
    let first = || rest.first().map(|s| s.to_ascii_lowercase());
    match verb {
        "push" | "pull" | "fetch" | "clone" | "clean" | "filter-branch" | "gc" | "prune"
        | "update-ref" | "send-email" | "apply" | "am" | "cherry-pick" | "revert" | "commit"
        | "merge" | "rebase" | "init" | "add" | "rm" | "mv" | "switch" | "submodule" | "daemon"
        | "instaweb" => true,
        "reset" => has(&["--hard", "--mixed"]),
        "checkout" => has(&["-f", "--force", "-b", "-B"]),
        "branch" => has(&["-d", "-D", "-m", "-M", "--delete", "--move"]),
        "tag" => has(&["-d", "--delete"]),
        "remote" => matches!(
            first().as_deref(),
            Some("add" | "set-url" | "remove" | "rm")
        ),
        "stash" => !matches!(first().as_deref(), Some("list" | "show")),
        _ => false,
    }
}

/// Whether the leading global options set an inline config key whose value
/// git executes as a command. Walks only the option run before the
/// subcommand; `-c`/`--config-env` carry the key, in either the
/// separate-word or `=`-joined form.
fn git_args_use_exec_capable_config(args: &[String]) -> bool {
    let mut i = 0;
    while let Some(tok) = args.get(i) {
        if tok == "--" || !tok.starts_with('-') || tok == "-" {
            return false;
        }
        if tok == "-c" || tok == "--config-env" {
            if args
                .get(i + 1)
                .is_some_and(|v| config_key_is_exec_capable(v))
            {
                return true;
            }
            i += 2;
            continue;
        }
        if let Some(key) = tok.strip_prefix("-c").filter(|k| !k.is_empty()) {
            if config_key_is_exec_capable(key) {
                return true;
            }
        }
        if let Some(key) = tok.strip_prefix("--config-env=") {
            if config_key_is_exec_capable(key) {
                return true;
            }
        }
        if !tok.contains('=') && GIT_GLOBAL_VALUE_OPTIONS.contains(&tok.as_str()) {
            i += 2;
        } else {
            i += 1;
        }
    }
    false
}

/// Whether a git config setting (`key` or `key=value`) runs code: an alias
/// or config include, a pager/editor/ssh/program/hook command, or any
/// per-driver hook key (`diff.<d>.command`, `filter.<d>.process`,
/// `trailer.<t>.command`, …). Matched by suffix so per-driver and
/// per-tool variants and new sections reusing these conventions are all
/// covered, rather than enumerating exact keys git keeps adding to.
fn config_key_is_exec_capable(setting: &str) -> bool {
    let (key, value) = match setting.split_once('=') {
        Some((k, v)) => (k.to_ascii_lowercase(), Some(v)),
        None => (setting.to_ascii_lowercase(), None),
    };
    // Config injection is dangerous whatever the value: it pulls in
    // arbitrary config that can define any of the command keys below.
    if key == "alias"
        || key.starts_with("alias.")
        || key == "include.path"
        || (key.starts_with("includeif.") && key.ends_with(".path"))
    {
        return true;
    }
    // The remaining keys name a command only when the value is a real
    // string. A boolean or empty value just toggles a setting and runs
    // nothing — `core.fsmonitor=true` selects git's built-in monitor,
    // `credential.helper=` clears helpers, `pager.log=false` disables
    // the pager — so those stay allowed.
    if value.is_none_or(is_git_boolean) {
        return false;
    }
    const EXEC_SUFFIXES: &[&str] = &[
        ".command",
        ".cmd",
        ".process",
        ".clean",
        ".smudge",
        ".textconv",
        ".helper",
        ".program",
        ".editor",
        ".sshcommand",
        ".fsmonitor",
        ".hookspath",
        ".external",
        ".askpass",
        ".packobjectshook",
        ".gitproxy",
    ];
    key == "core.pager"
        || key.starts_with("pager.")
        || EXEC_SUFFIXES.iter().any(|suffix| key.ends_with(suffix))
}

/// Whether a git config value is one of the boolean spellings, which only
/// toggle a setting and never name a command.
fn is_git_boolean(value: &str) -> bool {
    matches!(
        value.to_ascii_lowercase().as_str(),
        "" | "true" | "false" | "yes" | "no" | "on" | "off" | "1" | "0"
    )
}

/// Argument lists of every git invocation in `command`, across all
/// compound-command segments. Each segment is tokenised quote-aware, then
/// followed through launchers and `sh -c` wrappers.
fn git_invocations(command: &str) -> Vec<Vec<String>> {
    let mut out = Vec::new();
    for segment in PermissionManager::split_compound_command(command) {
        collect_git_invocations(&shell_words(&segment), GIT_NESTING_LIMIT, &mut out);
    }
    out
}

/// Whether every program the command runs is the real `git`. A confined
/// command of this kind may write the project's `.git` directory: git needs
/// that for `checkout`, `config`, `restore --staged`, and similar, and the
/// git gate has already vetted the operation. A command that also runs
/// anything else keeps `.git` read-only, so it cannot plant a hook there.
///
/// Fails closed: any segment whose base program is not plainly `git` makes
/// this false. Three forms that look like git but are not the trusted
/// binary are refused, since lifting `.git` write-protection for them would
/// hand metadata write access to attacker-controlled code:
/// - a launcher wrapper such as `env git`;
/// - a path-prefixed `git` (`./git`, `/tmp/x/git`) that could be a planted
///   binary — only a bare `git` qualifies;
/// - a dangerous env prefix (`PATH=`, `LD_PRELOAD=`, …) that could swap the
///   binary or hijack the real git's loader.
///
/// The caller only reaches it once the command is confinement-safe, so
/// there are no hidden subcommands ($(...), backticks) for a non-git
/// program to hide behind.
pub(super) fn command_runs_only_git(command: &str) -> bool {
    if PermissionManager::command_has_dangerous_env_prefix(command) {
        return false;
    }
    let mut saw_git = false;
    for segment in PermissionManager::split_compound_command(command) {
        let words = shell_words(&segment);
        let Some(base) = command_base_index(&words) else {
            continue;
        };
        if !base_is_bare_git(&words[base]) {
            return false;
        }
        saw_git = true;
    }
    saw_git
}

/// Whether `command` redirects output to a file (`>` or `>>`, including
/// `2>file`), ignoring the `2>&1` stderr-to-stdout dup. Git writes the
/// repository through its own file operations, never a shell redirect, so
/// a redirect in an otherwise git-only command is the command aiming a
/// write somewhere of its choosing — e.g. `git log > .git/hooks/pre-commit`
/// — rather than git doing its job. The confined `.git` write carve-out
/// uses this to stay closed for such a command.
pub(super) fn command_redirects_output(command: &str) -> bool {
    command.replace("2>&1", "").contains('>')
}

/// The subcommand verb (lower-cased) of the first dangerous git invocation
/// in `command`, or `"config"` when the offence is an exec-capable inline
/// config option. Drives the wording of the rejection message.
fn first_dangerous_git_verb(command: &str) -> Option<String> {
    for args in git_invocations(command) {
        if git_args_use_exec_capable_config(&args) {
            return Some("config".to_string());
        }
        for i in git_subcommand_candidates(&args) {
            let verb = args[i].to_ascii_lowercase();
            if git_subcommand_is_dangerous(&verb, &args[i + 1..]) {
                return Some(verb);
            }
        }
    }
    None
}

/// Whether `command` runs `git checkout` in a form that should prompt the
/// user — branch switches and HEAD detaches that mutate the working tree
/// without being destructive enough to hard-deny. Sees through global
/// options, quotes, launchers, and `sh -c`, and matches the exact
/// `checkout` subcommand so plumbing such as `git checkout-index` does not
/// over-trigger the prompt.
pub(super) fn command_contains_askable_git_checkout(command: &str) -> bool {
    git_invocations(command).iter().any(|args| {
        git_subcommand_candidates(args)
            .into_iter()
            .any(|i| args[i].eq_ignore_ascii_case("checkout"))
    })
}

/// Whether `token` is the `git` program however bash would spell it. The
/// quote and backslash characters bash strips while expanding a word are
/// removed — so `g\it`, `g""it`, `g'i't`, `\git`, and `'git'` all reduce
/// to `git` — subshell and group delimiters are dropped, and any
/// directory prefix is removed before the comparison. Backslash is a
/// bash escape here, not a path separator: the shell that runs these
/// commands is sh or bash on every platform sofos supports, so a
/// backslash is removed rather than split on.
fn base_is_git(token: &str) -> bool {
    let bare: String = token
        .chars()
        .filter(|c| !matches!(c, '\'' | '"' | '\\' | '(' | ')' | '{' | '}'))
        .collect();
    bare.rsplit('/')
        .next()
        .unwrap_or(&bare)
        .eq_ignore_ascii_case("git")
}

/// Whether `token` is a bare `git` with no directory prefix — stricter than
/// [`base_is_git`], which also accepts `/path/to/git`. The `.git`
/// write-protection carve-out uses this so a planted `./git` or a binary at
/// some other path cannot pose as the trusted git and earn write access to
/// the repository metadata. A bare invocation resolves `git` through the
/// child's `PATH`, the same binary the dangerous-env check guards.
fn base_is_bare_git(token: &str) -> bool {
    let bare: String = token
        .chars()
        .filter(|c| !matches!(c, '\'' | '"' | '\\' | '(' | ')' | '{' | '}'))
        .collect();
    bare.eq_ignore_ascii_case("git")
}

/// Returns the kind of expansion that would change the path between
/// our deny check and the shell touching it: `$` / `${...}`, backticks,
/// `~user`, or glob metacharacters (`?`, `*`, `[`, `{`). Plain `~/`
/// and bare `~` are allowed because they expand to a known prefix and
/// are handled by the tilde-expansion helper.
///
/// The Windows verbatim-path prefixes `\\?\` and `\\.\` are stripped
/// before the glob check so a canonical Windows path is not mistaken
/// for a glob just because the prefix contains `?`.
pub(super) fn path_token_shell_meta(tok: &str) -> Option<&'static str> {
    if tok.contains('$') {
        return Some("$ variable expansion");
    }
    if tok.contains('`') {
        return Some("backtick command substitution");
    }
    if tok.starts_with('~') && tok != "~" && !tok.starts_with("~/") {
        return Some("~user home expansion");
    }
    let body = strip_windows_verbatim_prefix(tok);
    if body.contains('?') || body.contains('*') || body.contains('[') || body.contains('{') {
        return Some("glob expansion");
    }
    None
}

/// Strip the Windows verbatim-path prefix (`\\?\`, `\\.\`, or the
/// forward-slash equivalents) when present so the remainder can be
/// inspected without the prefix's `?` or `.` being read as shell
/// metacharacters.
fn strip_windows_verbatim_prefix(tok: &str) -> &str {
    for prefix in [r"\\?\", r"\\.\", "//?/", "//./"] {
        if let Some(rest) = tok.strip_prefix(prefix) {
            return rest;
        }
    }
    tok
}

/// True for tokens that look like a path the shell will resolve at
/// run-time. Flag tokens (`--name=value`) and regex patterns fall
/// through so the stricter shell-meta check only fires on real paths.
fn token_looks_like_path(tok: &str) -> bool {
    tok.contains('/') || tok.starts_with('.') || tok.starts_with('~') || is_absolute_path(tok)
}

impl BashExecutor {
    /// Check all external paths (absolute or tilde) in a command against Bash path grants.
    /// Asks the user interactively for any paths not yet covered.
    pub(super) fn check_bash_external_paths(
        &self,
        command: &str,
        permission_manager: &mut PermissionManager,
    ) -> Result<()> {
        for token in command.split_whitespace() {
            let cleaned = token
                .trim_matches('"')
                .trim_matches('\'')
                .trim_matches(';')
                .trim();

            if cleaned.is_empty() {
                continue;
            }

            // `--flag=/path` and `--flag=~/path` tokens would otherwise
            // be swallowed whole by the `starts_with('-')` filter below,
            // so split at the first `=` to expose the path half. Without
            // this, `grep --include=/etc/passwd` bypasses the external-
            // path prompt entirely.
            let path_candidate = if cleaned.starts_with('-') {
                match cleaned.find('=') {
                    Some(i) => cleaned[i + 1..].trim_matches(|c: char| matches!(c, '"' | '\'')),
                    None => continue,
                }
            } else {
                cleaned
            };

            // Reject path tokens whose post-expansion shape we cannot check.
            if token_looks_like_path(path_candidate) {
                if let Some(kind) = path_token_shell_meta(path_candidate) {
                    return Err(SofosError::ToolExecution(format!(
                        "Path argument '{}' uses {} which can't be checked against the permission rules before the shell expands it\n\
                         Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
                        path_candidate, kind
                    )));
                }
            }

            // Check tilde before absolute so `~` / `~/foo` get expanded
            // first. `is_absolute_path` catches Unix (`/foo`) and
            // Windows (`C:\foo`, `\\server\share`) shapes on every
            // platform — `Path::is_absolute` alone would miss Unix
            // paths on Windows, letting a bash command referencing
            // `/etc/passwd` bypass the external-path prompt when the
            // binary runs there.
            if path_candidate.starts_with("~/") || path_candidate == "~" {
                let expanded = PermissionManager::expand_tilde_pub(path_candidate);
                self.check_bash_external_path(&expanded, permission_manager)?;
            } else if is_absolute_path(path_candidate) {
                self.check_bash_external_path(path_candidate, permission_manager)?;
            } else {
                // Workspace-relative token whose canonical resolution may
                // leave the workspace through a symlink. Canonicalize
                // against the workspace and, if the result lands outside,
                // route it through the same external-path gate.
                self.check_workspace_relative_escape(path_candidate, permission_manager)?;
            }
        }

        Ok(())
    }

    fn check_workspace_relative_escape(
        &self,
        path_candidate: &str,
        permission_manager: &mut PermissionManager,
    ) -> Result<()> {
        let joined = self.workspace.join(path_candidate);
        // Resolve symlinks through the deepest existing ancestor, so a
        // symlink that points outside the workspace is still caught when
        // its target does not exist yet. Plain `canonicalize` fails on a
        // missing leaf, which used to let such a path skip the
        // external-path gate.
        let resolved = crate::tools::bash::sandbox::canonicalize_existing_prefix(&joined);
        if resolved.starts_with(&self.workspace) {
            return Ok(());
        }
        let resolved_str = resolved.to_string_lossy().to_string();
        self.check_bash_external_path(&resolved_str, permission_manager)
    }

    /// Check a single external path against Bash path grants; ask user if not covered.
    pub(super) fn check_bash_external_path(
        &self,
        path: &str,
        permission_manager: &mut PermissionManager,
    ) -> Result<()> {
        // Canonicalize when possible; also keep a lexically normalized form
        // and the raw input so deny rules match every shape of the same path.
        let canonical = std::fs::canonicalize(path)
            .map(|p| p.to_string_lossy().to_string())
            .ok();
        let normalized = lexically_normalize(&PathBuf::from(path))
            .to_string_lossy()
            .to_string();
        let candidates: Vec<String> = match canonical {
            Some(c) if c == normalized || c == path => vec![c],
            Some(c) => vec![c, normalized.clone(), path.to_string()],
            None if normalized == path => vec![path.to_string()],
            None => vec![normalized.clone(), path.to_string()],
        };
        let check_path = candidates
            .first()
            .cloned()
            .unwrap_or_else(|| path.to_string());

        // Deny wins over allow; check every shape.
        for cand in &candidates {
            if permission_manager.is_bash_path_denied(cand) {
                return Err(SofosError::ToolExecution(format!(
                    "Bash access denied for path '{}'\n\
                     Hint: Blocked by deny rule in {}",
                    cand,
                    config_files_hint()
                )));
            }
        }

        // Already allowed by config?
        if permission_manager.is_bash_path_allowed(&check_path) {
            return Ok(());
        }

        let path_obj = std::path::Path::new(&check_path);

        // Session allowed?
        if let Ok(allowed_dirs) = self.bash_path_session_allowed.lock() {
            for dir in allowed_dirs.iter() {
                if path_obj.starts_with(std::path::Path::new(dir)) {
                    return Ok(());
                }
            }
        }

        // Session denied?
        if let Ok(denied_dirs) = self.bash_path_session_denied.lock() {
            for dir in denied_dirs.iter() {
                if path_obj.starts_with(std::path::Path::new(dir)) {
                    return Err(SofosError::ToolExecution(format!(
                        "Bash access denied for path '{}' (denied earlier this session)",
                        check_path
                    )));
                }
            }
        }

        let grant_dir = grant_dir_for_path(std::path::Path::new(&check_path));

        // Non-interactive mode (tests, piped input): deny with a config hint
        if !self.interactive {
            return Err(SofosError::ToolExecution(format!(
                "Command references path '{}' outside workspace\n\
                 Hint: Add Bash({}/**) to 'allow' list in {}",
                check_path, grant_dir, LOCAL_CONFIG_FILE
            )));
        }

        // Ask user interactively
        let (allowed, remember) = permission_manager.ask_user_path_permission("Bash", grant_dir)?;

        if allowed {
            if !remember {
                if let Ok(mut dirs) = self.bash_path_session_allowed.lock() {
                    // Session-only grant: store the file path itself, not
                    // the parent, so a second file under the same parent
                    // re-prompts. Persistent grants (remember=true) keep
                    // `Bash(parent/**)` because the user explicitly
                    // opted in to that scope through `ask_user_path_permission`.
                    dirs.insert(check_path.to_string());
                }
            }
            Ok(())
        } else {
            if !remember {
                if let Ok(mut dirs) = self.bash_path_session_denied.lock() {
                    dirs.insert(check_path.to_string());
                }
            }
            Err(SofosError::ToolExecution(format!(
                "Bash access denied by user for path '{}'",
                check_path
            )))
        }
    }

    pub(super) fn enforce_read_permissions(
        &self,
        permission_manager: &PermissionManager,
        command: &str,
    ) -> Result<()> {
        // Heuristic-based detection of file paths in commands.
        // Checks paths against Read deny rules (regardless of Bash path grants).
        // External path access is handled separately by check_bash_external_paths.
        for (index, token) in command.split_whitespace().enumerate() {
            let cleaned = token
                .trim_matches('"')
                .trim_matches('\'')
                .trim_matches(';')
                .trim();

            if cleaned.is_empty() || cleaned.starts_with('-') {
                continue;
            }

            // A canonical Windows path like `\\?\C:\file.txt` is absolute
            // but does not start with `/`, `.` or `~`; treat it as path-
            // shaped so the read-deny check fires on it too.
            let path_shaped = cleaned.contains('/')
                || cleaned.starts_with('.')
                || cleaned.starts_with('~')
                || is_absolute_path(cleaned);

            // The program token (index 0) is a read target only when it is
            // path-shaped, such as `./scripts/run.sh`; a bare command name
            // like `cat` names no file to read. Later tokens are checked
            // whether or not they look like a path.
            if index == 0 && !path_shaped {
                continue;
            }

            if path_shaped {
                if let Some(kind) = path_token_shell_meta(cleaned) {
                    return Err(SofosError::ToolExecution(format!(
                        "Read argument '{}' uses {} which can't be checked against the Read rules before the shell expands it\n\
                         Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
                        cleaned, kind
                    )));
                }
            }

            // Path candidates: looks-like-path, or a bare token with no
            // expansion meta (regex / ad-hoc strings fall through).
            let is_path = path_shaped
                || (!cleaned.contains('$')
                    && !cleaned.contains('`')
                    && !cleaned.contains('*')
                    && !cleaned.contains('?')
                    && !cleaned.contains('['));

            if is_path {
                // For deny rules: check if explicitly denied
                let (perm, matched_rule) =
                    permission_manager.check_read_permission_with_source(cleaned);
                match perm {
                    CommandPermission::Allowed => {}
                    CommandPermission::Denied => {
                        let config_source = if let Some(ref rule) = matched_rule {
                            permission_manager.get_rule_source(rule)
                        } else {
                            config_files_hint()
                        };
                        return Err(SofosError::ToolExecution(format!(
                            "Read access denied for path '{}' in command\n\
                             Hint: Blocked by deny rule in {}",
                            cleaned, config_source
                        )));
                    }
                    CommandPermission::Ask => {
                        return Err(SofosError::ToolExecution(format!(
                            "Path '{}' requires confirmation per config file\n\
                             Hint: Move it to 'allow' or 'deny' list.",
                            cleaned
                        )));
                    }
                }
            }
        }

        Ok(())
    }

    pub(super) fn is_safe_command_structure(&self, command: &str) -> bool {
        // Parent directory traversal — always blocked (use absolute paths for external access)
        if has_path_traversal(command) {
            return false;
        }

        // Command and process substitution hide subcommands from the
        // permission system, so `echo $(rm bad)` would otherwise be
        // classified by the base-command (`echo`).
        if detect_command_substitution(command).is_some() {
            return false;
        }

        // `$'...'` quoting decodes to other characters, so `$'\x67it' push`
        // would slip a disguised git command past the scans below.
        if detect_ansi_c_quoting(command) {
            return false;
        }

        // Note: absolute paths (/...) and tilde paths (~/) are now handled by
        // check_bash_external_paths which asks the user interactively.

        // Allow "2>&1" (stderr to stdout redirection) but block file output redirection
        let command_without_stderr_redirect = command.replace("2>&1", "");

        if command_without_stderr_redirect.contains('>')
            || command_without_stderr_redirect.contains(">>")
        {
            return false;
        }

        if command.contains("<<") {
            return false;
        }

        // Normalise whitespace before matching so `git\tpush`,
        // `git$IFS\tpush`, `git\\\npush` are seen as `git push`. Case is
        // preserved so the git scan can tell `-C` (change directory) from
        // `-c` (set config).
        if !self.is_safe_git_command(&normalize_command_whitespace(command)) {
            return false;
        }

        true
    }

    /// Whether `command` is free of dangerous git operations. Every git
    /// invocation it reaches — directly, behind a launcher such as `env` or
    /// `timeout`, or inside `sh -c "..."` — is parsed into its real
    /// subcommand (skipping leading global options however they are written)
    /// and rejected if it pushes, rewrites history, destroys the working
    /// tree, or sets an inline config value git would execute.
    pub(super) fn is_safe_git_command(&self, command: &str) -> bool {
        !git_invocations(command)
            .iter()
            .any(|args| git_args_are_dangerous(args))
    }

    pub(super) fn get_rejection_reason(&self, command: &str) -> String {
        let matcher_input = normalize_command_whitespace(command);

        if has_path_traversal(command) {
            return format!(
                "Command '{}' contains '..' as a path component (parent directory traversal)\n\
                 Hint: Use absolute paths for external directory access instead of '..'. \
                 Git revision ranges like `HEAD~5..HEAD` are allowed.",
                command
            );
        }

        if let Some(marker) = detect_command_substitution(command) {
            return format!(
                "Command '{}' uses shell substitution ('{}') which would run a hidden subcommand outside the permission system\n\
                 Hint: Run each step as its own bash call so the permission system can see it. Use single quotes if you need the literal characters.",
                command, marker
            );
        }

        if detect_ansi_c_quoting(command) {
            return format!(
                "Command '{}' uses the bash $'...' quoting form, which the shell rewrites into other characters and can hide a program name or git subcommand from the safety checks\n\
                 Hint: Write the characters directly, or pass them inside ordinary single or double quotes.",
                command
            );
        }

        if !self.is_safe_git_command(&matcher_input) {
            return self.get_git_rejection_reason(command);
        }

        let command_without_stderr_redirect = command.replace("2>&1", "");
        if command_without_stderr_redirect.contains('>')
            || command_without_stderr_redirect.contains(">>")
        {
            let edit_hint: String = if self.has_morph {
                format!(
                    "{}/{}",
                    ToolName::EditFile.as_str(),
                    ToolName::MorphEditFile.as_str()
                )
            } else {
                ToolName::EditFile.as_str().to_string()
            };
            return format!(
                "Command '{}' contains output redirection ('>' or '>>')\n\
                 Hint: Use write_file tool to create or {} to modify files. Note: '2>&1' is allowed.",
                command, edit_hint
            );
        }

        if command.contains("<<") {
            return format!(
                "Command '{}' contains here-doc ('<<')\n\
                 Hint: Use write_file tool to create files instead.",
                command
            );
        }

        format!(
            "Command '{}' is in the forbidden list (destructive or violates sandbox)\n\
             Hint: Use appropriate file operation tools instead.",
            command
        )
    }

    pub(super) fn get_git_rejection_reason(&self, command: &str) -> String {
        // Phrase the reason from the same subcommand the gate flagged, so
        // the message lines up with the rejection however the command was
        // written (global options, quotes, launchers, `sh -c`).
        let verb = first_dangerous_git_verb(&normalize_command_whitespace(command));
        match verb.as_deref() {
            Some("push") => format!(
                "Command '{command}' blocked: 'git push' sends data to remote repositories\n\
                 Hint: Use 'git status', 'git log', 'git diff' to view changes."
            ),
            Some(op @ ("pull" | "fetch")) => format!(
                "Command '{command}' blocked: 'git {op}' fetches data from remote repositories\n\
                 Hint: Use 'git status', 'git log', 'git diff' to view local changes."
            ),
            Some("clone") => format!(
                "Command '{command}' blocked: 'git clone' downloads repositories\n\
                 Hint: Clone repositories manually outside of Sofos."
            ),
            Some(op @ ("commit" | "add")) => format!(
                "Command '{command}' blocked: 'git {op}' modifies the git repository\n\
                 Hint: Use 'git status', 'git diff' to view changes. Create commits manually."
            ),
            Some(op @ ("reset" | "clean")) => format!(
                "Command '{command}' blocked: 'git {op}' is a destructive operation\n\
                 Hint: Use 'git status', 'git log', 'git diff' to view repository state."
            ),
            Some(op @ ("checkout" | "switch")) => format!(
                "Command '{command}' blocked: 'git {op}' changes branches or modifies the working directory\n\
                 Hint: Use 'git branch' to list branches, 'git status' to see the current branch."
            ),
            Some(op @ ("merge" | "rebase")) => format!(
                "Command '{command}' blocked: 'git {op}' modifies git history\n\
                 Hint: Perform merges/rebases manually outside of Sofos."
            ),
            Some("stash") => format!(
                "Command '{command}' blocked: 'git stash' modifies repository state\n\
                 Hint: Use 'git stash list' or 'git stash show' to view stashed changes."
            ),
            Some("remote") => format!(
                "Command '{command}' blocked: modifying git remotes is not allowed\n\
                 Hint: Use 'git remote -v' to view configured remotes."
            ),
            Some("submodule") => format!(
                "Command '{command}' blocked: 'git submodule' can fetch from remote repositories\n\
                 Hint: Manage submodules manually outside of Sofos."
            ),
            Some("config") => format!(
                "Command '{command}' blocked: a '-c'/'--config-env' option sets a git config value that runs an external command\n\
                 Hint: Run the git command without the inline config override."
            ),
            _ => format!(
                "Command '{command}' blocked: git operation modifies repository or accesses network\n\
                 Hint: Allowed git commands: status, log, diff, show, branch, remote -v, grep, blame"
            ),
        }
    }
}