trackfw 6.8.0 → 6.9.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 +65 -0
- package/package.json +1 -1
- package/src/config/index.js +23 -0
- package/src/validator/git-exec.js +72 -0
- package/src/validator/index.js +517 -10
package/README.md
CHANGED
|
@@ -722,6 +722,71 @@ The Codex integration is repository-scoped: `AGENTS.md` carries persistent instr
|
|
|
722
722
|
|
|
723
723
|
---
|
|
724
724
|
|
|
725
|
+
## Credential guard: detection, not prevention
|
|
726
|
+
|
|
727
|
+
trackfw can generate a **credential guard** hook that blocks agents from materializing real
|
|
728
|
+
credentials. `trackfw validate` also reports when that guard looks compromised:
|
|
729
|
+
|
|
730
|
+
- the hook is registered but the script is **missing** or not executable;
|
|
731
|
+
- the script's **contents diverge** from the template this version of trackfw generates;
|
|
732
|
+
- `credential_guard.mode` was **downgraded** from `block` on disk relative to your last commit.
|
|
733
|
+
|
|
734
|
+
**Read this before relying on it.**
|
|
735
|
+
|
|
736
|
+
These checks are **detection, not prevention.** trackfw *tells you* something diverged — it does
|
|
737
|
+
**not** stop it from happening. This is not an implementation gap: it was measured that no technical
|
|
738
|
+
prevention is possible, within trackfw's scope, against an agent that has unrestricted write access
|
|
739
|
+
to your workspace. An agent that can edit your files can delete the guard script, overwrite it with
|
|
740
|
+
a no-op, or relax the mode — and on most agent CLIs, a hook that fails to run simply lets the tool
|
|
741
|
+
call proceed.
|
|
742
|
+
|
|
743
|
+
What these checks are **not** able to see:
|
|
744
|
+
|
|
745
|
+
- **changes the adversary also commits** — once it is in your `HEAD`, there is nothing to diverge
|
|
746
|
+
from. What remains is the audit trail: the change shows up in your diff and in review.
|
|
747
|
+
- **anything with no commit to compare against** — a repository with no commits, or a file that was
|
|
748
|
+
never versioned. The check stays silent rather than guessing.
|
|
749
|
+
- **the difference between tampering and you being out of date.** The script carries no version
|
|
750
|
+
marker, so a divergence may simply mean you have not run `trackfw update` since upgrading. That is
|
|
751
|
+
why the message is worded neutrally and the rule defaults to a **warning**, which does **not**
|
|
752
|
+
change `validate`'s exit code. If you want CI to fail on it:
|
|
753
|
+
|
|
754
|
+
```yaml
|
|
755
|
+
# trackfw.yaml
|
|
756
|
+
rules:
|
|
757
|
+
credential_guard_script_integrity: error
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
- **the check can be switched off by the same edit it is meant to catch.** Rule severity is read
|
|
761
|
+
from `rules:` in the `trackfw.yaml` **on disk** — so an edit that both downgrades
|
|
762
|
+
`credential_guard.mode` *and* sets `credential_guard_mode_downgrade: off` silences the report,
|
|
763
|
+
with nothing committed. Anchoring `rules:` to your last commit is a known open item.
|
|
764
|
+
|
|
765
|
+
### If `trackfw update` suddenly reports something it used to ignore
|
|
766
|
+
|
|
767
|
+
Since the version that introduced anchoring, the credential-guard checks are resolved against **your
|
|
768
|
+
last commit**, not only against the file on disk — and they can **no longer be suppressed** via
|
|
769
|
+
`.trackfw-baseline.json`. This is deliberate: a check that can be switched off by the same
|
|
770
|
+
uncommitted edit it is meant to catch is not a check.
|
|
771
|
+
|
|
772
|
+
If one of them starts reporting after an upgrade, you have two legitimate ways out, and both leave a
|
|
773
|
+
trail:
|
|
774
|
+
|
|
775
|
+
```yaml
|
|
776
|
+
# trackfw.yaml — commit this change
|
|
777
|
+
rules:
|
|
778
|
+
credential_guard_hook_resolvable: off
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
...or fix the underlying cause (usually `trackfw update`, to regenerate the guard script and wiring).
|
|
782
|
+
|
|
783
|
+
One caveat worth knowing: `governance_mode: lenient` still turns **every** finding into a warning,
|
|
784
|
+
including these. Closing that is tracked separately.
|
|
785
|
+
|
|
786
|
+
The strongest protection remains the ordinary one: the guard script and `trackfw.yaml` are
|
|
787
|
+
**versioned files**. Review their diffs like you review any other code. Every limitation above has
|
|
788
|
+
the same escape hatch: the change is *visible* in `git diff`.
|
|
789
|
+
|
|
725
790
|
## What trackfw is not
|
|
726
791
|
|
|
727
792
|
- Not a project management SaaS — no accounts, no cloud sync, no data leaving your repository. A local dashboard is available via `trackfw serve`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
package/src/config/index.js
CHANGED
|
@@ -277,6 +277,28 @@ function parse(content, cfg) {
|
|
|
277
277
|
return false;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// parseRulesFromContent parses only the `rules:` mapping out of arbitrary trackfw.yaml content
|
|
281
|
+
// (e.g. a git-HEAD blob obtained via `git show HEAD:./trackfw.yaml`, not the CWD file load()
|
|
282
|
+
// reads) and returns it as name->severity. Used by the validator's HEAD-anchored credential-guard
|
|
283
|
+
// rules — see ADR-2026-08-12-severidade-das-regras-de-credential-guard-resolvida-pela-mais-estrita-
|
|
284
|
+
// entre-head-e-disco.md — which need `rules:` as it existed at a specific git ref, not the CWD, so
|
|
285
|
+
// they cannot go through the load() singleton (always reads the CWD file and caches it once per
|
|
286
|
+
// process). Reuses parse() over an ephemeral cfg object, mirroring Go's ParseRulesFromContent, so
|
|
287
|
+
// this and load() can never diverge on how `rules:` itself is read — purely additive, does not
|
|
288
|
+
// touch load()/parse() otherwise. Malformed content (parse() returning true) is treated the same
|
|
289
|
+
// as "no rules:" — an empty map — since the caller only wants a best-effort read of a historical
|
|
290
|
+
// git blob, not a fatal exit like load() has for the live CWD file.
|
|
291
|
+
function parseRulesFromContent(content) {
|
|
292
|
+
// parse() assumes cfg already has the nested-object shape defaults() provides for
|
|
293
|
+
// credentialGuard/update/sync/linkFields (it assigns into them, e.g. cfg.update.hooks = ...,
|
|
294
|
+
// never creates them) — so this cannot be a bare { rules: {} } literal or parse() throws on any
|
|
295
|
+
// content that sets one of those nested keys. rules starts empty (not seeded from defaults()),
|
|
296
|
+
// deliberately: the caller wants exactly what `rules:` in content declares, nothing else.
|
|
297
|
+
const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {} };
|
|
298
|
+
parse(content, cfg);
|
|
299
|
+
return cfg.rules;
|
|
300
|
+
}
|
|
301
|
+
|
|
280
302
|
const NAMESPACING_FLAT = 'flat';
|
|
281
303
|
const NAMESPACING_BY_AGENT = 'by_agent';
|
|
282
304
|
|
|
@@ -285,6 +307,7 @@ module.exports = {
|
|
|
285
307
|
reset,
|
|
286
308
|
defaults,
|
|
287
309
|
expandPath,
|
|
310
|
+
parseRulesFromContent,
|
|
288
311
|
NAMESPACING_FLAT,
|
|
289
312
|
NAMESPACING_BY_AGENT,
|
|
290
313
|
MALFORMED_CONFIG_MESSAGE,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process')
|
|
4
|
+
|
|
5
|
+
// ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1B.
|
|
6
|
+
// ADR: docs/adr/ADR-2026-08-12-severidade-das-regras-de-credential-guard-resolvida-pela-mais-
|
|
7
|
+
// estrita-entre-head-e-disco.md (Emenda 3).
|
|
8
|
+
//
|
|
9
|
+
// Achado do ML-3B, reproduzido por Zeus: headTrackfwYAML() e isGitWorktree() (index.js) invocavam
|
|
10
|
+
// execSync('git ...') sem limpar o ambiente herdado do processo — GIT_DIR/GIT_WORK_TREE
|
|
11
|
+
// redirecionados para outro repositório git (sem trackfw.yaml versionado) faziam a resolução do
|
|
12
|
+
// HEAD falhar EM SILÊNCIO, e credentialGuardRuleSeverity caía só no disco: derrota o mecanismo M4
|
|
13
|
+
// inteiro sem nenhum commit e sem sequer editar trackfw.yaml. Exposição NOVA para
|
|
14
|
+
// credential_guard_script_integrity e credential_guard_hook_resolvable — elas não dependiam de git
|
|
15
|
+
// antes do M4.
|
|
16
|
+
//
|
|
17
|
+
// TODA invocação de git em npm/src/validator passa a ir por gitOutput() abaixo — nunca chamar
|
|
18
|
+
// execSync/execFileSync('git', ...) diretamente em index.js.
|
|
19
|
+
|
|
20
|
+
// GIT_ENV_PREFIX é o prefixo de TODA variável de ambiente que o git(1) reconhece como
|
|
21
|
+
// configuração (GIT_DIR, GIT_WORK_TREE, GIT_CONFIG_*, GIT_CEILING_DIRECTORIES, etc.).
|
|
22
|
+
//
|
|
23
|
+
// A tentativa inicial deste ML era uma lista fechada (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR,
|
|
24
|
+
// GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES,
|
|
25
|
+
// GIT_CEILING_DIRECTORIES, GIT_NAMESPACE) justificada por "variáveis que redirecionam ONDE o
|
|
26
|
+
// repositório é lido". Essa justificativa estava ERRADA: o vetor real não é redirecionamento, é
|
|
27
|
+
// fazer o `git` sair com status != 0 por QUALQUER motivo — toda chamada deste módulo trata falha
|
|
28
|
+
// do subprocesso como "sem âncora, silêncio" (headTrackfwYAML) ou "fallback para disco"
|
|
29
|
+
// (credentialGuardRuleSeverity), então qualquer variável capaz de tornar o git fatal já é um
|
|
30
|
+
// bypass, redirecionando ou não. Provado com GIT_CONFIG_COUNT=abc (fora da lista fechada — injeta
|
|
31
|
+
// config arbitrária via GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n, e um valor
|
|
32
|
+
// malformado faz `git rev-parse --is-inside-work-tree` sair com "fatal: unable to parse
|
|
33
|
+
// command-line config", exit 128): a lista fechada não a cobria, e
|
|
34
|
+
// `credential_guard_mode_downgrade` silenciava por inteiro (headTrackfwYAML retornava ok=false),
|
|
35
|
+
// não só a severidade das outras duas regras.
|
|
36
|
+
//
|
|
37
|
+
// Por isso a abordagem correta é NEGATIVA por prefixo, não uma enumeração positiva: nenhuma
|
|
38
|
+
// invocação deste módulo (`rev-parse`, `show`, `log`, `symbolic-ref`) depende de qualquer GIT_*
|
|
39
|
+
// herdada do ambiente para funcionar corretamente — `-C dir` já ancora explicitamente o
|
|
40
|
+
// repositório, então git redescobre a partir de dir como se tivesse sido iniciado lá. Contextos
|
|
41
|
+
// legítimos que setam GIT_* (hooks do próprio git, `git submodule foreach`, worktrees vinculadas)
|
|
42
|
+
// continuam funcionando sem essas variáveis porque a descoberta normal a partir de `-C dir` já
|
|
43
|
+
// resolve o mesmo repositório.
|
|
44
|
+
const GIT_ENV_PREFIX = 'GIT_'
|
|
45
|
+
|
|
46
|
+
// cleanGitEnv retorna uma cópia de process.env sem nenhuma variável cujo nome comece com
|
|
47
|
+
// GIT_ENV_PREFIX — usado como `env` de toda invocação de git deste módulo.
|
|
48
|
+
function cleanGitEnv() {
|
|
49
|
+
const env = {}
|
|
50
|
+
for (const key of Object.keys(process.env)) {
|
|
51
|
+
if (!key.startsWith(GIT_ENV_PREFIX)) {
|
|
52
|
+
env[key] = process.env[key]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return env
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// gitOutput executa `git -C dir ...args` ancorado explicitamente em dir via `-C` — nunca
|
|
59
|
+
// dependendo só do cwd do processo/descoberta implícita — e com toda variável GIT_* removida
|
|
60
|
+
// do ambiente herdado. Lança (mesmo comportamento de execSync) se o comando sair com status != 0.
|
|
61
|
+
// dir vazio/undefined cai para process.cwd(), o mesmo comportamento que todo call site já assumia
|
|
62
|
+
// implicitamente antes deste ML.
|
|
63
|
+
function gitOutput(dir, args) {
|
|
64
|
+
const root = dir || process.cwd()
|
|
65
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
|
+
env: cleanGitEnv()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { gitOutput, cleanGitEnv, GIT_ENV_PREFIX }
|
package/src/validator/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
|
-
const {
|
|
5
|
+
const { gitOutput } = require('./git-exec')
|
|
6
6
|
const config = require('../config')
|
|
7
7
|
const { checkTraceIds } = require('./traceid')
|
|
8
8
|
|
|
@@ -138,10 +138,7 @@ function findAdrFile(basename) {
|
|
|
138
138
|
// Retorna null em caso de erro ou se não houver commits.
|
|
139
139
|
function gitLastModifiedTime(filePath) {
|
|
140
140
|
try {
|
|
141
|
-
const out =
|
|
142
|
-
encoding: 'utf8',
|
|
143
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
144
|
-
}).trim()
|
|
141
|
+
const out = gitOutput('.', ['log', '-1', '--format=%ct', '--', filePath]).trim()
|
|
145
142
|
if (out) return parseInt(out, 10) * 1000 // converter para ms
|
|
146
143
|
} catch (_) {}
|
|
147
144
|
return null
|
|
@@ -1085,11 +1082,10 @@ function branchNoMatchingRoadmapMessage(branch, candidates) {
|
|
|
1085
1082
|
// wip/ ou done/ cujo slug case com a branch. Aceita done/ para permitir encerramento do roadmap na
|
|
1086
1083
|
// própria branch, conforme a Definition of Done, sem reprovar o gate.
|
|
1087
1084
|
function validateBranchHasWIPRoadmap() {
|
|
1088
|
-
const { execSync } = require('child_process')
|
|
1089
1085
|
let branch = process.env.TRACKFW_BRANCH || ''
|
|
1090
1086
|
if (!branch && isGitWorktree(process.cwd())) {
|
|
1091
1087
|
try {
|
|
1092
|
-
branch =
|
|
1088
|
+
branch = gitOutput(process.cwd(), ['symbolic-ref', '--short', 'HEAD']).trim()
|
|
1093
1089
|
} catch {
|
|
1094
1090
|
branch = ''
|
|
1095
1091
|
}
|
|
@@ -1152,15 +1148,428 @@ function validateNoteOrphan(cwd) {
|
|
|
1152
1148
|
return msgs
|
|
1153
1149
|
}
|
|
1154
1150
|
|
|
1151
|
+
// CREDENTIAL_GUARD_SCRIPT_MARKER é o nome do script que a regra credential_guard_hook_resolvable
|
|
1152
|
+
// procura dentro dos comandos de hook de projeto.
|
|
1153
|
+
const CREDENTIAL_GUARD_SCRIPT_MARKER = 'trackfw-credential-guard.sh'
|
|
1154
|
+
|
|
1155
|
+
// CREDENTIAL_GUARD_HOOK_FILES é a lista fechada dos arquivos de hook de PROJETO que o trackfw
|
|
1156
|
+
// gera hoje e que podem conter uma entrada de credential-guard (ROADMAP-2026-08-12-mitigacao-do
|
|
1157
|
+
// -fail-open-do-credential-guard, ML-1A). Hooks de escopo GLOBAL (~/.trackfw/..., trackfw update
|
|
1158
|
+
// harness) ficam fora — caso distinto, fora do repositório do usuário, e a checagem de dedup
|
|
1159
|
+
// globalCredentialGuardInstalled*() já os pula de propósito nas entradas de projeto.
|
|
1160
|
+
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' },
|
|
1167
|
+
]
|
|
1168
|
+
|
|
1169
|
+
// resolveCredentialGuardHookPath resolve o valor bruto de um comando de hook (string extraída do
|
|
1170
|
+
// JSON) para um caminho de arquivo absoluto, usando exatamente as 3 formas de prefixo que o
|
|
1171
|
+
// trackfw emite hoje (docs/cli-parity.md, "Mecanismo de resolução de caminho dos hooks de
|
|
1172
|
+
// projeto, por CLI"):
|
|
1173
|
+
// 1. "$CLAUDE_PROJECT_DIR/…" / "$GEMINI_PROJECT_DIR/…" — placeholder de env var expandido em
|
|
1174
|
+
// runtime pelo próprio CLI, substituído aqui pela raiz do projeto.
|
|
1175
|
+
// 2. '"$(git rev-parse --show-toplevel)/…"' — substituição de shell entre aspas literais
|
|
1176
|
+
// (Codex). As aspas fazem parte do valor emitido e são removidas antes de resolver contra a
|
|
1177
|
+
// raiz do projeto.
|
|
1178
|
+
// 3. Caminho relativo puro, sem prefixo nenhum (Cursor/Copilot/Kiro) — resolvido diretamente
|
|
1179
|
+
// contra a raiz do projeto.
|
|
1180
|
+
// Qualquer valor que não bata em nenhuma das 3 formas retorna null — o chamador NÃO deve tratar
|
|
1181
|
+
// isso como violação. Não é função desta regra adivinhar wiring próprio do usuário fora dos
|
|
1182
|
+
// formatos que o trackfw gera.
|
|
1183
|
+
function resolveCredentialGuardHookPath(raw, root) {
|
|
1184
|
+
const claudePrefix = '$CLAUDE_PROJECT_DIR/'
|
|
1185
|
+
const geminiPrefix = '$GEMINI_PROJECT_DIR/'
|
|
1186
|
+
const codexPrefix = '"$(git rev-parse --show-toplevel)/'
|
|
1187
|
+
|
|
1188
|
+
if (raw.startsWith(claudePrefix)) {
|
|
1189
|
+
return path.join(root, raw.slice(claudePrefix.length))
|
|
1190
|
+
}
|
|
1191
|
+
if (raw.startsWith(geminiPrefix)) {
|
|
1192
|
+
return path.join(root, raw.slice(geminiPrefix.length))
|
|
1193
|
+
}
|
|
1194
|
+
if (raw.startsWith(codexPrefix) && raw.endsWith('"')) {
|
|
1195
|
+
const inner = raw.slice(codexPrefix.length, raw.length - 1)
|
|
1196
|
+
return path.join(root, inner)
|
|
1197
|
+
}
|
|
1198
|
+
if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw)) {
|
|
1199
|
+
// Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
|
|
1200
|
+
// (campo "bash"), Kiro (action.command).
|
|
1201
|
+
return path.join(root, raw)
|
|
1202
|
+
}
|
|
1203
|
+
return null
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// collectCredentialGuardCommands percorre recursivamente um valor JSON já decodificado e coleta
|
|
1207
|
+
// todo valor-string que referencia trackfw-credential-guard.sh, independentemente do nome do
|
|
1208
|
+
// campo que o contém.
|
|
1209
|
+
//
|
|
1210
|
+
// Os 6 formatos de hook usam campos diferentes para o comando: "command" (Claude/Codex/
|
|
1211
|
+
// 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
|
+
function collectCredentialGuardCommands(value, out) {
|
|
1214
|
+
if (typeof value === 'string') {
|
|
1215
|
+
if (value.includes(CREDENTIAL_GUARD_SCRIPT_MARKER)) out.push(value)
|
|
1216
|
+
return
|
|
1217
|
+
}
|
|
1218
|
+
if (Array.isArray(value)) {
|
|
1219
|
+
for (const item of value) collectCredentialGuardCommands(item, out)
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
if (value && typeof value === 'object') {
|
|
1223
|
+
for (const key of Object.keys(value)) collectCredentialGuardCommands(value[key], out)
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// validateCredentialGuardHookResolvable é a regra "credential_guard_hook_resolvable": para cada
|
|
1228
|
+
// arquivo de hook de PROJETO que existir, extrai os comandos que referenciam
|
|
1229
|
+
// trackfw-credential-guard.sh, resolve o caminho e verifica que o script existe e é executável.
|
|
1230
|
+
//
|
|
1231
|
+
// Riscos de regressão mapeados no roadmap:
|
|
1232
|
+
// - A regra só avalia entradas que EXISTEM. Ausência de entrada de guard é estado legítimo
|
|
1233
|
+
// (guard global instalado via `trackfw update harness`) — nunca é violação por si só.
|
|
1234
|
+
// - Arquivo de hook ausente é pulado em silêncio.
|
|
1235
|
+
// - Arquivo de hook presente mas com JSON inválido é pulado em silêncio — validar a forma do
|
|
1236
|
+
// JSON não é escopo desta regra.
|
|
1237
|
+
function validateCredentialGuardHookResolvable(cwd) {
|
|
1238
|
+
const root = cwd || process.cwd()
|
|
1239
|
+
const msgs = []
|
|
1240
|
+
|
|
1241
|
+
for (const hf of CREDENTIAL_GUARD_HOOK_FILES) {
|
|
1242
|
+
const fullPath = path.join(root, hf.relPath)
|
|
1243
|
+
let content
|
|
1244
|
+
try {
|
|
1245
|
+
content = fs.readFileSync(fullPath, 'utf8')
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
if (e.code === 'ENOENT') continue
|
|
1248
|
+
continue
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
let parsed
|
|
1252
|
+
try {
|
|
1253
|
+
parsed = JSON.parse(content)
|
|
1254
|
+
} catch (_) {
|
|
1255
|
+
continue
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const commands = []
|
|
1259
|
+
collectCredentialGuardCommands(parsed, commands)
|
|
1260
|
+
|
|
1261
|
+
const seen = new Set()
|
|
1262
|
+
for (const raw of commands) {
|
|
1263
|
+
if (seen.has(raw)) continue
|
|
1264
|
+
seen.add(raw)
|
|
1265
|
+
|
|
1266
|
+
const resolved = resolveCredentialGuardHookPath(raw, root)
|
|
1267
|
+
if (resolved === null) continue
|
|
1268
|
+
|
|
1269
|
+
let stat = null
|
|
1270
|
+
try {
|
|
1271
|
+
stat = fs.statSync(resolved)
|
|
1272
|
+
} catch (_) {
|
|
1273
|
+
stat = null
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (!stat) {
|
|
1277
|
+
msgs.push(`${hf.relPath} (${hf.cli}) references trackfw-credential-guard.sh resolved to "${resolved}", but the script does not exist — run \`trackfw update\` to regenerate it`)
|
|
1278
|
+
} else if ((stat.mode & 0o111) === 0) {
|
|
1279
|
+
msgs.push(`${hf.relPath} (${hf.cli}) references trackfw-credential-guard.sh resolved to "${resolved}", but the script is not executable — run \`trackfw update\` to regenerate it`)
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return msgs
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1155
1287
|
function isGitWorktree(dir) {
|
|
1156
1288
|
try {
|
|
1157
|
-
const out =
|
|
1289
|
+
const out = gitOutput(dir, ['rev-parse', '--is-inside-work-tree'])
|
|
1158
1290
|
return String(out).trim() === 'true'
|
|
1159
1291
|
} catch {
|
|
1160
1292
|
return false
|
|
1161
1293
|
}
|
|
1162
1294
|
}
|
|
1163
1295
|
|
|
1296
|
+
// ROADMAP-2026-08-12-deteccao-de-adulteracao-do-credential-guard-regra-de-validate, ML-1A.
|
|
1297
|
+
// ADR: docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-com-escrita-irrestrita-a-
|
|
1298
|
+
// resposta-e-deteccao-ancorada-no-git.md (Emenda 1: âncora POR ALVO, decidida na Barreira B0).
|
|
1299
|
+
// Ver internal/validator/validator_credential_guard_integrity.go para o raciocínio completo
|
|
1300
|
+
// (âncoras, severidades, e por que "trackfw.yaml sem a chave" é lido como HEAD sem a chave, não
|
|
1301
|
+
// disco sem a chave) — replicado aqui byte-a-byte na semântica, não na forma.
|
|
1302
|
+
|
|
1303
|
+
// CREDENTIAL_GUARD_SCRIPT_REFERENCE is a validator-local copy of the same template composed in
|
|
1304
|
+
// generators/hooks.js (CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_PROJECT_GUARD + CG_DETECTION_CORE
|
|
1305
|
+
// + CG_PROJECT_TAIL). Not required directly from generators/hooks.js because
|
|
1306
|
+
// generateCredentialGuardScript() writes to disk AND console.log()s a success line on every call
|
|
1307
|
+
// -- calling it from inside `trackfw validate` would leak a stray " \u2713
|
|
1308
|
+
// scripts/trackfw-credential-guard.sh" line into validate's output on every run, corrupting the
|
|
1309
|
+
// exact success message Cenario 29 fixes byte-for-byte. Kept as a literal copy (same choice made
|
|
1310
|
+
// in internal/validator/validator_credential_guard_integrity_reference.go for Go, for the same
|
|
1311
|
+
// reason) instead -- drift is caught by test/validator-credential-guard-script-reference.test.js,
|
|
1312
|
+
// which regenerates the real script via generateCredentialGuardScript() into a temp dir and
|
|
1313
|
+
// asserts byte-equality against this constant.
|
|
1314
|
+
const CREDENTIAL_GUARD_SCRIPT_REFERENCE = `#!/usr/bin/env bash
|
|
1315
|
+
# trackfw credential guard — PreToolUse/PostToolUse hook
|
|
1316
|
+
set -euo pipefail
|
|
1317
|
+
|
|
1318
|
+
INPUT=$(cat)
|
|
1319
|
+
|
|
1320
|
+
# Script is intentionally a no-op when executed outside the project root
|
|
1321
|
+
[ -f "trackfw.yaml" ] || exit 0
|
|
1322
|
+
|
|
1323
|
+
JWT_PATTERN='eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+'
|
|
1324
|
+
AWS_KEY_PATTERN='AKIA[0-9A-Z]{16}'
|
|
1325
|
+
|
|
1326
|
+
MATCH=""
|
|
1327
|
+
if printf '%s' "$INPUT" | grep -qE "$JWT_PATTERN"; then
|
|
1328
|
+
MATCH="JWT"
|
|
1329
|
+
elif printf '%s' "$INPUT" | grep -qE "$AWS_KEY_PATTERN"; then
|
|
1330
|
+
MATCH="AWS access key"
|
|
1331
|
+
fi
|
|
1332
|
+
|
|
1333
|
+
# The raw payload is JSON: any double quote inside the underlying tool_input.command is
|
|
1334
|
+
# escaped as \\" -- unescape those before scanning for redirect targets, or a quoted target
|
|
1335
|
+
# like "$TMPFILE" is seen as starting with a literal backslash instead of a variable
|
|
1336
|
+
# reference.
|
|
1337
|
+
RAW=$(printf '%s' "$INPUT" | sed 's/\\\\"/"/g')
|
|
1338
|
+
|
|
1339
|
+
# Ignore matches that are only ever written to an ephemeral destination
|
|
1340
|
+
# (mktemp-derived path or /dev/null). A match with no redirect at all
|
|
1341
|
+
# (printed to stdout, e.g.) or redirected to a plain file path still
|
|
1342
|
+
# alerts -- that is the incident this hook guards against.
|
|
1343
|
+
is_ephemeral_target() {
|
|
1344
|
+
local target
|
|
1345
|
+
target=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
|
|
1346
|
+
case "$target" in
|
|
1347
|
+
/dev/null) return 0 ;;
|
|
1348
|
+
*mktemp*) return 0 ;;
|
|
1349
|
+
esac
|
|
1350
|
+
if printf '%s' "$target" | grep -qE '^\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?$'; then
|
|
1351
|
+
local varname pattern
|
|
1352
|
+
varname=$(printf '%s' "$target" | sed -E 's/^\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?$/\\1/')
|
|
1353
|
+
pattern="*\${varname}="'$(mktemp'"*"
|
|
1354
|
+
case "$RAW" in
|
|
1355
|
+
$pattern) return 0 ;;
|
|
1356
|
+
esac
|
|
1357
|
+
fi
|
|
1358
|
+
return 1
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
REDIRECTS=$(printf '%s' "$RAW" | grep -oE '[0-9]?>>?[[:space:]]*[^[:space:]|&;,:]+' || true)
|
|
1362
|
+
|
|
1363
|
+
# Second detection layer: only runs when the payload scan above found nothing -- keeps the common
|
|
1364
|
+
# case (match already found) cheap and avoids reading files unnecessarily. Files above the size cap
|
|
1365
|
+
# are skipped silently.
|
|
1366
|
+
scan_file_for_pattern() {
|
|
1367
|
+
local path size
|
|
1368
|
+
path=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
|
|
1369
|
+
[ -n "$path" ] && [ -f "$path" ] || return 1
|
|
1370
|
+
size=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
|
|
1371
|
+
size=\${size:-0}
|
|
1372
|
+
[ "$size" -lt 1048576 ] || return 1
|
|
1373
|
+
if grep -qE "$JWT_PATTERN" "$path" 2>/dev/null; then
|
|
1374
|
+
MATCH="JWT"
|
|
1375
|
+
return 0
|
|
1376
|
+
fi
|
|
1377
|
+
if grep -qE "$AWS_KEY_PATTERN" "$path" 2>/dev/null; then
|
|
1378
|
+
MATCH="AWS access key"
|
|
1379
|
+
return 0
|
|
1380
|
+
fi
|
|
1381
|
+
return 1
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if [ -z "$MATCH" ] && [ -n "$REDIRECTS" ]; then
|
|
1385
|
+
while IFS= read -r line; do
|
|
1386
|
+
if [ -z "$line" ]; then
|
|
1387
|
+
continue
|
|
1388
|
+
fi
|
|
1389
|
+
target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
|
|
1390
|
+
if ! is_ephemeral_target "$target"; then
|
|
1391
|
+
scan_file_for_pattern "$target" && break
|
|
1392
|
+
fi
|
|
1393
|
+
done <<< "$REDIRECTS"
|
|
1394
|
+
fi
|
|
1395
|
+
|
|
1396
|
+
if [ -z "$MATCH" ]; then
|
|
1397
|
+
CMD_LINE=$(printf '%s' "$RAW" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
1398
|
+
if [ -n "$CMD_LINE" ]; then
|
|
1399
|
+
set -- $CMD_LINE
|
|
1400
|
+
cmd_name="\${1:-}"
|
|
1401
|
+
case "$cmd_name" in
|
|
1402
|
+
cat|head|tail|jq|grep)
|
|
1403
|
+
shift
|
|
1404
|
+
for token in "$@"; do
|
|
1405
|
+
scan_file_for_pattern "$token" && break
|
|
1406
|
+
done
|
|
1407
|
+
;;
|
|
1408
|
+
esac
|
|
1409
|
+
fi
|
|
1410
|
+
fi
|
|
1411
|
+
|
|
1412
|
+
[ -n "$MATCH" ] || exit 0
|
|
1413
|
+
|
|
1414
|
+
HAS_REDIRECT=0
|
|
1415
|
+
EXEMPT=1
|
|
1416
|
+
if [ -n "$REDIRECTS" ]; then
|
|
1417
|
+
while IFS= read -r line; do
|
|
1418
|
+
if [ -z "$line" ]; then
|
|
1419
|
+
continue
|
|
1420
|
+
fi
|
|
1421
|
+
HAS_REDIRECT=1
|
|
1422
|
+
target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
|
|
1423
|
+
if ! is_ephemeral_target "$target"; then
|
|
1424
|
+
EXEMPT=0
|
|
1425
|
+
fi
|
|
1426
|
+
done <<< "$REDIRECTS"
|
|
1427
|
+
fi
|
|
1428
|
+
|
|
1429
|
+
if [ "$HAS_REDIRECT" -eq 1 ] && [ "$EXEMPT" -eq 1 ]; then
|
|
1430
|
+
exit 0
|
|
1431
|
+
fi
|
|
1432
|
+
|
|
1433
|
+
DEFAULT_MODE="warn"
|
|
1434
|
+
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)
|
|
1435
|
+
case "$MODE" in
|
|
1436
|
+
warn|block) ;;
|
|
1437
|
+
*) MODE="$DEFAULT_MODE" ;;
|
|
1438
|
+
esac
|
|
1439
|
+
|
|
1440
|
+
if [ "$MODE" = "block" ]; then
|
|
1441
|
+
echo "trackfw-credential-guard: blocked - possible $MATCH detected in tool payload." >&2
|
|
1442
|
+
exit 2
|
|
1443
|
+
fi
|
|
1444
|
+
|
|
1445
|
+
echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
|
|
1446
|
+
|
|
1447
|
+
ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
|
|
1448
|
+
ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
|
|
1449
|
+
|
|
1450
|
+
case "$ROADMAP_DIR" in
|
|
1451
|
+
/*|../*|*/../*|*/..|..) ROADMAP_DIR="docs/roadmaps" ;;
|
|
1452
|
+
esac
|
|
1453
|
+
|
|
1454
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
1455
|
+
MSG="Possible $MATCH detected in tool payload - review before materializing credentials in plain text."
|
|
1456
|
+
MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
|
|
1457
|
+
|
|
1458
|
+
mkdir -p "$ROADMAP_DIR"
|
|
1459
|
+
printf '{"tool":"credential-guard","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
|
|
1460
|
+
"$MSG_ESC" \\
|
|
1461
|
+
"$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-credential-guard.json"
|
|
1462
|
+
|
|
1463
|
+
exit 0
|
|
1464
|
+
`
|
|
1465
|
+
|
|
1466
|
+
// validateCredentialGuardScriptIntegrity é a regra "credential_guard_script_integrity": compara
|
|
1467
|
+
// scripts/trackfw-credential-guard.sh em disco contra o template que esta versão do trackfw
|
|
1468
|
+
// geraria. Silenciosa quando o script não existe — ausência é escopo de
|
|
1469
|
+
// credential_guard_hook_resolvable, não desta regra.
|
|
1470
|
+
function validateCredentialGuardScriptIntegrity(cwd) {
|
|
1471
|
+
const root = cwd || process.cwd()
|
|
1472
|
+
const relPath = 'scripts/trackfw-credential-guard.sh'
|
|
1473
|
+
let content
|
|
1474
|
+
try {
|
|
1475
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8')
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
if (e.code === 'ENOENT') return []
|
|
1478
|
+
return [inspectionDiagnostic('credential_guard_script_integrity', relPath, e)]
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (content === CREDENTIAL_GUARD_SCRIPT_REFERENCE) return []
|
|
1482
|
+
|
|
1483
|
+
return [
|
|
1484
|
+
`${relPath} content diverges from the template this version of trackfw generates — ` +
|
|
1485
|
+
'if you did not edit this file by hand, run `trackfw update` to regenerate it',
|
|
1486
|
+
]
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES mirrors the shell script's own resolution of
|
|
1490
|
+
// credential_guard.mode (`grep -A 5 '^credential_guard:'`): the mode key is found on the matched
|
|
1491
|
+
// line or within the 5 lines following it.
|
|
1492
|
+
const CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES = 5
|
|
1493
|
+
|
|
1494
|
+
// extractCredentialGuardMode replica a leitura leve (grep/sed) que o próprio script faz —
|
|
1495
|
+
// deliberadamente não um parser YAML completo, para que a noção desta regra de "para o que
|
|
1496
|
+
// credential_guard.mode resolve" bata com o que roda de fato no hook.
|
|
1497
|
+
function extractCredentialGuardMode(content) {
|
|
1498
|
+
const lines = content.split('\n')
|
|
1499
|
+
const start = lines.findIndex(l => l.startsWith('credential_guard:'))
|
|
1500
|
+
if (start === -1) return { mode: '', ok: false }
|
|
1501
|
+
|
|
1502
|
+
const end = Math.min(start + 1 + CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES, lines.length)
|
|
1503
|
+
for (const line of lines.slice(start, end)) {
|
|
1504
|
+
const trimmed = line.trim()
|
|
1505
|
+
if (!trimmed.includes('mode:')) continue
|
|
1506
|
+
let rest = trimmed.startsWith('mode:') ? trimmed.slice('mode:'.length) : trimmed
|
|
1507
|
+
rest = rest.trim()
|
|
1508
|
+
const hashIdx = rest.indexOf('#')
|
|
1509
|
+
if (hashIdx >= 0) rest = rest.slice(0, hashIdx).trim()
|
|
1510
|
+
rest = rest.replace(/^["']+|["']+$/g, '')
|
|
1511
|
+
return { mode: rest, ok: true }
|
|
1512
|
+
}
|
|
1513
|
+
return { mode: '', ok: false }
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// headTrackfwYAML retorna o conteúdo de trackfw.yaml no HEAD do git, resolvido relativo a cwd
|
|
1517
|
+
// (não necessariamente a raiz do repo — `trackfw validate` pode rodar de um subdiretório). ok é
|
|
1518
|
+
// false sempre que não há âncora usável: não é worktree git, sem commits, ou trackfw.yaml não
|
|
1519
|
+
// versionado no HEAD — todos "sem âncora, silêncio", nunca erro.
|
|
1520
|
+
function headTrackfwYAML(cwd) {
|
|
1521
|
+
const root = cwd || process.cwd()
|
|
1522
|
+
if (!isGitWorktree(root)) return { content: '', ok: false }
|
|
1523
|
+
try {
|
|
1524
|
+
gitOutput(root, ['rev-parse', '--verify', 'HEAD'])
|
|
1525
|
+
} catch {
|
|
1526
|
+
return { content: '', ok: false }
|
|
1527
|
+
}
|
|
1528
|
+
try {
|
|
1529
|
+
const out = gitOutput(root, ['show', 'HEAD:./trackfw.yaml'])
|
|
1530
|
+
return { content: out, ok: true }
|
|
1531
|
+
} catch {
|
|
1532
|
+
return { content: '', ok: false }
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// validateCredentialGuardModeDowngrade é a regra "credential_guard_mode_downgrade": dispara apenas
|
|
1537
|
+
// quando credential_guard.mode era explicitamente "block" no HEAD e o trackfw.yaml atual em disco
|
|
1538
|
+
// não resolve mais para "block" (warn explícito, valor não reconhecido, ou chave/arquivo
|
|
1539
|
+
// ausente — todos os quais o próprio script resolveria como "warn", o DEFAULT_MODE da variante de
|
|
1540
|
+
// projeto).
|
|
1541
|
+
//
|
|
1542
|
+
// Silenciosa sempre que HEAD não é "block": isso é "sem âncora para detectar downgrade", não
|
|
1543
|
+
// "nada errado". A ausência da chave em DISCO nunca é tratada como silêncio — é exatamente a via
|
|
1544
|
+
// que esta regra existe para cobrir.
|
|
1545
|
+
function validateCredentialGuardModeDowngrade(cwd) {
|
|
1546
|
+
const root = cwd || process.cwd()
|
|
1547
|
+
const head = headTrackfwYAML(root)
|
|
1548
|
+
if (!head.ok) return []
|
|
1549
|
+
|
|
1550
|
+
const headMode = extractCredentialGuardMode(head.content)
|
|
1551
|
+
if (headMode.mode !== 'block') return []
|
|
1552
|
+
|
|
1553
|
+
let diskContent
|
|
1554
|
+
try {
|
|
1555
|
+
diskContent = fs.readFileSync(path.join(root, 'trackfw.yaml'), 'utf8')
|
|
1556
|
+
} catch (e) {
|
|
1557
|
+
if (e.code === 'ENOENT') return [credentialGuardModeDowngradeMessage()]
|
|
1558
|
+
return [inspectionDiagnostic('credential_guard_mode_downgrade', 'trackfw.yaml', e)]
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const diskMode = extractCredentialGuardMode(diskContent)
|
|
1562
|
+
if (diskMode.mode === 'block') return []
|
|
1563
|
+
|
|
1564
|
+
return [credentialGuardModeDowngradeMessage()]
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function credentialGuardModeDowngradeMessage() {
|
|
1568
|
+
return 'trackfw.yaml sets credential_guard.mode: block at the git HEAD commit, but the ' +
|
|
1569
|
+
'current file does not resolve to block — if this was intentional, commit the change; ' +
|
|
1570
|
+
'otherwise investigate before treating the credential guard as active'
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1164
1573
|
// _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.
|
|
1165
1574
|
// Populado em applyRule e nos pushs diretos do validateUnfiltered.
|
|
1166
1575
|
// Permanece em memória apenas durante a execução de uma chamada validate*.
|
|
@@ -1185,11 +1594,87 @@ function resetMeta() {
|
|
|
1185
1594
|
// RULE_DEFAULTS mapeia regras cujo default NÃO é 'error'.
|
|
1186
1595
|
const RULE_DEFAULTS = {
|
|
1187
1596
|
note_orphan: 'warning',
|
|
1597
|
+
// ROADMAP-2026-08-12-deteccao-de-adulteracao-do-credential-guard-regra-de-validate, ML-1A,
|
|
1598
|
+
// ADR-2026-08-12 Emenda 3: the script carries no version marker, so this rule cannot tell
|
|
1599
|
+
// legitimate drift (trackfw not updated yet) from tampering — kept a warning, never an error.
|
|
1600
|
+
// credential_guard_mode_downgrade is deliberately absent: it falls through to ruleSeverity's
|
|
1601
|
+
// 'error' default.
|
|
1602
|
+
credential_guard_script_integrity: 'warning',
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1A.
|
|
1606
|
+
// ADR: docs/adr/ADR-2026-08-12-severidade-das-regras-de-credential-guard-resolvida-pela-mais-
|
|
1607
|
+
// estrita-entre-head-e-disco.md.
|
|
1608
|
+
//
|
|
1609
|
+
// As 3 regras abaixo resolvem severidade de forma DIFERENTE de todas as outras ~38: comparam HEAD
|
|
1610
|
+
// contra disco e adotam a MAIS ESTRITA das duas, em vez de ler só o disco. Deliberado, não bug —
|
|
1611
|
+
// sem isso, estas 3 regras podem ser desligadas pela mesma edição NÃO COMMITADA que elas deveriam
|
|
1612
|
+
// denunciar (`rules: credential_guard_mode_downgrade: off` em trackfw.yaml, nunca commitado). Toda
|
|
1613
|
+
// outra regra continua passando por diskRuleSeverity, byte-idêntico a antes deste ADR.
|
|
1614
|
+
const CREDENTIAL_GUARD_ANCHORED_RULES = new Set([
|
|
1615
|
+
'credential_guard_hook_resolvable',
|
|
1616
|
+
'credential_guard_script_integrity',
|
|
1617
|
+
'credential_guard_mode_downgrade',
|
|
1618
|
+
])
|
|
1619
|
+
|
|
1620
|
+
// credentialGuardSeverityRank ordena severidades da menos para a mais estrita, para a comparação
|
|
1621
|
+
// "mais estrita vence" de credentialGuardRuleSeverity. Qualquer valor fora de 'off'/'warning' só
|
|
1622
|
+
// significa 'error' na prática — applyRule já trata qualquer valor não reconhecido como violation,
|
|
1623
|
+
// então este ranking espelha esse mesmo fallback em vez de introduzir um contrato mais rígido.
|
|
1624
|
+
function credentialGuardSeverityRank(s) {
|
|
1625
|
+
if (s === 'off') return 0
|
|
1626
|
+
if (s === 'warning') return 1
|
|
1627
|
+
return 2
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// credentialGuardStricterSeverity retorna a mais estrita entre a e b ('error' > 'warning' > 'off').
|
|
1631
|
+
function credentialGuardStricterSeverity(a, b) {
|
|
1632
|
+
return credentialGuardSeverityRank(a) >= credentialGuardSeverityRank(b) ? a : b
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// credentialGuardDefaultSeverity é o mesmo fallback "RULE_DEFAULTS > error" que diskRuleSeverity
|
|
1636
|
+
// usa quando trackfw.yaml não tem rules: <name> — extraído para credentialGuardRuleSeverity poder
|
|
1637
|
+
// aplicá-lo igualmente ao lado HEAD (que não tem equivalente de RULE_DEFAULTS próprio, já que
|
|
1638
|
+
// config.parseRulesFromContent só devolve o que rules: em si contém).
|
|
1639
|
+
function credentialGuardDefaultSeverity(name) {
|
|
1640
|
+
return RULE_DEFAULTS[name] || 'error'
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// credentialGuardRuleSeverity resolve a severidade de uma das 3 CREDENTIAL_GUARD_ANCHORED_RULES
|
|
1644
|
+
// como a MAIS ESTRITA entre HEAD e disco — direcional, não "ignora disco e usa só HEAD" (ver o
|
|
1645
|
+
// parecer §2 e o ADR — o caso comum, HEAD sem menção à regra, precisa resolver para o default, ou
|
|
1646
|
+
// seja o valor mais estrito possível, senão o disco venceria de volta silenciosamente sempre).
|
|
1647
|
+
//
|
|
1648
|
+
// Sem HEAD (não é git worktree, sem commits, ou trackfw.yaml não versionado no HEAD —
|
|
1649
|
+
// headTrackfwYAML's 3 casos de "sem âncora"): cai no disco puro, igual a qualquer outra regra.
|
|
1650
|
+
// ADR ponto de decisão 4: limite aceito, não um bypass acionável por adversário — nenhum desses 3
|
|
1651
|
+
// casos é alcançável por uma edição não commitada de trackfw.yaml sozinha.
|
|
1652
|
+
function credentialGuardRuleSeverity(name) {
|
|
1653
|
+
const diskSeverity = diskRuleSeverity(name)
|
|
1654
|
+
|
|
1655
|
+
const head = headTrackfwYAML()
|
|
1656
|
+
if (!head.ok) return diskSeverity
|
|
1657
|
+
|
|
1658
|
+
const headRules = config.parseRulesFromContent(head.content)
|
|
1659
|
+
const headSeverity = headRules[name] || credentialGuardDefaultSeverity(name)
|
|
1660
|
+
|
|
1661
|
+
return credentialGuardStricterSeverity(headSeverity, diskSeverity)
|
|
1188
1662
|
}
|
|
1189
1663
|
|
|
1190
1664
|
// ruleSeverity retorna a severidade configurada para uma regra ('error'|'warning'|'off').
|
|
1191
1665
|
// Prioridade: trackfw.yaml rules: > RULE_DEFAULTS > 'error'.
|
|
1666
|
+
//
|
|
1667
|
+
// Para as 3 CREDENTIAL_GUARD_ANCHORED_RULES, delega a credentialGuardRuleSeverity acima — ver o
|
|
1668
|
+
// comentário logo antes dessa constante para o porquê. Toda outra regra segue para
|
|
1669
|
+
// diskRuleSeverity, textualmente idêntico ao corpo desta função antes do ADR-2026-08-12.
|
|
1192
1670
|
function ruleSeverity(name) {
|
|
1671
|
+
if (CREDENTIAL_GUARD_ANCHORED_RULES.has(name)) return credentialGuardRuleSeverity(name)
|
|
1672
|
+
return diskRuleSeverity(name)
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// diskRuleSeverity é a resolução ordinária, só-disco, usada por toda regra exceto as 3
|
|
1676
|
+
// CREDENTIAL_GUARD_ANCHORED_RULES: trackfw.yaml rules: (CWD) > RULE_DEFAULTS > 'error'.
|
|
1677
|
+
function diskRuleSeverity(name) {
|
|
1193
1678
|
const cfg = config.load()
|
|
1194
1679
|
if (cfg.rules[name]) return cfg.rules[name]
|
|
1195
1680
|
if (RULE_DEFAULTS[name]) return RULE_DEFAULTS[name]
|
|
@@ -1255,6 +1740,9 @@ async function validateUnfiltered() {
|
|
|
1255
1740
|
applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
|
|
1256
1741
|
applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
|
|
1257
1742
|
applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
|
|
1743
|
+
applyRule('credential_guard_hook_resolvable', validateCredentialGuardHookResolvable(), violations, warnings)
|
|
1744
|
+
applyRule('credential_guard_script_integrity', validateCredentialGuardScriptIntegrity(), violations, warnings)
|
|
1745
|
+
applyRule('credential_guard_mode_downgrade', validateCredentialGuardModeDowngrade(), violations, warnings)
|
|
1258
1746
|
|
|
1259
1747
|
// Regras configuráveis via applyRule (popula _itemMeta automaticamente)
|
|
1260
1748
|
applyRule('req_has_adr', validateREQsHaveADR(), violations, warnings)
|
|
@@ -1288,6 +1776,16 @@ async function validateUnfiltered() {
|
|
|
1288
1776
|
|
|
1289
1777
|
// validate executa todas as validações, aplica ratchet (baseline) e modo lenient.
|
|
1290
1778
|
// Retorna { violations, warnings }.
|
|
1779
|
+
//
|
|
1780
|
+
// ADR-2026-08-12-severidade-das-regras-de-credential-guard-resolvida-pela-mais-estrita-entre-head-
|
|
1781
|
+
// e-disco: carve-out do baseline — violations/warnings de uma das 3
|
|
1782
|
+
// CREDENTIAL_GUARD_ANCHORED_RULES NUNCA são toleradas via .trackfw-baseline.json, não importa o
|
|
1783
|
+
// que o arquivo contenha para elas. Mecanismo DIFERENTE do HEAD-vs-disco em
|
|
1784
|
+
// credentialGuardRuleSeverity: .trackfw-baseline.json é .gitignore'd DE PROPÓSITO ("baseline local
|
|
1785
|
+
// de violations toleradas (nao versionado)"), então não há HEAD desse arquivo para comparar —
|
|
1786
|
+
// "exigir commit" simplesmente não se aplica a um arquivo que o projeto decidiu nunca versionar. A
|
|
1787
|
+
// única forma de fechar esse canal é excluir estas 3 regras da elegibilidade de ratchet, por nome,
|
|
1788
|
+
// independente do conteúdo da mensagem — daí a checagem via getItemMeta(msg).rule abaixo.
|
|
1291
1789
|
async function validate() {
|
|
1292
1790
|
const result = await validateUnfiltered()
|
|
1293
1791
|
let { violations, warnings } = result
|
|
@@ -1296,9 +1794,9 @@ async function validate() {
|
|
|
1296
1794
|
const baseline = loadBaseline()
|
|
1297
1795
|
if (baseline) {
|
|
1298
1796
|
const baselineSet = new Set(baseline.violations || [])
|
|
1299
|
-
violations = violations.filter(v => !baselineSet.has(v))
|
|
1797
|
+
violations = violations.filter(v => !baselineSet.has(v) || CREDENTIAL_GUARD_ANCHORED_RULES.has(getItemMeta(v).rule))
|
|
1300
1798
|
const baselineWarnSet = new Set(baseline.warnings || [])
|
|
1301
|
-
warnings = warnings.filter(w => !baselineWarnSet.has(w))
|
|
1799
|
+
warnings = warnings.filter(w => !baselineWarnSet.has(w) || CREDENTIAL_GUARD_ANCHORED_RULES.has(getItemMeta(w).rule))
|
|
1302
1800
|
}
|
|
1303
1801
|
|
|
1304
1802
|
// Modo lenient: mover violations para warnings, exit code 0
|
|
@@ -1505,4 +2003,13 @@ module.exports = {
|
|
|
1505
2003
|
// ML-1D (2026-08-01 — reconciliação de paridade: frontmatter-first)
|
|
1506
2004
|
extractFrontmatterField,
|
|
1507
2005
|
resolveAdrStatus,
|
|
2006
|
+
// ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
|
|
2007
|
+
resolveCredentialGuardHookPath,
|
|
2008
|
+
collectCredentialGuardCommands,
|
|
2009
|
+
validateCredentialGuardHookResolvable,
|
|
2010
|
+
// ROADMAP-2026-08-12-deteccao-de-adulteracao-do-credential-guard-regra-de-validate, ML-1A
|
|
2011
|
+
validateCredentialGuardScriptIntegrity,
|
|
2012
|
+
validateCredentialGuardModeDowngrade,
|
|
2013
|
+
extractCredentialGuardMode,
|
|
2014
|
+
CREDENTIAL_GUARD_SCRIPT_REFERENCE,
|
|
1508
2015
|
}
|