trackfw 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -472,15 +472,63 @@ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL
472
472
  const GBG_BACKTICK = '\\`'
473
473
 
474
474
  const GIT_BRANCH_GUARD_SCRIPT = `#!/usr/bin/env bash
475
- # trackfw git branch guard — bloqueia git commit/push/checkout -b brutos por subagente
475
+ # trackfw git branch guard — bloqueia git commit/push/checkout -b/branch/worktree add -b
476
+ # brutos por subagente
477
+ #
478
+ # TRIPWIRE, NÃO FRONTEIRA DE SEGURANÇA: detecta o caso óbvio — comando git literal, sem
479
+ # indireção de shell — não é defesa contra um agente adversário competente. Evasões que
480
+ # exigem tokenizar como o bash (ex.: git\${IFS}push, {git,push}, g""it push) permanecem
481
+ # abertas por decisão: ver docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-
482
+ # com-escrita-irrestrita-a-resposta-e-deteccao-ancorada-no-git.md. O stripping de
483
+ # env/command abaixo reconhece as formas SEM argumentos antes de git (env git ...,
484
+ # command git ...) e o env seguido de uma sequência de atribuições CHAVE=valor
485
+ # (env FOO=bar git ..., env FOO=bar BAZ=qux git ...) — env com FLAGS (env -i git ...,
486
+ # env --ignore-environment git ...) e command com flags (command -p git ...) continuam
487
+ # evadindo; declarado, não fechado (ver AC5 do ML que adicionou esse stripping). A
488
+ # segmentação abaixo
489
+ # (quote_aware_split) evita falso-positivo em texto citado — não deve ser lida como imune a
490
+ # evasão por citação/tokenização do shell.
476
491
  set -euo pipefail
477
492
  set -f
478
493
 
494
+ # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
495
+ # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
496
+ # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
497
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
498
+ # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
499
+ # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
500
+ # leitura.
501
+ _TRACKFW_STDIN=""
502
+ [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
503
+
504
+ # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
505
+ # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
506
+ # achar trackfw.yaml na raiz do projeto. Sem trackfw.yaml em nenhum ancestral, o guard não se
507
+ # aplica — fora de projeto trackfw não há trackfw ship como alternativa, e bloquear ali é custo
508
+ # sem contrapartida. Custo medido: só parameter expansion e test -f por nível, nenhum fork de
509
+ # processo; limitado pela profundidade do caminho.
510
+ _TRACKFW_ROOT_DIR=$(pwd -P)
511
+ _TRACKFW_FOUND=0
512
+ while :; do
513
+ if [ -f "$_TRACKFW_ROOT_DIR/trackfw.yaml" ]; then
514
+ _TRACKFW_FOUND=1
515
+ break
516
+ fi
517
+ if [ "$_TRACKFW_ROOT_DIR" = "/" ]; then
518
+ break
519
+ fi
520
+ _TRACKFW_ROOT_DIR="\${_TRACKFW_ROOT_DIR%/*}"
521
+ if [ -z "$_TRACKFW_ROOT_DIR" ]; then
522
+ _TRACKFW_ROOT_DIR="/"
523
+ fi
524
+ done
525
+ [ "$_TRACKFW_FOUND" -eq 1 ] || exit 0
526
+
479
527
  # --- 1. Obter o comando git bruto ------------------------------------------------------------
480
528
  if [ "$#" -gt 0 ]; then
481
529
  CMD_RAW="$*"
482
530
  else
483
- INPUT=$(cat 2>/dev/null || true)
531
+ INPUT="$_TRACKFW_STDIN"
484
532
  TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
485
533
  case "$TRIMMED" in
486
534
  \\{*)
@@ -513,15 +561,143 @@ fi
513
561
 
514
562
  [ -n "$CMD_RAW" ] || exit 0
515
563
 
516
- # --- 2. Casar contra "git (commit|push|checkout -b)", segmento por segmento -----------------
517
- # Cada segmento é um comando real (dividido por ; && || | e por quebra de linha). "git" só
518
- # conta se for o PRIMEIRO token do segmento (por basename, então /usr/bin/git também casa) —
519
- # nunca uma ocorrência solta em qualquer posição da string inteira. Isso evita: (a) o segundo
520
- # comando de uma cadeia escapar da checagem, (b) um path absoluto para o git escapar por
521
- # comparação de igualdade exata, e (c) texto de prosa (ex.: mensagem de commit mencionando
522
- # "git commit" no meio de uma frase) ser tratado como comando.
564
+ # --- 2. Pré-processamento anti-falso-positivo: neutraliza separadores reais (';', '&&',
565
+ # '||', '|', quebra de linha) que estão DENTRO de aspas ou de corpo de heredoc, para que
566
+ # conteúdo de mensagem (ex.: ` + "`" + `-m "linha 1\\nlinha 2"` + "`" + `) nunca seja fatiado em pseudo-segmentos
567
+ # e lido como comando -------------------------------------------------------------------
568
+ #
569
+ # strip_heredoc_bodies: remove o CORPO de blocos heredoc (<<DELIM ... DELIM), preservando a
570
+ # linha de abertura e a linha terminadora — cobre o padrão ` + "`" + `git commit -F- <<'EOF' ... EOF` + "`" + `
571
+ # (heredoc não citado, fora do escopo de quote_aware_split abaixo). Heurística por linha, não
572
+ # sintaxe completa de shell: só remove o corpo quando encontra a linha terminadora
573
+ # correspondente. Se o heredoc nunca fecha (terminador ausente ou não localizado), devolve o
574
+ # texto ORIGINAL sem qualquer alteração — lado seguro: mais restritivo é preferível a esconder
575
+ # um comando real atrás de um heredoc mal-formado.
576
+ strip_heredoc_bodies() {
577
+ printf '%s' "$1" | awk '
578
+ BEGIN {
579
+ dq = sprintf("%c", 34)
580
+ sq = sprintf("%c", 39)
581
+ in_heredoc = 0
582
+ delim = ""
583
+ ok = 1
584
+ }
585
+ {
586
+ raw = raw $0 "\\n"
587
+ if (in_heredoc) {
588
+ trimmed = $0
589
+ sub(/^[ \\t]+/, "", trimmed)
590
+ sub(/[ \\t]+$/, "", trimmed)
591
+ if (trimmed == delim) {
592
+ in_heredoc = 0
593
+ out = out $0 "\\n"
594
+ }
595
+ next
596
+ }
597
+ if (match($0, /<<-?[ \\t]*[^ \\t]+/)) {
598
+ d = substr($0, RSTART, RLENGTH)
599
+ sub(/^<<-?[ \\t]*/, "", d)
600
+ gsub(dq, "", d)
601
+ gsub(sq, "", d)
602
+ if (d != "") {
603
+ delim = d
604
+ in_heredoc = 1
605
+ }
606
+ }
607
+ out = out $0 "\\n"
608
+ }
609
+ END {
610
+ if (in_heredoc) ok = 0
611
+ if (ok) { printf "%s", out } else { printf "%s", raw }
612
+ }
613
+ '
614
+ }
615
+
616
+ # quote_aware_split: emite o texto com ';' isolado, '&&', '||' e '|' isolado convertidos em
617
+ # quebra de linha — EXCETO quando ocorrem dentro de uma string entre aspas simples ou duplas,
618
+ # caso em que são preservados como texto e uma quebra de linha real dentro das aspas vira
619
+ # espaço (nunca gera um novo pseudo-segmento). Substitui o antigo ` + "`" + `sed` + "`" + ` cego, que não
620
+ # distinguia texto citado de sintaxe de comando — a causa raiz do falso-positivo de linha de
621
+ # mensagem de commit iniciada por "git ...". Aspas não fechadas até o fim da entrada
622
+ # permanecem "abertas" até o fim — mesma semântica do shell real: uma aspa não fechada nunca
623
+ # deixa o texto seguinte executar como comando novo, só torna o restante parte da mesma
624
+ # string.
625
+ quote_aware_split() {
626
+ printf '%s' "$1" | awk '
627
+ BEGIN {
628
+ dq = sprintf("%c", 34)
629
+ sq = sprintf("%c", 39)
630
+ bs = sprintf("%c", 92)
631
+ nl = sprintf("%c", 10)
632
+ }
633
+ { s = (NR == 1) ? $0 : s nl $0 }
634
+ END {
635
+ n = length(s)
636
+ q = ""
637
+ out = ""
638
+ i = 1
639
+ while (i <= n) {
640
+ c = substr(s, i, 1)
641
+ if (q != "") {
642
+ if (q == dq && c == bs && i < n) {
643
+ nx = substr(s, i + 1, 1)
644
+ out = out c (nx == nl ? " " : nx)
645
+ i += 2
646
+ continue
647
+ }
648
+ if (c == q) {
649
+ q = ""
650
+ out = out c
651
+ i++
652
+ continue
653
+ }
654
+ out = out (c == nl ? " " : c)
655
+ i++
656
+ continue
657
+ }
658
+ if (c == dq || c == sq) {
659
+ q = c
660
+ out = out c
661
+ i++
662
+ continue
663
+ }
664
+ if (substr(s, i, 2) == "&&" || substr(s, i, 2) == "||") {
665
+ out = out nl
666
+ i += 2
667
+ continue
668
+ }
669
+ if (c == ";" || c == "|") {
670
+ out = out nl
671
+ i++
672
+ continue
673
+ }
674
+ out = out c
675
+ i++
676
+ }
677
+ printf "%s", out
678
+ }
679
+ '
680
+ }
681
+
682
+ # match_subcommand — casa contra "git (commit|push|checkout -b|switch -c)", segmento por
683
+ # segmento. Cada segmento é um comando real, obtido depois do pré-processamento acima
684
+ # (strip_heredoc_bodies + quote_aware_split), que converte ';', '&&', '||', '|' fora de aspas
685
+ # em quebra de linha e neutraliza os mesmos separadores quando aparecem dentro de
686
+ # aspas/heredoc. "git" só conta se for o PRIMEIRO token do segmento (por basename, então
687
+ # /usr/bin/git também casa) — nunca uma ocorrência solta em qualquer posição da string
688
+ # inteira. Isso evita: (a) o segundo comando de uma cadeia escapar da checagem, (b) um path
689
+ # absoluto para o git escapar por comparação de igualdade exata, e (c) texto de prosa —
690
+ # inclusive linha de mensagem de commit que COMEÇA com "git <sub>" (ex.: uma tabela
691
+ # documentando comandos bloqueados) — ser tratado como comando, porque esse texto agora nunca
692
+ # produz um novo segmento. ` + "`" + `git switch -c/-C/--create` + "`" + ` (forma alternativa a ` + "`" + `checkout -b` + "`" + `
693
+ # para criar branch) é reconhecido varrendo TODOS os tokens após o subcomando, não só o
694
+ # primeiro — cobre ` + "`" + `git switch --track -c feat/x` + "`" + ` (flag antes de -c).
695
+ # checkout -b é reconhecido do mesmo jeito: varre TODOS os tokens até achar -b/-B/--orphan,
696
+ # não só o primeiro. Prefixos env e command antes de git são descartados antes da checagem do
697
+ # basename — cobre env git push/command git push sem exigir tokenizar como o bash.
523
698
  match_subcommand() {
524
- normalized=$(printf '%s' "$1" | sed -e 's/&&/\\n/g' -e 's/||/\\n/g' -e 's/[;|]/\\n/g')
699
+ normalized=$(strip_heredoc_bodies "$1")
700
+ normalized=$(quote_aware_split "$normalized")
525
701
  while IFS= read -r seg; do
526
702
  seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
527
703
  [ -n "$seg_trimmed" ] || continue
@@ -529,6 +705,29 @@ match_subcommand() {
529
705
  set -- $seg_trimmed
530
706
  first="$1"
531
707
  base="\${first##*/}"
708
+ while [ "$base" = "env" ] || [ "$base" = "command" ]; do
709
+ is_env="$base"
710
+ shift
711
+ [ "$#" -gt 0 ] || break
712
+ if [ "$is_env" = "env" ]; then
713
+ while [ "$#" -gt 0 ]; do
714
+ case "$1" in
715
+ -*)
716
+ break
717
+ ;;
718
+ *=*)
719
+ shift
720
+ ;;
721
+ *)
722
+ break
723
+ ;;
724
+ esac
725
+ done
726
+ [ "$#" -gt 0 ] || break
727
+ fi
728
+ first="$1"
729
+ base="\${first##*/}"
730
+ done
532
731
  [ "$base" = "git" ] || continue
533
732
  shift
534
733
 
@@ -562,11 +761,217 @@ match_subcommand() {
562
761
  return 0
563
762
  ;;
564
763
  checkout)
565
- if [ "\${1:-}" = "-b" ]; then
566
- echo "checkout-b"
764
+ for tok2 in "$@"; do
765
+ case "$tok2" in
766
+ -b|-B|--orphan|--orphan=*)
767
+ echo "checkout-b"
768
+ return 0
769
+ ;;
770
+ esac
771
+ done
772
+ # git checkout -- <path> | git checkout . descarta alterações não commitadas do
773
+ # caminho indicado, de forma irreversível, no worktree compartilhado — bloqueia
774
+ # quando '--' aparece em qualquer posição (forma explícita de pathspec) ou quando
775
+ # '.' aparece como token isolado. 'git checkout <branch>' sem nenhum dos dois
776
+ # segue liberado por decisão (distinguir branch de caminho sem '--' é ambíguo, e
777
+ # adivinhar produziria falso-positivo).
778
+ checkout_path=0
779
+ for tok2 in "$@"; do
780
+ case "$tok2" in
781
+ --|.)
782
+ checkout_path=1
783
+ ;;
784
+ esac
785
+ done
786
+ if [ "$checkout_path" = "1" ]; then
787
+ echo "checkout-path"
567
788
  return 0
568
789
  fi
569
790
  ;;
791
+ switch)
792
+ for tok2 in "$@"; do
793
+ case "$tok2" in
794
+ -c|-C|--create|--create=*|--force-create|--force-create=*)
795
+ echo "switch-c"
796
+ return 0
797
+ ;;
798
+ esac
799
+ done
800
+ ;;
801
+ stash)
802
+ # git stash: liberado só para leitura (list/show) — bloqueia a forma bare
803
+ # (equivale a "push"), push, save, clear e drop. Decisão de KG: bloquear a
804
+ # classe inteira, não só os literais medidos (ver REQ). Repositório com um único
805
+ # worktree compartilhado entre subagentes paralelos — um stash de um agente
806
+ # remove as alterações não commitadas de todos os outros.
807
+ stash_sub="\${1:-}"
808
+ case "$stash_sub" in
809
+ list|show)
810
+ ;;
811
+ *)
812
+ echo "stash"
813
+ return 0
814
+ ;;
815
+ esac
816
+ ;;
817
+ reset)
818
+ # Só --hard bloqueia, em qualquer posição de token — --soft/--mixed (inclusive
819
+ # sem flag, que é --mixed implícito) seguem liberados: --soft é o contorno
820
+ # padrão para reempurrar trabalho staged via ` + "`" + `trackfw ship -m "..."` + "`" + ` (ainda falta commitar após --soft).
821
+ for tok2 in "$@"; do
822
+ case "$tok2" in
823
+ --hard)
824
+ echo "reset-hard"
825
+ return 0
826
+ ;;
827
+ esac
828
+ done
829
+ ;;
830
+ clean)
831
+ # Bloqueia qualquer forma com force (-f, -fd, -fx, --force) ou -x/-X, EXCETO
832
+ # quando -n/--dry-run também está presente (dry-run nunca apaga nada).
833
+ clean_dry=0
834
+ clean_force=0
835
+ for tok2 in "$@"; do
836
+ case "$tok2" in
837
+ -n|--dry-run)
838
+ clean_dry=1
839
+ ;;
840
+ -f*|--force|--force=*|-x|-X)
841
+ clean_force=1
842
+ ;;
843
+ esac
844
+ done
845
+ if [ "$clean_dry" != "1" ] && [ "$clean_force" = "1" ]; then
846
+ echo "clean-force"
847
+ return 0
848
+ fi
849
+ ;;
850
+ restore)
851
+ # git restore --staged SOZINHO nunca toca o working tree (mexe só no
852
+ # index), então segue liberado mesmo com path. Mas --worktree/-W (com ou
853
+ # sem --staged junto) SEMPRE afeta o working tree — inclusive
854
+ # "--staged --worktree", que restaura os dois — então bloqueia sempre que
855
+ # --worktree/-W aparecer, e também no caso padrão (sem --staged em
856
+ # nenhuma forma) com um argumento posicional (o path).
857
+ restore_staged=0
858
+ restore_worktree=0
859
+ restore_positional=0
860
+ for tok2 in "$@"; do
861
+ case "$tok2" in
862
+ --staged)
863
+ restore_staged=1
864
+ ;;
865
+ --worktree|-W)
866
+ restore_worktree=1
867
+ ;;
868
+ -*)
869
+ ;;
870
+ *)
871
+ restore_positional=1
872
+ ;;
873
+ esac
874
+ done
875
+ if [ "$restore_positional" = "1" ]; then
876
+ if [ "$restore_worktree" = "1" ] || [ "$restore_staged" != "1" ]; then
877
+ echo "restore-path"
878
+ return 0
879
+ fi
880
+ fi
881
+ ;;
882
+ branch)
883
+ # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
884
+ # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
885
+ # --format=, --points-at, -d/-D/--delete) — bloquear leitura seria pior que a
886
+ # brecha. Só bloqueia: (a) -c/-C/-m/-M/--copy/--move (cria/renomeia branch,
887
+ # qualquer posição de token) ou (b) um argumento posicional puro (nome da branch a
888
+ # criar), a menos que -d/-D/--delete também esteja presente (delete tem
889
+ # posicional legítimo — o nome a apagar). Flags de valor conhecidas (--contains,
890
+ # --no-contains, --sort, --format, --points-at, --merged, --no-merged) têm seu
891
+ # valor seguinte pulado quando vem em token separado, para não ser lido como
892
+ # posicional de criação.
893
+ branch_action=0
894
+ has_delete=0
895
+ saw_positional=0
896
+ skip_next=0
897
+ for tok2 in "$@"; do
898
+ if [ "$skip_next" = "1" ]; then
899
+ skip_next=0
900
+ continue
901
+ fi
902
+ case "$tok2" in
903
+ -c|-C|-m|-M|--copy|--copy=*|--move|--move=*)
904
+ branch_action=1
905
+ ;;
906
+ -d|-D|--delete|--delete=*)
907
+ has_delete=1
908
+ ;;
909
+ --contains|--no-contains|--sort|--format|--points-at|--merged|--no-merged)
910
+ skip_next=1
911
+ ;;
912
+ -*)
913
+ ;;
914
+ *)
915
+ saw_positional=1
916
+ ;;
917
+ esac
918
+ done
919
+ if [ "$has_delete" != "1" ]; then
920
+ if [ "$branch_action" = "1" ] || [ "$saw_positional" = "1" ]; then
921
+ echo "branch-create"
922
+ return 0
923
+ fi
924
+ fi
925
+ ;;
926
+ worktree)
927
+ if [ "\${1:-}" = "add" ]; then
928
+ shift
929
+ for tok2 in "$@"; do
930
+ case "$tok2" in
931
+ -b|-B)
932
+ echo "worktree-add-b"
933
+ return 0
934
+ ;;
935
+ esac
936
+ done
937
+ elif [ "\${1:-}" = "remove" ]; then
938
+ # git worktree remove SEM -f/--force já recusa sozinho quando há alteração não
939
+ # commitada no worktree indicado — só a forma com force é irreversível o bastante
940
+ # para bloquear aqui.
941
+ shift
942
+ for tok2 in "$@"; do
943
+ case "$tok2" in
944
+ -f|--force)
945
+ echo "worktree-remove-force"
946
+ return 0
947
+ ;;
948
+ esac
949
+ done
950
+ fi
951
+ ;;
952
+ update-ref)
953
+ # git update-ref reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o
954
+ # objeto apontado nem exigir push — foi o mecanismo que tornou alcançável o exploit
955
+ # descrito no ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md
956
+ # (Emenda 1): forjar origin/<base> localmente para desviar o commit-alvo de trackfw
957
+ # release tag. Sem forma de leitura equivalente a bloquear seletivamente — a
958
+ # subcommand inteira é escrita — bloqueia sempre, sem exceção de token.
959
+ echo "update-ref"
960
+ return 0
961
+ ;;
962
+ rm)
963
+ # git rm -f/--force apaga do working tree e do index de forma irreversível, mesma
964
+ # classe de git clean -f/git reset --hard já bloqueados acima — sem exceção para
965
+ # --cached (destrancar do index sem -f já segue liberado por não precisar de force).
966
+ for tok2 in "$@"; do
967
+ case "$tok2" in
968
+ -f*|--force|--force=*)
969
+ echo "rm-force"
970
+ return 0
971
+ ;;
972
+ esac
973
+ done
974
+ ;;
570
975
  esac
571
976
  done <<EOF
572
977
  $normalized
@@ -578,13 +983,46 @@ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
578
983
 
579
984
  case "$SUBCOMMAND" in
580
985
  checkout-b)
581
- REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
986
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
987
+ ;;
988
+ switch-c)
989
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
990
+ ;;
991
+ branch-create)
992
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
993
+ ;;
994
+ worktree-add-b)
995
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
582
996
  ;;
583
997
  commit)
584
- REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
998
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
585
999
  ;;
586
1000
  push)
587
- REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
1001
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw push` + GBG_BACKTICK + ` (para empurrar commits já criados), ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + ` (para commit+push+PR em uma etapa) ou ` + GBG_BACKTICK + `trackfw release tag` + GBG_BACKTICK + ` (para publicar uma tag de release). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1002
+ ;;
1003
+ stash)
1004
+ REASON="trackfw: git stash bruto bloqueado — worktree compartilhado entre subagentes, um stash remove as alterações não commitadas de todos os outros. ` + GBG_BACKTICK + `git stash list` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git stash show` + GBG_BACKTICK + ` seguem liberados; para guardar trabalho em progresso, use uma branch própria via ` + GBG_BACKTICK + `trackfw branch new` + GBG_BACKTICK + ` e commit nela. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1005
+ ;;
1006
+ reset-hard)
1007
+ REASON="trackfw: git reset --hard bruto bloqueado — descarta de forma irreversível as alterações não commitadas de todo o worktree compartilhado. ` + GBG_BACKTICK + `git reset --soft` + GBG_BACKTICK + `/` + GBG_BACKTICK + `--mixed` + GBG_BACKTICK + ` seguem liberados (ex.: ` + GBG_BACKTICK + `git reset --soft HEAD~1` + GBG_BACKTICK + ` é o caminho padrão; use ` + GBG_BACKTICK + `trackfw ship -m "..."` + GBG_BACKTICK + ` para commitar e empurrar). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1008
+ ;;
1009
+ clean-force)
1010
+ REASON="trackfw: git clean -f/-x bruto bloqueado — apaga arquivos não rastreados do worktree compartilhado, de forma irreversível. ` + GBG_BACKTICK + `git clean -n` + GBG_BACKTICK + `/` + GBG_BACKTICK + `--dry-run` + GBG_BACKTICK + ` segue liberado para revisar antes o que seria apagado. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1011
+ ;;
1012
+ restore-path)
1013
+ REASON="trackfw: git restore <path> bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_BACKTICK + `git restore --staged` + GBG_BACKTICK + ` (não toca o working tree) segue liberado; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1014
+ ;;
1015
+ checkout-path)
1016
+ REASON="trackfw: git checkout -- <path>/git checkout . bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_BACKTICK + `git checkout <branch>` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git switch <branch>` + GBG_BACKTICK + ` seguem liberados; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1017
+ ;;
1018
+ update-ref)
1019
+ REASON="trackfw: git update-ref bruto bloqueado — reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o objeto apontado nem exigir push, o que permite forjar o commit-alvo que ` + GBG_BACKTICK + `trackfw release tag` + GBG_BACKTICK + ` publicaria. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1020
+ ;;
1021
+ worktree-remove-force)
1022
+ REASON="trackfw: git worktree remove -f/--force bruto bloqueado — remove um worktree e descarta de forma irreversível qualquer alteração não commitada nele. ` + GBG_BACKTICK + `git worktree remove` + GBG_BACKTICK + ` sem force segue liberado (recusa sozinho quando há algo não commitado). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1023
+ ;;
1024
+ rm-force)
1025
+ REASON="trackfw: git rm -f/--force bruto bloqueado — apaga arquivos do working tree e do index de forma irreversível, mesma classe de ` + GBG_BACKTICK + `git clean -f` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git reset --hard` + GBG_BACKTICK + ` já bloqueados. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
588
1026
  ;;
