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.
@@ -1167,13 +1167,28 @@ const GIT_BRANCH_GUARD_SCRIPT_MARKER = 'trackfw-git-branch-guard.sh'
1167
1167
  // -fail-open-do-credential-guard, ML-1A). Hooks de escopo GLOBAL (~/.trackfw/..., trackfw update
1168
1168
  // harness) ficam fora — caso distinto, fora do repositório do usuário, e a checagem de dedup
1169
1169
  // globalCredentialGuardInstalled*() já os pula de propósito nas entradas de projeto.
1170
+ //
1171
+ // requiresCommandType (ROADMAP-2026-08-17 ML-4B, port of Go's credentialGuardHookFile.
1172
+ // requiresCommandType): true for every CLI whose writer always emits a sibling "type":"command"
1173
+ // field (Claude/Codex/Gemini, GitHub Copilot CLI, Kiro) — a command match found WITHOUT that
1174
+ // sibling is a structurally malformed entry the CLI silently never executes (hades-tf ML-4A
1175
+ // barrier finding), not merely "absent". false only for Cursor, whose schema
1176
+ // ({"command":...}) never carries a "type" field at all.
1177
+ //
1178
+ // requiresVarOrShellPrefix (ROADMAP-2026-08-21 ML-1B, port of Go's
1179
+ // credentialGuardHookFile.requiresVarOrShellPrefix): true for Claude/Codex/Gemini — the
1180
+ // ADR-2026-08-11 requires these CLIs to anchor the hook path with $VAR/... or "$(git ...)/..."
1181
+ // because their hooks run from the agent's cwd, not necessarily the project root. A bare
1182
+ // relative path ("scripts/...") silently fails from any subdirectory (REQ-2026-08-17 root
1183
+ // cause). false for Cursor/Copilot/Kiro, where the bare relative path IS the correct form —
1184
+ // flagging them would be a false-positive (the dominant risk of this REQ).
1170
1185
  const CREDENTIAL_GUARD_HOOK_FILES = [
1171
- { relPath: '.claude/settings.json', cli: 'Claude Code' },
1172
- { relPath: '.codex/hooks.json', cli: 'Codex CLI' },
1173
- { relPath: '.gemini/settings.json', cli: 'Gemini CLI' },
1174
- { relPath: '.cursor/hooks.json', cli: 'Cursor' },
1175
- { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI' },
1176
- { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro' },
1186
+ { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true, requiresVarOrShellPrefix: true },
1187
+ { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true, requiresVarOrShellPrefix: true },
1188
+ { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true, requiresVarOrShellPrefix: true },
1189
+ { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false, requiresVarOrShellPrefix: false },
1190
+ { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI', requiresCommandType: true, requiresVarOrShellPrefix: false },
1191
+ { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro', requiresCommandType: true, requiresVarOrShellPrefix: false },
1177
1192
  ]
1178
1193
 
1179
1194
  // resolveCredentialGuardHookPath resolve o valor bruto de um comando de hook (string extraída do
@@ -1205,20 +1220,97 @@ function resolveCredentialGuardHookPath(raw, root) {
1205
1220
  const inner = raw.slice(codexPrefix.length, raw.length - 1)
1206
1221
  return path.join(root, inner)
1207
1222
  }
1208
- if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw)) {
1223
+ if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw) && !raw.startsWith('~/')) {
1209
1224
  // Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
1210
1225
  // (campo "bash"), Kiro (action.command).
1226
+ // ~/… é excluído: é classe 1 (tilde expande para $HOME — ancorado) mas o validator não expande
1227
+ // o til; ok=false (null) silencia sem acusar.
1211
1228
  return path.join(root, raw)
1212
1229
  }
1213
1230
  return null
1214
1231
  }
1215
1232
 
1233
+ // HOOK_ANCHORAGE_CLASS_* classifica a semântica de ancoragem de um valor de comando de hook.
1234
+ // Avaliada apenas para CLIs com requiresVarOrShellPrefix=true. Decisão: ADR-2026-08-22.
1235
+ const HOOK_ANCHORAGE_CLASS_ANCHORED = 1
1236
+ const HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT = 2
1237
+ const HOOK_ANCHORAGE_CLASS_UNDECIDABLE = 3
1238
+
1239
+ // stripOuterQuotesForClassify remove aspas duplas envolventes de raw, se presentes. Necessário
1240
+ // porque "$PWD/scripts/…" entre aspas (achado D.3) deve receber o mesmo veredito que $PWD/…
1241
+ // sem aspas: as aspas são sintaxe, não semântica de ancoragem.
1242
+ function stripOuterQuotesForClassify(raw) {
1243
+ if (raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"') {
1244
+ return raw.slice(1, raw.length - 1)
1245
+ }
1246
+ return raw
1247
+ }
1248
+
1249
+ // hookValueWasQuoted reporta se raw tinha aspas duplas externas que stripOuterQuotesForClassify
1250
+ // removeria. Usado para distinguir ~/… (tilde expande para $HOME — classe 1) de "~/…" (tilde
1251
+ // NÃO expande dentro de aspas duplas — classe 2).
1252
+ function hookValueWasQuoted(raw) {
1253
+ return raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"'
1254
+ }
1255
+
1256
+ // classifyHookAnchorage retorna a classe de ancoragem de rawStripped (com aspas externas já
1257
+ // removidas). wasQuoted indica se o valor original tinha aspas externas (obtido com
1258
+ // hookValueWasQuoted antes de chamar stripOuterQuotesForClassify). Ver HOOK_ANCHORAGE_CLASS_*
1259
+ // e ADR-2026-08-22.
1260
+ function classifyHookAnchorage(rawStripped, wasQuoted) {
1261
+ // Classe 1 — ancora na raiz do projeto.
1262
+ if (
1263
+ rawStripped.startsWith('$CLAUDE_PROJECT_DIR/') ||
1264
+ rawStripped.startsWith('$GEMINI_PROJECT_DIR/') ||
1265
+ rawStripped.startsWith('$(git rev-parse --show-toplevel)/') ||
1266
+ path.isAbsolute(rawStripped)
1267
+ ) {
1268
+ return HOOK_ANCHORAGE_CLASS_ANCHORED
1269
+ }
1270
+ // ~/… sem aspas: tilde expande para $HOME em qualquer shell POSIX — semanticamente ancorado.
1271
+ // "~/…" com aspas: tilde NÃO expande dentro de aspas duplas, logo a forma quebra — classe 2.
1272
+ if (rawStripped.startsWith('~/') && !wasQuoted) {
1273
+ return HOOK_ANCHORAGE_CLASS_ANCHORED
1274
+ }
1275
+ // Classe 2 — expande a partir do cwd.
1276
+ // ${PWD}/… tem a mesma semântica de $PWD/… (PWD é mandado pelo POSIX, sempre o cwd).
1277
+ if (
1278
+ rawStripped.startsWith('$PWD/') ||
1279
+ rawStripped.startsWith('${PWD}/') ||
1280
+ rawStripped.startsWith('./') ||
1281
+ rawStripped.startsWith('../') ||
1282
+ (!rawStripped.startsWith('$') && !path.isAbsolute(rawStripped))
1283
+ ) {
1284
+ return HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT
1285
+ }
1286
+ // Classe 3 — indecidível; silêncio declarado.
1287
+ return HOOK_ANCHORAGE_CLASS_UNDECIDABLE
1288
+ }
1289
+
1290
+ // cwdDependentReason retorna o sufixo de mensagem específico por forma para violações de
1291
+ // classe 2, iniciando em "with a …". Dois formatos: formas contendo $PWD ou ${PWD} (em qualquer
1292
+ // posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; demais recebem
1293
+ // "bare relative path". Usa includes (não startsWith) para cobrir sh -c "$PWD/…" e env FOO=x $PWD/….
1294
+ function cwdDependentReason(rawStripped) {
1295
+ if (rawStripped.includes('$PWD') || rawStripped.includes('${PWD}')) {
1296
+ return 'with a $PWD path — $PWD expands to the current working directory, not the project root; run `trackfw update` to fix it'
1297
+ }
1298
+ return "with a bare relative path — this command only resolves from the project root and will silently fail when the agent's cwd is a subdirectory; run `trackfw update` to fix it"
1299
+ }
1300
+
1216
1301
  // collectCommandsWithMarker percorre recursivamente um valor JSON já decodificado e coleta todo
