trackfw 6.10.0 → 7.1.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/README.md +0 -3
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +59 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +75 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +23 -4
- package/src/commands/integrations.js +23 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +320 -12
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +925 -6
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +0 -21
- package/src/i18n/locales/es-ES.json +0 -21
- package/src/i18n/locales/pt-BR.json +0 -21
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +133 -19
- package/src/lib/fatal-error.js +64 -0
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +259 -95
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +1060 -27
- package/src/commands/plugins.js +0 -184
package/src/validator/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
|
+
const os = require('os')
|
|
4
5
|
const path = require('path')
|
|
6
|
+
const crypto = require('crypto')
|
|
5
7
|
const { gitOutput } = require('./git-exec')
|
|
6
8
|
const config = require('../config')
|
|
7
9
|
const { checkTraceIds } = require('./traceid')
|
|
10
|
+
const { loadProvenance } = require('../thirdparty/provenance')
|
|
8
11
|
|
|
9
12
|
const STALE_WIP_DAYS = 7
|
|
10
13
|
let staleWipNowMs = () => Date.now()
|
|
@@ -1152,18 +1155,32 @@ function validateNoteOrphan(cwd) {
|
|
|
1152
1155
|
// procura dentro dos comandos de hook de projeto.
|
|
1153
1156
|
const CREDENTIAL_GUARD_SCRIPT_MARKER = 'trackfw-credential-guard.sh'
|
|
1154
1157
|
|
|
1158
|
+
// GIT_BRANCH_GUARD_SCRIPT_MARKER é o nome do script que a regra git_branch_guard_hook_resolvable
|
|
1159
|
+
// procura dentro dos comandos de hook de projeto (ROADMAP-2026-08-15-trackfw-validate-deve-
|
|
1160
|
+
// detectar-scripts-de-hook-ausentes-ou-desatualizados, ML-2A — port de
|
|
1161
|
+
// internal/validator/validator_git_branch_guard.go's gitBranchGuardScriptMarker). Mesmo padrão de
|
|
1162
|
+
// CREDENTIAL_GUARD_SCRIPT_MARKER — só o nome do arquivo muda.
|
|
1163
|
+
const GIT_BRANCH_GUARD_SCRIPT_MARKER = 'trackfw-git-branch-guard.sh'
|
|
1164
|
+
|
|
1155
1165
|
// CREDENTIAL_GUARD_HOOK_FILES é a lista fechada dos arquivos de hook de PROJETO que o trackfw
|
|
1156
1166
|
// gera hoje e que podem conter uma entrada de credential-guard (ROADMAP-2026-08-12-mitigacao-do
|
|
1157
1167
|
// -fail-open-do-credential-guard, ML-1A). Hooks de escopo GLOBAL (~/.trackfw/..., trackfw update
|
|
1158
1168
|
// harness) ficam fora — caso distinto, fora do repositório do usuário, e a checagem de dedup
|
|
1159
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.
|
|
1160
1177
|
const CREDENTIAL_GUARD_HOOK_FILES = [
|
|
1161
|
-
{ relPath: '.claude/settings.json', cli: 'Claude Code' },
|
|
1162
|
-
{ relPath: '.codex/hooks.json', cli: 'Codex CLI' },
|
|
1163
|
-
{ relPath: '.gemini/settings.json', cli: 'Gemini CLI' },
|
|
1164
|
-
{ relPath: '.cursor/hooks.json', cli: 'Cursor' },
|
|
1165
|
-
{ relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI' },
|
|
1166
|
-
{ relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro' },
|
|
1178
|
+
{ relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
|
|
1179
|
+
{ relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
|
|
1180
|
+
{ relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
|
|
1181
|
+
{ relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
|
|
1182
|
+
{ relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
|
|
1183
|
+
{ relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro', requiresCommandType: true },
|
|
1167
1184
|
]
|
|
1168
1185
|
|
|
1169
1186
|
// resolveCredentialGuardHookPath resolve o valor bruto de um comando de hook (string extraída do
|
|
@@ -1203,30 +1220,67 @@ function resolveCredentialGuardHookPath(raw, root) {
|
|
|
1203
1220
|
return null
|
|
1204
1221
|
}
|
|
1205
1222
|
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1223
|
+
// collectCommandsWithMarker percorre recursivamente um valor JSON já decodificado e coleta todo
|
|
1224
|
+
// valor-string que contém marker, independentemente do nome do campo que o contém — junto com um
|
|
1225
|
+
// sinal estrutural (typeIsCommand) de que o objeto imediato que o contém também tem
|
|
1226
|
+
// "type":"command" como campo irmão (ROADMAP-2026-08-17 ML-4B, port de guardCommandMatch em
|
|
1227
|
+
// internal/validator/validator_credential_guard.go). Cada entrada de `out` é
|
|
1228
|
+
// {raw, typeIsCommand}.
|
|
1209
1229
|
//
|
|
1210
1230
|
// Os 6 formatos de hook usam campos diferentes para o comando: "command" (Claude/Codex/
|
|
1211
1231
|
// Gemini/Cursor), "bash" (GitHub Copilot CLI), "action.command" (Kiro). Varrer por VALOR em vez
|
|
1212
|
-
// de por caminho de chave evita acoplar esta regra à forma exata de cada schema.
|
|
1213
|
-
|
|
1232
|
+
// de por caminho de chave evita acoplar esta regra à forma exata de cada schema. Todo schema aqui
|
|
1233
|
+
// coloca "type" como IRMÃO do campo do comando dentro do MESMO objeto — nunca aninhado mais fundo
|
|
1234
|
+
// — então basta ler "type" do objeto que collectCommandsWithMarker já está visitando; não é
|
|
1235
|
+
// necessária uma travessia schema-aware separada.
|
|
1236
|
+
//
|
|
1237
|
+
// Generalizado (ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-
|
|
1238
|
+
// desatualizados, ML-2A, port de collectCommandsWithMarker em
|
|
1239
|
+
// internal/validator/validator_git_branch_guard.go) para aceitar qualquer marker — originalmente
|
|
1240
|
+
// collectCredentialGuardCommands, hardcoded para CREDENTIAL_GUARD_SCRIPT_MARKER; reusado agora
|
|
1241
|
+
// também para GIT_BRANCH_GUARD_SCRIPT_MARKER, sem duplicar a travessia recursiva.
|
|
1242
|
+
function collectCommandsWithMarker(value, marker, out) {
|
|
1214
1243
|
if (typeof value === 'string') {
|
|
1215
|
-
|
|
1244
|
+
// Top-level/loose string match, outside any enclosing object — defensive fallback, not
|
|
1245
|
+
// expected to fire since every hook file read here is rooted at a JSON object. No enclosing
|
|
1246
|
+
// object means no "type" sibling to read.
|
|
1247
|
+
if (value.includes(marker)) out.push({ raw: value, typeIsCommand: false })
|
|
1216
1248
|
return
|
|
1217
1249
|
}
|
|
1218
1250
|
if (Array.isArray(value)) {
|
|
1219
|
-
for (const item of value)
|
|
1251
|
+
for (const item of value) collectCommandsWithMarker(item, marker, out)
|
|
1220
1252
|
return
|
|
1221
1253
|
}
|
|
1222
1254
|
if (value && typeof value === 'object') {
|
|
1223
|
-
|
|
1255
|
+
const typeIsCommand = value.type === 'command'
|
|
1256
|
+
for (const key of Object.keys(value)) {
|
|
1257
|
+
const val = value[key]
|
|
1258
|
+
if (typeof val === 'string') {
|
|
1259
|
+
if (val.includes(marker)) out.push({ raw: val, typeIsCommand })
|
|
1260
|
+
continue
|
|
1261
|
+
}
|
|
1262
|
+
collectCommandsWithMarker(val, marker, out)
|
|
1263
|
+
}
|
|
1224
1264
|
}
|
|
1225
1265
|
}
|
|
1226
1266
|
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1267
|
+
// collectCredentialGuardCommands é o wrapper específico de credential-guard sobre
|
|
1268
|
+
// collectCommandsWithMarker — preservado para compatibilidade de assinatura (exportada
|
|
1269
|
+
// historicamente com 2 argumentos).
|
|
1270
|
+
function collectCredentialGuardCommands(value, out) {
|
|
1271
|
+
collectCommandsWithMarker(value, CREDENTIAL_GUARD_SCRIPT_MARKER, out)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// validateGuardHookResolvable é a implementação genérica compartilhada pelas regras
|
|
1275
|
+
// "credential_guard_hook_resolvable" e "git_branch_guard_hook_resolvable": para cada arquivo de
|
|
1276
|
+
// hook de PROJETO que existir, extrai os comandos que referenciam scriptMarker, resolve o caminho
|
|
1277
|
+
// e verifica que o script existe e é executável.
|
|
1278
|
+
//
|
|
1279
|
+
// Generalizado a partir da antiga validateCredentialGuardHookResolvable (ROADMAP-2026-08-15-
|
|
1280
|
+
// trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-desatualizados, ML-2A, port de
|
|
1281
|
+
// validateGuardHookResolvable em internal/validator/validator_credential_guard.go) — a lógica de
|
|
1282
|
+
// resolução de caminho por CLI é idêntica para os 2 scripts, só o marker e o texto da mensagem
|
|
1283
|
+
// mudam.
|
|
1230
1284
|
//
|
|
1231
1285
|
// Riscos de regressão mapeados no roadmap:
|
|
1232
1286
|
// - A regra só avalia entradas que EXISTEM. Ausência de entrada de guard é estado legítimo
|
|
@@ -1234,7 +1288,12 @@ function collectCredentialGuardCommands(value, out) {
|
|
|
1234
1288
|
// - Arquivo de hook ausente é pulado em silêncio.
|
|
1235
1289
|
// - Arquivo de hook presente mas com JSON inválido é pulado em silêncio — validar a forma do
|
|
1236
1290
|
// JSON não é escopo desta regra.
|
|
1237
|
-
|
|
1291
|
+
//
|
|
1292
|
+
// process.cwd() no Node já retorna o caminho FÍSICO (resolve symlinks via getcwd(3) diretamente),
|
|
1293
|
+
// diferente de os.Getwd() do Go — ver o comentário sobre EvalSymlinks em
|
|
1294
|
+
// internal/validator/validator_git_branch_guard.go's validateGuardHookResolvable equivalente
|
|
1295
|
+
// (validator_credential_guard.go). Nenhuma resolução extra de symlink é necessária aqui.
|
|
1296
|
+
function validateGuardHookResolvable(scriptMarker, cwd) {
|
|
1238
1297
|
const root = cwd || process.cwd()
|
|
1239
1298
|
const msgs = []
|
|
1240
1299
|
|
|
@@ -1256,16 +1315,27 @@ function validateCredentialGuardHookResolvable(cwd) {
|
|
|
1256
1315
|
}
|
|
1257
1316
|
|
|
1258
1317
|
const commands = []
|
|
1259
|
-
|
|
1318
|
+
collectCommandsWithMarker(parsed, scriptMarker, commands)
|
|
1260
1319
|
|
|
1261
1320
|
const seen = new Set()
|
|
1262
|
-
for (const
|
|
1263
|
-
|
|
1264
|
-
seen.
|
|
1321
|
+
for (const m of commands) {
|
|
1322
|
+
const seenKey = `${m.raw}${m.typeIsCommand}`
|
|
1323
|
+
if (seen.has(seenKey)) continue
|
|
1324
|
+
seen.add(seenKey)
|
|
1265
1325
|
|
|
1266
|
-
const resolved = resolveCredentialGuardHookPath(raw, root)
|
|
1326
|
+
const resolved = resolveCredentialGuardHookPath(m.raw, root)
|
|
1267
1327
|
if (resolved === null) continue
|
|
1268
1328
|
|
|
1329
|
+
// ROADMAP-2026-08-17 ML-4B: a command that resolves to a real path but sits inside a
|
|
1330
|
+
// structurally malformed entry (missing/wrong "type" where this CLI's schema requires it —
|
|
1331
|
+
// hades-tf ML-4A barrier finding) will NEVER be executed by the CLI, regardless of whether
|
|
1332
|
+
// the script itself exists and is executable. Reported instead of the exists/executable
|
|
1333
|
+
// checks below, which assume a structurally valid entry.
|
|
1334
|
+
if (hf.requiresCommandType && !m.typeIsCommand) {
|
|
1335
|
+
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`)
|
|
1336
|
+
continue
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1269
1339
|
let stat = null
|
|
1270
1340
|
try {
|
|
1271
1341
|
stat = fs.statSync(resolved)
|
|
@@ -1274,9 +1344,9 @@ function validateCredentialGuardHookResolvable(cwd) {
|
|
|
1274
1344
|
}
|
|
1275
1345
|
|
|
1276
1346
|
if (!stat) {
|
|
1277
|
-
msgs.push(`${hf.relPath} (${hf.cli}) references
|
|
1347
|
+
msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script does not exist — run \`trackfw update\` to regenerate it`)
|
|
1278
1348
|
} else if ((stat.mode & 0o111) === 0) {
|
|
1279
|
-
msgs.push(`${hf.relPath} (${hf.cli}) references
|
|
1349
|
+
msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script is not executable — run \`trackfw update\` to regenerate it`)
|
|
1280
1350
|
}
|
|
1281
1351
|
}
|
|
1282
1352
|
}
|
|
@@ -1284,6 +1354,18 @@ function validateCredentialGuardHookResolvable(cwd) {
|
|
|
1284
1354
|
return msgs
|
|
1285
1355
|
}
|
|
1286
1356
|
|
|
1357
|
+
// validateCredentialGuardHookResolvable é a regra "credential_guard_hook_resolvable" — ver
|
|
1358
|
+
// validateGuardHookResolvable para a implementação compartilhada.
|
|
1359
|
+
function validateCredentialGuardHookResolvable(cwd) {
|
|
1360
|
+
return validateGuardHookResolvable(CREDENTIAL_GUARD_SCRIPT_MARKER, cwd)
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// validateGitBranchGuardHookResolvable é a regra "git_branch_guard_hook_resolvable" — ver
|
|
1364
|
+
// validateGuardHookResolvable para a implementação compartilhada.
|
|
1365
|
+
function validateGitBranchGuardHookResolvable(cwd) {
|
|
1366
|
+
return validateGuardHookResolvable(GIT_BRANCH_GUARD_SCRIPT_MARKER, cwd)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1287
1369
|
function isGitWorktree(dir) {
|
|
1288
1370
|
try {
|
|
1289
1371
|
const out = gitOutput(dir, ['rev-parse', '--is-inside-work-tree'])
|
|
@@ -1486,6 +1568,809 @@ function validateCredentialGuardScriptIntegrity(cwd) {
|
|
|
1486
1568
|
]
|
|
1487
1569
|
}
|
|
1488
1570
|
|
|
1571
|
+
// ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-desatualizados,
|
|
1572
|
+
// ML-2A: port of internal/validator/validator_git_branch_guard.go — adds git-branch-guard
|
|
1573
|
+
// coverage to the two existing credential-guard checks (existence/executability via
|
|
1574
|
+
// validateGuardHookResolvable above, and content-drift integrity via
|
|
1575
|
+
// validateGitBranchGuardScriptIntegrity below), plus the GLOBAL-scope check that was missing for
|
|
1576
|
+
// BOTH guards before this ML (same gap the Go implementation closed first).
|
|
1577
|
+
|
|
1578
|
+
// GBG_REF_BACKTICK — validator-local copy of the same escaped-backtick helper hooks.js's
|
|
1579
|
+
// GBG_BACKTICK uses (npm/src/generators/hooks.js), to embed REASON's `` `cmd` `` inside the
|
|
1580
|
+
// script's double-quoted shell string without breaking this JS template literal.
|
|
1581
|
+
const GBG_REF_BACKTICK = '\\`'
|
|
1582
|
+
|
|
1583
|
+
// GIT_BRANCH_GUARD_SCRIPT_REFERENCE is a validator-local copy of the
|
|
1584
|
+
// scripts/trackfw-git-branch-guard.sh template composed in npm/src/generators/hooks.js
|
|
1585
|
+
// (GIT_BRANCH_GUARD_SCRIPT const). Not required directly from generators/hooks.js: like
|
|
1586
|
+
// CREDENTIAL_GUARD_SCRIPT_REFERENCE above, generateGitBranchGuardScript() writes to disk AND
|
|
1587
|
+
// console.log()s a success line on every call — calling it from inside `trackfw validate` would
|
|
1588
|
+
// leak a stray " ✓ scripts/trackfw-git-branch-guard.sh" line into validate's output. There is no
|
|
1589
|
+
// import-cycle constraint in Node (unlike Go's internal/validator ↔ internal/generators cycle —
|
|
1590
|
+
// see gitBranchGuardScriptReference's doc comment in
|
|
1591
|
+
// internal/validator/validator_git_branch_guard_reference.go) but the console.log side effect is
|
|
1592
|
+
// reason enough on its own to keep a local copy here, matching the choice already made for
|
|
1593
|
+
// credential-guard in this same file.
|
|
1594
|
+
//
|
|
1595
|
+
// Unlike CREDENTIAL_GUARD_SCRIPT_REFERENCE, GIT_BRANCH_GUARD_SCRIPT_REFERENCE is used VERBATIM for
|
|
1596
|
+
// both the project scope and the global scope (generateGitBranchGuardScript /
|
|
1597
|
+
// generateGlobalGitBranchGuardScript write the exact same GIT_BRANCH_GUARD_SCRIPT constant) — so
|
|
1598
|
+
// this single reference constant covers both git_branch_guard_script_integrity (project,
|
|
1599
|
+
// scripts/trackfw-git-branch-guard.sh) and the global integrity check
|
|
1600
|
+
// (~/.trackfw/scripts/trackfw-git-branch-guard.sh), no second reference constant needed.
|
|
1601
|
+
//
|
|
1602
|
+
// Drift between this copy and the real generator is caught by
|
|
1603
|
+
// "GIT_BRANCH_GUARD_SCRIPT_REFERENCE é byte-idêntico ao que generateGitBranchGuardScript emite"
|
|
1604
|
+
// (npm/tests/git_branch_guard.test.js): it regenerates the script via generateGitBranchGuardScript
|
|
1605
|
+
// into a temp dir and asserts byte-equality against this constant.
|
|
1606
|
+
const GIT_BRANCH_GUARD_SCRIPT_REFERENCE = `#!/usr/bin/env bash
|
|
1607
|
+
# trackfw git branch guard — bloqueia git commit/push/checkout -b/branch/worktree add -b
|
|
1608
|
+
# brutos por subagente
|
|
1609
|
+
#
|
|
1610
|
+
# TRIPWIRE, NÃO FRONTEIRA DE SEGURANÇA: detecta o caso óbvio — comando git literal, sem
|
|
1611
|
+
# indireção de shell — não é defesa contra um agente adversário competente. Evasões que
|
|
1612
|
+
# exigem tokenizar como o bash (ex.: git\${IFS}push, {git,push}, g""it push) permanecem
|
|
1613
|
+
# abertas por decisão: ver docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-
|
|
1614
|
+
# com-escrita-irrestrita-a-resposta-e-deteccao-ancorada-no-git.md. O stripping de
|
|
1615
|
+
# env/command abaixo reconhece as formas SEM argumentos antes de git (env git ...,
|
|
1616
|
+
# command git ...) e o env seguido de uma sequência de atribuições CHAVE=valor
|
|
1617
|
+
# (env FOO=bar git ..., env FOO=bar BAZ=qux git ...) — env com FLAGS (env -i git ...,
|
|
1618
|
+
# env --ignore-environment git ...) e command com flags (command -p git ...) continuam
|
|
1619
|
+
# evadindo; declarado, não fechado (ver AC5 do ML que adicionou esse stripping). A
|
|
1620
|
+
# segmentação abaixo
|
|
1621
|
+
# (quote_aware_split) evita falso-positivo em texto citado — não deve ser lida como imune a
|
|
1622
|
+
# evasão por citação/tokenização do shell.
|
|
1623
|
+
set -euo pipefail
|
|
1624
|
+
set -f
|
|
1625
|
+
|
|
1626
|
+
# --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
|
|
1627
|
+
# global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
|
|
1628
|
+
# quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
|
|
1629
|
+
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
|
|
1630
|
+
# se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
|
|
1631
|
+
# esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
|
|
1632
|
+
# leitura.
|
|
1633
|
+
_TRACKFW_STDIN=""
|
|
1634
|
+
[ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
|
|
1635
|
+
|
|
1636
|
+
# --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
|
|
1637
|
+
# projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
|
|
1638
|
+
# achar trackfw.yaml na raiz do projeto. Sem trackfw.yaml em nenhum ancestral, o guard não se
|
|
1639
|
+
# aplica — fora de projeto trackfw não há trackfw ship como alternativa, e bloquear ali é custo
|
|
1640
|
+
# sem contrapartida. Custo medido: só parameter expansion e test -f por nível, nenhum fork de
|
|
1641
|
+
# processo; limitado pela profundidade do caminho.
|
|
1642
|
+
_TRACKFW_ROOT_DIR=$(pwd -P)
|
|
1643
|
+
_TRACKFW_FOUND=0
|
|
1644
|
+
while :; do
|
|
1645
|
+
if [ -f "$_TRACKFW_ROOT_DIR/trackfw.yaml" ]; then
|
|
1646
|
+
_TRACKFW_FOUND=1
|
|
1647
|
+
break
|
|
1648
|
+
fi
|
|
1649
|
+
if [ "$_TRACKFW_ROOT_DIR" = "/" ]; then
|
|
1650
|
+
break
|
|
1651
|
+
fi
|
|
1652
|
+
_TRACKFW_ROOT_DIR="\${_TRACKFW_ROOT_DIR%/*}"
|
|
1653
|
+
if [ -z "$_TRACKFW_ROOT_DIR" ]; then
|
|
1654
|
+
_TRACKFW_ROOT_DIR="/"
|
|
1655
|
+
fi
|
|
1656
|
+
done
|
|
1657
|
+
[ "$_TRACKFW_FOUND" -eq 1 ] || exit 0
|
|
1658
|
+
|
|
1659
|
+
# --- 1. Obter o comando git bruto ------------------------------------------------------------
|
|
1660
|
+
if [ "$#" -gt 0 ]; then
|
|
1661
|
+
CMD_RAW="$*"
|
|
1662
|
+
else
|
|
1663
|
+
INPUT="$_TRACKFW_STDIN"
|
|
1664
|
+
TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
|
|
1665
|
+
case "$TRIMMED" in
|
|
1666
|
+
\\{*)
|
|
1667
|
+
CMD_RAW=""
|
|
1668
|
+
if command -v jq >/dev/null 2>&1; then
|
|
1669
|
+
CMD_RAW=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // .command // .tool_info.command_line // .hook_input.command // empty' 2>/dev/null || true)
|
|
1670
|
+
fi
|
|
1671
|
+
if [ -z "$CMD_RAW" ] || [ "$CMD_RAW" = "null" ]; then
|
|
1672
|
+
CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
|
|
1673
|
+
fi
|
|
1674
|
+
if [ -z "$CMD_RAW" ]; then
|
|
1675
|
+
CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_info"[[:space:]]*:[[:space:]]*{[^}]*"command_line"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
|
|
1676
|
+
fi
|
|
1677
|
+
if [ -z "$CMD_RAW" ]; then
|
|
1678
|
+
CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"hook_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
|
|
1679
|
+
fi
|
|
1680
|
+
if [ -z "$CMD_RAW" ]; then
|
|
1681
|
+
CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
|
|
1682
|
+
fi
|
|
1683
|
+
;;
|
|
1684
|
+
*)
|
|
1685
|
+
CMD_RAW="$INPUT"
|
|
1686
|
+
;;
|
|
1687
|
+
esac
|
|
1688
|
+
fi
|
|
1689
|
+
|
|
1690
|
+
if [ -z "$CMD_RAW" ]; then
|
|
1691
|
+
CMD_RAW="\${TRACKFW_GIT_COMMAND:-}"
|
|
1692
|
+
fi
|
|
1693
|
+
|
|
1694
|
+
[ -n "$CMD_RAW" ] || exit 0
|
|
1695
|
+
|
|
1696
|
+
# --- 2. Pré-processamento anti-falso-positivo: neutraliza separadores reais (';', '&&',
|
|
1697
|
+
# '||', '|', quebra de linha) que estão DENTRO de aspas ou de corpo de heredoc, para que
|
|
1698
|
+
# conteúdo de mensagem (ex.: ` + "`" + `-m "linha 1\\nlinha 2"` + "`" + `) nunca seja fatiado em pseudo-segmentos
|
|
1699
|
+
# e lido como comando -------------------------------------------------------------------
|
|
1700
|
+
#
|
|
1701
|
+
# strip_heredoc_bodies: remove o CORPO de blocos heredoc (<<DELIM ... DELIM), preservando a
|
|
1702
|
+
# linha de abertura e a linha terminadora — cobre o padrão ` + "`" + `git commit -F- <<'EOF' ... EOF` + "`" + `
|
|
1703
|
+
# (heredoc não citado, fora do escopo de quote_aware_split abaixo). Heurística por linha, não
|
|
1704
|
+
# sintaxe completa de shell: só remove o corpo quando encontra a linha terminadora
|
|
1705
|
+
# correspondente. Se o heredoc nunca fecha (terminador ausente ou não localizado), devolve o
|
|
1706
|
+
# texto ORIGINAL sem qualquer alteração — lado seguro: mais restritivo é preferível a esconder
|
|
1707
|
+
# um comando real atrás de um heredoc mal-formado.
|
|
1708
|
+
strip_heredoc_bodies() {
|
|
1709
|
+
printf '%s' "$1" | awk '
|
|
1710
|
+
BEGIN {
|
|
1711
|
+
dq = sprintf("%c", 34)
|
|
1712
|
+
sq = sprintf("%c", 39)
|
|
1713
|
+
in_heredoc = 0
|
|
1714
|
+
delim = ""
|
|
1715
|
+
ok = 1
|
|
1716
|
+
}
|
|
1717
|
+
{
|
|
1718
|
+
raw = raw $0 "\\n"
|
|
1719
|
+
if (in_heredoc) {
|
|
1720
|
+
trimmed = $0
|
|
1721
|
+
sub(/^[ \\t]+/, "", trimmed)
|
|
1722
|
+
sub(/[ \\t]+$/, "", trimmed)
|
|
1723
|
+
if (trimmed == delim) {
|
|
1724
|
+
in_heredoc = 0
|
|
1725
|
+
out = out $0 "\\n"
|
|
1726
|
+
}
|
|
1727
|
+
next
|
|
1728
|
+
}
|
|
1729
|
+
if (match($0, /<<-?[ \\t]*[^ \\t]+/)) {
|
|
1730
|
+
d = substr($0, RSTART, RLENGTH)
|
|
1731
|
+
sub(/^<<-?[ \\t]*/, "", d)
|
|
1732
|
+
gsub(dq, "", d)
|
|
1733
|
+
gsub(sq, "", d)
|
|
1734
|
+
if (d != "") {
|
|
1735
|
+
delim = d
|
|
1736
|
+
in_heredoc = 1
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
out = out $0 "\\n"
|
|
1740
|
+
}
|
|
1741
|
+
END {
|
|
1742
|
+
if (in_heredoc) ok = 0
|
|
1743
|
+
if (ok) { printf "%s", out } else { printf "%s", raw }
|
|
1744
|
+
}
|
|
1745
|
+
'
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
# quote_aware_split: emite o texto com ';' isolado, '&&', '||' e '|' isolado convertidos em
|
|
1749
|
+
# quebra de linha — EXCETO quando ocorrem dentro de uma string entre aspas simples ou duplas,
|
|
1750
|
+
# caso em que são preservados como texto e uma quebra de linha real dentro das aspas vira
|
|
1751
|
+
# espaço (nunca gera um novo pseudo-segmento). Substitui o antigo ` + "`" + `sed` + "`" + ` cego, que não
|
|
1752
|
+
# distinguia texto citado de sintaxe de comando — a causa raiz do falso-positivo de linha de
|
|
1753
|
+
# mensagem de commit iniciada por "git ...". Aspas não fechadas até o fim da entrada
|
|
1754
|
+
# permanecem "abertas" até o fim — mesma semântica do shell real: uma aspa não fechada nunca
|
|
1755
|
+
# deixa o texto seguinte executar como comando novo, só torna o restante parte da mesma
|
|
1756
|
+
# string.
|
|
1757
|
+
quote_aware_split() {
|
|
1758
|
+
printf '%s' "$1" | awk '
|
|
1759
|
+
BEGIN {
|
|
1760
|
+
dq = sprintf("%c", 34)
|
|
1761
|
+
sq = sprintf("%c", 39)
|
|
1762
|
+
bs = sprintf("%c", 92)
|
|
1763
|
+
nl = sprintf("%c", 10)
|
|
1764
|
+
}
|
|
1765
|
+
{ s = (NR == 1) ? $0 : s nl $0 }
|
|
1766
|
+
END {
|
|
1767
|
+
n = length(s)
|
|
1768
|
+
q = ""
|
|
1769
|
+
out = ""
|
|
1770
|
+
i = 1
|
|
1771
|
+
while (i <= n) {
|
|
1772
|
+
c = substr(s, i, 1)
|
|
1773
|
+
if (q != "") {
|
|
1774
|
+
if (q == dq && c == bs && i < n) {
|
|
1775
|
+
nx = substr(s, i + 1, 1)
|
|
1776
|
+
out = out c (nx == nl ? " " : nx)
|
|
1777
|
+
i += 2
|
|
1778
|
+
continue
|
|
1779
|
+
}
|
|
1780
|
+
if (c == q) {
|
|
1781
|
+
q = ""
|
|
1782
|
+
out = out c
|
|
1783
|
+
i++
|
|
1784
|
+
continue
|
|
1785
|
+
}
|
|
1786
|
+
out = out (c == nl ? " " : c)
|
|
1787
|
+
i++
|
|
1788
|
+
continue
|
|
1789
|
+
}
|
|
1790
|
+
if (c == dq || c == sq) {
|
|
1791
|
+
q = c
|
|
1792
|
+
out = out c
|
|
1793
|
+
i++
|
|
1794
|
+
continue
|
|
1795
|
+
}
|
|
1796
|
+
if (substr(s, i, 2) == "&&" || substr(s, i, 2) == "||") {
|
|
1797
|
+
out = out nl
|
|
1798
|
+
i += 2
|
|
1799
|
+
continue
|
|
1800
|
+
}
|
|
1801
|
+
if (c == ";" || c == "|") {
|
|
1802
|
+
out = out nl
|
|
1803
|
+
i++
|
|
1804
|
+
continue
|
|
1805
|
+
}
|
|
1806
|
+
out = out c
|
|
1807
|
+
i++
|
|
1808
|
+
}
|
|
1809
|
+
printf "%s", out
|
|
1810
|
+
}
|
|
1811
|
+
'
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
# match_subcommand — casa contra "git (commit|push|checkout -b|switch -c)", segmento por
|
|
1815
|
+
# segmento. Cada segmento é um comando real, obtido depois do pré-processamento acima
|
|
1816
|
+
# (strip_heredoc_bodies + quote_aware_split), que converte ';', '&&', '||', '|' fora de aspas
|
|
1817
|
+
# em quebra de linha e neutraliza os mesmos separadores quando aparecem dentro de
|
|
1818
|
+
# aspas/heredoc. "git" só conta se for o PRIMEIRO token do segmento (por basename, então
|
|
1819
|
+
# /usr/bin/git também casa) — nunca uma ocorrência solta em qualquer posição da string
|
|
1820
|
+
# inteira. Isso evita: (a) o segundo comando de uma cadeia escapar da checagem, (b) um path
|
|
1821
|
+
# absoluto para o git escapar por comparação de igualdade exata, e (c) texto de prosa —
|
|
1822
|
+
# inclusive linha de mensagem de commit que COMEÇA com "git <sub>" (ex.: uma tabela
|
|
1823
|
+
# documentando comandos bloqueados) — ser tratado como comando, porque esse texto agora nunca
|
|
1824
|
+
# produz um novo segmento. ` + "`" + `git switch -c/-C/--create` + "`" + ` (forma alternativa a ` + "`" + `checkout -b` + "`" + `
|
|
1825
|
+
# para criar branch) é reconhecido varrendo TODOS os tokens após o subcomando, não só o
|
|
1826
|
+
# primeiro — cobre ` + "`" + `git switch --track -c feat/x` + "`" + ` (flag antes de -c).
|
|
1827
|
+
# checkout -b é reconhecido do mesmo jeito: varre TODOS os tokens até achar -b/-B/--orphan,
|
|
1828
|
+
# não só o primeiro. Prefixos env e command antes de git são descartados antes da checagem do
|
|
1829
|
+
# basename — cobre env git push/command git push sem exigir tokenizar como o bash.
|
|
1830
|
+
match_subcommand() {
|
|
1831
|
+
normalized=$(strip_heredoc_bodies "$1")
|
|
1832
|
+
normalized=$(quote_aware_split "$normalized")
|
|
1833
|
+
while IFS= read -r seg; do
|
|
1834
|
+
seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
|
|
1835
|
+
[ -n "$seg_trimmed" ] || continue
|
|
1836
|
+
|
|
1837
|
+
set -- $seg_trimmed
|
|
1838
|
+
first="$1"
|
|
1839
|
+
base="\${first##*/}"
|
|
1840
|
+
while [ "$base" = "env" ] || [ "$base" = "command" ]; do
|
|
1841
|
+
is_env="$base"
|
|
1842
|
+
shift
|
|
1843
|
+
[ "$#" -gt 0 ] || break
|
|
1844
|
+
if [ "$is_env" = "env" ]; then
|
|
1845
|
+
while [ "$#" -gt 0 ]; do
|
|
1846
|
+
case "$1" in
|
|
1847
|
+
-*)
|
|
1848
|
+
break
|
|
1849
|
+
;;
|
|
1850
|
+
*=*)
|
|
1851
|
+
shift
|
|
1852
|
+
;;
|
|
1853
|
+
*)
|
|
1854
|
+
break
|
|
1855
|
+
;;
|
|
1856
|
+
esac
|
|
1857
|
+
done
|
|
1858
|
+
[ "$#" -gt 0 ] || break
|
|
1859
|
+
fi
|
|
1860
|
+
first="$1"
|
|
1861
|
+
base="\${first##*/}"
|
|
1862
|
+
done
|
|
1863
|
+
[ "$base" = "git" ] || continue
|
|
1864
|
+
shift
|
|
1865
|
+
|
|
1866
|
+
sub=""
|
|
1867
|
+
while [ "$#" -gt 0 ]; do
|
|
1868
|
+
tok="$1"
|
|
1869
|
+
case "$tok" in
|
|
1870
|
+
-C|-c|--work-tree|--git-dir|--namespace)
|
|
1871
|
+
if [ "$#" -ge 2 ]; then shift 2; else shift; fi
|
|
1872
|
+
continue
|
|
1873
|
+
;;
|
|
1874
|
+
-*)
|
|
1875
|
+
shift
|
|
1876
|
+
continue
|
|
1877
|
+
;;
|
|
1878
|
+
*)
|
|
1879
|
+
sub="$tok"
|
|
1880
|
+
shift
|
|
1881
|
+
break
|
|
1882
|
+
;;
|
|
1883
|
+
esac
|
|
1884
|
+
done
|
|
1885
|
+
|
|
1886
|
+
case "$sub" in
|
|
1887
|
+
commit)
|
|
1888
|
+
echo "commit"
|
|
1889
|
+
return 0
|
|
1890
|
+
;;
|
|
1891
|
+
push)
|
|
1892
|
+
echo "push"
|
|
1893
|
+
return 0
|
|
1894
|
+
;;
|
|
1895
|
+
checkout)
|
|
1896
|
+
for tok2 in "$@"; do
|
|
1897
|
+
case "$tok2" in
|
|
1898
|
+
-b|-B|--orphan|--orphan=*)
|
|
1899
|
+
echo "checkout-b"
|
|
1900
|
+
return 0
|
|
1901
|
+
;;
|
|
1902
|
+
esac
|
|
1903
|
+
done
|
|
1904
|
+
;;
|
|
1905
|
+
switch)
|
|
1906
|
+
for tok2 in "$@"; do
|
|
1907
|
+
case "$tok2" in
|
|
1908
|
+
-c|-C|--create|--create=*|--force-create|--force-create=*)
|
|
1909
|
+
echo "switch-c"
|
|
1910
|
+
return 0
|
|
1911
|
+
;;
|
|
1912
|
+
esac
|
|
1913
|
+
done
|
|
1914
|
+
;;
|
|
1915
|
+
branch)
|
|
1916
|
+
# git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
|
|
1917
|
+
# --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
|
|
1918
|
+
# --format=, --points-at, -d/-D/--delete) — bloquear leitura seria pior que a
|
|
1919
|
+
# brecha. Só bloqueia: (a) -c/-C/-m/-M/--copy/--move (cria/renomeia branch,
|
|
1920
|
+
# qualquer posição de token) ou (b) um argumento posicional puro (nome da branch a
|
|
1921
|
+
# criar), a menos que -d/-D/--delete também esteja presente (delete tem
|
|
1922
|
+
# posicional legítimo — o nome a apagar). Flags de valor conhecidas (--contains,
|
|
1923
|
+
# --no-contains, --sort, --format, --points-at, --merged, --no-merged) têm seu
|
|
1924
|
+
# valor seguinte pulado quando vem em token separado, para não ser lido como
|
|
1925
|
+
# posicional de criação.
|
|
1926
|
+
branch_action=0
|
|
1927
|
+
has_delete=0
|
|
1928
|
+
saw_positional=0
|
|
1929
|
+
skip_next=0
|
|
1930
|
+
for tok2 in "$@"; do
|
|
1931
|
+
if [ "$skip_next" = "1" ]; then
|
|
1932
|
+
skip_next=0
|
|
1933
|
+
continue
|
|
1934
|
+
fi
|
|
1935
|
+
case "$tok2" in
|
|
1936
|
+
-c|-C|-m|-M|--copy|--copy=*|--move|--move=*)
|
|
1937
|
+
branch_action=1
|
|
1938
|
+
;;
|
|
1939
|
+
-d|-D|--delete|--delete=*)
|
|
1940
|
+
has_delete=1
|
|
1941
|
+
;;
|
|
1942
|
+
--contains|--no-contains|--sort|--format|--points-at|--merged|--no-merged)
|
|
1943
|
+
skip_next=1
|
|
1944
|
+
;;
|
|
1945
|
+
-*)
|
|
1946
|
+
;;
|
|
1947
|
+
*)
|
|
1948
|
+
saw_positional=1
|
|
1949
|
+
;;
|
|
1950
|
+
esac
|
|
1951
|
+
done
|
|
1952
|
+
if [ "$has_delete" != "1" ]; then
|
|
1953
|
+
if [ "$branch_action" = "1" ] || [ "$saw_positional" = "1" ]; then
|
|
1954
|
+
echo "branch-create"
|
|
1955
|
+
return 0
|
|
1956
|
+
fi
|
|
1957
|
+
fi
|
|
1958
|
+
;;
|
|
1959
|
+
worktree)
|
|
1960
|
+
if [ "\${1:-}" = "add" ]; then
|
|
1961
|
+
shift
|
|
1962
|
+
for tok2 in "$@"; do
|
|
1963
|
+
case "$tok2" in
|
|
1964
|
+
-b|-B)
|
|
1965
|
+
echo "worktree-add-b"
|
|
1966
|
+
return 0
|
|
1967
|
+
;;
|
|
1968
|
+
esac
|
|
1969
|
+
done
|
|
1970
|
+
fi
|
|
1971
|
+
;;
|
|
1972
|
+
esac
|
|
1973
|
+
done <<EOF
|
|
1974
|
+
$normalized
|
|
1975
|
+
EOF
|
|
1976
|
+
return 1
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
|
|
1980
|
+
|
|
1981
|
+
case "$SUBCOMMAND" in
|
|
1982
|
+
checkout-b)
|
|
1983
|
+
REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1984
|
+
;;
|
|
1985
|
+
switch-c)
|
|
1986
|
+
REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1987
|
+
;;
|
|
1988
|
+
branch-create)
|
|
1989
|
+
REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1990
|
+
;;
|
|
1991
|
+
worktree-add-b)
|
|
1992
|
+
REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1993
|
+
;;
|
|
1994
|
+
commit)
|
|
1995
|
+
REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1996
|
+
;;
|
|
1997
|
+
push)
|
|
1998
|
+
REASON="trackfw: git push bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw ship` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
|
|
1999
|
+
;;
|
|
2000
|
+
*)
|
|
2001
|
+
exit 0
|
|
2002
|
+
;;
|
|
2003
|
+
esac
|
|
2004
|
+
|
|
2005
|
+
printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
|
|
2006
|
+
echo "$REASON" >&2
|
|
2007
|
+
exit 2
|
|
2008
|
+
`
|
|
2009
|
+
|
|
2010
|
+
// validateGitBranchGuardScriptIntegrity é a regra "git_branch_guard_script_integrity": compara
|
|
2011
|
+
// scripts/trackfw-git-branch-guard.sh em disco contra o template que esta versão do trackfw
|
|
2012
|
+
// geraria. Espelha validateCredentialGuardScriptIntegrity exatamente — mesmo contrato de silêncio
|
|
2013
|
+
// na ausência (existência é responsabilidade de git_branch_guard_hook_resolvable, não desta regra).
|
|
2014
|
+
function validateGitBranchGuardScriptIntegrity(cwd) {
|
|
2015
|
+
const root = cwd || process.cwd()
|
|
2016
|
+
const relPath = 'scripts/trackfw-git-branch-guard.sh'
|
|
2017
|
+
let content
|
|
2018
|
+
try {
|
|
2019
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8')
|
|
2020
|
+
} catch (e) {
|
|
2021
|
+
if (e.code === 'ENOENT') return []
|
|
2022
|
+
return [inspectionDiagnostic('git_branch_guard_script_integrity', relPath, e)]
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (content === GIT_BRANCH_GUARD_SCRIPT_REFERENCE) return []
|
|
2026
|
+
|
|
2027
|
+
return [
|
|
2028
|
+
`${relPath} content diverges from the template this version of trackfw generates — ` +
|
|
2029
|
+
'if you did not edit this file by hand, run `trackfw update` to regenerate it',
|
|
2030
|
+
]
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// CREDENTIAL_GUARD_GLOBAL_SCRIPT_REFERENCE is a validator-local copy of the GLOBAL-scope
|
|
2034
|
+
// ~/.trackfw/scripts/trackfw-credential-guard.sh template composed in npm/src/generators/hooks.js
|
|
2035
|
+
// (GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL_TAIL). This is a
|
|
2036
|
+
// DIFFERENT template than CREDENTIAL_GUARD_SCRIPT_REFERENCE (the project-scope variant): the
|
|
2037
|
+
// global variant omits the project-guard block ("no-op outside a trackfw.yaml project") and
|
|
2038
|
+
// defaults credential_guard.mode to "block" instead of "warn" — mirrors Go's
|
|
2039
|
+
// credentialGuardGlobalScriptReference
|
|
2040
|
+
// (internal/validator/validator_credential_guard_global_reference.go). Comparing the global
|
|
2041
|
+
// on-disk script against CREDENTIAL_GUARD_SCRIPT_REFERENCE (the project template) would be a
|
|
2042
|
+
// guaranteed false positive for every user with the global harness installed.
|
|
2043
|
+
const CREDENTIAL_GUARD_GLOBAL_SCRIPT_REFERENCE = `#!/usr/bin/env bash
|
|
2044
|
+
# trackfw credential guard — PreToolUse/PostToolUse hook
|
|
2045
|
+
set -euo pipefail
|
|
2046
|
+
|
|
2047
|
+
INPUT=$(cat)
|
|
2048
|
+
|
|
2049
|
+
JWT_PATTERN='eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+'
|
|
2050
|
+
AWS_KEY_PATTERN='AKIA[0-9A-Z]{16}'
|
|
2051
|
+
|
|
2052
|
+
MATCH=""
|
|
2053
|
+
if printf '%s' "$INPUT" | grep -qE "$JWT_PATTERN"; then
|
|
2054
|
+
MATCH="JWT"
|
|
2055
|
+
elif printf '%s' "$INPUT" | grep -qE "$AWS_KEY_PATTERN"; then
|
|
2056
|
+
MATCH="AWS access key"
|
|
2057
|
+
fi
|
|
2058
|
+
|
|
2059
|
+
# The raw payload is JSON: any double quote inside the underlying tool_input.command is
|
|
2060
|
+
# escaped as \\" -- unescape those before scanning for redirect targets, or a quoted target
|
|
2061
|
+
# like "$TMPFILE" is seen as starting with a literal backslash instead of a variable
|
|
2062
|
+
# reference.
|
|
2063
|
+
RAW=$(printf '%s' "$INPUT" | sed 's/\\\\"/"/g')
|
|
2064
|
+
|
|
2065
|
+
# Ignore matches that are only ever written to an ephemeral destination
|
|
2066
|
+
# (mktemp-derived path or /dev/null). A match with no redirect at all
|
|
2067
|
+
# (printed to stdout, e.g.) or redirected to a plain file path still
|
|
2068
|
+
# alerts -- that is the incident this hook guards against.
|
|
2069
|
+
is_ephemeral_target() {
|
|
2070
|
+
local target
|
|
2071
|
+
target=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
|
|
2072
|
+
case "$target" in
|
|
2073
|
+
/dev/null) return 0 ;;
|
|
2074
|
+
*mktemp*) return 0 ;;
|
|
2075
|
+
esac
|
|
2076
|
+
if printf '%s' "$target" | grep -qE '^\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?$'; then
|
|
2077
|
+
local varname pattern
|
|
2078
|
+
varname=$(printf '%s' "$target" | sed -E 's/^\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?$/\\1/')
|
|
2079
|
+
pattern="*\${varname}="'$(mktemp'"*"
|
|
2080
|
+
case "$RAW" in
|
|
2081
|
+
$pattern) return 0 ;;
|
|
2082
|
+
esac
|
|
2083
|
+
fi
|
|
2084
|
+
return 1
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
REDIRECTS=$(printf '%s' "$RAW" | grep -oE '[0-9]?>>?[[:space:]]*[^[:space:]|&;,:]+' || true)
|
|
2088
|
+
|
|
2089
|
+
# Second detection layer: only runs when the payload scan above found nothing -- keeps the common
|
|
2090
|
+
# case (match already found) cheap and avoids reading files unnecessarily. Files above the size cap
|
|
2091
|
+
# are skipped silently.
|
|
2092
|
+
scan_file_for_pattern() {
|
|
2093
|
+
local path size
|
|
2094
|
+
path=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
|
|
2095
|
+
[ -n "$path" ] && [ -f "$path" ] || return 1
|
|
2096
|
+
size=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
|
|
2097
|
+
size=\${size:-0}
|
|
2098
|
+
[ "$size" -lt 1048576 ] || return 1
|
|
2099
|
+
if grep -qE "$JWT_PATTERN" "$path" 2>/dev/null; then
|
|
2100
|
+
MATCH="JWT"
|
|
2101
|
+
return 0
|
|
2102
|
+
fi
|
|
2103
|
+
if grep -qE "$AWS_KEY_PATTERN" "$path" 2>/dev/null; then
|
|
2104
|
+
MATCH="AWS access key"
|
|
2105
|
+
return 0
|
|
2106
|
+
fi
|
|
2107
|
+
return 1
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
if [ -z "$MATCH" ] && [ -n "$REDIRECTS" ]; then
|
|
2111
|
+
while IFS= read -r line; do
|
|
2112
|
+
if [ -z "$line" ]; then
|
|
2113
|
+
continue
|
|
2114
|
+
fi
|
|
2115
|
+
target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
|
|
2116
|
+
if ! is_ephemeral_target "$target"; then
|
|
2117
|
+
scan_file_for_pattern "$target" && break
|
|
2118
|
+
fi
|
|
2119
|
+
done <<< "$REDIRECTS"
|
|
2120
|
+
fi
|
|
2121
|
+
|
|
2122
|
+
if [ -z "$MATCH" ]; then
|
|
2123
|
+
CMD_LINE=$(printf '%s' "$RAW" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
2124
|
+
if [ -n "$CMD_LINE" ]; then
|
|
2125
|
+
set -- $CMD_LINE
|
|
2126
|
+
cmd_name="\${1:-}"
|
|
2127
|
+
case "$cmd_name" in
|
|
2128
|
+
cat|head|tail|jq|grep)
|
|
2129
|
+
shift
|
|
2130
|
+
for token in "$@"; do
|
|
2131
|
+
scan_file_for_pattern "$token" && break
|
|
2132
|
+
done
|
|
2133
|
+
;;
|
|
2134
|
+
esac
|
|
2135
|
+
fi
|
|
2136
|
+
fi
|
|
2137
|
+
|
|
2138
|
+
[ -n "$MATCH" ] || exit 0
|
|
2139
|
+
|
|
2140
|
+
HAS_REDIRECT=0
|
|
2141
|
+
EXEMPT=1
|
|
2142
|
+
if [ -n "$REDIRECTS" ]; then
|
|
2143
|
+
while IFS= read -r line; do
|
|
2144
|
+
if [ -z "$line" ]; then
|
|
2145
|
+
continue
|
|
2146
|
+
fi
|
|
2147
|
+
HAS_REDIRECT=1
|
|
2148
|
+
target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
|
|
2149
|
+
if ! is_ephemeral_target "$target"; then
|
|
2150
|
+
EXEMPT=0
|
|
2151
|
+
fi
|
|
2152
|
+
done <<< "$REDIRECTS"
|
|
2153
|
+
fi
|
|
2154
|
+
|
|
2155
|
+
if [ "$HAS_REDIRECT" -eq 1 ] && [ "$EXEMPT" -eq 1 ]; then
|
|
2156
|
+
exit 0
|
|
2157
|
+
fi
|
|
2158
|
+
|
|
2159
|
+
DEFAULT_MODE="block"
|
|
2160
|
+
MODE=$(grep -A 5 '^credential_guard:' trackfw.yaml 2>/dev/null | grep 'mode:' | head -1 | sed -E 's/^[[:space:]]*mode:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\\"'" || true)
|
|
2161
|
+
case "$MODE" in
|
|
2162
|
+
warn|block) ;;
|
|
2163
|
+
*) MODE="$DEFAULT_MODE" ;;
|
|
2164
|
+
esac
|
|
2165
|
+
|
|
2166
|
+
if [ "$MODE" = "block" ]; then
|
|
2167
|
+
echo "trackfw-credential-guard: blocked - possible $MATCH detected in tool payload." >&2
|
|
2168
|
+
exit 2
|
|
2169
|
+
fi
|
|
2170
|
+
|
|
2171
|
+
echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
|
|
2172
|
+
|
|
2173
|
+
ROADMAP_DIR="docs/roadmaps"
|
|
2174
|
+
if [ ! -d "$ROADMAP_DIR" ]; then
|
|
2175
|
+
exit 0
|
|
2176
|
+
fi
|
|
2177
|
+
|
|
2178
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
2179
|
+
MSG="Possible $MATCH detected in tool payload - review before materializing credentials in plain text."
|
|
2180
|
+
MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
|
|
2181
|
+
|
|
2182
|
+
mkdir -p "$ROADMAP_DIR"
|
|
2183
|
+
printf '{"tool":"credential-guard","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
|
|
2184
|
+
"$MSG_ESC" \\
|
|
2185
|
+
"$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-credential-guard.json"
|
|
2186
|
+
|
|
2187
|
+
exit 0
|
|
2188
|
+
`
|
|
2189
|
+
|
|
2190
|
+
// globalGuardConfigFile associates a GLOBAL (per-CLI, $HOME-rooted) hook/settings config file with
|
|
2191
|
+
// the CLI that consumes it, for the global-scope guard checks below. Distinct from
|
|
2192
|
+
// CREDENTIAL_GUARD_HOOK_FILES, whose relPath is rooted at the PROJECT root, not $HOME.
|
|
2193
|
+
//
|
|
2194
|
+
// GLOBAL_GUARD_CONFIG_FILES is the closed list of GLOBAL hook/settings files `trackfw update
|
|
2195
|
+
// harness` can write a guard entry into — the global-scope counterpart of
|
|
2196
|
+
// CREDENTIAL_GUARD_HOOK_FILES. Paths and CLI labels ported from Go's globalGuardConfigFiles
|
|
2197
|
+
// (internal/validator/validator_git_branch_guard.go) — note this list DIFFERS from
|
|
2198
|
+
// CREDENTIAL_GUARD_HOOK_FILES for Copilot and Kiro (global scope uses .copilot/settings.json and
|
|
2199
|
+
// .kiro/hooks/trackfw-credential-guard.json, not the project-scope attention-hook paths).
|
|
2200
|
+
// requiresCommandType (ROADMAP-2026-08-17 ML-4B) mirrors CREDENTIAL_GUARD_HOOK_FILES'
|
|
2201
|
+
// requiresCommandType above — true for every CLI except Cursor.
|
|
2202
|
+
const GLOBAL_GUARD_CONFIG_FILES = [
|
|
2203
|
+
{ relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
|
|
2204
|
+
{ relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
|
|
2205
|
+
{ relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
|
|
2206
|
+
{ relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
|
|
2207
|
+
{ relPath: '.copilot/settings.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
|
|
2208
|
+
{ relPath: '.kiro/hooks/trackfw-credential-guard.json', cli: 'Kiro', requiresCommandType: true },
|
|
2209
|
+
]
|
|
2210
|
+
|
|
2211
|
+
// globalGuardConfigPath resolves the actual on-disk path (relative to $HOME) that
|
|
2212
|
+
// validateGuardGlobalHookResolvable must read for a given (GLOBAL_GUARD_CONFIG_FILES entry,
|
|
2213
|
+
// scriptMarker) pair. Port of Go's globalGuardConfigPath (internal/validator/
|
|
2214
|
+
// validator_git_branch_guard.go) — see that function's doc comment for the full rationale
|
|
2215
|
+
// (5 CLIs share one merge-based file across both guards; Kiro is the sole exception, with a
|
|
2216
|
+
// dedicated file per guard because its writer rewrites the whole document wholesale).
|
|
2217
|
+
//
|
|
2218
|
+
// ROADMAP-2026-08-17 ML-3B: before this function existed, GLOBAL_GUARD_CONFIG_FILES only ever
|
|
2219
|
+
// pointed Kiro at trackfw-credential-guard.json for BOTH guards, so git_branch_guard_hook_resolvable
|
|
2220
|
+
// never inspected ~/.kiro/hooks/trackfw-git-branch-guard.json at all.
|
|
2221
|
+
function globalGuardConfigPath(gf, scriptMarker) {
|
|
2222
|
+
if (gf.cli === 'Kiro' && scriptMarker === GIT_BRANCH_GUARD_SCRIPT_MARKER) {
|
|
2223
|
+
return '.kiro/hooks/trackfw-git-branch-guard.json'
|
|
2224
|
+
}
|
|
2225
|
+
return gf.relPath
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// validateGuardGlobalHookResolvable is the GLOBAL-scope counterpart of
|
|
2229
|
+
// validateGuardHookResolvable: for each of the 6 GLOBAL_GUARD_CONFIG_FILES that exists AND
|
|
2230
|
+
// references scriptMarker, verifies the referenced script exists and is executable. Port of Go's
|
|
2231
|
+
// validateGuardGlobalHookResolvable (internal/validator/validator_git_branch_guard.go) — see that
|
|
2232
|
+
// function's doc comment for the full trigger-condition and fail-open rationale.
|
|
2233
|
+
//
|
|
2234
|
+
// Global entries are written by npm/src/generators (harnessCredentialGuardTarget*-equivalent code
|
|
2235
|
+
// in agentfiles-equivalent generators) as fully resolved absolute paths, never a placeholder like
|
|
2236
|
+
// $CLAUDE_PROJECT_DIR — so, unlike the project-scope resolveCredentialGuardHookPath, no
|
|
2237
|
+
// prefix-stripping is needed here: any matched command that is NOT already an absolute path is
|
|
2238
|
+
// skipped (never treated as a violation).
|
|
2239
|
+
//
|
|
2240
|
+
// Fail-open: unresolvable $HOME, unreadable file, or invalid JSON all skip that file in silence —
|
|
2241
|
+
// same contract validateGuardHookResolvable already has for project-scope files.
|
|
2242
|
+
function validateGuardGlobalHookResolvable(scriptMarker) {
|
|
2243
|
+
const home = os.homedir()
|
|
2244
|
+
if (!home) return []
|
|
2245
|
+
|
|
2246
|
+
const msgs = []
|
|
2247
|
+
for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
|
|
2248
|
+
const relPath = globalGuardConfigPath(gf, scriptMarker)
|
|
2249
|
+
const fullPath = path.join(home, relPath)
|
|
2250
|
+
let content
|
|
2251
|
+
try {
|
|
2252
|
+
content = fs.readFileSync(fullPath, 'utf8')
|
|
2253
|
+
} catch (e) {
|
|
2254
|
+
if (e.code === 'ENOENT') continue
|
|
2255
|
+
continue
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
let parsed
|
|
2259
|
+
try {
|
|
2260
|
+
parsed = JSON.parse(content)
|
|
2261
|
+
} catch (_) {
|
|
2262
|
+
continue
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const commands = []
|
|
2266
|
+
collectCommandsWithMarker(parsed, scriptMarker, commands)
|
|
2267
|
+
|
|
2268
|
+
const seen = new Set()
|
|
2269
|
+
for (const m of commands) {
|
|
2270
|
+
const seenKey = `${m.raw} ${m.typeIsCommand}`
|
|
2271
|
+
if (seen.has(seenKey)) continue
|
|
2272
|
+
seen.add(seenKey)
|
|
2273
|
+
|
|
2274
|
+
if (!path.isAbsolute(m.raw)) continue
|
|
2275
|
+
|
|
2276
|
+
// ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
|
|
2277
|
+
// with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
|
|
2278
|
+
// version, another tool's merge) is silently never executed by the CLI, even though the
|
|
2279
|
+
// script itself exists and is fine. Reported instead of the exists/executable checks below,
|
|
2280
|
+
// which assume the entry is structurally valid.
|
|
2281
|
+
if (gf.requiresCommandType && !m.typeIsCommand) {
|
|
2282
|
+
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`)
|
|
2283
|
+
continue
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
let stat = null
|
|
2287
|
+
try {
|
|
2288
|
+
stat = fs.statSync(m.raw)
|
|
2289
|
+
} catch (_) {
|
|
2290
|
+
stat = null
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (!stat) {
|
|
2294
|
+
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`)
|
|
2295
|
+
} else if ((stat.mode & 0o111) === 0) {
|
|
2296
|
+
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`)
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
return msgs
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// validateGuardGlobalScriptIntegrity is the GLOBAL-scope counterpart of
|
|
2305
|
+
// validateCredentialGuardScriptIntegrity/validateGitBranchGuardScriptIntegrity: verifies the
|
|
2306
|
+
// content of ~/.trackfw/scripts/<scriptFileName> (the fixed location the global generators write
|
|
2307
|
+
// to) against referenceContent byte-for-byte. Port of Go's validateGuardGlobalScriptIntegrity
|
|
2308
|
+
// (internal/validator/validator_git_branch_guard.go).
|
|
2309
|
+
//
|
|
2310
|
+
// ROADMAP-2026-08-17 (guard global cabeado com no-op / integridade independente de fiação), ML-3A:
|
|
2311
|
+
// deliberately triggers on ARTIFACT EXISTENCE, not on any GLOBAL_GUARD_CONFIG_FILES entry
|
|
2312
|
+
// referencing scriptMarker — the old per-config trigger meant a script trackfw itself wrote (via
|
|
2313
|
+
// `trackfw update harness`) but that no config yet pointed at could rot indefinitely with
|
|
2314
|
+
// `validate` green. "If trackfw wrote the script, trackfw verifies the script" — existence is the
|
|
2315
|
+
// only precondition; wiring is irrelevant to whether the artifact itself has drifted.
|
|
2316
|
+
//
|
|
2317
|
+
// Fail-open on absence: a script trackfw never wrote (user never ran `update harness`) is not an
|
|
2318
|
+
// error.
|
|
2319
|
+
//
|
|
2320
|
+
// Single evaluation per script (not per referencing config): caps the message count at 1
|
|
2321
|
+
// regardless of how many (or how few — including zero) configs reference the same on-disk path —
|
|
2322
|
+
// this is what prevents double-reporting now that git-branch-guard has both global wiring (Wave 2)
|
|
2323
|
+
// and a global artifact.
|
|
2324
|
+
//
|
|
2325
|
+
// scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
|
|
2326
|
+
// trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
|
|
2327
|
+
function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
|
|
2328
|
+
const home = os.homedir()
|
|
2329
|
+
if (!home) return []
|
|
2330
|
+
|
|
2331
|
+
const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
|
|
2332
|
+
let content
|
|
2333
|
+
try {
|
|
2334
|
+
content = fs.readFileSync(scriptPath, 'utf8')
|
|
2335
|
+
} catch (_) {
|
|
2336
|
+
// Not installed is not a violation — same contract as every other *_script_integrity check
|
|
2337
|
+
// (project-scope and global) in this file.
|
|
2338
|
+
return []
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
if (content === referenceContent) return []
|
|
2342
|
+
|
|
2343
|
+
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`]
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// validateCredentialGuardGlobalHookResolvable / validateCredentialGuardGlobalScriptIntegrity /
|
|
2347
|
+
// validateGitBranchGuardGlobalHookResolvable / validateGitBranchGuardGlobalScriptIntegrity are the
|
|
2348
|
+
// 4 thin wrappers wired in validateUnfiltered below — each folds its messages into the SAME rule
|
|
2349
|
+
// name as its project-scope counterpart (credential_guard_hook_resolvable,
|
|
2350
|
+
// credential_guard_script_integrity, git_branch_guard_hook_resolvable,
|
|
2351
|
+
// git_branch_guard_script_integrity respectively), so no new rules: entries in trackfw.yaml are
|
|
2352
|
+
// needed. Port of the 4 equivalent wrappers in
|
|
2353
|
+
// internal/validator/validator_git_branch_guard.go.
|
|
2354
|
+
function validateCredentialGuardGlobalHookResolvable() {
|
|
2355
|
+
return validateGuardGlobalHookResolvable(CREDENTIAL_GUARD_SCRIPT_MARKER)
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function validateCredentialGuardGlobalScriptIntegrity() {
|
|
2359
|
+
return validateGuardGlobalScriptIntegrity(CREDENTIAL_GUARD_SCRIPT_MARKER, CREDENTIAL_GUARD_GLOBAL_SCRIPT_REFERENCE)
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function validateGitBranchGuardGlobalHookResolvable() {
|
|
2363
|
+
return validateGuardGlobalHookResolvable(GIT_BRANCH_GUARD_SCRIPT_MARKER)
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function validateGitBranchGuardGlobalScriptIntegrity() {
|
|
2367
|
+
return validateGuardGlobalScriptIntegrity(GIT_BRANCH_GUARD_SCRIPT_MARKER, GIT_BRANCH_GUARD_SCRIPT_REFERENCE)
|
|
2368
|
+
}
|
|
2369
|
+
// NOTE: CREDENTIAL_GUARD_SCRIPT_MARKER === 'trackfw-credential-guard.sh' and
|
|
2370
|
+
// GIT_BRANCH_GUARD_SCRIPT_MARKER === 'trackfw-git-branch-guard.sh' — both markers are already the
|
|
2371
|
+
// literal script filenames, so they double as scriptFileName above with no extra constant needed
|
|
2372
|
+
// (same reuse the Go port relies on).
|
|
2373
|
+
|
|
1489
2374
|
// CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES mirrors the shell script's own resolution of
|
|
1490
2375
|
// credential_guard.mode (`grep -A 5 '^credential_guard:'`): the mode key is found on the matched
|
|
1491
2376
|
// line or within the 5 lines following it.
|
|
@@ -1600,6 +2485,12 @@ const RULE_DEFAULTS = {
|
|
|
1600
2485
|
// credential_guard_mode_downgrade is deliberately absent: it falls through to ruleSeverity's
|
|
1601
2486
|
// 'error' default.
|
|
1602
2487
|
credential_guard_script_integrity: 'warning',
|
|
2488
|
+
// ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-desatualizados,
|
|
2489
|
+
// ML-2A: same rationale as credential_guard_script_integrity above — the script carries no
|
|
2490
|
+
// version marker, so this rule cannot tell legitimate drift from tampering.
|
|
2491
|
+
// git_branch_guard_hook_resolvable is deliberately absent from this map (falls through to
|
|
2492
|
+
// 'error'), mirroring credential_guard_hook_resolvable.
|
|
2493
|
+
git_branch_guard_script_integrity: 'warning',
|
|
1603
2494
|
}
|
|
1604
2495
|
|
|
1605
2496
|
// ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1A.
|
|
@@ -1719,6 +2610,122 @@ function saveBaseline(violations, warnings) {
|
|
|
1719
2610
|
fs.writeFileSync(BASELINE_FILE, JSON.stringify(bf, null, 2), 'utf8')
|
|
1720
2611
|
}
|
|
1721
2612
|
|
|
2613
|
+
function sha256Hex(buf) {
|
|
2614
|
+
return crypto.createHash('sha256').update(buf).digest('hex')
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// THIRDPARTY_ORIGIN é o valor de Claim.origin que marca um artefato do manifest como instalado
|
|
2618
|
+
// por `third-party install` (ADR-2026-08-15 D11).
|
|
2619
|
+
const THIRDPARTY_ORIGIN = 'thirdparty'
|
|
2620
|
+
|
|
2621
|
+
// validateThirdPartyArtifactHasProvenance implementa a regra "thirdparty_artifact_has_provenance"
|
|
2622
|
+
// (ADR-2026-08-15 D2) — a detecção real, ancorada em git, por trás do guardrail
|
|
2623
|
+
// TRACKFW_ORCHESTRATOR_SESSION. NUNCA faz fetch de rede (D6): lê só
|
|
2624
|
+
// .trackfw/integrations-manifest.json, .trackfw/thirdparty-provenance.json e
|
|
2625
|
+
// .trackfw/thirdparty-quarantine/<checksum>.json, todos já em disco (e, por convenção deste
|
|
2626
|
+
// projeto, versionados no repositório).
|
|
2627
|
+
//
|
|
2628
|
+
// Duas ramificações, ambas fatais (error — a regra está deliberadamente ausente de RULE_DEFAULTS):
|
|
2629
|
+
// 1. um artifact do manifest carrega um claim com origin === "thirdparty" mas
|
|
2630
|
+
// thirdparty-provenance.json não tem entrada chaveada por aquele destino;
|
|
2631
|
+
// 2. existe entrada de proveniência, mas seu installed_sha256 não pode ser reconciliado com o que
|
|
2632
|
+
// de fato está em disco no destino declarado.
|
|
2633
|
+
//
|
|
2634
|
+
// Sobre a ramificação 2 (ADR-2026-08-15 D2-bis, ML-3B) — checksum_sha256 é sha256 dos bytes BRUTOS
|
|
2635
|
+
// (D6), mas o arquivo instalado é sempre NormalizeThirdPartyContent(raw) — não é a função
|
|
2636
|
+
// identidade em geral, então comparar checksum_sha256 direto contra sha256(arquivo instalado)
|
|
2637
|
+
// produz falso-positivo em toda instalação legítima cujo conteúdo bruto não fosse já exatamente
|
|
2638
|
+
// TrimSpace+newline único. A resolução ML-3A usava o registro de quarentena como ponte entre os
|
|
2639
|
+
// dois domínios — correta, mas tornava um artefato de ESTÁGIO (.trackfw/thirdparty-quarantine/,
|
|
2640
|
+
// destinado a ser podado) dependência obrigatória de um gate PERMANENTE. D2-bis resolve isso com
|
|
2641
|
+
// um segundo campo na entrada de proveniência, installed_sha256 = sha256(bytes NORMALIZADOS),
|
|
2642
|
+
// calculado no momento do install pelo mesmo código que grava o arquivo (npm/src/commands/thirdparty.js).
|
|
2643
|
+
// checksum_sha256 permanece intocado, é a âncora de aprovação D8c. A ramificação 2 agora compara
|
|
2644
|
+
// sha256(arquivo instalado) diretamente contra entry.installed_sha256 — dois domínios já
|
|
2645
|
+
// normalizados, sem ponte via quarentena. A ausência da quarentena deixou de ser erro desta regra.
|
|
2646
|
+
function validateThirdPartyArtifactHasProvenance(cwd) {
|
|
2647
|
+
const root = cwd || process.cwd()
|
|
2648
|
+
|
|
2649
|
+
const manifestPath = path.join(root, '.trackfw', 'integrations-manifest.json')
|
|
2650
|
+
let manifest
|
|
2651
|
+
try {
|
|
2652
|
+
const raw = fs.readFileSync(manifestPath, 'utf8')
|
|
2653
|
+
manifest = JSON.parse(raw)
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
if (err.code === 'ENOENT') return []
|
|
2656
|
+
throw new Error(`thirdparty_artifact_has_provenance: read ${manifestPath}: ${err.message}`)
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
const destinations = []
|
|
2660
|
+
for (const [destination, artifact] of Object.entries(manifest.artifacts || {})) {
|
|
2661
|
+
const claims = artifact.claims || []
|
|
2662
|
+
if (claims.some(claim => claim.origin === THIRDPARTY_ORIGIN)) destinations.push(destination)
|
|
2663
|
+
}
|
|
2664
|
+
if (destinations.length === 0) return []
|
|
2665
|
+
destinations.sort()
|
|
2666
|
+
|
|
2667
|
+
let prov
|
|
2668
|
+
try {
|
|
2669
|
+
prov = loadProvenance(root)
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
throw new Error(`thirdparty_artifact_has_provenance: ${err.message}`)
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const msgs = []
|
|
2675
|
+
for (const destination of destinations) {
|
|
2676
|
+
// Provenance keys are NOT the manifest's absolute destination —
|
|
2677
|
+
// verified empirically against the real install command
|
|
2678
|
+
// (npm/src/commands/thirdparty.js): verifyApproval/upsertProvenanceEntry
|
|
2679
|
+
// are called with the project-root-relative (or "~/"-prefixed,
|
|
2680
|
+
// global-scope) destination string BEFORE IntegrationManager.resolve()
|
|
2681
|
+
// joins it against root to produce the absolute manifest key. Every
|
|
2682
|
+
// claim reached here came from the PROJECT manifest, so its scope is
|
|
2683
|
+
// always "project" (a global-scope claim lives in the home manifest
|
|
2684
|
+
// instead, which this rule intentionally never reads). path.relative
|
|
2685
|
+
// inverts resolve()'s path.resolve(root, relative) exactly. Mirrors
|
|
2686
|
+
// internal/validator/validator_thirdparty_provenance.go.
|
|
2687
|
+
const provenanceKey = path.relative(root, destination)
|
|
2688
|
+
const entry = (prov.entries || {})[provenanceKey]
|
|
2689
|
+
if (!entry) {
|
|
2690
|
+
msgs.push(
|
|
2691
|
+
`thirdparty_artifact_has_provenance: "${destination}" is claimed as a third-party artifact but has no ` +
|
|
2692
|
+
'entry in .trackfw/thirdparty-provenance.json — obtain a favorable hades-tf review and record an ' +
|
|
2693
|
+
'approved provenance entry for this destination before this can pass validate (D2 branch i)'
|
|
2694
|
+
)
|
|
2695
|
+
continue
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
let installed
|
|
2699
|
+
try {
|
|
2700
|
+
installed = fs.readFileSync(destination)
|
|
2701
|
+
} catch (err) {
|
|
2702
|
+
msgs.push(
|
|
2703
|
+
`thirdparty_artifact_has_provenance: "${destination}" is claimed as a third-party artifact with an ` +
|
|
2704
|
+
`approved provenance entry, but the destination file could not be read (${err.message})`
|
|
2705
|
+
)
|
|
2706
|
+
continue
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// entry.installed_sha256 may be `undefined` (key absent — e.g. an
|
|
2710
|
+
// approver-authored entry from before `install` ever ran, or a partial
|
|
2711
|
+
// install that failed between manager.Install and the installed_sha256
|
|
2712
|
+
// upsert). Coerce to '' so the comparison and the message text match
|
|
2713
|
+
// Go (zero-value "") and Python (.get(..., "")) byte-for-byte — do NOT
|
|
2714
|
+
// interpolate entry.installed_sha256 directly, that renders the
|
|
2715
|
+
// JS-only literal "undefined" into the violation message.
|
|
2716
|
+
const installedSHA256 = entry.installed_sha256 || ''
|
|
2717
|
+
if (sha256Hex(installed) !== installedSHA256) {
|
|
2718
|
+
msgs.push(
|
|
2719
|
+
`thirdparty_artifact_has_provenance: "${destination}" — installed content does not match ` +
|
|
2720
|
+
`installed_sha256 ${installedSHA256} recorded in .trackfw/thirdparty-provenance.json — the ` +
|
|
2721
|
+
'artifact was modified after approval or installed outside the fetch/install flow (D2 branch ii, D2-bis)'
|
|
2722
|
+
)
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
return msgs
|
|
2727
|
+
}
|
|
2728
|
+
|
|
1722
2729
|
// validateUnfiltered executa todas as validações e retorna { violations, warnings } sem ratchet.
|
|
1723
2730
|
async function validateUnfiltered() {
|
|
1724
2731
|
resetMeta()
|
|
@@ -1740,9 +2747,18 @@ async function validateUnfiltered() {
|
|
|
1740
2747
|
applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
|
|
1741
2748
|
applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
|
|
1742
2749
|
applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
|
|
1743
|
-
applyRule('credential_guard_hook_resolvable', validateCredentialGuardHookResolvable(), violations, warnings)
|
|
1744
|
-
applyRule('credential_guard_script_integrity', validateCredentialGuardScriptIntegrity(), violations, warnings)
|
|
2750
|
+
applyRule('credential_guard_hook_resolvable', validateCredentialGuardHookResolvable().concat(validateCredentialGuardGlobalHookResolvable()), violations, warnings)
|
|
2751
|
+
applyRule('credential_guard_script_integrity', validateCredentialGuardScriptIntegrity().concat(validateCredentialGuardGlobalScriptIntegrity()), violations, warnings)
|
|
1745
2752
|
applyRule('credential_guard_mode_downgrade', validateCredentialGuardModeDowngrade(), violations, warnings)
|
|
2753
|
+
// ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-desatualizados,
|
|
2754
|
+
// ML-2A — port de internal/validator/validator.go's wiring das 4 regras/wrappers em
|
|
2755
|
+
// validator_git_branch_guard.go.
|
|
2756
|
+
applyRule('git_branch_guard_hook_resolvable', validateGitBranchGuardHookResolvable().concat(validateGitBranchGuardGlobalHookResolvable()), violations, warnings)
|
|
2757
|
+
applyRule('git_branch_guard_script_integrity', validateGitBranchGuardScriptIntegrity().concat(validateGitBranchGuardGlobalScriptIntegrity()), violations, warnings)
|
|
2758
|
+
|
|
2759
|
+
// ADR-2026-08-15-gate-de-duas-fases-..., ML-3A (D2): detecção ancorada em git por trás do
|
|
2760
|
+
// guardrail TRACKFW_ORCHESTRATOR_SESSION.
|
|
2761
|
+
applyRule('thirdparty_artifact_has_provenance', validateThirdPartyArtifactHasProvenance(), violations, warnings)
|
|
1746
2762
|
|
|
1747
2763
|
// Regras configuráveis via applyRule (popula _itemMeta automaticamente)
|
|
1748
2764
|
applyRule('req_has_adr', validateREQsHaveADR(), violations, warnings)
|
|
@@ -2012,4 +3028,21 @@ module.exports = {
|
|
|
2012
3028
|
validateCredentialGuardModeDowngrade,
|
|
2013
3029
|
extractCredentialGuardMode,
|
|
2014
3030
|
CREDENTIAL_GUARD_SCRIPT_REFERENCE,
|
|
3031
|
+
// ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-desatualizados,
|
|
3032
|
+
// ML-2A
|
|
3033
|
+
collectCommandsWithMarker,
|
|
3034
|
+
validateGuardHookResolvable,
|
|
3035
|
+
validateGitBranchGuardHookResolvable,
|
|
3036
|
+
validateGitBranchGuardScriptIntegrity,
|
|
3037
|
+
GIT_BRANCH_GUARD_SCRIPT_REFERENCE,
|
|
3038
|
+
CREDENTIAL_GUARD_GLOBAL_SCRIPT_REFERENCE,
|
|
3039
|
+
GLOBAL_GUARD_CONFIG_FILES,
|
|
3040
|
+
validateGuardGlobalHookResolvable,
|
|
3041
|
+
validateGuardGlobalScriptIntegrity,
|
|
3042
|
+
// ROADMAP-2026-08-15-instalacao-de-skills-de-terceiro-via-url-para-agentes-especialistas, ML-3A
|
|
3043
|
+
validateThirdPartyArtifactHasProvenance,
|
|
3044
|
+
validateCredentialGuardGlobalHookResolvable,
|
|
3045
|
+
validateCredentialGuardGlobalScriptIntegrity,
|
|
3046
|
+
validateGitBranchGuardGlobalHookResolvable,
|
|
3047
|
+
validateGitBranchGuardGlobalScriptIntegrity,
|
|
2015
3048
|
}
|