589
1027
  *)
590
1028
  exit 0
@@ -832,17 +1270,95 @@ function readGlobalHookJSON(...relParts) {
832
1270
  }
833
1271
  }
834
1272
 
835
- /** Read-only counterpart of mergeClaudeHookArray. */
1273
+ /**
1274
+ * Collapses runs of consecutive slashes ("//" -> "/", any position,
1275
+ * including leading) and strips a trailing slash, so two on-disk forms of
1276
+ * the SAME script path compare equal regardless of incidental formatting
1277
+ * (e.g. $HOME resolving with a trailing slash, as happens with macOS's
1278
+ * $TMPDIR, or a hand-edited config file). Does NOT resolve "." / ".."
1279
+ * segments or symlinks -- those transforms would let unrelated paths
1280
+ * compare equal (silently disarming the dedup, the more dangerous failure
1281
+ * mode here) and symlink resolution errors on a path that does not exist
1282
+ * yet, which every caller here must fail OPEN on. Hand-rolled instead of
1283
+ * path.normalize because it disagrees with Go's filepath.Clean and Python's
1284
+ * os.path.normpath on leading "//" and trailing "/" handling (measured) --
1285
+ * mirrored byte-for-byte in internal/generators/agentfiles.go
1286
+ * (normalizeGuardPath) and pypi/trackfw/generators/hooks.py
1287
+ * (_normalize_guard_path). Never call with anything other than a script
1288
+ * path -- it is not a general string normalizer.
1289
+ */
1290
+ function normalizeGuardPath(p) {
1291
+ if (!p) return p
1292
+ let out = ''
1293
+ let prevSlash = false
1294
+ for (const ch of p) {
1295
+ if (ch === '/') {
1296
+ if (prevSlash) continue
1297
+ prevSlash = true
1298
+ } else {
1299
+ prevSlash = false
1300
+ }
1301
+ out += ch
1302
+ }
1303
+ if (out.length > 1 && out.endsWith('/')) {
1304
+ out = out.replace(/\/+$/, '') || '/'
1305
+ }
1306
+ return out
1307
+ }
1308
+
1309
+ /** Reports whether a and b denote the same script command path after normalizeGuardPath. */
1310
+ function samePathCommand(a, b) {
1311
+ return normalizeGuardPath(a) === normalizeGuardPath(b)
1312
+ }
1313
+
1314
+ /**
1315
+ * Read-only counterpart of mergeClaudeHookArray. Compares command paths via
1316
+ * samePathCommand (normalized), not raw string equality.
1317
+ *
1318
+ * ROADMAP-2026-08-17 ML-4B: also requires the sibling `type` field to equal
1319
+ * "command" -- mergeClaudeHookArray always writes
1320
+ * {type:'command',command:...}, and Claude/Codex/Gemini all silently ignore
1321
+ * a hook entry missing "type":"command" (measured, hades-tf ML-4A barrier
1322
+ * finding: a global entry with the correct command but no `type` field
1323
+ * looked "installed" to the dedup, so the project-scope entry was skipped
1324
+ * in favor of a global entry that never actually executes -- leaving BOTH
1325
+ * scopes silently unprotected while `trackfw validate` stayed green).
1326
+ * Requiring "type":"command" here closes that gap: a malformed global entry
1327
+ * is now treated as "not installed", so the project-scope entry gets
1328
+ * re-wired instead of being skipped.
1329
+ */
836
1330
  function hookArrayHasCommand(existing, matcher, command) {
837
1331
  const arr = Array.isArray(existing) ? existing : []
838
1332
  for (const item of arr) {
839
1333
  if (!item || item.matcher !== matcher) continue
840
1334
  const inner = Array.isArray(item.hooks) ? item.hooks : []
841
- if (inner.some(h => h && h.command === command)) return true
1335
+ if (inner.some(h => h && h.type === 'command' && typeof h.command === 'string' && samePathCommand(h.command, command))) return true
842
1336
  }
843
1337
  return false
844
1338
  }
