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.
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/commit.js +7 -3
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +33 -0
- package/src/commands/index.js +3 -0
- package/src/commands/integrations.js +79 -2
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +10 -2
- package/src/commands/update-harness.js +319 -12
- package/src/commands/update.js +1 -1
- package/src/config/index.js +17 -2
- package/src/generators/hooks.js +650 -45
- package/src/generators/init.js +12 -0
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/assets/agents/architect.md +12 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/manager.js +118 -17
- package/src/integrations/render.js +116 -2
- package/src/lib/fatal-error.js +64 -0
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +226 -93
- package/src/validator/index.js +686 -95
package/src/generators/hooks.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
#
|
|
522
|
-
# "git commit
|
|
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=$(
|
|
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
|
-
|
|
566
|
-
|
|
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 +
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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.
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
|
|
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.
|
|
1030
|
-
//
|
|
1031
|
-
|
|
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.
|
|
1101
|
-
//
|
|
1102
|
-
|
|
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,
|
|
1263
|
-
//
|
|
1264
|
-
|
|
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.
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
}
|