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/validator/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1297
|
-
|
|
1298
|
-
seen.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
1613
|
-
#
|
|
1614
|
-
#
|
|
1615
|
-
#
|
|
1616
|
-
#
|
|
1617
|
-
#
|
|
1618
|
-
# "git commit
|
|
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=$(
|
|
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
|
-
|
|
1662
|
-
|
|
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 +
|
|
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
|
|
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
|
|
1935
|
-
|
|
1936
|
-
seen.
|
|
1937
|
-
|
|
1938
|
-
|
|
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(`~/${
|
|
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(`~/${
|
|
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:
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
// (internal/validator/validator_git_branch_guard.go)
|
|
1963
|
-
//
|
|
1964
|
-
//
|
|
1965
|
-
//
|
|
1966
|
-
|
|
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
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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
|
|
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
|