845
1339
 
1340
+ /**
1341
+ * Read-only, path-normalized counterpart of hasEntry -- used ONLY by the
1342
+ * global-dedup read paths (globalXInstalledCursor/Copilot below), never by
1343
+ * the write-side merge/idempotency helpers (mergeSimpleCommandArray,
1344
+ * injectCursorHooks), which must keep comparing raw strings so their
1345
+ * idempotency behavior does not drift from Go/Python. See samePathCommand's
1346
+ * doc comment for why the value must always be a script path.
1347
+ *
1348
+ * ROADMAP-2026-08-17 ML-4B: requireCommandType mirrors Go's
1349
+ * simpleArrayHasValue -- Copilot entries (mergeCredentialGuardCopilotHooks)
1350
+ * always carry "type":"command" and Copilot ignores an entry without it
1351
+ * (same hades-tf ML-4A finding as hookArrayHasCommand above), so Copilot
1352
+ * callers pass true. Cursor entries (mergeCredentialGuardCursorHooks,
1353
+ * {command:...}) never carry a `type` field -- not part of Cursor's schema
1354
+ * -- so requiring it there would make this always return false for a
1355
+ * perfectly valid, executing Cursor entry; Cursor callers pass false. Do
1356
+ * NOT uniformize this across CLIs.
1357
+ */
1358
+ function hasEntryPath(arr, field, value, requireCommandType) {
1359
+ return Array.isArray(arr) && arr.some(e => e && (!requireCommandType || e.type === 'command') && typeof e[field] === 'string' && samePathCommand(e[field], value))
1360
+ }
1361
+
846
1362
  function globalCredentialGuardInstalledClaude() {
847
1363
  const scriptPath = globalCredentialGuardScriptPath()
848
1364
  if (!scriptPath) return false
@@ -872,7 +1388,7 @@ function globalCredentialGuardInstalledCursor() {
872
1388
  if (!scriptPath) return false
873
1389
  const root = readGlobalHookJSON('.cursor', 'hooks.json')
874
1390
  if (!root || !root.hooks) return false
875
- return hasEntry(root.hooks.beforeShellExecution, 'command', scriptPath)
1391
+ return hasEntryPath(root.hooks.beforeShellExecution, 'command', scriptPath, false)
876
1392
  }
877
1393
 
878
1394
  function globalCredentialGuardInstalledCopilot() {
@@ -880,7 +1396,7 @@ function globalCredentialGuardInstalledCopilot() {
880
1396
  if (!scriptPath) return false
881
1397
  const root = readGlobalHookJSON('.copilot', 'settings.json')
882
1398
  if (!root || !root.hooks) return false
883
- return hasEntry(root.hooks.preToolUse, 'bash', scriptPath)
1399
+ return hasEntryPath(root.hooks.preToolUse, 'bash', scriptPath, true)
884
1400
  }
885
1401
 
886
1402
  /**
@@ -900,6 +1416,68 @@ function globalCredentialGuardInstalledKiro() {
900
1416
  }
901
1417
  }
902
1418
 
1419
+ // ---------------------------------------------------------------------------
1420
+ // git-branch-guard global-installed dedup (ROADMAP-2026-08-17 Wave 2/ML-2B)
1421
+ //
1422
+ // Mirrors the globalCredentialGuardInstalled<Tool> family above exactly,
1423
+ // pointed at ~/.trackfw/scripts/trackfw-git-branch-guard.sh instead of
1424
+ // trackfw-credential-guard.sh. Only 5 of the 6 credential-guard dedup
1425
+ // targets have a git-branch-guard counterpart: Kiro's project-scope
1426
+ // injector never wires git-branch-guard at all (see injectKiroHooks'
1427
+ // git-branch-guard doc comment), so there is no
1428
+ // globalGitBranchGuardInstalledKiro. Windsurf/AmazonQ wire git-branch-guard
1429
+ // at project scope but have no global-scope target (ML-2A only added
1430
+ // targets for the 6 CLIs credential-guard already covers) and no
1431
+ // credential-guard dedup precedent either -- consistent, not a gap.
1432
+ // ---------------------------------------------------------------------------
1433
+
1434
+ /** Mirrors Go's globalGitBranchGuardScriptPath. */
1435
+ function globalGitBranchGuardScriptPath() {
1436
+ const home = os.homedir()
1437
+ if (!home) return null
1438
+ return path.join(home, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
1439
+ }
1440
+
1441
+ function globalGitBranchGuardInstalledClaude() {
1442
+ const scriptPath = globalGitBranchGuardScriptPath()
1443
+ if (!scriptPath) return false
1444
+ const root = readGlobalHookJSON('.claude', 'settings.json')
1445
+ if (!root || !root.hooks) return false
1446
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
1447
+ }
1448
+
1449
+ function globalGitBranchGuardInstalledCodex() {
1450
+ const scriptPath = globalGitBranchGuardScriptPath()
1451
+ if (!scriptPath) return false
1452
+ const root = readGlobalHookJSON('.codex', 'hooks.json')
1453
+ if (!root || !root.hooks) return false
1454
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
1455
+ }
1456
+
1457
+ function globalGitBranchGuardInstalledGemini() {
1458
+ const scriptPath = globalGitBranchGuardScriptPath()
1459
+ if (!scriptPath) return false
1460
+ const root = readGlobalHookJSON('.gemini', 'settings.json')
1461
+ if (!root || !root.hooks) return false
1462
+ return hookArrayHasCommand(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
1463
+ }
1464
+
1465
+ function globalGitBranchGuardInstalledCursor() {
1466
+ const scriptPath = globalGitBranchGuardScriptPath()
1467
+ if (!scriptPath) return false
1468
+ const root = readGlobalHookJSON('.cursor', 'hooks.json')
1469
+ if (!root || !root.hooks) return false
1470
+ return hasEntryPath(root.hooks.beforeShellExecution, 'command', scriptPath, false)
1471
+ }
1472
+
1473
+ function globalGitBranchGuardInstalledCopilot() {
1474
+ const scriptPath = globalGitBranchGuardScriptPath()
1475
+ if (!scriptPath) return false
1476
+ const root = readGlobalHookJSON('.copilot', 'settings.json')
1477
+ if (!root || !root.hooks) return false
1478
+ return hasEntryPath(root.hooks.preToolUse, 'bash', scriptPath, true)
1479
+ }
1480
+
903
1481
  // ---------------------------------------------------------------------------
904
1482
  // generateAttentionScripts — writes the two shell scripts to scripts/
905
1483
  // ---------------------------------------------------------------------------
@@ -966,11 +1544,13 @@ function injectClaudeHooks(cwd) {
966
1544
  }
967
1545
 
968
1546
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 1): PreToolUse-only, matcher
969
- // "Bash" — blocks raw `git commit`/`git push`/`git checkout -b` before they execute. No
970
- // global-install dedup here (unlike GUARD_CMD_CLAUDE above): there is no per-CLI global
971
- // git-branch-guard harness target yet (only the plain script-generation call at
972
- // GenerateGlobalGitBranchGuardScript/generateGlobalGitBranchGuardScript exists so far).
973
- data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CLAUDE)
1547
+ // "Bash" — blocks raw `git commit`/`git push`/`git checkout -b` before they execute.
1548
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip the project-scope entry when the global
1549
+ // one is already installed (`trackfw update harness --targets claude-git-branch-guard`),
1550
+ // so the guard doesn't fire (and print its block message) twice per Bash call.
1551
+ if (!globalGitBranchGuardInstalledClaude()) {
1552
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CLAUDE)
1553
+ }
974
1554
 
975
1555
  writeJSON(filePath, data)
976
1556
  }