1217
- // valor-string que contém marker, independentemente do nome do campo que o contém.
1302
+ // valor-string que contém marker, independentemente do nome do campo que o contém — junto com um
1303
+ // sinal estrutural (typeIsCommand) de que o objeto imediato que o contém também tem
1304
+ // "type":"command" como campo irmão (ROADMAP-2026-08-17 ML-4B, port de guardCommandMatch em
1305
+ // internal/validator/validator_credential_guard.go). Cada entrada de `out` é
1306
+ // {raw, typeIsCommand}.
1218
1307
  //
1219
1308
  // Os 6 formatos de hook usam campos diferentes para o comando: "command" (Claude/Codex/
1220
1309
  // Gemini/Cursor), "bash" (GitHub Copilot CLI), "action.command" (Kiro). Varrer por VALOR em vez
1221
- // de por caminho de chave evita acoplar esta regra à forma exata de cada schema.
1310
+ // de por caminho de chave evita acoplar esta regra à forma exata de cada schema. Todo schema aqui
1311
+ // coloca "type" como IRMÃO do campo do comando dentro do MESMO objeto — nunca aninhado mais fundo
1312
+ // — então basta ler "type" do objeto que collectCommandsWithMarker já está visitando; não é
1313
+ // necessária uma travessia schema-aware separada.
1222
1314
  //
1223
1315
  // Generalizado (ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-
1224
1316
  // desatualizados, ML-2A, port de collectCommandsWithMarker em
