trackfw 6.7.1 → 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/generators/hooks.js +169 -60
- 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,
|
package/src/generators/hooks.js
CHANGED
|
@@ -85,14 +85,20 @@ function mergeClaudeHookArray(existing, matcher, command) {
|
|
|
85
85
|
return arr
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
// matching the given matcher inside a
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
88
|
+
// migrateHookCommand rewrites a legacy hook command to a new one, in place, for every entry
|
|
89
|
+
// matching the given matcher inside a "matcher + hooks[].command" shaped array -- the format
|
|
90
|
+
// shared by Claude, Codex and Gemini's merge-based settings files (PreToolUse/PostToolUse/
|
|
91
|
+
// PermissionRequest/Notification/BeforeTool/AfterTool). Used to fix settings files already written
|
|
92
|
+
// by an older trackfw before a command string changes -- without this, re-running
|
|
93
|
+
// `trackfw init`/`update` only ever appends the new (fixed) command alongside the stale one (merge
|
|
94
|
+
// dedup in mergeClaudeHookArray keys on the exact command string, so it can't tell "same guard, new
|
|
95
|
+
// path" from "a different hook"), leaving the broken entry in place to keep firing and failing
|
|
96
|
+
// forever. Originally written for Claude only (hence the doc comment history); generalized
|
|
97
|
+
// (ROADMAP-2026-08-11 ML-1A) so Codex/Gemini injectors can call it too, ahead of the
|
|
98
|
+
// mechanism-specific string changes those CLIs' waves make. Must always be called before the
|
|
99
|
+
// corresponding mergeClaudeHookArray call for the same matcher, or the merge's exact-string dedup
|
|
100
|
+
// will append a duplicate instead of rewriting in place.
|
|
101
|
+
function migrateHookCommand(existing, matcher, oldCommand, newCommand) {
|
|
96
102
|
const arr = Array.isArray(existing) ? existing : []
|
|
97
103
|
for (const item of arr) {
|
|
98
104
|
if (!item || item.matcher !== matcher) continue
|
|
@@ -434,9 +440,76 @@ function generateGlobalCredentialGuardScript(home) {
|
|
|
434
440
|
console.log(' ✓ .trackfw/scripts/trackfw-credential-guard.sh')
|
|
435
441
|
}
|
|
436
442
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
443
|
+
// SIGNAL_CMD_*/CLEANUP_CMD_* — split per-CLI (ROADMAP-2026-08-11 ML-2A) from what used to be two
|
|
444
|
+
// constants (SIGNAL_CMD/CLEANUP_CMD) shared by all 6 CLI injectors. Mutating a shared constant to
|
|
445
|
+
// fix one CLI's path-resolution mechanism silently changed the emission of the other 5 -- Wave 0 of
|
|
446
|
+
// that roadmap proved Cursor and Copilot are *already correct* (Cursor hooks run from the project
|
|
447
|
+
// root; Copilot emits the native "cwd": "." field), so an accidental shared-constant edit there
|
|
448
|
+
// would be a regression in verified-good wiring, not just scope creep. Each constant starts equal
|
|
449
|
+
// to the pre-split literal; only the Claude ones (ML-2A, this change) move to the
|
|
450
|
+
// $CLAUDE_PROJECT_DIR-pinned form below. GUARD_CMD was later split too (ML-3A) once Codex's guard
|
|
451
|
+
// command needed to change -- see the GUARD_CMD_* block below.
|
|
452
|
+
// ROADMAP-2026-08-11 ML-3A: Codex CLI does not expose a project-root env var for repo-local hooks
|
|
453
|
+
// (unlike Claude's $CLAUDE_PROJECT_DIR or Gemini's $GEMINI_PROJECT_DIR) -- the only documented
|
|
454
|
+
// mechanism is shell substitution. Per ADR-2026-08-11 ("Codex — alterar, com dependência explícita
|
|
455
|
+
// de shell e git"), the command is wrapped in literal double quotes around
|
|
456
|
+
// `$(git rev-parse --show-toplevel)`, matching every repo-local hook example in the official Codex
|
|
457
|
+
// docs (https://developers.openai.com/codex/config-advanced): "For repo-local hooks, prefer
|
|
458
|
+
// resolving from the git root instead of using a relative path such as `.codex/hooks/...`."
|
|
459
|
+
const CODEX_ROOT = '"$(git rev-parse --show-toplevel)'
|
|
460
|
+
|
|
461
|
+
const SIGNAL_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-attention-signal.sh'
|
|
462
|
+
const SIGNAL_CMD_CODEX = CODEX_ROOT + '/scripts/trackfw-attention-signal.sh"'
|
|
463
|
+
// $GEMINI_PROJECT_DIR (ROADMAP-2026-08-11 ML-4A): distinct from the session-following
|
|
464
|
+
// $GEMINI_CWD, documented and used in 100% of the Gemini CLI's official hook command
|
|
465
|
+
// examples (ADR-2026-08-11, "Gemini CLI — alterar, por argumento de assimetria"). Expanded
|
|
466
|
+
// by the Gemini CLI runtime itself -- no shell substitution needed, no literal quotes.
|
|
467
|
+
const SIGNAL_CMD_GEMINI = '$GEMINI_PROJECT_DIR/scripts/trackfw-attention-signal.sh'
|
|
468
|
+
const SIGNAL_CMD_KIRO = 'scripts/trackfw-attention-signal.sh'
|
|
469
|
+
const SIGNAL_CMD_COPILOT = 'scripts/trackfw-attention-signal.sh'
|
|
470
|
+
const SIGNAL_CMD_CURSOR = 'scripts/trackfw-attention-signal.sh'
|
|
471
|
+
|
|
472
|
+
const CLEANUP_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-attention-cleanup.sh'
|
|
473
|
+
const CLEANUP_CMD_CODEX = CODEX_ROOT + '/scripts/trackfw-attention-cleanup.sh"'
|
|
474
|
+
const CLEANUP_CMD_GEMINI = '$GEMINI_PROJECT_DIR/scripts/trackfw-attention-cleanup.sh'
|
|
475
|
+
const CLEANUP_CMD_KIRO = 'scripts/trackfw-attention-cleanup.sh'
|
|
476
|
+
const CLEANUP_CMD_COPILOT = 'scripts/trackfw-attention-cleanup.sh'
|
|
477
|
+
const CLEANUP_CMD_CURSOR = 'scripts/trackfw-attention-cleanup.sh'
|
|
478
|
+
|
|
479
|
+
// Pre-ML-2A literal value of SIGNAL_CMD_CLAUDE/CLEANUP_CMD_CLAUDE, kept only as the `oldCommand`
|
|
480
|
+
// argument to the migration calls in injectClaudeHooks below (rewrites settings.json entries
|
|
481
|
+
// written by a pre-ML-2A trackfw in place, mirroring the GUARD_CMD_CLAUDE migration pattern).
|
|
482
|
+
const SIGNAL_CMD_CLAUDE_LEGACY = 'scripts/trackfw-attention-signal.sh'
|
|
483
|
+
const CLEANUP_CMD_CLAUDE_LEGACY = 'scripts/trackfw-attention-cleanup.sh'
|
|
484
|
+
|
|
485
|
+
// Pre-ML-3A literal value of SIGNAL_CMD_CODEX/CLEANUP_CMD_CODEX, kept only as the `oldCommand`
|
|
486
|
+
// argument to the migration calls in injectCodexHooks below.
|
|
487
|
+
const SIGNAL_CMD_CODEX_LEGACY = 'scripts/trackfw-attention-signal.sh'
|
|
488
|
+
const CLEANUP_CMD_CODEX_LEGACY = 'scripts/trackfw-attention-cleanup.sh'
|
|
489
|
+
|
|
490
|
+
// Pre-ML-4A literal value of SIGNAL_CMD_GEMINI/CLEANUP_CMD_GEMINI/GUARD_CMD_GEMINI, kept only as
|
|
491
|
+
// the `oldCommand` argument to the migration calls in injectGeminiHooks below.
|
|
492
|
+
const SIGNAL_CMD_GEMINI_LEGACY = 'scripts/trackfw-attention-signal.sh'
|
|
493
|
+
const CLEANUP_CMD_GEMINI_LEGACY = 'scripts/trackfw-attention-cleanup.sh'
|
|
494
|
+
const GUARD_CMD_GEMINI_LEGACY = 'scripts/trackfw-credential-guard.sh'
|
|
495
|
+
|
|
496
|
+
// GUARD_CMD_* -- split per-CLI (ROADMAP-2026-08-11 ML-3A) from what used to be one constant
|
|
497
|
+
// (GUARD_CMD) shared by Codex/Gemini/Kiro/Copilot/Cursor (Claude already had its own,
|
|
498
|
+
// GUARD_CMD_CLAUDE, since ML-2A). Same rationale as the SIGNAL_CMD_*/CLEANUP_CMD_* split above:
|
|
499
|
+
// Cursor and Copilot are verified-correct wiring (ML-0A), so a shared-constant edit made for Codex
|
|
500
|
+
// would have silently regressed them. Each constant starts equal to the pre-split literal; only
|
|
501
|
+
// GUARD_CMD_CODEX (this ML) moves to the new form below.
|
|
502
|
+
// Legacy relative-path literal, pre-dating both the ML-2A Claude fix and the ML-3A Codex fix --
|
|
503
|
+
// kept only as the `oldCommand` argument to migration calls (Claude's migration below, and Codex's
|
|
504
|
+
// until this ML rewrites it), mirroring the SIGNAL_CMD_CLAUDE_LEGACY/CLEANUP_CMD_CLAUDE_LEGACY
|
|
505
|
+
// pattern above.
|
|
506
|
+
const GUARD_CMD_LEGACY = 'scripts/trackfw-credential-guard.sh'
|
|
507
|
+
const GUARD_CMD_CODEX = CODEX_ROOT + '/scripts/trackfw-credential-guard.sh"'
|
|
508
|
+
const GUARD_CMD_GEMINI = '$GEMINI_PROJECT_DIR/scripts/trackfw-credential-guard.sh'
|
|
509
|
+
const GUARD_CMD_KIRO = 'scripts/trackfw-credential-guard.sh'
|
|
510
|
+
const GUARD_CMD_COPILOT = 'scripts/trackfw-credential-guard.sh'
|
|
511
|
+
const GUARD_CMD_CURSOR = 'scripts/trackfw-credential-guard.sh'
|
|
512
|
+
|
|
440
513
|
// Claude Code only (2026-08-09 fix, reported in production against the CMDB project): Claude Code
|
|
441
514
|
// resolves a bare relative hook command against the hook's *dynamic* cwd (tracks `cd`s the agent
|
|
442
515
|
// runs during the session), not the project root -- confirmed against
|
|
@@ -579,14 +652,24 @@ function injectClaudeHooks(cwd) {
|
|
|
579
652
|
const data = readJSON(filePath)
|
|
580
653
|
|
|
581
654
|
if (!data.hooks) data.hooks = {}
|
|
582
|
-
|
|
655
|
+
|
|
656
|
+
// Migration (ROADMAP-2026-08-11 ML-2A): rewrite any stale relative-path attention-signal/cleanup
|
|
657
|
+
// command from an older trackfw run before merging the $CLAUDE_PROJECT_DIR-pinned one below, so
|
|
658
|
+
// upgrading doesn't just append a second, still-cwd-fragile entry alongside the fixed one (same
|
|
659
|
+
// "No such file or directory" bug class as the credential-guard fix below, and the same
|
|
660
|
+
// migrate-before-merge ordering requirement -- mergeClaudeHookArray's dedup keys on the exact
|
|
661
|
+
// command string, so it can't tell "same hook, new path" from "a different hook").
|
|
662
|
+
migrateHookCommand(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD_CLAUDE_LEGACY, SIGNAL_CMD_CLAUDE)
|
|
663
|
+
migrateHookCommand(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD_CLAUDE_LEGACY, CLEANUP_CMD_CLAUDE)
|
|
664
|
+
|
|
665
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD_CLAUDE)
|
|
583
666
|
|
|
584
667
|
// Rewrite any stale relative-path credential-guard command from an older trackfw run before
|
|
585
668
|
// merging the fixed one below, so upgrading doesn't just append a second, still-broken entry
|
|
586
669
|
// alongside the new one (see GUARD_CMD_CLAUDE comment for the "No such file or directory" bug).
|
|
587
670
|
for (const matcher of ['Bash', 'Read', 'Write|Edit']) {
|
|
588
|
-
|
|
589
|
-
|
|
671
|
+
migrateHookCommand(data.hooks.PreToolUse, matcher, GUARD_CMD_LEGACY, GUARD_CMD_CLAUDE)
|
|
672
|
+
migrateHookCommand(data.hooks.PostToolUse, matcher, GUARD_CMD_LEGACY, GUARD_CMD_CLAUDE)
|
|
590
673
|
}
|
|
591
674
|
|
|
592
675
|
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
@@ -599,7 +682,7 @@ function injectClaudeHooks(cwd) {
|
|
|
599
682
|
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Read', GUARD_CMD_CLAUDE)
|
|
600
683
|
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Write|Edit', GUARD_CMD_CLAUDE)
|
|
601
684
|
}
|
|
602
|
-
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion',
|
|
685
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD_CLAUDE)
|
|
603
686
|
if (!globalCredentialGuardInstalledClaude()) {
|
|
604
687
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD_CLAUDE)
|
|
605
688
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Read', GUARD_CMD_CLAUDE)
|
|
@@ -633,19 +716,30 @@ function injectCodexHooks(cwd) {
|
|
|
633
716
|
const data = readJSON(filePath)
|
|
634
717
|
|
|
635
718
|
if (!data.hooks) data.hooks = {}
|
|
636
|
-
|
|
719
|
+
|
|
720
|
+
// Migration wiring (ROADMAP-2026-08-11 ML-1A, strings updated in ML-3A): rewrites any stale
|
|
721
|
+
// relative-path entry from before this fix in place, so `trackfw update` doesn't just append the
|
|
722
|
+
// new $(git rev-parse ...) entry alongside the still-cwd-fragile old one.
|
|
723
|
+
migrateHookCommand(data.hooks.PermissionRequest, '.*', SIGNAL_CMD_CODEX_LEGACY, SIGNAL_CMD_CODEX)
|
|
724
|
+
migrateHookCommand(data.hooks.PreToolUse, 'Bash', GUARD_CMD_LEGACY, GUARD_CMD_CODEX)
|
|
725
|
+
migrateHookCommand(data.hooks.PreToolUse, 'apply_patch', GUARD_CMD_LEGACY, GUARD_CMD_CODEX)
|
|
726
|
+
migrateHookCommand(data.hooks.PostToolUse, '.*', CLEANUP_CMD_CODEX_LEGACY, CLEANUP_CMD_CODEX)
|
|
727
|
+
migrateHookCommand(data.hooks.PostToolUse, 'Bash', GUARD_CMD_LEGACY, GUARD_CMD_CODEX)
|
|
728
|
+
migrateHookCommand(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD_LEGACY, GUARD_CMD_CODEX)
|
|
729
|
+
|
|
730
|
+
data.hooks.PermissionRequest = mergeClaudeHookArray(data.hooks.PermissionRequest, '.*', SIGNAL_CMD_CODEX)
|
|
637
731
|
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
638
732
|
// Wave 2 to apply_patch): skip project-scope credential-guard when the global one is already
|
|
639
733
|
// installed for this CLI.
|
|
640
734
|
const skipCodexCG = globalCredentialGuardInstalledCodex()
|
|
641
735
|
if (!skipCodexCG) {
|
|
642
|
-
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash',
|
|
643
|
-
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'apply_patch',
|
|
736
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD_CODEX)
|
|
737
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'apply_patch', GUARD_CMD_CODEX)
|
|
644
738
|
}
|
|
645
|
-
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*',
|
|
739
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*', CLEANUP_CMD_CODEX)
|
|
646
740
|
if (!skipCodexCG) {
|
|
647
|
-
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash',
|
|
648
|
-
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch',
|
|
741
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD_CODEX)
|
|
742
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD_CODEX)
|
|
649
743
|
}
|
|
650
744
|
|
|
651
745
|
writeJSON(filePath, data)
|
|
@@ -682,21 +776,36 @@ function injectGeminiHooks(cwd) {
|
|
|
682
776
|
const data = readJSON(filePath)
|
|
683
777
|
|
|
684
778
|
if (!data.hooks) data.hooks = {}
|
|
685
|
-
|
|
779
|
+
|
|
780
|
+
// Migration wiring (ROADMAP-2026-08-11 ML-1A): old === new is a functional no-op today, but
|
|
781
|
+
// proves the call point exists and runs before the merge below. The wave that changes the
|
|
782
|
+
// Gemini command strings (ML-4A) updates the oldCommand argument here instead of adding this
|
|
783
|
+
// call from scratch -- without it, the merge's exact-string dedup would append a duplicate
|
|
784
|
+
// alongside the stale entry.
|
|
785
|
+
migrateHookCommand(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD_GEMINI_LEGACY, SIGNAL_CMD_GEMINI)
|
|
786
|
+
migrateHookCommand(data.hooks.BeforeTool, 'run_shell_command', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
787
|
+
migrateHookCommand(data.hooks.BeforeTool, 'read_file|read_many_files', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
788
|
+
migrateHookCommand(data.hooks.BeforeTool, 'write_file|replace', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
789
|
+
migrateHookCommand(data.hooks.AfterTool, '*', CLEANUP_CMD_GEMINI_LEGACY, CLEANUP_CMD_GEMINI)
|
|
790
|
+
migrateHookCommand(data.hooks.AfterTool, 'run_shell_command', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
791
|
+
migrateHookCommand(data.hooks.AfterTool, 'read_file|read_many_files', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
792
|
+
migrateHookCommand(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD_GEMINI_LEGACY, GUARD_CMD_GEMINI)
|
|
793
|
+
|
|
794
|
+
data.hooks.Notification = mergeClaudeHookArray(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD_GEMINI)
|
|
686
795
|
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
687
796
|
// Wave 2 to read_file|read_many_files / write_file|replace): skip project-scope
|
|
688
797
|
// credential-guard when the global one is already installed.
|
|
689
798
|
const skipGeminiCG = globalCredentialGuardInstalledGemini()
|
|
690
799
|
if (!skipGeminiCG) {
|
|
691
|
-
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command',
|
|
692
|
-
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'read_file|read_many_files',
|
|
693
|
-
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'write_file|replace',
|
|
800
|
+
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GUARD_CMD_GEMINI)
|
|
801
|
+
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'read_file|read_many_files', GUARD_CMD_GEMINI)
|
|
802
|
+
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'write_file|replace', GUARD_CMD_GEMINI)
|
|
694
803
|
}
|
|
695
|
-
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*',
|
|
804
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*', CLEANUP_CMD_GEMINI)
|
|
696
805
|
if (!skipGeminiCG) {
|
|
697
|
-
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'run_shell_command',
|
|
698
|
-
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'read_file|read_many_files',
|
|
699
|
-
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace',
|
|
806
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'run_shell_command', GUARD_CMD_GEMINI)
|
|
807
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'read_file|read_many_files', GUARD_CMD_GEMINI)
|
|
808
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD_GEMINI)
|
|
700
809
|
}
|
|
701
810
|
|
|
702
811
|
writeJSON(filePath, data)
|
|
@@ -732,14 +841,14 @@ function injectKiroHooks(cwd) {
|
|
|
732
841
|
description: 'Signals trackfw board when agent executes a tool',
|
|
733
842
|
trigger: 'PreToolUse',
|
|
734
843
|
matcher: '*',
|
|
735
|
-
action: { type: 'command', command:
|
|
844
|
+
action: { type: 'command', command: SIGNAL_CMD_KIRO },
|
|
736
845
|
},
|
|
737
846
|
{
|
|
738
847
|
name: 'trackfw-attention-cleanup',
|
|
739
848
|
description: 'Clears trackfw board attention after tool completes',
|
|
740
849
|
trigger: 'PostToolUse',
|
|
741
850
|
matcher: '*',
|
|
742
|
-
action: { type: 'command', command:
|
|
851
|
+
action: { type: 'command', command: CLEANUP_CMD_KIRO },
|
|
743
852
|
},
|
|
744
853
|
]
|
|
745
854
|
|
|
@@ -753,14 +862,14 @@ function injectKiroHooks(cwd) {
|
|
|
753
862
|
description: 'Blocks/warns on possible plaintext credential materialization before a shell command executes',
|
|
754
863
|
trigger: 'PreToolUse',
|
|
755
864
|
matcher: 'shell',
|
|
756
|
-
action: { type: 'command', command:
|
|
865
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
757
866
|
},
|
|
758
867
|
{
|
|
759
868
|
name: 'trackfw-credential-guard-post',
|
|
760
869
|
description: 'Warns on possible plaintext credential materialization after a shell command executes',
|
|
761
870
|
trigger: 'PostToolUse',
|
|
762
871
|
matcher: 'shell',
|
|
763
|
-
action: { type: 'command', command:
|
|
872
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
764
873
|
},
|
|
765
874
|
// Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): "read" and "write" are the
|
|
766
875
|
// documented Kiro tool-category aliases (fs_read/fs_write), same pattern as "shell" above.
|
|
@@ -769,28 +878,28 @@ function injectKiroHooks(cwd) {
|
|
|
769
878
|
description: 'Blocks/warns on possible plaintext credential materialization before a file read',
|
|
770
879
|
trigger: 'PreToolUse',
|
|
771
880
|
matcher: 'read',
|
|
772
|
-
action: { type: 'command', command:
|
|
881
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
773
882
|
},
|
|
774
883
|
{
|
|
775
884
|
name: 'trackfw-credential-guard-read-post',
|
|
776
885
|
description: 'Warns on possible plaintext credential materialization after a file read',
|
|
777
886
|
trigger: 'PostToolUse',
|
|
778
887
|
matcher: 'read',
|
|
779
|
-
action: { type: 'command', command:
|
|
888
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
780
889
|
},
|
|
781
890
|
{
|
|
782
891
|
name: 'trackfw-credential-guard-write-pre',
|
|
783
892
|
description: 'Blocks/warns on possible plaintext credential materialization before a file write',
|
|
784
893
|
trigger: 'PreToolUse',
|
|
785
894
|
matcher: 'write',
|
|
786
|
-
action: { type: 'command', command:
|
|
895
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
787
896
|
},
|
|
788
897
|
{
|
|
789
898
|
name: 'trackfw-credential-guard-write-post',
|
|
790
899
|
description: 'Warns on possible plaintext credential materialization after a file write',
|
|
791
900
|
trigger: 'PostToolUse',
|
|
792
901
|
matcher: 'write',
|
|
793
|
-
action: { type: 'command', command:
|
|
902
|
+
action: { type: 'command', command: GUARD_CMD_KIRO },
|
|
794
903
|
},
|
|
795
904
|
)
|
|
796
905
|
}
|
|
@@ -834,19 +943,19 @@ function injectKiroHooks(cwd) {
|
|
|
834
943
|
function injectCopilotHooks(cwd) {
|
|
835
944
|
const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
|
|
836
945
|
|
|
837
|
-
const preToolUse = [{ type: 'command', bash:
|
|
838
|
-
const postToolUse = [{ type: 'command', bash:
|
|
946
|
+
const preToolUse = [{ type: 'command', bash: SIGNAL_CMD_COPILOT, cwd: '.', timeoutSec: 10 }]
|
|
947
|
+
const postToolUse = [{ type: 'command', bash: CLEANUP_CMD_COPILOT, cwd: '.', timeoutSec: 10 }]
|
|
839
948
|
|
|
840
949
|
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
841
950
|
// Wave 2 to view / create|edit): skip project-scope credential-guard entries when the global
|
|
842
951
|
// one is already installed.
|
|
843
952
|
if (!globalCredentialGuardInstalledCopilot()) {
|
|
844
|
-
preToolUse.push({ type: 'command', matcher: 'bash', bash:
|
|
845
|
-
preToolUse.push({ type: 'command', matcher: 'view', bash:
|
|
846
|
-
preToolUse.push({ type: 'command', matcher: 'create|edit', bash:
|
|
847
|
-
postToolUse.push({ type: 'command', matcher: 'bash', bash:
|
|
848
|
-
postToolUse.push({ type: 'command', matcher: 'view', bash:
|
|
849
|
-
postToolUse.push({ type: 'command', matcher: 'create|edit', bash:
|
|
953
|
+
preToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
954
|
+
preToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
955
|
+
preToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
956
|
+
postToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
957
|
+
postToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
958
|
+
postToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
|
|
850
959
|
}
|
|
851
960
|
|
|
852
961
|
const data = {
|
|
@@ -932,29 +1041,29 @@ function injectCursorHooks(cwd) {
|
|
|
932
1041
|
// Migrate any legacy top-level preToolUse/postToolUse trackfw entries
|
|
933
1042
|
// (written by trackfw before this ML) into the nested, real hooks.
|
|
934
1043
|
if (!Array.isArray(data.hooks.preToolUse)) data.hooks.preToolUse = []
|
|
935
|
-
if (!hasEntry(data.hooks.preToolUse, 'command',
|
|
936
|
-
data.hooks.preToolUse.push({ command:
|
|
1044
|
+
if (!hasEntry(data.hooks.preToolUse, 'command', SIGNAL_CMD_CURSOR)) {
|
|
1045
|
+
data.hooks.preToolUse.push({ command: SIGNAL_CMD_CURSOR })
|
|
937
1046
|
}
|
|
938
|
-
removeKnownCommandFromLegacyTopLevelArray(data, 'preToolUse',
|
|
1047
|
+
removeKnownCommandFromLegacyTopLevelArray(data, 'preToolUse', SIGNAL_CMD_CURSOR)
|
|
939
1048
|
|
|
940
1049
|
if (!Array.isArray(data.hooks.postToolUse)) data.hooks.postToolUse = []
|
|
941
|
-
if (!hasEntry(data.hooks.postToolUse, 'command',
|
|
942
|
-
data.hooks.postToolUse.push({ command:
|
|
1050
|
+
if (!hasEntry(data.hooks.postToolUse, 'command', CLEANUP_CMD_CURSOR)) {
|
|
1051
|
+
data.hooks.postToolUse.push({ command: CLEANUP_CMD_CURSOR })
|
|
943
1052
|
}
|
|
944
|
-
removeKnownCommandFromLegacyTopLevelArray(data, 'postToolUse',
|
|
1053
|
+
removeKnownCommandFromLegacyTopLevelArray(data, 'postToolUse', CLEANUP_CMD_CURSOR)
|
|
945
1054
|
|
|
946
1055
|
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
947
1056
|
// Wave 2 to Read/Write via the generic preToolUse/postToolUse events): skip project-scope
|
|
948
1057
|
// credential-guard entries when the global one is already installed.
|
|
949
1058
|
if (!globalCredentialGuardInstalledCursor()) {
|
|
950
1059
|
if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
|
|
951
|
-
if (!hasEntry(data.hooks.beforeShellExecution, 'command',
|
|
952
|
-
data.hooks.beforeShellExecution.push({ command:
|
|
1060
|
+
if (!hasEntry(data.hooks.beforeShellExecution, 'command', GUARD_CMD_CURSOR)) {
|
|
1061
|
+
data.hooks.beforeShellExecution.push({ command: GUARD_CMD_CURSOR })
|
|
953
1062
|
}
|
|
954
1063
|
|
|
955
1064
|
if (!Array.isArray(data.hooks.afterShellExecution)) data.hooks.afterShellExecution = []
|
|
956
|
-
if (!hasEntry(data.hooks.afterShellExecution, 'command',
|
|
957
|
-
data.hooks.afterShellExecution.push({ command:
|
|
1065
|
+
if (!hasEntry(data.hooks.afterShellExecution, 'command', GUARD_CMD_CURSOR)) {
|
|
1066
|
+
data.hooks.afterShellExecution.push({ command: GUARD_CMD_CURSOR })
|
|
958
1067
|
}
|
|
959
1068
|
|
|
960
1069
|
// Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): wired via the generic
|
|
@@ -965,19 +1074,19 @@ function injectCursorHooks(cwd) {
|
|
|
965
1074
|
// unfiltered signal entry and these matcher-scoped guard entries share the same array, so
|
|
966
1075
|
// dedup must also check `matcher`.
|
|
967
1076
|
const hasGuardMatcherEntry = (arr, matcher) =>
|
|
968
|
-
Array.isArray(arr) && arr.some(e => e && e.command ===
|
|
1077
|
+
Array.isArray(arr) && arr.some(e => e && e.command === GUARD_CMD_CURSOR && e.matcher === matcher)
|
|
969
1078
|
|
|
970
1079
|
if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Read')) {
|
|
971
|
-
data.hooks.preToolUse.push({ command:
|
|
1080
|
+
data.hooks.preToolUse.push({ command: GUARD_CMD_CURSOR, matcher: 'Read' })
|
|
972
1081
|
}
|
|
973
1082
|
if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Write')) {
|
|
974
|
-
data.hooks.preToolUse.push({ command:
|
|
1083
|
+
data.hooks.preToolUse.push({ command: GUARD_CMD_CURSOR, matcher: 'Write' })
|
|
975
1084
|
}
|
|
976
1085
|
if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Read')) {
|
|
977
|
-
data.hooks.postToolUse.push({ command:
|
|
1086
|
+
data.hooks.postToolUse.push({ command: GUARD_CMD_CURSOR, matcher: 'Read' })
|
|
978
1087
|
}
|
|
979
1088
|
if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Write')) {
|
|
980
|
-
data.hooks.postToolUse.push({ command:
|
|
1089
|
+
data.hooks.postToolUse.push({ command: GUARD_CMD_CURSOR, matcher: 'Write' })
|
|
981
1090
|
}
|
|
982
1091
|
}
|
|
983
1092
|
|
|
@@ -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
|
}
|