@@ -1026,9 +1606,12 @@ function injectCodexHooks(cwd) {
1026
1606
  }
1027
1607
 
1028
1608
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 2): PreToolUse-only, matcher
1029
- // "Bash" -- git commands only ever run via the Bash tool, never apply_patch. No
1030
- // global-install dedup (see GBG_CMD_CLAUDE comment).
1031
- data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CODEX)
1609
+ // "Bash" -- git commands only ever run via the Bash tool, never apply_patch.
1610
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1611
+ // already installed (`trackfw update harness --targets codex-git-branch-guard`).
1612
+ if (!globalGitBranchGuardInstalledCodex()) {
1613
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CODEX)
1614
+ }
1032
1615
 
1033
1616
  writeJSON(filePath, data)
1034
1617
  }
@@ -1097,9 +1680,12 @@ function injectGeminiHooks(cwd) {
1097
1680
  }
1098
1681
 
1099
1682
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 3): BeforeTool-only, matcher
1100
- // "run_shell_command" -- git commands only ever run via the shell tool. No global-install
1101
- // dedup (see GBG_CMD_CLAUDE comment).
1102
- data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GBG_CMD_GEMINI)
1683
+ // "run_shell_command" -- git commands only ever run via the shell tool.
1684
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1685
+ // already installed (`trackfw update harness --targets gemini-git-branch-guard`).
1686
+ if (!globalGitBranchGuardInstalledGemini()) {
1687
+ data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GBG_CMD_GEMINI)
1688
+ }
1103
1689
 