@@ -1227,7 +1319,10 @@ function resolveCredentialGuardHookPath(raw, root) {
1227
1319
  // também para GIT_BRANCH_GUARD_SCRIPT_MARKER, sem duplicar a travessia recursiva.
1228
1320
  function collectCommandsWithMarker(value, marker, out) {
1229
1321
  if (typeof value === 'string') {
1230
- if (value.includes(marker)) out.push(value)
1322
+ // Top-level/loose string match, outside any enclosing object — defensive fallback, not
1323
+ // expected to fire since every hook file read here is rooted at a JSON object. No enclosing
1324
+ // object means no "type" sibling to read.
1325
+ if (value.includes(marker)) out.push({ raw: value, typeIsCommand: false })
1231
1326
  return
1232
1327
  }
1233
1328
  if (Array.isArray(value)) {
@@ -1235,7 +1330,15 @@ function collectCommandsWithMarker(value, marker, out) {
1235
1330
  return
1236
1331
  }
1237
1332
  if (value && typeof value === 'object') {
1238
- for (const key of Object.keys(value)) collectCommandsWithMarker(value[key], marker, out)
1333
+ const typeIsCommand = value.type === 'command'
1334
+ for (const key of Object.keys(value)) {
1335
+ const val = value[key]
1336
+ if (typeof val === 'string') {
1337
+ if (val.includes(marker)) out.push({ raw: val, typeIsCommand })
1338
+ continue
1339
+ }
1340
+ collectCommandsWithMarker(val, marker, out)
1341
+ }
1239
1342
  }
1240
1343
  }
1241
1344
 
@@ -1293,13 +1396,36 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
1293
1396
  collectCommandsWithMarker(parsed, scriptMarker, commands)
1294
1397
 
1295
1398
  const seen = new Set()
1296
- for (const raw of commands) {
1297
- if (seen.has(raw)) continue
1298
- seen.add(raw)
1399
+ for (const m of commands) {
1400
+ const seenKey = `${m.raw}${m.typeIsCommand}`
1401
+ if (seen.has(seenKey)) continue
1402
+ seen.add(seenKey)
1403
+
1404
+ // ADR-2026-08-22: classificar por ancoragem ANTES de resolver. Aspas externas são
1405
+ // sintaxe, não semântica — removê-las antes garante que "$PWD/…" receba o mesmo veredito.
1406
+ // wasQuoted distingue ~/… (classe 1) de "~/…" (classe 2).
1407
+ const wasQuoted = hookValueWasQuoted(m.raw)
1408
+ const rawStripped = stripOuterQuotesForClassify(m.raw)
1409
+ const anchorageClass = classifyHookAnchorage(rawStripped, wasQuoted)
1410
+ if (hf.requiresVarOrShellPrefix && anchorageClass === HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT) {
1411
+ msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} ${cwdDependentReason(rawStripped)}`)
1412
+ continue
1413
+ }
1299
1414
 
1300
- const resolved = resolveCredentialGuardHookPath(raw, root)
1415
+ // Classe 1 (ancorado) e classe 3 (indecidível) prosseguem para a resolução existente.
1416
+ const resolved = resolveCredentialGuardHookPath(m.raw, root)
1301
1417
  if (resolved === null) continue
1302
1418
 
1419
+ // ROADMAP-2026-08-17 ML-4B: a command that resolves to a real path but sits inside a
1420
+ // structurally malformed entry (missing/wrong "type" where this CLI's schema requires it —
1421
+ // hades-tf ML-4A barrier finding) will NEVER be executed by the CLI, regardless of whether
1422
+ // the script itself exists and is executable. Reported instead of the exists/executable
1423
+ // checks below, which assume a structurally valid entry.
1424
+ if (hf.requiresCommandType && !m.typeIsCommand) {
1425
+ msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the hook entry is missing "type":"command" (or has an invalid type) — ${hf.cli} will silently never execute it; run \`trackfw update\` to regenerate it`)
1426
+ continue
1427
+ }
1428
+
1303
1429
  let stat = null
1304
1430
  try {
1305
1431
  stat = fs.statSync(resolved)
@@ -1568,15 +1694,63 @@ const GBG_REF_BACKTICK = '\\`'
1568
1694
  // (npm/tests/git_branch_guard.test.js): it regenerates the script via generateGitBranchGuardScript
1569
1695
  // into a temp dir and asserts byte-equality against this constant.
1570
1696
  const GIT_BRANCH_GUARD_SCRIPT_REFERENCE = `#!/usr/bin/env bash
1571
- # trackfw git branch guard — bloqueia git commit/push/checkout -b brutos por subagente
1697
+ # trackfw git branch guard — bloqueia git commit/push/checkout -b/branch/worktree add -b
1698
+ # brutos por subagente
1699
+ #
1700
+ # TRIPWIRE, NÃO FRONTEIRA DE SEGURANÇA: detecta o caso óbvio — comando git literal, sem
1701
+ # indireção de shell — não é defesa contra um agente adversário competente. Evasões que
1702
+ # exigem tokenizar como o bash (ex.: git\${IFS}push, {git,push}, g""it push) permanecem
1703
+ # abertas por decisão: ver docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-
1704
+ # com-escrita-irrestrita-a-resposta-e-deteccao-ancorada-no-git.md. O stripping de
1705
+ # env/command abaixo reconhece as formas SEM argumentos antes de git (env git ...,
1706
+ # command git ...) e o env seguido de uma sequência de atribuições CHAVE=valor
1707
+ # (env FOO=bar git ..., env FOO=bar BAZ=qux git ...) — env com FLAGS (env -i git ...,
1708
+ # env --ignore-environment git ...) e command com flags (command -p git ...) continuam
1709
+ # evadindo; declarado, não fechado (ver AC5 do ML que adicionou esse stripping). A
1710
+ # segmentação abaixo
1711
+ # (quote_aware_split) evita falso-positivo em texto citado — não deve ser lida como imune a
1712
+ # evasão por citação/tokenização do shell.
1572
1713
  set -euo pipefail
1573
1714
  set -f
1574
1715
 
1716
+ # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
1717
+ # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
1718
+ # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
1719
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
1720
+ # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
1721
+ # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
1722
+ # leitura.
1723
+ _TRACKFW_STDIN=""
1724
+ [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
1725
+
1726
+ # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
1727
+ # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
1728
+ # achar trackfw.yaml na raiz do projeto. Sem trackfw.yaml em nenhum ancestral, o guard não se
1729
+ # aplica — fora de projeto trackfw não há trackfw ship como alternativa, e bloquear ali é custo
1730
+ # sem contrapartida. Custo medido: só parameter expansion e test -f por nível, nenhum fork de
1731
+ # processo; limitado pela profundidade do caminho.
1732
+ _TRACKFW_ROOT_DIR=$(pwd -P)
1733
+ _TRACKFW_FOUND=0
1734
+ while :; do
1735
+ if [ -f "$_TRACKFW_ROOT_DIR/trackfw.yaml" ]; then
1736
+ _TRACKFW_FOUND=1
1737
+ break
1738
+ fi
1739
+ if [ "$_TRACKFW_ROOT_DIR" = "/" ]; then
1740
+ break
1741
+ fi
1742
+ _TRACKFW_ROOT_DIR="\${_TRACKFW_ROOT_DIR%/*}"
1743
+ if [ -z "$_TRACKFW_ROOT_DIR" ]; then
1744
+ _TRACKFW_ROOT_DIR="/"
1745
+ fi
1746
+ done
1747
+ [ "$_TRACKFW_FOUND" -eq 1 ] || exit 0
1748
+
1575
1749
  # --- 1. Obter o comando git bruto ------------------------------------------------------------
1576
1750
  if [ "$#" -gt 0 ]; then
1577
1751
  CMD_RAW="$*"
1578
1752
  else
1579
- INPUT=$(cat 2>/dev/null || true)
1753
+ INPUT="$_TRACKFW_STDIN"
1580
1754
  TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
1581
1755
  case "$TRIMMED" in
1582
1756
  \\{*)
@@ -1609,15 +1783,143 @@ fi
1609
1783
 
1610
1784
  [ -n "$CMD_RAW" ] || exit 0
1611
1785
 
1612
- # --- 2. Casar contra "git (commit|push|checkout -b)", segmento por segmento -----------------
1613
- # Cada segmento é um comando real (dividido por ; && || | e por quebra de linha). "git" só
1614
- # conta se for o PRIMEIRO token do segmento (por basename, então /usr/bin/git também casa) —
1615
- # nunca uma ocorrência solta em qualquer posição da string inteira. Isso evita: (a) o segundo
1616
- # comando de uma cadeia escapar da checagem, (b) um path absoluto para o git escapar por
1617
- # comparação de igualdade exata, e (c) texto de prosa (ex.: mensagem de commit mencionando
1618
- # "git commit" no meio de uma frase) ser tratado como comando.
1786
+ # --- 2. Pré-processamento anti-falso-positivo: neutraliza separadores reais (';', '&&',
1787
+ # '||', '|', quebra de linha) que estão DENTRO de aspas ou de corpo de heredoc, para que
1788
+ # conteúdo de mensagem (ex.: ` + "`" + `-m "linha 1\\nlinha 2"` + "`" + `) nunca seja fatiado em pseudo-segmentos
1789
+ # e lido como comando -------------------------------------------------------------------
1790
+ #
1791
+ # strip_heredoc_bodies: remove o CORPO de blocos heredoc (<<DELIM ... DELIM), preservando a
1792
+ # linha de abertura e a linha terminadora — cobre o padrão ` + "`" + `git commit -F- <<'EOF' ... EOF` + "`" + `
1793
+ # (heredoc não citado, fora do escopo de quote_aware_split abaixo). Heurística por linha, não
1794
+ # sintaxe completa de shell: só remove o corpo quando encontra a linha terminadora
1795
+ # correspondente. Se o heredoc nunca fecha (terminador ausente ou não localizado), devolve o
1796
+ # texto ORIGINAL sem qualquer alteração — lado seguro: mais restritivo é preferível a esconder
1797
+ # um comando real atrás de um heredoc mal-formado.
1798
+ strip_heredoc_bodies() {
1799
+ printf '%s' "$1" | awk '
1800
+ BEGIN {
1801
+ dq = sprintf("%c", 34)
1802
+ sq = sprintf("%c", 39)
1803
+ in_heredoc = 0
1804
+ delim = ""
1805
+ ok = 1
1806
+ }
1807
+ {
1808
+ raw = raw $0 "\\n"
1809
+ if (in_heredoc) {
1810
+ trimmed = $0
1811
+ sub(/^[ \\t]+/, "", trimmed)
1812
+ sub(/[ \\t]+$/, "", trimmed)
1813
+ if (trimmed == delim) {
1814
+ in_heredoc = 0
1815
+ out = out $0 "\\n"
1816
+ }
1817
+ next
1818
+ }
1819
+ if (match($0, /<<-?[ \\t]*[^ \\t]+/)) {
1820
+ d = substr($0, RSTART, RLENGTH)
1821
+ sub(/^<<-?[ \\t]*/, "", d)
1822
+ gsub(dq, "", d)
1823
+ gsub(sq, "", d)
1824
+ if (d != "") {
1825
+ delim = d
1826
+ in_heredoc = 1
1827
+ }
1828
+ }
1829
+ out = out $0 "\\n"
1830
+ }
1831
+ END {
1832
+ if (in_heredoc) ok = 0
1833
+ if (ok) { printf "%s", out } else { printf "%s", raw }
1834
+ }
1835
+ '
1836
+ }
1837
+
1838
+ # quote_aware_split: emite o texto com ';' isolado, '&&', '||' e '|' isolado convertidos em
1839
+ # quebra de linha — EXCETO quando ocorrem dentro de uma string entre aspas simples ou duplas,
1840
+ # caso em que são preservados como texto e uma quebra de linha real dentro das aspas vira
1841
+ # espaço (nunca gera um novo pseudo-segmento). Substitui o antigo ` + "`" + `sed` + "`" + ` cego, que não
1842
+ # distinguia texto citado de sintaxe de comando — a causa raiz do falso-positivo de linha de
1843
+ # mensagem de commit iniciada por "git ...". Aspas não fechadas até o fim da entrada
1844
+ # permanecem "abertas" até o fim — mesma semântica do shell real: uma aspa não fechada nunca
1845
+ # deixa o texto seguinte executar como comando novo, só torna o restante parte da mesma
1846
+ # string.
1847
+ quote_aware_split() {
1848
+ printf '%s' "$1" | awk '
1849
+ BEGIN {
1850
+ dq = sprintf("%c", 34)
1851
+ sq = sprintf("%c", 39)
1852
+ bs = sprintf("%c", 92)
1853
+ nl = sprintf("%c", 10)
1854
+ }
1855
+ { s = (NR == 1) ? $0 : s nl $0 }
1856
+ END {
1857
+ n = length(s)
1858
+ q = ""
1859
+ out = ""
1860
+ i = 1
1861
+ while (i <= n) {
1862
+ c = substr(s, i, 1)
1863
+ if (q != "") {
1864
+ if (q == dq && c == bs && i < n) {
1865
+ nx = substr(s, i + 1, 1)
1866
+ out = out c (nx == nl ? " " : nx)
1867
+ i += 2
1868
+ continue
1869
+ }
1870
+ if (c == q) {
1871
+ q = ""
1872
+ out = out c
1873
+ i++
1874
+ continue
1875
+ }
1876
+ out = out (c == nl ? " " : c)
1877
+ i++
1878
+ continue
1879
+ }
1880
+ if (c == dq || c == sq) {
1881
+ q = c
1882
+ out = out c
1883
+ i++
1884
+ continue
1885
+ }
1886
+ if (substr(s, i, 2) == "&&" || substr(s, i, 2) == "||") {
1887
+ out = out nl
1888
+ i += 2
1889
+ continue
1890
+ }
1891
+ if (c == ";" || c == "|") {
1892
+ out = out nl
1893
+ i++
1894
+ continue
1895
+ }
1896
+ out = out c
1897
+ i++
1898
+ }
1899
+ printf "%s", out
1900
+ }
1901
+ '
1902
+ }
1903
+
1904
+ # match_subcommand — casa contra "git (commit|push|checkout -b|switch -c)", segmento por
1905
+ # segmento. Cada segmento é um comando real, obtido depois do pré-processamento acima
1906
+ # (strip_heredoc_bodies + quote_aware_split), que converte ';', '&&', '||', '|' fora de aspas
1907
+ # em quebra de linha e neutraliza os mesmos separadores quando aparecem dentro de
1908
+ # aspas/heredoc. "git" só conta se for o PRIMEIRO token do segmento (por basename, então
1909
+ # /usr/bin/git também casa) — nunca uma ocorrência solta em qualquer posição da string
1910
+ # inteira. Isso evita: (a) o segundo comando de uma cadeia escapar da checagem, (b) um path
1911
+ # absoluto para o git escapar por comparação de igualdade exata, e (c) texto de prosa —
1912
+ # inclusive linha de mensagem de commit que COMEÇA com "git <sub>" (ex.: uma tabela
1913
+ # documentando comandos bloqueados) — ser tratado como comando, porque esse texto agora nunca
1914
+ # produz um novo segmento. ` + "`" + `git switch -c/-C/--create` + "`" + ` (forma alternativa a ` + "`" + `checkout -b` + "`" + `
1915
+ # para criar branch) é reconhecido varrendo TODOS os tokens após o subcomando, não só o
1916
+ # primeiro — cobre ` + "`" + `git switch --track -c feat/x` + "`" + ` (flag antes de -c).
1917
+ # checkout -b é reconhecido do mesmo jeito: varre TODOS os tokens até achar -b/-B/--orphan,
1918
+ # não só o primeiro. Prefixos env e command antes de git são descartados antes da checagem do
1919
+ # basename — cobre env git push/command git push sem exigir tokenizar como o bash.
1619
1920
  match_subcommand() {
1620
- normalized=$(printf '%s' "$1" | sed -e 's/&&/\\n/g' -e 's/||/\\n/g' -e 's/[;|]/\\n/g')
1921
+ normalized=$(strip_heredoc_bodies "$1")
1922
+ normalized=$(quote_aware_split "$normalized")
1621
1923
  while IFS= read -r seg; do
1622
1924
  seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
1623
1925
  [ -n "$seg_trimmed" ] || continue
@@ -1625,6 +1927,29 @@ match_subcommand() {
1625
1927
  set -- $seg_trimmed
1626
1928
  first="$1"
1627
1929
  base="\${first##*/}"
1930
+ while [ "$base" = "env" ] || [ "$base" = "command" ]; do
1931
+ is_env="$base"
1932
+ shift
1933
+ [ "$#" -gt 0 ] || break
1934
+ if [ "$is_env" = "env" ]; then
1935
+ while [ "$#" -gt 0 ]; do
1936
+ case "$1" in
1937
+ -*)
1938
+ break
1939
+ ;;
1940
+ *=*)
1941
+ shift
1942
+ ;;
1943
+ *)
1944
+ break
1945
+ ;;
1946
+ esac
1947
+ done
1948
+ [ "$#" -gt 0 ] || break
1949
+ fi
1950
+ first="$1"
1951
+ base="\${first##*/}"
1952
+ done
1628
1953
  [ "$base" = "git" ] || continue
1629
1954
  shift
1630
1955
 
@@ -1658,11 +1983,217 @@ match_subcommand() {
1658
1983
  return 0
1659
1984
  ;;
1660
1985
  checkout)
1661
- if [ "\${1:-}" = "-b" ]; then
1662
- echo "checkout-b"
1986
+ for tok2 in "$@"; do
1987
+ case "$tok2" in
1988
+ -b|-B|--orphan|--orphan=*)
1989
+ echo "checkout-b"
1990
+ return 0
1991
+ ;;
1992
+ esac
1993
+ done
1994
+ # git checkout -- <path> | git checkout . descarta alterações não commitadas do
1995
+ # caminho indicado, de forma irreversível, no worktree compartilhado — bloqueia
1996
+ # quando '--' aparece em qualquer posição (forma explícita de pathspec) ou quando
1997
+ # '.' aparece como token isolado. 'git checkout <branch>' sem nenhum dos dois
1998
+ # segue liberado por decisão (distinguir branch de caminho sem '--' é ambíguo, e
1999
+ # adivinhar produziria falso-positivo).
2000
+ checkout_path=0
2001
+ for tok2 in "$@"; do
2002
+ case "$tok2" in
2003
+ --|.)
2004
+ checkout_path=1
2005
+ ;;
2006
+ esac
2007
+ done
2008
+ if [ "$checkout_path" = "1" ]; then
2009
+ echo "checkout-path"
2010
+ return 0
2011
+ fi
2012
+ ;;
2013
+ switch)
2014
+ for tok2 in "$@"; do
2015
+ case "$tok2" in
2016
+ -c|-C|--create|--create=*|--force-create|--force-create=*)
2017
+ echo "switch-c"
2018
+ return 0
2019
+ ;;
2020
+ esac
2021
+ done
2022
+ ;;
2023
+ stash)
2024
+ # git stash: liberado só para leitura (list/show) — bloqueia a forma bare
2025
+ # (equivale a "push"), push, save, clear e drop. Decisão de KG: bloquear a
2026
+ # classe inteira, não só os literais medidos (ver REQ). Repositório com um único
2027
+ # worktree compartilhado entre subagentes paralelos — um stash de um agente
2028
+ # remove as alterações não commitadas de todos os outros.
2029
+ stash_sub="\${1:-}"
2030
+ case "$stash_sub" in
2031
+ list|show)
2032
+ ;;
2033
+ *)
2034
+ echo "stash"
2035
+ return 0
2036
+ ;;
2037
+ esac
2038
+ ;;
2039
+ reset)
2040
+ # Só --hard bloqueia, em qualquer posição de token — --soft/--mixed (inclusive
2041
+ # sem flag, que é --mixed implícito) seguem liberados: --soft é o contorno
2042
+ # padrão para reempurrar trabalho staged via ` + "`" + `trackfw ship -m "..."` + "`" + ` (ainda falta commitar após --soft).
2043
+ for tok2 in "$@"; do
2044
+ case "$tok2" in
2045
+ --hard)
2046
+ echo "reset-hard"
2047
+ return 0
2048
+ ;;
2049
+ esac
2050
+ done
2051
+ ;;
2052
+ clean)
2053
+ # Bloqueia qualquer forma com force (-f, -fd, -fx, --force) ou -x/-X, EXCETO
2054
+ # quando -n/--dry-run também está presente (dry-run nunca apaga nada).
2055
+ clean_dry=0
2056
+ clean_force=0
2057
+ for tok2 in "$@"; do
2058
+ case "$tok2" in
2059
+ -n|--dry-run)
2060
+ clean_dry=1
2061
+ ;;
2062
+ -f*|--force|--force=*|-x|-X)
2063
+ clean_force=1
2064
+ ;;
2065
+ esac
2066
+ done
2067
+ if [ "$clean_dry" != "1" ] && [ "$clean_force" = "1" ]; then
2068
+ echo "clean-force"
1663
2069
  return 0
1664
2070
  fi
1665
2071
  ;;
2072
+ restore)
2073
+ # git restore --staged SOZINHO nunca toca o working tree (mexe só no
2074
+ # index), então segue liberado mesmo com path. Mas --worktree/-W (com ou
2075
+ # sem --staged junto) SEMPRE afeta o working tree — inclusive
2076
+ # "--staged --worktree", que restaura os dois — então bloqueia sempre que
2077
+ # --worktree/-W aparecer, e também no caso padrão (sem --staged em
2078
+ # nenhuma forma) com um argumento posicional (o path).
2079
+ restore_staged=0
2080
+ restore_worktree=0
2081
+ restore_positional=0
2082
+ for tok2 in "$@"; do
2083
+ case "$tok2" in
2084
+ --staged)
2085
+ restore_staged=1
2086
+ ;;
2087
+ --worktree|-W)
2088
+ restore_worktree=1
2089
+ ;;
2090
+ -*)
2091
+ ;;
2092
+ *)
2093
+ restore_positional=1
2094
+ ;;
2095
+ esac
2096
+ done
2097
+ if [ "$restore_positional" = "1" ]; then
2098
+ if [ "$restore_worktree" = "1" ] || [ "$restore_staged" != "1" ]; then
2099
+ echo "restore-path"
2100
+ return 0
2101
+ fi
2102
+ fi
2103
+ ;;
2104
+ branch)
2105
+ # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
2106
+ # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
2107
+ # --format=, --points-at, -d/-D/--delete) — bloquear leitura seria pior que a
2108
+ # brecha. Só bloqueia: (a) -c/-C/-m/-M/--copy/--move (cria/renomeia branch,
2109
+ # qualquer posição de token) ou (b) um argumento posicional puro (nome da branch a
2110
+ # criar), a menos que -d/-D/--delete também esteja presente (delete tem
2111
+ # posicional legítimo — o nome a apagar). Flags de valor conhecidas (--contains,
2112
+ # --no-contains, --sort, --format, --points-at, --merged, --no-merged) têm seu
2113
+ # valor seguinte pulado quando vem em token separado, para não ser lido como
2114
+ # posicional de criação.
2115
+ branch_action=0
2116
+ has_delete=0
2117
+ saw_positional=0
2118
+ skip_next=0
2119
+ for tok2 in "$@"; do
2120
+ if [ "$skip_next" = "1" ]; then
2121
+ skip_next=0
2122
+ continue
2123
+ fi
2124
+ case "$tok2" in
2125
+ -c|-C|-m|-M|--copy|--copy=*|--move|--move=*)
2126
+ branch_action=1
2127
+ ;;
2128
+ -d|-D|--delete|--delete=*)
2129
+ has_delete=1
2130
+ ;;
2131
+ --contains|--no-contains|--sort|--format|--points-at|--merged|--no-merged)
2132
+ skip_next=1
2133
+ ;;
2134
+ -*)
2135
+ ;;
2136
+ *)
2137
+ saw_positional=1
2138
+ ;;
2139
+ esac
2140
+ done
2141
+ if [ "$has_delete" != "1" ]; then
2142
+ if [ "$branch_action" = "1" ] || [ "$saw_positional" = "1" ]; then
2143
+ echo "branch-create"
2144
+ return 0
2145
+ fi
2146
+ fi
2147
+ ;;
2148
+ worktree)
2149
+ if [ "\${1:-}" = "add" ]; then
2150
+ shift
2151
+ for tok2 in "$@"; do
2152
+ case "$tok2" in
2153
+ -b|-B)
2154
+ echo "worktree-add-b"
2155
+ return 0
2156
+ ;;
2157
+ esac
2158
+ done
2159
+ elif [ "\${1:-}" = "remove" ]; then
2160
+ # git worktree remove SEM -f/--force já recusa sozinho quando há alteração não
2161
+ # commitada no worktree indicado — só a forma com force é irreversível o bastante
2162
+ # para bloquear aqui.
2163
+ shift
2164
+ for tok2 in "$@"; do
2165
+ case "$tok2" in
2166
+ -f|--force)
2167
+ echo "worktree-remove-force"
2168
+ return 0
2169
+ ;;
2170
+ esac
2171
+ done
2172
+ fi
2173
+ ;;
2174
+ update-ref)
2175
+ # git update-ref reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o
2176
+ # objeto apontado nem exigir push — foi o mecanismo que tornou alcançável o exploit
2177
+ # descrito no ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md
2178
+ # (Emenda 1): forjar origin/<base> localmente para desviar o commit-alvo de trackfw
2179
+ # release tag. Sem forma de leitura equivalente a bloquear seletivamente — a
2180
+ # subcommand inteira é escrita — bloqueia sempre, sem exceção de token.
2181
+ echo "update-ref"
2182
+ return 0
2183
+ ;;
2184
+ rm)
2185
+ # git rm -f/--force apaga do working tree e do index de forma irreversível, mesma
2186
+ # classe de git clean -f/git reset --hard já bloqueados acima — sem exceção para
2187
+ # --cached (destrancar do index sem -f já segue liberado por não precisar de force).
2188
+ for tok2 in "$@"; do
2189
+ case "$tok2" in
2190
+ -f*|--force|--force=*)
2191
+ echo "rm-force"
2192
+ return 0
2193
+ ;;
2194
+ esac
2195
+ done
2196
+ ;;
1666
2197
  esac
1667
2198
  done <<EOF
1668
2199
  $normalized
@@ -1674,13 +2205,46 @@ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
1674
2205
 
1675
2206
  case "$SUBCOMMAND" in
1676
2207
  checkout-b)
1677
- REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2208
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2209
+ ;;
2210
+ switch-c)
2211
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2212
+ ;;
2213
+ branch-create)
2214
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2215
+ ;;
2216
+ worktree-add-b)
2217
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1678
2218
  ;;
1679
2219
  commit)
1680
- REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2220
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1681
2221
  ;;
1682
2222
  push)
1683
- REASON="trackfw: git push bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw ship` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2223
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw push` + GBG_REF_BACKTICK + ` (para empurrar commits já criados), ` + GBG_REF_BACKTICK + `trackfw ship` + GBG_REF_BACKTICK + ` (para commit+push+PR em uma etapa) ou ` + GBG_REF_BACKTICK + `trackfw release tag` + GBG_REF_BACKTICK + ` (para publicar uma tag de release). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2224
+ ;;
2225
+ stash)
2226
+ REASON="trackfw: git stash bruto bloqueado — worktree compartilhado entre subagentes, um stash remove as alterações não commitadas de todos os outros. ` + GBG_REF_BACKTICK + `git stash list` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git stash show` + GBG_REF_BACKTICK + ` seguem liberados; para guardar trabalho em progresso, use uma branch própria via ` + GBG_REF_BACKTICK + `trackfw branch new` + GBG_REF_BACKTICK + ` e commit nela. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2227
+ ;;
2228
+ reset-hard)
2229
+ REASON="trackfw: git reset --hard bruto bloqueado — descarta de forma irreversível as alterações não commitadas de todo o worktree compartilhado. ` + GBG_REF_BACKTICK + `git reset --soft` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `--mixed` + GBG_REF_BACKTICK + ` seguem liberados (ex.: ` + GBG_REF_BACKTICK + `git reset --soft HEAD~1` + GBG_REF_BACKTICK + ` é o caminho padrão; use ` + GBG_REF_BACKTICK + `trackfw ship -m "..."` + GBG_REF_BACKTICK + ` para commitar e empurrar). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2230
+ ;;
2231
+ clean-force)
2232
+ REASON="trackfw: git clean -f/-x bruto bloqueado — apaga arquivos não rastreados do worktree compartilhado, de forma irreversível. ` + GBG_REF_BACKTICK + `git clean -n` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `--dry-run` + GBG_REF_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."
2233
+ ;;
2234
+ restore-path)
2235
+ REASON="trackfw: git restore <path> bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_REF_BACKTICK + `git restore --staged` + GBG_REF_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."
2236
+ ;;
2237
+ checkout-path)
2238
+ REASON="trackfw: git checkout -- <path>/git checkout . bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_REF_BACKTICK + `git checkout <branch>` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git switch <branch>` + GBG_REF_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."
2239
+ ;;
2240
+ update-ref)
2241
+ 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_REF_BACKTICK + `trackfw release tag` + GBG_REF_BACKTICK + ` publicaria. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2242
+ ;;
2243
+ worktree-remove-force)
2244
+ 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_REF_BACKTICK + `git worktree remove` + GBG_REF_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."
2245
+ ;;
2246
+ rm-force)
2247
+ REASON="trackfw: git rm -f/--force bruto bloqueado — apaga arquivos do working tree e do index de forma irreversível, mesma classe de ` + GBG_REF_BACKTICK + `git clean -f` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git reset --hard` + GBG_REF_BACKTICK + ` já bloqueados. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1684
2248
  ;;