1104
1690
  writeJSON(filePath, data)
1105
1691
  }
@@ -1259,9 +1845,14 @@ function injectCopilotHooks(cwd) {
1259
1845
  // Go's InjectCopilotHooks (internal/generators/agentfiles.go), which made the same
1260
1846
  // choice for the same reason; the CLI-flag/permissions-config.json approach is a
1261
1847
  // documented, deliberate divergence from the roadmap's literal wording. This file is
1262
- // overwritten wholesale every run, so no dedup-against-global check is needed --
1263
- // added unconditionally, matching the always-on attention-signal/cleanup entries.
1264
- preToolUse.push({ type: 'command', matcher: 'bash', bash: GBG_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
1848
+ // overwritten wholesale every run, but that only means there is no migration concern
1849
+ // -- it does not exempt this entry from the dedup-against-global check
1850
+ // (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1851
+ // already installed (`trackfw update harness --targets copilot-git-branch-guard`),
1852
+ // same reasoning as the credential-guard dedup above.
1853
+ if (!globalGitBranchGuardInstalledCopilot()) {
1854
+ preToolUse.push({ type: 'command', matcher: 'bash', bash: GBG_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
1855
+ }
1265
1856
 
1266
1857
  const data = {
1267
1858
  version: 1,
@@ -1396,11 +1987,19 @@ function injectCursorHooks(cwd) {
1396
1987
  }
1397
1988
 
1398
1989
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 5): beforeShellExecution-only --
1399
- // Cursor's dedicated pre-execution Bash event; git commands only ever run there. No
1400
- // global-install dedup (see GBG_CMD_CLAUDE comment).
1401
- if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
1402
- if (!hasEntry(data.hooks.beforeShellExecution, 'command', GBG_CMD_CURSOR)) {
1403
- data.hooks.beforeShellExecution.push({ command: GBG_CMD_CURSOR })
1990
+ // Cursor's dedicated pre-execution Bash event; git commands only ever run there.
1991
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1992
+ // already installed (`trackfw update harness --targets cursor-git-branch-guard`). The
1993
+ // key is intentionally only touched inside this conditional (never created outside it)
1994
+ // so that when BOTH credential-guard and git-branch-guard are deduped away, the key
1995
+ // stays absent from the emitted JSON rather than becoming a present-but-empty array --
1996
+ // matches Go's InjectCursorHooks, which check-agent-hooks-parity.sh's structural
1997
+ // comparator treats as significant (absent key vs empty array is drift, not noise).
1998
+ if (!globalGitBranchGuardInstalledCursor()) {
1999
+ if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
2000
+ if (!hasEntry(data.hooks.beforeShellExecution, 'command', GBG_CMD_CURSOR)) {
2001
+ data.hooks.beforeShellExecution.push({ command: GBG_CMD_CURSOR })
2002
+ }
1404
2003
  }
1405
2004
 
1406
2005
  writeJSON(filePath, data)
@@ -1525,7 +2124,17 @@ function injectWindsurfHooks(cwd) {
1525
2124
  // written on first creation so the default agent keeps today's unrestricted
1526
2125
  // tool access (this fix does not narrow what any agent can do, only where
1527
2126
  // the deny wiring lives). Mirrors Go's InjectAmazonQHooks
1528
- // (internal/generators/agentfiles.go) field-for-field.
2127
+ // (internal/generators/agentfiles.go) field-for-field -- Go is the
2128
+ // canonical set (ROADMAP-2026-08-20, ML-1A-bis): only `name`, `description`
2129
+ // and `tools` are written on first creation. `prompt`/`mcpServers`/
2130
+ // `toolAliases`/`allowedTools`/`resources`/`useLegacyMcpJson` were written
2131
+ // here until ML-1A-bis and are now deliberately NOT written -- an extra
2132
+ // field the real schema doesn't expect risks failing validation, whereas an
2133
+ // absent optional field usually doesn't (asymmetry-of-risk decision, not a
2134
+ // verification against the live Amazon Q schema -- see docs/cli-parity.md
2135
+ // for the recorded limit). Pre-existing occurrences of the dropped fields
2136
+ // in a user's file are left untouched (never removed, only no longer
2137
+ // created).
1529
2138
  //
1530
2139
  // Native custom-agent toolset restriction (REQ acceptance criterion): still
1531
2140
  // NOT implemented here -- this ML only wires the guard/deny fields on the
@@ -1542,13 +2151,7 @@ function injectAmazonQHooks(cwd) {
1542
2151
  const defaults = {
1543
2152
  name: 'q_cli_default',
1544
2153
  description: 'trackfw-managed default agent — wires the git branch guard hook/denylist. See docs/cli-parity.md.',
1545
- prompt: null,
1546
- mcpServers: {},
1547
2154
  tools: ['*'],
1548
- toolAliases: {},
1549
- allowedTools: [],
1550
- resources: [],
1551
- useLegacyMcpJson: false,
1552
2155
  }
1553
2156
  for (const [k, v] of Object.entries(defaults)) {
1554
2157
  if (!Object.prototype.hasOwnProperty.call(data, k)) data[k] = v
@@ -1650,4 +2253,6 @@ module.exports = {
1650
2253
  mergeClaudeHookArray,
1651
2254
  mergeSimpleCommandArray,
1652
2255
  mergeCopilotHookArray,
2256
+ normalizeGuardPath,
2257
+ samePathCommand,
1653
2258
  }