1685
2249
  *)
1686
2250
  exit 0
@@ -1882,15 +2446,34 @@ exit 0
1882
2446
  // (internal/validator/validator_git_branch_guard.go) — note this list DIFFERS from
1883
2447
  // CREDENTIAL_GUARD_HOOK_FILES for Copilot and Kiro (global scope uses .copilot/settings.json and
1884
2448
  // .kiro/hooks/trackfw-credential-guard.json, not the project-scope attention-hook paths).
2449
+ // requiresCommandType (ROADMAP-2026-08-17 ML-4B) mirrors CREDENTIAL_GUARD_HOOK_FILES'
2450
+ // requiresCommandType above — true for every CLI except Cursor.
1885
2451
  const GLOBAL_GUARD_CONFIG_FILES = [
1886
- { relPath: '.claude/settings.json', cli: 'Claude Code' },
1887
- { relPath: '.codex/hooks.json', cli: 'Codex CLI' },
1888
- { relPath: '.gemini/settings.json', cli: 'Gemini CLI' },
1889
- { relPath: '.cursor/hooks.json', cli: 'Cursor' },
1890
- { relPath: '.copilot/settings.json', cli: 'GitHub Copilot CLI' },
1891
- { relPath: '.kiro/hooks/trackfw-credential-guard.json', cli: 'Kiro' },
2452
+ { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
2453
+ { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
2454
+ { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
2455
+ { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
2456
+ { relPath: '.copilot/settings.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
2457
+ { relPath: '.kiro/hooks/trackfw-credential-guard.json', cli: 'Kiro', requiresCommandType: true },
1892
2458
  ]
1893
2459
 
2460
+ // globalGuardConfigPath resolves the actual on-disk path (relative to $HOME) that
2461
+ // validateGuardGlobalHookResolvable must read for a given (GLOBAL_GUARD_CONFIG_FILES entry,
2462
+ // scriptMarker) pair. Port of Go's globalGuardConfigPath (internal/validator/
2463
+ // validator_git_branch_guard.go) — see that function's doc comment for the full rationale
2464
+ // (5 CLIs share one merge-based file across both guards; Kiro is the sole exception, with a
2465
+ // dedicated file per guard because its writer rewrites the whole document wholesale).
2466
+ //
2467
+ // ROADMAP-2026-08-17 ML-3B: before this function existed, GLOBAL_GUARD_CONFIG_FILES only ever
2468
+ // pointed Kiro at trackfw-credential-guard.json for BOTH guards, so git_branch_guard_hook_resolvable
2469
+ // never inspected ~/.kiro/hooks/trackfw-git-branch-guard.json at all.
2470
+ function globalGuardConfigPath(gf, scriptMarker) {
2471
+ if (gf.cli === 'Kiro' && scriptMarker === GIT_BRANCH_GUARD_SCRIPT_MARKER) {
2472
+ return '.kiro/hooks/trackfw-git-branch-guard.json'
2473
+ }
2474
+ return gf.relPath
2475
+ }
2476
+
1894
2477
  // validateGuardGlobalHookResolvable is the GLOBAL-scope counterpart of
1895
2478
  // validateGuardHookResolvable: for each of the 6 GLOBAL_GUARD_CONFIG_FILES that exists AND
1896
2479
  // references scriptMarker, verifies the referenced script exists and is executable. Port of Go's
@@ -1911,7 +2494,8 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1911
2494
 
1912
2495
  const msgs = []
1913
2496
  for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
1914
- const fullPath = path.join(home, gf.relPath)
2497
+ const relPath = globalGuardConfigPath(gf, scriptMarker)
2498
+ const fullPath = path.join(home, relPath)
1915
2499
  let content
1916
2500
  try {
1917
2501
  content = fs.readFileSync(fullPath, 'utf8')
@@ -1931,23 +2515,34 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1931
2515
  collectCommandsWithMarker(parsed, scriptMarker, commands)
1932
2516
 
1933
2517
  const seen = new Set()
1934
- for (const raw of commands) {
1935
- if (seen.has(raw)) continue
1936
- seen.add(raw)
1937
-
1938
- if (!path.isAbsolute(raw)) continue
2518
+ for (const m of commands) {
2519
+ const seenKey = `${m.raw} ${m.typeIsCommand}`
2520
+ if (seen.has(seenKey)) continue
2521
+ seen.add(seenKey)
2522
+
2523
+ if (!path.isAbsolute(m.raw)) continue
2524
+
2525
+ // ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
2526
+ // with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
2527
+ // version, another tool's merge) is silently never executed by the CLI, even though the
2528
+ // script itself exists and is fine. Reported instead of the exists/executable checks below,
2529
+ // which assume the entry is structurally valid.
2530
+ if (gf.requiresCommandType && !m.typeIsCommand) {
2531
+ msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the hook entry is missing "type":"command" (or has an invalid type) — ${gf.cli} will silently never execute it; run \`trackfw update harness\` to regenerate it`)
2532
+ continue
2533
+ }
1939
2534
 
1940
2535
  let stat = null
1941
2536
  try {
1942
- stat = fs.statSync(raw)
2537
+ stat = fs.statSync(m.raw)
1943
2538
  } catch (_) {
1944
2539
  stat = null
1945
2540
  }
1946
2541
 
1947
2542
  if (!stat) {
1948
- msgs.push(`~/${gf.relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${raw}", but the script does not exist — run \`trackfw update harness\` to regenerate it`)
2543
+ msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script does not exist — run \`trackfw update harness\` to regenerate it`)
1949
2544
  } else if ((stat.mode & 0o111) === 0) {
1950
- msgs.push(`~/${gf.relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${raw}", but the script is not executable — run \`trackfw update harness\` to regenerate it`)
2545
+ msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script is not executable — run \`trackfw update harness\` to regenerate it`)
1951
2546
  }
1952
2547
  }
1953
2548
  }
@@ -1956,61 +2551,45 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1956
2551
  }
1957
2552
 
1958
2553
  // validateGuardGlobalScriptIntegrity is the GLOBAL-scope counterpart of
1959
- // validateCredentialGuardScriptIntegrity/validateGitBranchGuardScriptIntegrity: for each of the 6
1960
- // GLOBAL_GUARD_CONFIG_FILES that references scriptMarker, verifies the referenced script's content
1961
- // matches referenceContent byte-for-byte. Port of Go's validateGuardGlobalScriptIntegrity
1962
- // (internal/validator/validator_git_branch_guard.go) — same trigger condition and fail-open
1963
- // contract as validateGuardGlobalHookResolvable above; does not re-validate existence/
1964
- // executability, only content, matching the project-scope split between *_hook_resolvable and
1965
- // *_script_integrity.
1966
- function validateGuardGlobalScriptIntegrity(scriptMarker, referenceContent) {
2554
+ // validateCredentialGuardScriptIntegrity/validateGitBranchGuardScriptIntegrity: verifies the
2555
+ // content of ~/.trackfw/scripts/<scriptFileName> (the fixed location the global generators write
2556
+ // to) against referenceContent byte-for-byte. Port of Go's validateGuardGlobalScriptIntegrity
2557
+ // (internal/validator/validator_git_branch_guard.go).
2558
+ //
2559
+ // ROADMAP-2026-08-17 (guard global cabeado com no-op / integridade independente de fiação), ML-3A:
2560
+ // deliberately triggers on ARTIFACT EXISTENCE, not on any GLOBAL_GUARD_CONFIG_FILES entry
2561
+ // referencing scriptMarker — the old per-config trigger meant a script trackfw itself wrote (via
2562
+ // `trackfw update harness`) but that no config yet pointed at could rot indefinitely with
2563
+ // `validate` green. "If trackfw wrote the script, trackfw verifies the script" — existence is the
2564
+ // only precondition; wiring is irrelevant to whether the artifact itself has drifted.
2565
+ //
2566
+ // Fail-open on absence: a script trackfw never wrote (user never ran `update harness`) is not an
2567
+ // error.
2568
+ //
2569
+ // Single evaluation per script (not per referencing config): caps the message count at 1
2570
+ // regardless of how many (or how few — including zero) configs reference the same on-disk path —
2571
+ // this is what prevents double-reporting now that git-branch-guard has both global wiring (Wave 2)
2572
+ // and a global artifact.
2573
+ //
2574
+ // scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
2575
+ // trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
2576
+ function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
1967
2577
  const home = os.homedir()
1968
2578
  if (!home) return []
1969
2579
 
1970
- const msgs = []
1971
- for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
1972
- const fullPath = path.join(home, gf.relPath)
1973
- let content
1974
- try {
1975
- content = fs.readFileSync(fullPath, 'utf8')
1976
- } catch (e) {
1977
- if (e.code === 'ENOENT') continue
1978
- continue
1979
- }
1980
-
1981
- let parsed
1982
- try {
1983
- parsed = JSON.parse(content)
1984
- } catch (_) {
1985
- continue
1986
- }
1987
-
1988
- const commands = []
1989
- collectCommandsWithMarker(parsed, scriptMarker, commands)
1990
-
1991
- const seen = new Set()
1992
- for (const raw of commands) {
1993
- if (seen.has(raw)) continue
1994
- seen.add(raw)
1995
-
1996
- if (!path.isAbsolute(raw)) continue
1997
-
1998
- let scriptContent
1999
- try {
2000
- scriptContent = fs.readFileSync(raw, 'utf8')
2001
- } catch (_) {
2002
- // Existence/readability is validateGuardGlobalHookResolvable's job — do not double-report
2003
- // the same underlying condition under two rule names.
2004
- continue
2005
- }
2006
-
2007
- if (scriptContent === referenceContent) continue
2008
-
2009
- msgs.push(`${raw} (global scope, referenced from ~/${gf.relPath}, ${gf.cli}) content diverges from the template this version of trackfw generates — if you did not edit this file by hand, run \`trackfw update harness\` to regenerate it`)
2010
- }
2580
+ const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
2581
+ let content
2582
+ try {
2583
+ content = fs.readFileSync(scriptPath, 'utf8')
2584
+ } catch (_) {
2585
+ // Not installed is not a violation — same contract as every other *_script_integrity check
2586
+ // (project-scope and global) in this file.
2587
+ return []
2011
2588
  }
2012
2589
 
2013
- return msgs
2590
+ if (content === referenceContent) return []
2591
+
2592
+ return [`${scriptPath} (global scope) content diverges from the template this version of trackfw generates — if you did not edit this file by hand, run \`trackfw update harness\` to regenerate it`]
2014
2593
  }
2015
2594
 
2016
2595
  // validateCredentialGuardGlobalHookResolvable / validateCredentialGuardGlobalScriptIntegrity /
@@ -2036,6 +2615,10 @@ function validateGitBranchGuardGlobalHookResolvable() {
2036
2615
  function validateGitBranchGuardGlobalScriptIntegrity() {
2037
2616
  return validateGuardGlobalScriptIntegrity(GIT_BRANCH_GUARD_SCRIPT_MARKER, GIT_BRANCH_GUARD_SCRIPT_REFERENCE)
2038
2617
  }
2618
+ // NOTE: CREDENTIAL_GUARD_SCRIPT_MARKER === 'trackfw-credential-guard.sh' and
2619
+ // GIT_BRANCH_GUARD_SCRIPT_MARKER === 'trackfw-git-branch-guard.sh' — both markers are already the
2620
+ // literal script filenames, so they double as scriptFileName above with no extra constant needed
2621
+ // (same reuse the Go port relies on).
2039
2622
 
2040
2623
  // CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES mirrors the shell script's own resolution of
2041
2624
  // credential_guard.mode (`grep -A 5 '^credential_guard:'`): the mode key is found on the matched
@@ -2687,6 +3270,14 @@ module.exports = {
2687
3270
  resolveAdrStatus,
2688
3271
  // ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
2689
3272
  resolveCredentialGuardHookPath,
3273
+ // ADR-2026-08-22 (ML-1A): classificação por ancoragem
3274
+ HOOK_ANCHORAGE_CLASS_ANCHORED,
3275
+ HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT,
3276
+ HOOK_ANCHORAGE_CLASS_UNDECIDABLE,
3277
+ stripOuterQuotesForClassify,
3278
+ hookValueWasQuoted,
3279
+ classifyHookAnchorage,
3280
+ cwdDependentReason,
2690
3281
  collectCredentialGuardCommands,
2691
3282
  validateCredentialGuardHookResolvable,
2692
3283
  // ROADMAP-2026-08-12-deteccao-de-adulteracao-do-credential-guard-regra-de-validate, ML-1A